字符串操作
本节系统讲解 PostgreSQL 的字符串函数与操作符。你将掌握长度计算、拼接、截取、查找替换、大小写转换、填充、正则匹配等常用操作的用法与陷阱,并能写出可读性良好的字符串处理 SQL。
前置知识
阅读本节前,建议先了解:定长与变长类型
基础概念
PostgreSQL 的字符串处理能力分为四层,从下到上依次是:
| 层次 | 内容 | 典型成员 | 遵循标准 |
|---|---|---|---|
| 操作符 | SQL 标准操作符 | ` | |
| SQL 标准函数 | SQL 标准定义的函数 | char_length、substring、upper、trim、position、overlay | SQL 标准 |
| PostgreSQL 扩展函数 | PostgreSQL 独有的便捷函数 | length、strpos、split_part、string_agg、concat、format | PostgreSQL 扩展 |
| 正则函数 | 基于 POSIX 正则的匹配与替换 | regexp_match、regexp_replace、regexp_split_to_table | PostgreSQL 扩展 |
关键术语:
- 字符(Character):按编码解码后的可见单元,一个汉字算 1 个字符。
- 字节(Byte):存储单位,UTF-8 下一个汉字占 3 字节。
- 字节序/字符序(Collation):决定比较与排序规则,影响
LIKE之外的排序行为。 - POSIX 正则表达式(POSIX Regular Expression):PostgreSQL
~系列操作符使用的正则方言,与LIKE的%/_完全不同。
字符长度与字节长度是两件不同的事,这是字符串操作中最常见的混淆点:
SELECT
'中国' AS val,
length('中国') AS char_len, -- 字符数
octet_length('中国') AS byte_len, -- 字节数
bit_length('中国') AS bit_len; -- 位数 val | char_len | byte_len | bit_len
------+----------+----------+---------
中国 | 2 | 6 | 48在 UTF-8 编码下,一个汉字 3 字节,所以 char_len = 2 而 byte_len = 6。
语法与用法
字符串函数数量众多,按用途分组记忆最有效:
-- 长度
length(str) / char_length(str) / octet_length(str) / bit_length(str)
-- 拼接
str1 || str2
concat(str1, str2, ...)
concat_ws(sep, str1, str2, ...)
-- 大小写
upper(str) / lower(str) / initcap(str)
-- 截取
substring(str FROM start FOR count) / substr(str, start, count)
left(str, n) / right(str, n)
split_part(str, delim, n)
-- 查找
position(sub IN str) / strpos(str, sub)
starts_with(str, prefix)
-- 修剪与填充
trim([LEADING|TRAILING|BOTH] [chars] FROM str)
ltrim(str[, chars]) / rtrim(str[, chars]) / btrim(str[, chars])
lpad(str, len[, fill]) / rpad(str, len[, fill])
-- 替换与变换
replace(str, from, to)
translate(str, from, to)
overlay(str PLACING newstr FROM start [FOR count])
reverse(str)
repeat(str, n)
format(fmt, args...)
-- 正则
str ~ pattern / str ~* pattern / str !~ pattern / str !~* pattern
regexp_replace(str, pattern, replacement [, flags])
regexp_match(str, pattern [, flags])
regexp_split_to_array(str, pattern [, flags])最简可运行示例:
SELECT
'postgresql' || '18' AS concat_op, -- 操作符拼接
concat('Post', 'greSQL') AS concat_fn, -- 函数拼接
concat_ws('-', '2026', '09', '22') AS concat_ws_fn, -- 带分隔符
upper('demo') AS up, -- 转大写
length('PostgreSQL 18') AS len, -- 字符长度
substr('PostgreSQL', 1, 4) AS sub; -- 截取 concat_op | concat_fn | concat_ws_fn | up | len | sub
-------------+-----------+--------------+------+-----+------
postgresql18 | PostgreSQL | 2026-09-22 | DEMO | 15 | Post详细说明
长度函数:字符数还是字节数
四个长度函数各司其职,选错会直接产生 bug:
| 函数 | 返回值含义 | '中国' 结果 | 常见用途 |
|---|---|---|---|
length(str) | 字符数(等价于 char_length) | 2 | 业务长度校验 |
char_length(str) | 字符数(SQL 标准写法) | 2 | 业务长度校验(可移植) |
octet_length(str) | 字节数 | 6 | 估算存储、对接网络协议 |
bit_length(str) | 位数 | 48 | 极少使用 |
SELECT
length('中国') AS cn_char,
octet_length('中国') AS cn_byte,
char_length('中国') AS cn_char_std,
octet_length('a') AS ascii_byte,
length('') AS empty_char,
length(NULL) AS null_len; cn_char | cn_byte | cn_char_std | ascii_byte | empty_char | null_len
---------+---------+-------------+------------+------------+----------
2 | 6 | 2 | 1 | 0 |两点注意:
length(NULL)返回NULL,不是0。业务里做校验时要写length(col) > 0而非<> '',否则NULL行会漏掉。VARCHAR(10)的长度限制按字符计,不是字节。因此VARCHAR(10)可以存 10 个汉字(占 30 字节)。
拼接:|| 与 concat 的关键差异
|| 是 SQL 标准操作符,concat 是 PostgreSQL 扩展函数。它们的空值处理完全不同:
SELECT
'a' || NULL AS op_with_null, -- NULL 传染
concat('a', NULL) AS fn_with_null, -- NULL 被跳过
concat('a', NULL, 'b') AS fn_skip_null,
concat_ws('-', 'a', NULL, 'b') AS ws_skip_null,
'' || 'x' AS empty_left; op_with_null | fn_with_null | fn_skip_null | ws_skip_null | empty_left
--------------+--------------+--------------+--------------+------------
| a | ab | a-b | x| 表达式 | 行为 | 适用场景 |
|---|---|---|
a || b | 任一侧为 NULL 则结果为 NULL | 需要"数据缺失就整体缺失"的语义 |
concat(a, b) | 忽略 NULL,其余照常拼接 | 拼接可选字段生成展示文本 |
concat_ws(sep, ...) | 忽略 NULL,且不给空位加分隔符 | 拼接地址、全名等带分隔符的文本 |
concat_ws 的行为尤其重要——它不会产生 a--b 这类多余分隔符:
-- 地址拼接:某些字段可能为 NULL
SELECT concat_ws(', ', '北京市', NULL, '朝阳区', NULL, '建国路 1 号') AS address; address
-----------------------------
北京市, 朝阳区, 建国路 1 号注意 concat / concat_ws 不接收数组作为可变参数(VARIADIC 除外),它们是 "any" 伪类型参数,会把所有入参转成文本:
SELECT concat(1, 2, 3) AS num_concat, concat(true, 3.14) AS mixed_concat; num_concat | mixed_concat
------------+--------------
123 | true3.14如果需要显式格式化输出,用 format 函数更清晰:
SELECT format('用户 %s 于 %s 注册,消费 %s 元', 'zhangsan', '2026-09-22', 1234.50) AS msg; msg
--------------------------------------------------------
用户 zhangsan 于 2026-09-22 注册,消费 1234.50 元format 的占位符:%s(文本)、%I(标识符,会自动加双引号)、%L(字面量,会自动加单引号)、%%(百分号本身)。
截取与切分
| 函数 | 说明 | 越界行为 |
|---|---|---|
substr(str, start, count) | 从 start 位置取 count 个字符 | 自动截断到字符串末尾,不报错 |
substring(str FROM start FOR count) | SQL 标准写法,等价 | 同上 |
left(str, n) | 取左侧 n 个字符 | n 超过长度则返回整串 |
right(str, n) | 取右侧 n 个字符 | 同上 |
split_part(str, delim, n) | 按分隔符取第 n 段(从 1 开始) | 段不存在返回空串 '' |
SELECT
substr('PostgreSQL', 1, 4) AS s1,
substr('PostgreSQL', 6) AS s2,
substr('PostgreSQL', -3) AS s3_negative, -- 负数从末尾算
left('PostgreSQL', 4) AS l,
right('PostgreSQL', 4) AS r,
split_part('a,b,c,d', ',', 2) AS part2,
split_part('a,b,c,d', ',', 9) AS part_missing; s1 | s2 | s3_negative | l | r | part2 | part_missing
------+------------+-------------+------+------+-------+--------------
Post | greSQL | SQL | Post | eSQL | b |substr 的负数起始位置是按"从末尾倒数"解释的,这在解析文件扩展名等场景很有用:
SELECT
substr('report.2026.pdf', -3) AS ext, -- 取扩展名
split_part('report.2026.pdf', '.', 2) AS second; ext | second
-----+--------
pdf | 2026string_to_array 与 array_to_string 是 split_part 的数组版本,适合段数不定的场景:
SELECT
string_to_array('a,b,c', ',') AS arr,
array_to_string(string_to_array('a,b,c', ','), '-') AS joined,
unnest(string_to_array('a,b,c', ',')) AS each; arr | joined | each
--------+--------+------
{a,b,c} | a-b-c | a
| | b
| | c修剪与填充
trim 系列函数既可以用 SQL 标准语法,也可以用 PostgreSQL 函数写法:
SELECT
trim(' hello ') AS both_trim,
trim(BOTH ' ' FROM ' hello ') AS both_std,
trim(LEADING 'x' FROM 'xxhelloxx') AS leading_trim,
trim(TRAILING 'x' FROM 'xxhelloxx') AS trailing_trim,
btrim('xxhelloxx', 'x') AS btrim_fn,
ltrim('xxhelloxx', 'x') AS ltrim_fn,
rtrim('xxhelloxx', 'x') AS rtrim_fn; both_trim | both_std | leading_trim | trailing_trim | btrim_fn | ltrim_fn | rtrim_fn
-----------+----------+--------------+---------------+----------+----------+----------
hello | hello | helloxx | xxhello | hello | helloxx | xxhello默认只去除空格,不包含 \t、\n、\r。清理用户输入时要显式指定字符集:
SELECT
trim(E'\t hello \n') AS default_trim, -- \n 未去除
trim(E'\t hello \n' , E' \t\n\r') AS full_trim; -- 全部去除 default_trim | full_trim
--------------+-----------
hello | hello填充函数用于对齐输出或生成固定宽度编码:
SELECT
lpad('42', 5, '0') AS zero_pad, -- 左填充零
rpad('ab', 5, '*') AS right_pad, -- 右填充星号
lpad('abcdef', 3) AS truncate_left, -- 超长时从左截断
rpad('abcdef', 3) AS truncate_right, -- 超长时从左截断
lpad('7', 4) AS default_pad; -- 默认填充空格 zero_pad | right_pad | truncate_left | truncate_right | default_pad
----------+-----------+---------------+----------------+-------------
00042 | ab*** | abc | abc | 7注意 lpad / rpad 在目标长度小于原串长度时会截断(保留左侧字符),不会报错。这个"静默截断"在生成定长编号时容易埋下隐患。
大小写与比较
SELECT
upper('postgresql') AS up,
lower('PostgreSQL') AS lo,
initcap('hello world') AS init; up | lo | init
--------------+------------+-------------
POSTGRESQL | postgresql | Hello Worldupper / lower 的行为依赖 数据库的字符集编码(Character Set Encoding)与排序规则(Collation)。对于 ASCII 之外的字符,转换结果可能不符合预期:
SELECT
upper('a') AS ascii_up,
upper('é') AS accent_up, -- 依赖 locale,UTF-8 数据库下通常正确
upper('ß') AS sharp_s; -- 德语 ß 的大写在老版本可能是 'ß' 或 'SS' ascii_up | accent_up | sharp_s
----------+-----------+---------
A | É | ß不要依赖 upper / lower 做大小写不敏感的查找,应改用 ILIKE 或 citext 扩展,或建函数索引:
-- 反例:无法使用普通索引
SELECT * FROM users WHERE lower(email) = 'a@b.com';
-- 正例 1:建函数索引
CREATE INDEX idx_users_lower_email ON users (lower(email));
-- 正例 2:使用 ILIKE(需配合 pg_trgm 或表达式索引才高效)
SELECT * FROM users WHERE email ILIKE 'a@b.com';查找与定位
| 表达式 | 返回 | 未找到时 | 说明 |
|---|---|---|---|
position(sub IN str) | 字符位置(从 1 起) | 0 | SQL 标准 |
strpos(str, sub) | 字符位置(从 1 起) | 0 | PostgreSQL 扩展,参数顺序与 position 相反 |
starts_with(str, prefix) | 布尔 | false | 判断前缀 |
SELECT
position('SQL' IN 'PostgreSQL') AS std_pos,
strpos('PostgreSQL', 'SQL') AS fn_pos,
strpos('PostgreSQL', 'xyz') AS not_found,
starts_with('PostgreSQL', 'Post') AS sw,
starts_with('PostgreSQL', 'post') AS sw_case; std_pos | fn_pos | not_found | sw | sw_case
---------+--------+-----------+-----+---------
9 | 9 | 0 | t | f参数顺序陷阱:position(x IN y) 是"在 y 里找 x",而 strpos(y, x) 是"在 y 里找 x"——两者语义相同,但参数书写顺序相反,混用极易出错。
starts_with 是区分大小写的。判断后缀需要自己组合:
SELECT
right('report.pdf', 4) = '.pdf' AS suffix_check,
'report.pdf' LIKE '%.pdf' AS suffix_like; suffix_check | suffix_like
--------------+-------------
t | t替换与变换
SELECT
replace('2026-09-22', '-', '/') AS rep,
translate('abcdef', 'abc', 'xyz') AS trans, -- 逐字符映射
reverse('abcdef') AS rev,
repeat('ab', 3) AS rep_n,
overlay('PostgreSQL' PLACING 'gre' FROM 6) AS ov; rep | trans | rev | rep_n | ov
------------+--------+--------+-------+-----------
2026/09/22 | xyzdef | fedcba | ababab | Postgrereplace 与 translate 的区别必须分清:
| 函数 | 语义 | 示例 |
|---|---|---|
replace(str, from, to) | 把子串 from 整体替换为 to | replace('aXbXc', 'X', '--') → a--b--c |
translate(str, from, to) | 按字符位置一一映射 | translate('aXbXc', 'X', '-') → a-b-c |
translate 的规则:from 中第 n 个字符被映射为 to 中第 n 个字符;若 to 比 from 短,多余字符被删除:
SELECT
translate('hello', 'el', 'ip') AS mapped, -- e→i, l→p
translate('hello', 'l', '') AS deleted; -- l 被删除 mapped | deleted
--------+---------
hippo | heooverlay 用新串覆盖指定区间,长度由新串决定(除非显式给出 FOR count):
SELECT
overlay('PostgreSQL' PLACING 'XYZ' FROM 6 FOR 3) AS same_len,
overlay('PostgreSQL' PLACING 'XYZ' FROM 6) AS grow; same_len | grow
----------+-----------
PostgrXYZL | PostgrXYZSQL正则表达式操作
PostgreSQL 的 ~ 系列操作符实现 POSIX 正则匹配,与 LIKE 的 % / _ 完全不同:
| 操作符 | 大小写敏感 | 说明 |
|---|---|---|
~ | 敏感 | 匹配 |
~* | 不敏感 | 匹配 |
!~ | 敏感 | 不匹配 |
!~* | 不敏感 | 不匹配 |
SELECT
'abc123' ~ '^[a-z]+[0-9]+$' AS posix_match,
'abc123' LIKE '%123' AS like_match,
'abc123' !~ '^[0-9]+$' AS neg_match,
'ABC123' ~ '^[a-z]+' AS case_sensitive,
'ABC123' ~* '^[a-z]+' AS case_insensitive; posix_match | like_match | neg_match | case_sensitive | case_insensitive
-------------+------------+-----------+----------------+------------------
t | t | t | f | t常用 POSIX 字符类:
| 写法 | 含义 | 等价写法 |
|---|---|---|
[[:digit:]] | 数字 | [0-9] |
[[:alpha:]] | 字母 | [a-zA-Z] |
[[:alnum:]] | 字母或数字 | [a-zA-Z0-9] |
[[:space:]] | 空白字符 | \s(需 E'' 转义) |
[[:punct:]] | 标点符号 | — |
[[:upper:]] / [[:lower:]] | 大写 / 小写字母 | — |
三个正则函数覆盖了提取、替换、切分:
SELECT
regexp_match('订单 A-2026-0922 已发货', '([A-Z])-(\d{4})-(\d{4})') AS matched,
regexp_replace('13812345678', '(\d{3})\d{4}(\d{4})', '\1****\2') AS masked,
regexp_split_to_array('a1b22c333', '[0-9]+') AS splitted; matched | masked | splitted
------------------+--------------+---------------
{A,2026,0922} | 138****5678 | {a,b,c}regexp_match 只返回第一处匹配的分组数组;要提取全部匹配,需要用 regexp_matches 配合 g 标志:
SELECT regexp_matches('a1 b2 c3', '[a-z]([0-9])', 'g') AS all_matches; all_matches
-------------
{1}
{2}
{3}替换字符串中的 \1、\2 表示捕获组。转义时注意标准符合字符串:反斜杠在 standard_conforming_strings=on(默认)时无需双写,但若写成 E'...' 则需写 \\1。
LIKE、SIMILAR TO 与正则的取舍
三种模式匹配方式的比较:
| 特性 | LIKE | SIMILAR TO | ~(POSIX 正则) |
|---|---|---|---|
| 通配符 | %(任意长度)、_(单字符) | %、_ + 正则元字符 | 完整正则语法 |
| 大小写不敏感 | 用 ILIKE | 不支持 | 用 ~* |
| 能否用索引 | 能(前缀锚定 + text_pattern_ops) | 通常不能 | 通常不能(除非 pg_trgm) |
| 语法来源 | SQL 标准 | SQL 标准 | POSIX |
| 性能 | 最好 | 中等 | 较慢 |
LIKE 是绝大多数场景的正确选择,因为它可以走索引:
-- 能用 B-tree 索引(前缀匹配,无前导通配符)
SELECT * FROM users WHERE email LIKE 'zhang%';
-- 不能用普通 B-tree 索引(前导通配符)
SELECT * FROM users WHERE email LIKE '%@example.com';为了支持大小写不敏感的 LIKE 及非 C 排序规则下的前缀匹配,需要显式指定操作符类:
CREATE INDEX idx_users_email_pattern ON users (email text_pattern_ops);
CREATE INDEX idx_users_email_ilike ON users (email varchar_pattern_ops);ILIKE 在非 C locale 下默认无法用普通索引,需建 lower() 表达式索引配合:
CREATE INDEX idx_users_lower_email ON users (lower(email));
-- 查询改写为:
SELECT * FROM users WHERE lower(email) LIKE 'zhang%';字符串聚合
string_agg 把多行值聚合成一个字符串,是报表、导出场景的核心函数:
SELECT string_agg(name, ', ' ORDER BY name) AS all_names
FROM users; all_names
----------------------------
lisi, wangwu, zhangsan语法为 string_agg(expr, delimiter [ORDER BY ...])。必须显式写 ORDER BY,否则聚合顺序在并行计划下是不确定的。
两参数版本接收 text,也可以用 bytea 分隔符处理二进制。与数组聚合 array_agg 的对比:
| 函数 | 返回类型 | 适用场景 |
|---|---|---|
string_agg(col, ',') | TEXT | 生成 CSV 风格文本 |
array_agg(col) | 数组 | 需要保序、保类型,后续再用 unnest 展开 |
SELECT
string_agg(DISTINCT status, '/' ORDER BY status) AS statuses,
array_agg(DISTINCT status ORDER BY status) AS status_arr
FROM orders; statuses | status_arr
---------------+--------------------------
cancelled/paid/pending | {cancelled,paid,pending}quote_* 与哈希函数
生成 SQL 文本、动态 SQL 时,这三个函数是安全防线:
| 函数 | 作用 | 示例 |
|---|---|---|
quote_literal(str) | 加单引号并转义内部单引号 | quote_literal('it''s') |
quote_ident(str) | 加双引号并转义内部双引号 | quote_ident('order') |
quote_nullable(str) | 同 quote_literal,但 NULL 返回 'NULL' | — |
SELECT
quote_literal(E'O''Brien') AS lit,
quote_ident('user table') AS ident,
quote_nullable(NULL) AS null_lit; lit | ident | null_lit
------------+--------------+----------
'O''Brien' | "user table" | NULL常用哈希函数:
SELECT
md5('hello') AS md5_hash,
encode(sha256('hello'::bytea), 'hex') AS sha256_hex,
encode(hmac('hello', 'secret', 'sha256'), 'hex') AS hmac_hex; md5_hash | sha256_hex
----------------------------------+----------------------------------------------------------------
5d41402abc4b2a76b9719d911017c592 | 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824md5 返回 32 位十六进制字符串,sha256 / hmac 返回 bytea,需要 encode(..., 'hex') 转可读形式。注意 md5 已不适合做密码存储,请使用 pgcrypto 的 crypt() 配合 gen_salt('bf')。
隐式类型转换与操作符解析
字符串操作中大量出现 unknown 类型字面量,理解其解析规则可避免"操作符不存在"的报错:
SELECT
'2026-09-22'::date + 1 AS date_add, -- 显式转换后运算
pg_typeof('abc') AS untyped, -- unknown
pg_typeof('abc'::text) AS typed; date_add | untyped | typed
------------+---------+-------
2026-09-23 | unknown | text字符串字面量的初始类型是 unknown,PostgreSQL 会根据上下文推断目标类型。这解释了两个常见现象:
-- 现象 1:unknown 字面量可隐式转为任意类型
SELECT '123'::int, '2026-09-22'::timestamp;
-- 现象 2:两个 unknown 相加会因为无法推断类型而报错
SELECT '1' + '2';ERROR: operator is not unique: unknown + unknown
LINE 1: SELECT '1' + '2';
^
HINT: Could not choose a best candidate operator. You might need to add explicit type casts.实战示例
以下示例在 demo 库中按顺序执行。
第 1 步:建表并插入数据
DROP TABLE IF EXISTS users;
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
username VARCHAR(32) NOT NULL,
email TEXT NOT NULL,
full_name TEXT,
phone VARCHAR(20),
bio TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
INSERT INTO users (username, email, full_name, phone, bio) VALUES
('zhangsan', 'ZhangSan@Example.com', '张三', '13812345678', ' 热爱数据库 '),
('lisi', 'lisi@example.com', '李四', '13987654321', NULL),
('wangwu', 'WANGWU@example.com', NULL, NULL, E'\t PostgreSQL 爱好者 \n');第 2 步:规范化清洗——去空格 + 统一大小写
SELECT
id,
'[' || bio || ']' AS raw_bio,
'[' || btrim(bio, E' \t\n\r') || ']' AS cleaned_bio
FROM users
ORDER BY id; id | raw_bio | cleaned_bio
----+--------------------+--------------------
1 | [ 热爱数据库 ] | [热爱数据库]
2 | |
3 | [ PostgreSQL 爱好者
] | [PostgreSQL 爱好者]第 3 步:邮箱标准化(小写 + 拆分用户名与域名)
SELECT
username,
email AS raw_email,
lower(email) AS norm_email,
split_part(lower(email), '@', 1) AS local_part,
split_part(lower(email), '@', 2) AS domain
FROM users
ORDER BY id; username | raw_email | norm_email | local_part | domain
----------+------------------------+------------------------+------------+-------------
zhangsan | ZhangSan@Example.com | zhangsan@example.com | zhangsan | example.com
lisi | lisi@example.com | lisi@example.com | lisi | example.com
wangwu | WANGWU@example.com | wangwu@example.com | wangwu | example.com第 4 步:手机号脱敏(正则替换捕获组)
SELECT
username,
phone,
COALESCE(
regexp_replace(phone, '^(\d{3})\d{4}(\d{4})$', '\1****\2'),
'未填写'
) AS masked_phone
FROM users
ORDER BY id; username | phone | masked_phone
----------+-------------+--------------
zhangsan | 13812345678 | 138****5678
lisi | 13987654321 | 139****4321
wangwu | | 未填写第 5 步:生成展示名(concat_ws 处理缺失字段)
SELECT
username,
CONCAT_WS(' / ', full_name, username, email) AS display_name,
format('%s <%s>', COALESCE(full_name, username), lower(email)) AS mail_addr
FROM users
ORDER BY id; username | display_name | mail_addr
----------+------------------------------------+---------------------------
zhangsan | 张三 / zhangsan / ZhangSan@Example.com | 张三 <zhangsan@example.com>
lisi | 李四 / lisi / lisi@example.com | 李四 <lisi@example.com>
wangwu | wangwu / WANGWU@example.com | wangwu <wangwu@example.com>注意 display_name 中 concat_ws 不会因为 full_name 为 NULL 而多出分隔符,这正是它优于 || 的地方。
第 6 步:按域名聚合用户清单
SELECT
split_part(lower(email), '@', 2) AS domain,
count(*) AS user_count,
string_agg(username, ', ' ORDER BY username) AS members
FROM users
GROUP BY split_part(lower(email), '@', 2)
ORDER BY user_count DESC, domain; domain | user_count | members
-------------+------------+--------------------------
example.com | 3 | lisi, wangwu, zhangsan第 7 步:校验用户名格式(正则 + 布尔判断)
SELECT
username,
username ~ '^[a-z][a-z0-9_]{2,31}$' AS valid_format,
length(username) AS char_len,
octet_length(username) AS byte_len
FROM users
ORDER BY id; username | valid_format | char_len | byte_len
----------+--------------+----------+----------
zhangsan | t | 8 | 8
lisi | t | 4 | 4
wangwu | t | 6 | 6第 8 步:一次查询汇总所有字符串统计
SELECT
count(*) AS total_rows,
count(bio) AS bio_not_null,
count(*) FILTER (WHERE bio ~ '\S') AS bio_non_blank,
sum(length(coalesce(full_name, ''))) AS total_name_chars,
max(length(btrim(bio, E' \t\n\r'))) AS max_clean_bio_len
FROM users; total_rows | bio_not_null | bio_non_blank | total_name_chars | max_clean_bio_len
------------+--------------+---------------+------------------+-------------------
3 | 2 | 2 | 4 | 15注意事项
1. || 遇到 NULL 会把整串变成 NULL
SELECT '张三' || NULL || '的订单'; -- 返回 NULL
SELECT concat('张三', NULL, '的订单'); -- 返回 '张三的订单'拼接可能为空的列时优先用 concat_ws,或先 coalesce(col, '')。
2. length 返回字符数,octet_length 返回字节数
业务长度校验通常用 length,但对接有字节长度限制的系统(如某些短信网关 70 字节)必须用 octet_length:
-- 中文短信:70 字节上限下只能放 23 个汉字(69 字节)
SELECT
octet_length('中') * 23 AS cn_23_bytes,
octet_length('中') * 24 AS cn_24_bytes; cn_23_bytes | cn_24_bytes
-------------+-------------
69 | 723. lpad / rpad 会静默截断
SELECT lpad('20260922', 6, '0') AS truncated; -- 结果为 '202609'想要"超长就报错"的语义,需要自己加 length 校验:
SELECT CASE WHEN length(v) > 6 THEN NULL ELSE lpad(v, 6, '0') END
FROM (VALUES ('20260922')) AS t(v);4. substr 的负数与零起始位置
SELECT
substr('abcdef', 0, 3) AS start_zero, -- 等价于 substr('abcdef', 1, 2)
substr('abcdef', -2, 3) AS start_negative; -- 从倒数第 2 个开始取 3 个 start_zero | start_negative
------------+----------------
ab | ef起始位置小于 1 时,取出的长度会相应减少(因为实际从位置 1 开始,但仍要求"累计到 start+count-1 为止")。这个规则很反直觉,解析定长报文时要格外小心。
5. replace 与 translate 不可互换
SELECT
replace('a-b-c', '-', '_') AS rep, -- 子串替换
translate('a-b-c', '-', '_') AS trans; -- 字符映射(这里结果恰好相同)差异在于多字符场景:replace('abc', 'ab', 'x') 得 xc,而 translate('abc', 'ab', 'x') 得 x c 中的 a→x、b 被删除,结果为 xc——看似相同但机制完全不同。只有 replace 支持多字符子串。
6. LIKE 的前导通配符无法使用 B-tree 索引
SELECT * FROM users WHERE email LIKE '%example.com'; -- 全表扫描需要此类查询时改用 pg_trgm 扩展的 GIN 索引:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_users_email_trgm ON users USING gin (email gin_trgm_ops);7. string_agg 不写 ORDER BY 顺序不确定
-- 顺序不确定
SELECT string_agg(username, ',') FROM users;
-- 顺序确定
SELECT string_agg(username, ',' ORDER BY id) FROM users;8. 正则表达式默认是"部分匹配"
'abc123' ~ 'abc' 返回 t,因为 ~ 只要求存在匹配子串。要整串匹配必须加锚点:
SELECT
'abc123' ~ 'abc' AS partial,
'abc123' ~ '^abc$' AS anchored,
'abc123' ~ '^abc' AS prefix_anchored; partial | anchored | prefix_anchored
---------+----------+-----------------
t | f | t9. 多字节字符的 position 按字符计数
SELECT
position('好' IN '你好世界') AS pos_char, -- 2
octet_length('你') AS first_byte; -- 3 pos_char | first_byte
----------+------------
2 | 310. 字符串比较受排序规则影响
在 C 排序规则下 'A' < 'a',在 en_US.UTF-8 下 'a' < 'A'。跨库迁移数据时要注意:
SELECT
'A' < 'a' AS default_cmp,
'A' COLLATE "C" < 'a' COLLATE "C" AS c_collation_cmp; default_cmp | c_collation_cmp
-------------+-----------------
f | t最佳实践
1. 用 concat_ws 代替 || 拼接可能为空的列
-- 推荐
SELECT concat_ws(', ', province, city, district, street) AS address FROM customers;
-- 不推荐:任一段为 NULL 时整串为 NULL
SELECT province || ', ' || city || ', ' || district || ', ' || street FROM customers;2. 长度校验统一用 length(),字节限制用 octet_length()
ALTER TABLE users ADD CONSTRAINT chk_username_len
CHECK (length(username) BETWEEN 3 AND 32);3. 清洗用户输入时显式指定 trim 字符集
UPDATE users
SET full_name = btrim(full_name, E' \t\n\r\u00a0') -- 含不换行空格
WHERE full_name IS NOT NULL;\u00a0 是不换行空格(NBSP),在网页复制粘贴场景中非常常见,普通 trim 无法去除。
4. 前缀匹配统一用 LIKE 'xxx%' 而非 strpos
-- 可用索引
SELECT * FROM users WHERE email LIKE 'zhang%';
-- 不可用索引(把函数套在列上破坏了索引可用性)
SELECT * FROM users WHERE strpos(email, 'zhang') = 1;5. 用 format 生成动态 SQL,用 quote_ident / quote_literal 防注入
CREATE OR REPLACE FUNCTION count_rows(p_table TEXT)
RETURNS BIGINT
LANGUAGE plpgsql AS $$
DECLARE
v_count BIGINT;
BEGIN
EXECUTE format('SELECT count(*) FROM %I', p_table) INTO v_count;
RETURN v_count;
END;
$$;%I 会自动处理标识符引号与转义,避免拼接 SQL 带来的注入风险。
6. 正则查询量大时建 pg_trgm GIN 索引
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_users_email_trgm ON users USING gin (email gin_trgm_ops);
-- 以下查询可走索引
SELECT * FROM users WHERE email ILIKE '%example%';7. 提取固定格式数据优先用 split_part,比正则快得多
-- 推荐:简单分隔场景
SELECT split_part(email, '@', 2) FROM users;
-- 不推荐:正则开销更大
SELECT (regexp_match(email, '@(.+)$'))[1] FROM users;8. 哈希值统一 encode(..., 'hex') 输出,避免二进制乱码
SELECT
encode(sha256('data'::bytea), 'hex') AS sha256_hex,
encode(sha256('data'::bytea), 'base64') AS sha256_b64;9. 大批量字符串更新的性能提示
replace / regexp_replace 在长文本上开销较大。若目标列上有索引,整列更新会触发大量索引维护。分批处理:
UPDATE users
SET bio = replace(bio, '旧关键词', '新关键词')
WHERE id IN (SELECT id FROM users WHERE bio LIKE '%旧关键词%' LIMIT 1000);10. citext 扩展处理大小写不敏感的唯一约束
若"邮箱唯一且大小写不敏感"是硬需求,用扩展类型比反复 lower() 更干净:
CREATE EXTENSION IF NOT EXISTS citext;
ALTER TABLE users ALTER COLUMN email TYPE citext;
-- 此时 UNIQUE 约束天然大小写不敏感下一节
继续学习:字符集与编码