CONCAT / || 拼接
拼接(Concatenation)是把多个值首尾相接成一个字符串的最基础操作。PostgreSQL 提供了三种手段:SQL 标准操作符 ||、宽容空值的函数 concat()、以及带分隔符的 concat_ws()。三者在空值处理上行为完全不同,选错会静默产生错误结果。本节将逐一拆解它们的语义差异、类型转换规则与性能边界,让你在任何拼接场景下都能选对工具。
前置知识
阅读本节前,建议先了解:批量操作优化
基础概念
拼接看起来只是「加法」,但 PostgreSQL 的拼接涉及三个正交维度:用什么语法、空值怎么算、非文本类型怎么转。
| 概念 | 术语 | 说明 |
|---|---|---|
| 拼接 | Concatenation | 将两个或多个字符串首尾相接 |
| 空值传染 | NULL Propagation | 任一侧为 NULL,整个表达式即为 NULL |
| 空值跳过 | NULL Skipping | 忽略 NULL 参数,其余部分照常拼接 |
| 操作符重载 | Operator Overloading | || 按操作数类型分派到不同实现 |
| 可变参数 | VARIADIC | 函数接受任意数量同类型参数 |
| 隐式类型转换 | Implicit Cast | 非文本类型自动转为 text 参与拼接 |
| 伪类型 | Pseudo-Type | "any" 这类只能出现在函数签名中的类型 |
| 字段上限 | MaxAllocSize | 单个字段最大约 1 GB,拼接超限会报错 |
三种拼接手段的核心差异:
| 写法 | 属于 | NULL 处理 | 接收参数 | 典型用途 |
|---|---|---|---|---|
a || b | SQL 标准操作符 | 传染:结果变 NULL | 两个操作数 | 拼接必填字段 |
concat(a, b, ...) | PostgreSQL 扩展函数 | 跳过:忽略 NULL | 任意数量、任意类型 | 拼接可选字段 |
concat_ws(sep, a, b, ...) | PostgreSQL 扩展函数 | 跳过:忽略 NULL,且不补分隔符 | 任意数量、任意类型 | 拼接带分隔符的文本 |
format(fmt, a, b) | PostgreSQL 扩展函数 | 按模板:%s 遇 NULL 输出空串 | 任意数量、任意类型 | 需要精确控制格式 |
选择流程:
拼接的字段可能为 NULL 吗?
├─ 否 → 用 || (标准、最快、可走表达式索引)
└─ 是 → 你希望结果是 NULL 还是忽略空值?
├─ 希望整体为 NULL → 仍用 ||
└─ 希望忽略空值 → 需要分隔符吗?
├─ 需要 → concat_ws(sep, ...)
└─ 不需要 → concat(...)一句话记忆:|| 是「数据不全就整体作废」,concat 是「有几段拼几段」。
语法与用法
-- 操作符:两侧必须是可拼接的类型
表达式1 || 表达式2
-- 函数:可变参数,最少 1 个
concat(值1 [, 值2 [, ...]])
concat_ws(分隔符, 值1 [, 值2 [, ...]])
-- 模板函数:占位符 %s / %I / %L / %%
format(模板 [, 值1 [, ...]])
-- 相关配套函数
string_agg(值, 分隔符) -- 聚合多行(见 SPLIT_PART / STRING_AGG 一节)
array_to_string(数组, 分隔符) -- 数组转字符串
array_agg(值) -- 聚合成数组最小可运行示例:
-- 三种写法的直观对比
SELECT
'postgresql' || '18' AS by_operator, -- 操作符
concat('postgresql', 18) AS by_concat, -- 函数,数字自动转文本
concat_ws('-', 'v', '18', 0) AS by_concat_ws, -- 带分隔符
format('%s-%s', 'v', 18) AS by_format; -- 模板 by_operator | by_concat | by_concat_ws | by_format
-------------+------------+--------------+-----------
postgresql18 | postgresql18 | v-18-0 | v-18准备本节贯穿使用的示例表:
-- 用户表:full_name / phone / bio 都可能为 NULL
CREATE TABLE users (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username TEXT NOT NULL,
full_name TEXT,
email TEXT,
phone TEXT,
city TEXT,
address TEXT,
bio TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO users (username, full_name, email, phone, city, address, bio) VALUES
('zhangsan', '张三', 'zhangsan@example.com', '13800000001', '北京市', '朝阳区建国路 1 号', 'DBA'),
('lisi', '李四', 'lisi@example.com', NULL, '上海市', '浦东新区世纪大道 2 号', NULL),
('wangwu', NULL, 'wangwu@example.com', '13800000003', NULL, NULL, '后端开发'),
('zhaoliu', '赵六', NULL, NULL, '广州市', NULL, NULL);-- 对比:同一批数据,三种写法的输出差异
SELECT
id,
full_name || ' (' || username || ')' AS op_style,
concat(full_name, ' (', username, ')') AS concat_style,
concat_ws(' / ', city, address, phone) AS ws_style
FROM users
ORDER BY id; id | op_style | concat_style | ws_style
----+-----------------------+----------------------+----------------------------
1 | 张三 (zhangsan) | 张三 (zhangsan) | 北京市 / 朝阳区建国路 1 号 / 13800000001
2 | 李四 (lisi) | 李四 (lisi) | 上海市 / 浦东新区世纪大道 2 号
3 | | wangwu () | 13800000003
4 | 赵六 (zhaoliu) | 赵六 (zhaoliu) | 广州市第 3 行是最值得注意的一行:|| 因为 full_name IS NULL 而让整列变空,concat 仍然拼出了 wangwu (),concat_ws 则干净地跳过了三个 NULL。
详细说明
|| 操作符:SQL 标准写法
|| 是 SQL 标准(SQL:1989 起)定义的字符串拼接操作符,同时也是 PostgreSQL 中重载最多的操作符之一。它有独立的索引可用性优势——优化器能识别 || 表达式并在表达式索引上做匹配。
SELECT
'a' || 'b' AS t_t, -- text || text
'a' || 1 AS t_i, -- text || anynonarray → text
'n=' || 1.50::numeric AS t_n, -- numeric 保留尾随零吗?
'b=' || true AS t_b, -- boolean → text
'd=' || DATE '2026-09-22' AS t_d; -- date → text t_t | t_i | t_n | t_b | t_d
-----+-----+--------+------+------------
ab | a1 | n=1.50 | b=true | d=2026-09-22注意 1.50::numeric 拼接后保留了尾随零 1.50——numeric 的文本输出遵循其 scale,这是与 float8 不同的行为(1.50::float8 输出为 1.5)。
|| 的空值行为必须牢记:
SELECT
'a' || NULL AS with_null,
NULL || 'b' AS null_left,
NULL || NULL AS all_null,
'' || 'x' AS empty_left,
'x' || '' AS empty_right,
'' || '' AS both_empty; with_null | null_left | all_null | empty_left | empty_right | both_empty
-----------+-----------+----------+------------+-------------+------------
| | | x | x |结论:空串 '' 是「有值」,NULL 是「无值」。'' || 'x' 得到 'x',而 NULL || 'x' 得到 NULL。这个区别在生成报表文本时尤其关键。
concat:忽略 NULL 的可变参数函数
concat 的签名是 concat(VARIADIC "any") → text。两个要点:
- 参数是伪类型
"any",意味着任何类型都能直接传入,由函数内部调用该类型的输出函数转为文本; - 由于
"any"伪类型不能有真正的VARIADIC数组形式,concat不能直接展开数组。
SELECT
concat('a', NULL) AS skip_null,
concat('a', NULL, 'b') AS middle_null,
concat(NULL, NULL) AS all_null,
concat('count=', 42, ', ok=', true) AS mixed,
concat() AS no_args; skip_null | middle_null | all_null | mixed | no_args
-----------+-------------+----------+---------------+---------
a | ab | | count=42, ok=true |注意 concat(NULL, NULL) 返回空串而非 NULL,因为两个参数都被跳过,剩余部分为空。这与 || 截然不同,是迁移代码时最容易踩的坑。
concat 的隐式转换能力很强,但也带来歧义:
-- 数字与布尔会被转成文本
SELECT concat(1, 2, 3) AS num_concat, concat(1, 2, 3, '') AS forced_text; num_concat | forced_text
------------+-------------
123 | 123concat(1, 2, 3) 得到 123 而不是 6——它从不做算术。若想要 6,必须先算再拼:
SELECT (1 + 2 + 3)::text AS computed; -- 6concat_ws:带分隔符且跳过 NULL
concat_ws 是 concat with separator 的缩写,签名 concat_ws(sep text, VARIADIC "any") → text。
它的独特之处在于:分隔符只加在有值的参数之间,不会产生 a,,b 或 a--b 这类空位。
SELECT
concat_ws(', ', '北京市', NULL, '朝阳区', NULL, '建国路 1 号') AS address,
concat_ws('-', 'a', NULL, 'b', NULL) AS simple,
concat_ws('-', NULL, NULL) AS all_null,
concat_ws(NULL, 'a', 'b') AS null_sep; address | simple | all_null | null_sep
-----------------------------+--------+----------+----------
北京市, 朝阳区, 建国路 1 号 | a-b | |两个边界情况:
- 所有参数都为
NULL:结果为空串''(因为没有任何「有值」的参数需要拼接); - 分隔符本身为
NULL:整个结果为NULL——分隔符不会被跳过。这是concat_ws唯一的NULL传染路径,用变量做分隔符时要小心。
与 || 相比,concat_ws 在地址、全名、标签这类「字段可选」的场景几乎总是更优选择:
-- 不推荐:任何一个字段为 NULL,整条地址就没了
SELECT address_line1 || ', ' || address_line2 || ', ' || city AS addr_bad FROM users;
-- 推荐:干净地跳过缺失部分
SELECT concat_ws(', ', address_line1, address_line2, city) AS addr_ok FROM users;非文本类型如何被转换
|| 与 concat 都会对操作数做隐式转换,但规则来源不同:
| 操作数类型 | || 的处理 | concat 的处理 |
|---|---|---|
text / varchar / char | 直接拼接 | 直接拼接 |
integer / numeric | text || anynonarray,转文本 | 转文本 |
boolean | 转 t / f 吗?不,输出函数的 true / false | true / false |
date / timestamp | 依赖 DateStyle 设置 | 同左 |
jsonb | 转文本 | 转文本 |
bytea | 转义形式 | 转义形式 |
| 数组 | 触发数组拼接重载 | 输出数组字面量 {...} |
布尔类型在 || 下的行为值得单独验证:
SELECT
true || 'x' AS bool_by_op, -- 走 anynonarray 重载
'x' || true AS bool_by_op2,
concat(true, 'x') AS bool_by_fn; bool_by_op | bool_by_op2 | bool_by_fn
------------+-------------+-----------
truex | xtrue | truex若业务要求输出「是/否」,必须显式转换:
SELECT
concat('已支付:', CASE WHEN true THEN '是' ELSE '否' END) AS cn_bool,
format('已支付:%s', CASE WHEN false THEN '是' ELSE '否' END) AS cn_bool_fmt; cn_bool | cn_bool_fmt
-------------+-------------
已支付:是 | 已支付:否数组与 ||:容易被忽略的重载
|| 在 PostgreSQL 中还被重载为数组拼接操作符。当任意一侧是数组时,字符串拼接的重载会输给数组拼接:
SELECT
ARRAY[1, 2] || ARRAY[3] AS arr_arr, -- 数组接数组
ARRAY[1, 2] || 3 AS arr_elem, -- 元素追加到尾部
0 || ARRAY[1, 2] AS elem_arr, -- 元素插入到头部
ARRAY['a'] || ARRAY['b', 'c'] AS text_arr; arr_arr | arr_elem | elem_arr | text_arr
-----------+----------+----------+------------
{1,2,3} | {1,2,3} | {0,1,2} | {a,b,c}另一个高频错误:两侧都不是字符串时,|| 根本无重载可用,直接报错。
-- 错误!两个整数都没有数组类型,也没有 anynonarray 可用的字符串侧
SELECT 1 || 2;
-- ERROR: operator does not exist: integer || integer
-- LINE 1: SELECT 1 || 2;
-- ^
-- HINT: No operator matches the given name and argument types.
-- You might need to add explicit type casts.这与 MySQL(SELECT 1 || 2 得 12)、SQLite(得 12)行为完全不同——它们是隐式转字符串,PostgreSQL 要求至少一侧是字符串类型才会走 text || anynonarray 重载:
-- 正确:至少一侧显式转文本
SELECT 1 || 2::text AS int_text, -- 12(右侧先转 text)
'1' || 2 AS text_int, -- 12(左侧是 text 字面量)
(1::text || 2::text) AS both_cast, -- 12(两侧都显式转)
concat(1, 2) AS via_concat; -- 12(concat 自身会转) int_text | text_int | both_cast | via_concat
----------+----------+-----------+------------
12 | 12 | 12 | 12而如果两个整数都被"误当"成数组元素,还可能触发数组拼接重载,得到意想不到的数组而不是字符串。所以生成编号、拼接数字字段时,务必显式 ::text 或 to_char()。
易错点来了:下面这个写法看起来想拼字符串,实际得到的是数组:
-- 错误!期望得到 'a,b,c',实际得到数组
SELECT 'a' || ARRAY['b', 'c']; ?column?
--------------
{a,b,c}要得到字符串,必须显式指定类型或用函数:
SELECT
'a' || ARRAY['b', 'c']::text AS by_cast_text, -- 数组整体转文本
ARRAY['a', 'b', 'c']::text AS whole,
array_to_string(ARRAY['a', 'b', 'c'], ',') AS joined; -- 推荐 by_cast_text | whole | joined
--------------+---------+--------
{a,b,c} | {a,b,c} | a,b,c只有 array_to_string 才得到真正的 a,b,c。而 concat 接收数组时同样输出字面量:
SELECT concat(ARRAY[1, 2, 3]) AS arr_via_concat; arr_via_concat
----------------
{1,2,3}若确实想把数组元素逐个拼进 concat,需要展开:
-- 用 unnest 展开后聚合,或直接 string_agg
SELECT string_agg(part, '-') AS from_array
FROM unnest(ARRAY['a', 'b', 'c']) AS part; from_array
------------
a-b-cformat:需要精确控制时
当拼接涉及占位对齐、字面量转义、标识符引用时,format 比反复用 || 更安全:
SELECT
format('用户 %s 于 %s 注册', 'zhangsan', DATE '2026-09-22') AS s_plain,
format('金额:%s 元', 1234.5) AS s_num,
format('表名 %%s 不替换') AS s_escape,
format('空值:[%s]', NULL) AS s_null,
format('标识符:%I', 'select') AS s_ident, -- 自动加双引号
format('字面量:%L', 'it''s ok') AS s_literal; -- 自动转义 s_plain | s_num | s_escape | s_null | s_ident | s_literal
----------------------------+-------------+---------------+--------+-----------+------------------
用户 zhangsan 于 2026-09-22 注册 | 金额:1234.5 元 | 表名 %s 不替换 | 空值:[] | 标识符:"select" | 字面量:'it''s ok'%s 遇到 NULL 输出空串(等同于 concat 的跳过语义);%I 与 %L 会做 SQL 安全转义,是动态 SQL 拼装的正确方式。
拼接性能与 1 GB 上限
字符串拼接的时间复杂度与结果长度相关。一行一行累积拼接(acc || next)在循环中会产生 O(n²) 的拷贝成本。PostgreSQL 内部对少数次拼接有优化,但大批量累积应交给聚合函数:
-- 不推荐:逐行拼接,每行都要复制已有结果
DO $$
DECLARE
acc text := '';
BEGIN
FOR i IN 1..10000 LOOP
acc := acc || i::text; -- O(n²) 拷贝
END LOOP;
END $$;
-- 推荐:一次聚合完成
SELECT string_agg(i::text, '')
FROM generate_series(1, 10000) AS i;单个字段(text / varchar)的上限受 MaxAllocSize 限制,约为 1 GB(1073741823 字节)。超过会直接报错:
-- 会报错:requested length too large
SELECT repeat('a', 1000000000) || repeat('b', 1000000000);ERROR: invalid memory alloc request size 2000000000在生成大文本(导出 CSV、拼接大 JSON)时,务必分批处理,不要试图一次性拼出超过 1 GB 的值。
拼接与索引:表达式索引
拼接结果可以被索引,前提是表达式必须完全确定:
-- 创建索引前先确认表达式可用于索引
CREATE INDEX idx_users_fullkey ON users ((username || '@' || coalesce(city, 'unknown')));
ANALYZE users;
-- 查询时必须写成与索引完全一致的表达式,否则用不上
EXPLAIN (COSTS OFF)
SELECT id FROM users WHERE username || '@' || coalesce(city, 'unknown') = 'zhangsan@北京市'; QUERY PLAN
----------------------------------------------------------
Index Only Scan using idx_users_fullkey on users
Index Cond: ((username || '@'::text || COALESCE(city, 'unknown'::text)) = 'zhangsan@北京市'::text)注意三点:
coalesce是必需的——NULL参与的表达式中,NULL值不会被传统 B-tree 索引收录(除非NULLS NOT DISTINCT索引);- 查询表达式必须与索引定义逐字符一致,包括
coalesce的默认值; concat是STABLE而非IMMUTABLE?实际上concat被标记为STABLE,因此不能用于表达式索引,只有||可以。
验证函数易变性:
SELECT
p.proname,
CASE p.provolatile
WHEN 'i' THEN 'IMMUTABLE'
WHEN 's' THEN 'STABLE'
WHEN 'v' THEN 'VOLATILE'
END AS volatility
FROM pg_proc p
WHERE p.proname IN ('concat', 'concat_ws', 'format')
ORDER BY p.proname; proname | volatility
-----------+------------
concat | STABLE
concat_ws | STABLE
format | STABLEconcat 之所以是 STABLE,是因为其结果依赖 DateStyle 等会话参数——同一个 timestamptz 在不同 DateStyle 下输出文本不同。这直接决定了表达式索引必须用 || 配合 ::text。
拼接与排序规则
拼接本身不做比较,但拼接结果参与 ORDER BY / DISTINCT 时受排序规则(Collation)影响:
SELECT concat('a', 'B') AS v, upper(concat('a', 'B')) AS u; v | u
----+----
aB | AB如果拼接用于唯一键(例如 order_no = channel || '-' || id),务必确认排序规则不会把视觉上不同的值判为相等。在 C 排序规则下 'a' = 'A' 为假,在 en_US.UTF-8 下同样为假,但在某些大小写不敏感的 ICU 规则下可能为真。
-- 查看当前排序规则
SELECT datname, datcollate, datctype FROM pg_database WHERE datname = current_database(); datname | datcollate | datctype
---------+--------------+--------------
demo | zh_CN.UTF-8 | zh_CN.UTF-8实战示例
场景一:生成用户展示卡片
需求:为后台列表生成一行展示文本,缺失字段要优雅跳过,格式为 姓名 (用户名) · 城市 · 电话。
-- 步骤 1:先看清数据分布,确认哪些列有 NULL
SELECT
count(*) AS total,
count(*) FILTER (WHERE full_name IS NULL) AS no_name,
count(*) FILTER (WHERE city IS NULL) AS no_city,
count(*) FILTER (WHERE phone IS NULL) AS no_phone
FROM users; total | no_name | no_city | no_phone
-------+---------+---------+----------
4 | 1 | 1 | 2-- 步骤 2:用 concat_ws 一次成型,彻底规避 NULL 问题
SELECT
id,
concat_ws(' · ', concat_ws(' ', full_name, '(' || username || ')'), city, phone) AS card
FROM users
ORDER BY id; id | card
----+------------------------------------------
1 | 张三 (zhangsan) · 北京市 · 13800000001
2 | 李四 (lisi) · 上海市
3 | (wangwu) · 13800000003
4 | 赵六 (zhaoliu) · 广州市第 3 行展示了嵌套 concat_ws 的一个副作用:内层 concat_ws(' ', NULL, '(wangwu)') 返回 ' (wangwu)'(只有 NULL 被跳过,前面的分隔符没有参与判断),因此多了一个前导空格。这正是内层拼接的典型陷阱。
-- 步骤 3:修正——内层也先构造完整片段,再统一由外层加分隔符
SELECT
id,
concat_ws(' · ', nullif(concat_ws(' ', full_name, nullif('(' || username || ')', '()')), ''), city, phone) AS card
FROM users
ORDER BY id; id | card
----+------------------------------------------
1 | 张三 (zhangsan) · 北京市 · 13800000001
2 | 李四 (lisi) · 上海市
3 | wangwu · 13800000003
4 | 赵六 (zhaoliu) · 广州市结论:当内层拼接带有固定文本(如括号)时,应改用 nullif(..., '') 把「只剩空壳」的结果重新变回 NULL,外层 concat_ws 才能正确跳过。
场景二:拼接业务主键与批量查询
需求:订单号由渠道前缀 + 日期 + 序号构成,要求定长、可比较、可索引。
CREATE TABLE orders (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_no TEXT NOT NULL,
channel TEXT NOT NULL,
seq_no INTEGER NOT NULL,
amount NUMERIC(12, 2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);
-- 步骤 1:用 lpad 保证定长,日期用 to_char 固定格式
INSERT INTO orders (order_no, channel, seq_no, amount, created_at)
SELECT
'ON-' || upper(channel) || '-' || to_char(d, 'YYYYMMDD') || '-' || lpad(seq_no::text, 6, '0'),
channel,
seq_no,
round((random() * 1000)::numeric, 2),
d::timestamptz
FROM (
SELECT c AS channel, s AS seq_no, DATE '2026-09-01' + (s % 3) AS d
FROM unnest(ARRAY['web', 'app', 'pos']) AS c
CROSS JOIN generate_series(1, 5) AS s
) t;
-- 步骤 2:验证定长与唯一性
SELECT
count(*) AS total,
count(DISTINCT order_no) AS distinct_no,
min(length(order_no)) AS min_len,
max(length(order_no)) AS max_len
FROM orders; total | distinct_no | min_len | max_len
-------+-------------+---------+---------
15 | 15 | 23 | 23-- 步骤 3:拼接出的键可以走索引
CREATE UNIQUE INDEX idx_orders_no ON orders (order_no);
ANALYZE orders;
EXPLAIN (COSTS OFF)
SELECT id, amount FROM orders WHERE order_no = 'ON-WEB-20260901-000001'; QUERY PLAN
---------------------------------------------------------------------------
Index Scan using idx_orders_no on orders
Index Cond: (order_no = 'ON-WEB-20260901-000001'::text)-- 步骤 4:按前缀做范围扫描(拼接键的常见用法)
SELECT channel, count(*) AS cnt
FROM orders
WHERE order_no LIKE 'ON-APP-%'
GROUP BY channel; channel | cnt
---------+-----
app | 5因为前缀固定且定长,LIKE 'ON-APP-%' 这类查询可以被 B-tree 索引优化(前提是 C 排序规则或 text_pattern_ops)。若库的排序规则是 zh_CN.UTF-8,需要额外建索引:
CREATE INDEX idx_orders_no_pattern ON orders (order_no text_pattern_ops);注意事项
||遇到NULL会让整个结果变NULL,这是最常见的静默错误来源:sql-- 错误!full_name 为 NULL 时整列为空 SELECT full_name || ' <' || email || '>' FROM users; -- 正确 SELECT concat_ws(' ', full_name, '<' || email || '>') FROM users;concat会把所有参数转为文本,concat(1, 2, 3)得到'123'而非'6',需要计算时先算后拼。concat_ws的分隔符为NULL时整体为NULL,用变量做分隔符必须保证非空:sql-- 错误!sep 为 NULL 时结果全丢 SELECT concat_ws(sep, a, b) FROM t; -- 正确 SELECT concat_ws(coalesce(sep, ','), a, b) FROM t;||与数组混用会触发数组拼接重载,'a' || ARRAY['b']得到{a,b}而非字符串,必须显式::text。concat/concat_ws/format都是STABLE不是IMMUTABLE,不能用于表达式索引;表达式索引只能用||与::text。表达式索引中的表达式必须与查询逐字符一致,
coalesce的默认值写错一个字符就用不上索引。隐式类型转换依赖会话参数,
DateStyle/IntervalStyle/extra_float_digits不同,拼接结果的文本可能不同,服务端与客户端环境不一致时会导致对不上。不要用字符串拼接构造 SQL,务必用
format的%I/%L:拼接用户输入到 SQL 文本属于高危操作
只要拼接的片段里有用户可控内容,一个单引号或分号就能改写语句,务必改用参数化查询或
format('%I')/format('%L')。sql-- 错误!SQL 注入风险 EXECUTE 'SELECT * FROM ' || tbl_name; -- 正确 EXECUTE format('SELECT * FROM %I', tbl_name);单字段上限约 1 GB,超限报
invalid memory alloc request size,大文本导出必须分批。循环中反复
acc := acc || x是 O(n²),超过几千次就该换成string_agg或array_to_string。concat遇到bytea会输出转义形式(如\x48656c6c6f),若想拼二进制请用bytea || bytea而不是concat。char(n)参与拼接时尾随空格的处理不一致:char(n)的尾部空格在存储层被填充,拼接时通常被裁剪,但对length的影响需实测确认。
最佳实践
- 默认用
concat_ws拼接带分隔符的可选字段,它的空值跳过语义最贴合业务直觉。 - 必填字段拼接用
||,语义清晰、性能更好、且可用于表达式索引。 - 需要精确格式控制时用
format,尤其是动态 SQL、日志模板、对齐输出。 - 拼接前统一类型:
::text显式转换优于依赖隐式转换,能避免DateStyle等会话参数带来的差异。 - 数字拼接前想清楚格式,金额用
to_char(amount, 'FM999999990.00')控制小数位,不要直接|| amount。 - 布尔值一定要转成业务文案(
CASE WHEN ... THEN '是' ELSE '否' END),true/false几乎不会出现在最终报表里。 - 数组转字符串用
array_to_string,不要用||或concat。 - 生成业务键时保证定长,用
lpad/to_char固定宽度,便于范围查询与排序。 - 唯一键用
concat_ws时注意它跳过NULL,concat_ws('-', 'a', NULL, 'b')与concat_ws('-', 'a', 'b')结果相同,可能造成键碰撞——必要时用coalesce(field, '')显式占位。 - 批量拼接优先
string_agg,它是一次聚合,避免逐行累积的拷贝开销。 - 给表达式索引写查询时,直接把索引定义的表达式复制过去,不要「凭记忆重写」。
- 动态 SQL 只用
format+%I/%L,永远不要手工拼接标识符或字面量。
下一节