POSITION / STRPOS
查找子串位置(Substring Search)是判断「是否包含」「从哪开始截」「分割点在哪」的通用基础操作。PostgreSQL 提供 position()(SQL 标准语法)、strpos()(函数式写法)与 regexp_instr()(正则版,15+)三条路径。三者的返回值语义、参数顺序、是否接受正则都不同,混用极易写出「看似能跑、结果反了」的查询。本节讲清它们的对应关系与性能差异。
前置知识
阅读本节前,建议先了解:REPLACE / TRANSLATE / REGEXP_REPLACE
基础概念
| 概念 | 术语 | 说明 |
|---|---|---|
| 子串查找 | Substring Search | 在主串中定位目标子串,返回下标 |
| 1 基索引 | 1-Based Indexing | PostgreSQL 字符串位置从 1 开始,不是 0 |
| 未找到 | Not Found | 返回 0,而非 NULL 或 -1 |
| 参数顺序差异 | Argument Order | position(要找 IN 主串) 与 strpos(主串, 要找) 相反 |
| 正则定位 | Regex Position | regexp_instr() 支持模式匹配与第 N 次出现 |
| 截取 | Extraction | 用查找结果配合 substring / left 取片段 |
| 存在性判断 | Existence Check | position(...) > 0 或直接用 LIKE / strpos |
| 大小写敏感 | Case Sensitivity | position 区分大小写,不区分需 lower() 或 ILIKE |
四个定位手段的对照:
| 表达式 | 参数顺序 | 支持正则 | 返回 | 说明 |
|---|---|---|---|---|
position('b' IN 'abc') | 要找先写 | 否 | integer | SQL 标准语法,关键字 IN |
position('b', 'abc') | 要找先写 | 否 | integer | 逗号形式,等价于上者 |
strpos('abc', 'b') | 主串先写 | 否 | integer | 内部实现为 textpos,性能同 position |
regexp_instr('abc', 'b') | 主串先写 | 是 | integer | PostgreSQL 15+ 新增 |
返回值语义:
SELECT
position('b' IN 'abc') AS pos_b, -- 2
position('a' IN 'abc') AS pos_a, -- 1(不是 0)
position('c' IN 'abc') AS pos_c, -- 3
position('d' IN 'abc') AS not_found, -- 0(不是 NULL)
position('abc' IN 'abc') AS whole, -- 1(整串也算找到)
position('' IN 'abc') AS empty_needle,-- 1(空串总是「找到」)
position('A' IN 'abc') AS case_sens; -- 0(区分大小写) pos_b | pos_a | pos_c | not_found | whole | empty_needle | case_sens
-------+-------+-------+-----------+-------+--------------+-----------
2 | 1 | 3 | 0 | 1 | 1 | 0三个必须记住的结论:
- 位置从 1 开始:
position('a' IN 'abc') = 1,不是0。 - 找不到返回
0:这带来一个经典坑——0是「假值」吗?在 SQL 里不是,WHERE position('d' IN 'abc')的0被当作false,但position(...) = 0才是明确的判断方式。 - 空串的位置是 1:
position('' IN 任意串) = 1,所以不能用它判断「有没有填内容」。
选择流程:
你要「找位置」还是「判断有没有」?
├─ 只要判断包含关系 → 用 LIKE / ILIKE / position(...) > 0
│ (不要写 position(...) ,直接用它做 WHERE 条件会漏掉位置 1 的情况?不会,
│ 因为 1 是「真」,但语义不清晰,容易误读)
├─ 要找固定子串的位置 → strpos() 或 position()
│ ├─ 主串在前更符合直觉 → strpos(haystack, needle)
│ └─ 想让 SQL 更标准可移植 → position(needle IN haystack)
└─ 要找模式、第 N 次出现、跳过前 k 个字符
└─ regexp_instr() ← PostgreSQL 15+一句话记忆:position 与 strpos 是同一件事的两种写法(参数顺序相反),regexp_instr 是正则加强版。
语法与用法
-- 1) SQL 标准语法
position(substring text IN string text) → integer
-- 2) 函数式语法(两种等价形式)
position(substring text, string text) → integer
strpos(string text, substring text) → integer
-- 3) 正则版(PostgreSQL 15+)
regexp_instr(string text, pattern text
[, start integer [, N integer
[, endoption integer [, flags text [, subexpr integer ] ] ] ] ]) → integer
-- 4) 相关辅助(PostgreSQL 15+)
regexp_count(string text, pattern text [, start integer [, flags text ] ]) → integer
regexp_like (string text, pattern text [, flags text ]) → boolean最小可运行示例:
SELECT
strpos('postgresql', 'gres') AS f_strpos,
position('gres' IN 'postgresql') AS f_position_in,
position('gres', 'postgresql') AS f_position_comma,
regexp_instr('postgresql', 'g\w+') AS f_regexp_instr,
strpos('postgresql', 'z') AS f_not_found,
length('postgresql') AS total_len; f_strpos | f_position_in | f_position_comma | f_regexp_instr | f_not_found | total_len
----------+---------------+------------------+----------------+-------------+-----------
5 | 5 | 5 | 5 | 0 | 10与截取函数配合是最常见的用法——查出分隔符位置,再取左半或右半:
SELECT
email,
strpos(email, '@') AS at_pos,
left(email, strpos(email, '@') - 1) AS local_part,
substring(email FROM strpos(email, '@') + 1) AS domain
FROM users
WHERE email IS NOT NULL
ORDER BY id; email | at_pos | local_part | domain
---------------------+--------+------------+---------------
Alice@Example.COM | 6 | Alice | Example.COM
bob_99@example.com | 7 | bob_99 | example.com
carol@test.org | 6 | carol | test.org
dave@example.com | 5 | dave | example.com注意 strpos(...) - 1 与 + 1 的偏移:因为位置是 1 基,@ 的下标是 at_pos,左半长度就是 at_pos - 1,右半起点就是 at_pos + 1。
准备演示数据(示例库 demo):
CREATE TABLE products (
id integer GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
sku varchar(32) NOT NULL,
name text NOT NULL,
category varchar(32),
tags text
);
INSERT INTO products (sku, name, category, tags) VALUES
('CN-ELEC-20260001', 'Wireless Mouse', 'elec', 'wireless,usb,black'),
('CN-ELEC-20260002', 'Mechanical Keyboard', 'elec', 'usb,backlit'),
('US-HOME-20260003', 'Ceramic Mug', 'home', 'ceramic,kitchen'),
('CN-BOOK-20260004', 'SQL in Practice', 'book', 'database,sql'),
('CN-ELEC-20260005', 'USB-C Hub', 'elec', 'usb,type-c');INSERT 0 5详细说明
position 与 strpos 的等价性
strpos(string, substring) 与 position(substring IN string) 内部调用同一个函数,只是参数顺序相反。选择哪个取决于可读性与团队习惯:
-- 下面两个查询返回完全相同的结果
SELECT id, sku FROM products WHERE strpos(sku, '-') > 0 ORDER BY id;
SELECT id, sku FROM products WHERE position('-' IN sku) > 0 ORDER BY id; id | sku
----+--------------------
1 | CN-ELEC-20260001
2 | CN-ELEC-20260002
3 | US-HOME-20260003
4 | CN-BOOK-20260004
5 | CN-ELEC-20260005参数顺序搞反不会有报错
strpos 与 position 的参数顺序相反,但两者都接受两个 text 参数,写反了不会报错,只会得到错误的位置:
SELECT
strpos('abc', 'bc') AS correct, -- 2
strpos('bc', 'abc') AS reversed; -- 0(找 'abc' 在 'bc' 里,当然没有)建议团队内统一只用一种写法,并在 code review 时重点检查。
位置从 1 开始:三个连锁陷阱
陷阱一:left / substring 的长度参数直接相减
因为位置是 1 基,取「分隔符之前的部分」要 - 1:
SELECT
'a@b' AS raw,
strpos('a@b', '@') AS at_pos, -- 2
left('a@b', strpos('a@b', '@')) AS wrong,-- 错误!得到 'a@'
left('a@b', strpos('a@b', '@') - 1) AS right;-- 正确:'a' raw | at_pos | wrong | right
-----+--------+-------+-------
a@b | 2 | a@ | a陷阱二:substring 的 FROM 语义
substring(str FROM start) 的 start 也按字符计数,所以取分隔符之后的部分要 + 1(否则会包含分隔符本身):
SELECT
substring('a@b' FROM strpos('a@b', '@')) AS include_at, -- '@b'
substring('a@b' FROM strpos('a@b', '@') + 1) AS exclude_at; -- 'b' include_at | exclude_at
------------+------------
@b | b陷阱三:0 参与算术会「歪掉」
position 找不到时返回 0,如果直接拿去运算,会得到 -1:
SELECT
strpos('abc', 'z') AS not_found, -- 0
strpos('abc', 'z') - 1 AS minus_one, -- -1
left('abc', strpos('abc', 'z') - 1) AS left_neg; -- ''(长度 -1 被当作 0) not_found | minus_one | left_neg
-----------+-----------+----------
0 | -1 |left(str, -1) 在 PostgreSQL 中的语义是「去掉最后 1 个字符」,而不是「取前 -1 个」!所以在 not_found 情形下 left('abc', -1) 会返回 'ab'。这类静默错误非常隐蔽,正确做法是先判断:
SELECT
CASE WHEN strpos(s, '@') > 0
THEN left(s, strpos(s, '@') - 1)
ELSE s
END
FROM (VALUES ('a@b'), ('no-at')) AS t(s); case
------
a
no-atregexp_instr 的参数逐个说明
regexp_instr(string, pattern [, start [, N [, endoption [, flags [, subexpr]]]]])| 参数 | 含义 | 默认值 |
|---|---|---|
string | 主串 | 必填 |
pattern | POSIX 正则 | 必填 |
start | 从第几个字符开始扫描 | 1 |
N | 要第几次匹配(0 表示不做次数限定) | 1 |
endoption | 0 = 返回匹配起点;1 = 返回匹配终点之后的位置 | 0 |
flags | 与 regexp_replace 相同的 flag 字符 | '' |
subexpr | 返回第几个捕获组的位置(0 = 整个匹配) | 0 |
SELECT
regexp_instr('a1b2c3', '\d') AS first_digit, -- 2
regexp_instr('a1b2c3', '\d', 1, 2) AS second_digit, -- 4
regexp_instr('a1b2c3', '\d', 1, 3) AS third_digit, -- 6
regexp_instr('a1b2c3', '\d', 1, 1, 1) AS end_after, -- 3(匹配 '1' 结束于 2,之后是 3)
regexp_instr('2026-09', '(\d+)-(\d+)', 1, 1, 0, '', 2) AS group2, -- 6
regexp_instr('ABC', 'b', 1, 1, 0, 'i') AS case_insensitive; -- 2 first_digit | second_digit | third_digit | end_after | group2 | case_insensitive
-------------+--------------+-------------+-----------+--------+------------------
2 | 4 | 6 | 3 | 6 | 2endoption = 1 常用来配合 substring 取「匹配结束后的剩余部分」:
-- 取第一个数字之后的所有内容
SELECT
s,
regexp_instr(s, '\d+', 1, 1, 1) AS after_first_num,
substring(s FROM regexp_instr(s, '\d+', 1, 1, 1)) AS tail
FROM (VALUES ('abc123def'), ('no-digit-here')) AS t(s); s | after_first_num | tail
--------------+-----------------+--------------
abc123def | 7 | def
no-digit-here| 1 | no-digit-here注意第二行:未匹配时
regexp_instr返回0,因此endoption = 1也得到0;substring(s FROM 0)会把0当作「从第 1 个字符算,且长度 0」的边界情形,实际返回整串。这类边界务必用CASE显式处理。
与 LIKE、IN、regexp_like 的取舍
判断「是否包含」有四条路,选择取决于是否需要正则与是否需要索引:
| 写法 | 语义 | 能用 B-tree 索引 | 说明 |
|---|---|---|---|
col = 'abc' | 全等 | 是 | 最快 |
col LIKE 'abc%' | 前缀匹配 | 是(需 text_pattern_ops / C 排序) | 大写前缀友好 |
col LIKE '%abc%' | 任意位置包含 | 否 | 慢,需 pg_trgm GIN |
col ILIKE '%abc%' | 不区分大小写包含 | 否 | 同上 |
position('abc' IN col) > 0 | 任意位置包含 | 否 | 与 LIKE '%abc%' 等价 |
regexp_like(col, 'a.c') | 正则 | 否 | PostgreSQL 15+ |
-- 三种「包含」写法,语义完全一致,性能也接近
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id FROM products WHERE name LIKE '%key%';
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id FROM products WHERE position('key' IN name) > 0;
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id FROM products WHERE regexp_like(name, 'key'); QUERY PLAN
----------------------------------------------------------------------------
Seq Scan on products (actual time=0.013..0.014 rows=1 loops=1)
Filter: (name ~~ '%key%'::text)
Buffers: shared hit=1
Planning Time: 0.061 ms
Execution Time: 0.028 ms QUERY PLAN
----------------------------------------------------------------------------
Seq Scan on products (actual time=0.012..0.013 rows=1 loops=1)
Filter: (strpos(name, 'key'::text) > 0)
Buffers: shared hit=1
Planning Time: 0.058 ms
Execution Time: 0.026 ms QUERY PLAN
----------------------------------------------------------------------------
Seq Scan on products (actual time=0.015..0.017 rows=1 loops=1)
Filter: regexp_like(name, 'key'::text)
Buffers: shared hit=1
Planning Time: 0.094 ms
Execution Time: 0.031 ms三者都走顺序扫描(因为模式以 % 开头),执行时间同量级。区别在于可读性与可索引性:真正需要加速时应装 pg_trgm 扩展建 GIN 索引:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX products_name_trgm_idx ON products USING gin (name gin_trgm_ops);
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT id FROM products WHERE name LIKE '%key%'; QUERY PLAN
-----------------------------------------------------------------------------------------
Bitmap Heap Scan on products (actual time=0.021..0.022 rows=1 loops=1)
Recheck Cond: (name ~~ '%key%'::text)
Heap Blocks: exact=1
-> Bitmap Index Scan on products_name_trgm_idx (actual time=0.012..0.012 rows=1 loops=1)
Index Cond: (name ~~ '%key%'::text)
Buffers: shared hit=6
Planning Time: 0.198 ms
Execution Time: 0.046 mspg_trgm 的适用范围
pg_trgm 把字符串切成三元组(trigram)建索引,支持 LIKE '%x%'、ILIKE '%x%'、正则 ~ 的加速,但要求关键词长度至少 3 个字符,短于 3 字符的搜索仍会退化为全表扫描。
多字节字符与 position 的计数单位
position 与 strpos 的返回值是字符位置,不是字节位置。这在 UTF-8 下处理中文时很关键:
SELECT
position('世' IN 'hello 世界') AS cjk_pos,
octet_length('hello ') AS prefix_bytes,
octet_length('hello 世') AS incl_cjk_bytes; cjk_pos | prefix_bytes | incl_cjk_bytes
---------+--------------+----------------
7 | 6 | 9'世'是第 7 个字符(hello共 6 个字符),但它的字节起点是第 7 个字节(前缀hello是 6 字节 ASCII)——此例巧合相同。- 若前缀含中文,字符位与字节位就会分叉:
SELECT
position('b' IN '中a b') AS char_pos, -- 5
octet_length('中a ') AS byte_prefix; -- 5(3 + 1 + 1) char_pos | byte_prefix
----------+-------------
5 | 5要按字节定位(如处理二进制协议、截断带外编码的存储),应改用 convert_to(str, 'UTF8') 转成 bytea 后用 position 的 bytea 版本:
SELECT
position('b'::bytea IN convert_to('中a b', 'UTF8')) AS byte_pos,
position('b' IN '中a b') AS char_pos; byte_pos | char_pos
----------+----------
6 | 5bytea 版本返回第 6 个字节,text 版本返回第 5 个字符,差异来自 中 占 3 字节。
性能:position 是简单扫描
position / strpos 在 C 层使用 Boyer-Moore-Horspool 类算法做子串搜索,复杂度近似 O(n + m),不需要编译正则。与 regexp_instr 相比,在长文本上差距明显:
\timing on
-- 场景:在 5 万行、每行约 200 字符的文本里查找固定子串
SELECT count(*) FROM products WHERE strpos(name, 'usb') > 0;
SELECT count(*) FROM products WHERE regexp_instr(name, 'usb') > 0;
SELECT count(*) FROM products WHERE name LIKE '%usb%'; count
-------
0
Time: 7.214 ms
count
-------
0
Time: 41.876 ms
count
-------
0
Time: 7.031 msregexp_instr 比 strpos 慢约 5~6 倍;LIKE '%x%' 与 strpos 基本持平(内部都走同一类匹配器)。
表达式索引对 strpos 有效,对 regexp_instr 也有条件有效
strpos、position、regexp_instr、regexp_like 都是 IMMUTABLE(当 flags 为常量时),理论上可建表达式索引,但以位置为输出的索引几乎没有查询价值(WHERE strpos(...) = 5 这种条件太少见)。真正有价值的是把「是否存在」固化成生成列:
ALTER TABLE products
ADD COLUMN is_usb boolean
GENERATED ALWAYS AS (strpos(tags, 'usb') > 0) STORED;
CREATE INDEX products_is_usb_idx ON products (is_usb);实战示例
场景一:解析 SKU 编码
SKU 形如 CN-ELEC-20260001,需要拆出国家码、品类码、序号:
SELECT
sku,
split_part(sku, '-', 1) AS country,
split_part(sku, '-', 2) AS segment,
split_part(sku, '-', 3) AS serial,
strpos(sku, '-') AS first_dash,
strpos(sku, '-') + 1 AS second_start,
position('-' IN substring(sku FROM strpos(sku, '-') + 1)) AS second_rel
FROM products
ORDER BY id; sku | country | segment | serial | first_dash | second_start | second_rel
-------------------+---------+---------+----------+------------+--------------+------------
CN-ELEC-20260001 | CN | ELEC | 20260001 | 3 | 4 | 4
CN-ELEC-20260002 | CN | ELEC | 20260002 | 3 | 4 | 4
US-HOME-20260003 | US | HOME | 20260003 | 3 | 4 | 4
CN-BOOK-20260004 | CN | BOOK | 20260004 | 3 | 4 | 4
CN-ELEC-20260005 | CN | ELEC | 20260005 | 3 | 4 | 4second_rel 是相对于子串的位置(4),要换算成原串的绝对位置需要加上偏移 first_dash:
SELECT
sku,
strpos(sku, '-') + position('-' IN substring(sku FROM strpos(sku, '-') + 1)) AS second_dash_abs
FROM products
ORDER BY id; sku | second_dash_abs
-------------------+-----------------
CN-ELEC-20260001 | 8
CN-ELEC-20260002 | 8
US-HOME-20260003 | 8
CN-BOOK-20260004 | 8
CN-ELEC-20260005 | 8这个例子说明:
position只在给定串内计算位置。想找「第 2 个连字符」,更清晰的做法是用split_part,或直接改用支持「第 N 次」的regexp_instr:sqlSELECT sku, regexp_instr(sku, '-', 1, 2) AS second_dash FROM products ORDER BY id;textsku | second_dash -------------------+------------- CN-ELEC-20260001 | 8 CN-ELEC-20260002 | 8 US-HOME-20260003 | 8 CN-BOOK-20260004 | 8 CN-ELEC-20260005 | 8
场景二:拆分邮箱与统计域名分布
-- 拆分邮箱为本地部分与域名,并按域名聚合
SELECT
split_part(email, '@', 2) AS domain,
count(*) AS users_count,
min(left(email, strpos(email, '@') - 1)) AS sample_local
FROM users
WHERE strpos(email, '@') > 0
GROUP BY 1
ORDER BY users_count DESC, domain; domain | users_count | sample_local
---------------+-------------+--------------
example.com | 3 | bob_99
Example.COM | 1 | Alice
test.org | 1 | carol注意 example.com 与 Example.COM 被当成两个不同域名——position 与 group by 都区分大小写。真实业务中应先归一:
SELECT
lower(split_part(email, '@', 2)) AS domain,
count(*) AS users_count
FROM users
WHERE strpos(email, '@') > 0
GROUP BY 1
ORDER BY users_count DESC; domain | users_count
-------------+-------------
example.com | 4
test.org | 1场景三:判断标签是否存在并分类
SELECT
id,
sku,
tags,
strpos(tags, 'usb') > 0 AS has_usb,
strpos(tags, 'wireless') > 0 AS has_wireless,
CASE
WHEN strpos(tags, 'usb') > 0 AND strpos(tags, 'wireless') > 0 THEN '无线 USB'
WHEN strpos(tags, 'usb') > 0 THEN '有线 USB'
ELSE '其他'
END AS conn_type
FROM products
ORDER BY id; id | sku | tags | has_usb | has_wireless | conn_type
----+-------------------+-----------------------+---------+--------------+-----------
1 | CN-ELEC-20260001 | wireless,usb,black | t | t | 无线 USB
2 | CN-ELEC-20260002 | usb,backlit | t | f | 有线 USB
3 | US-HOME-20260003 | ceramic,kitchen | f | f | 其他
4 | CN-BOOK-20260004 | database,sql | f | f | 其他
5 | CN-ELEC-20260005 | usb,type-c | t | f | 有线 USB「子串匹配」不是「标签匹配」
strpos(tags, 'usb') > 0 对 'usb-c'、'notusb'、'usb,type-c' 都会返回真。真正的标签匹配应该用 usb = ANY(string_to_array(tags, ',')):
SELECT id, sku
FROM products
WHERE 'usb' = ANY (string_to_array(tags, ','))
ORDER BY id; id | sku
----+-------------------
1 | CN-ELEC-20260001
2 | CN-ELEC-20260002
5 | CN-ELEC-20260005string_to_array 会把 'usb-c' 当作一个完整元素,从而避免误命中。
注意事项
位置从 1 开始,不是 0。这是从 0 基语言(JavaScript、Python、C)迁移过来最容易犯的错:
sqlSELECT strpos('abc', 'a'); -- 1(不是 0)找不到返回
0,不是NULL、不是-1:sqlSELECT strpos('abc', 'z'), position('z' IN 'abc'); -- 0 | 0strpos与position参数顺序相反,写反不报错:sqlSELECT strpos('abc', 'b'); -- 正确写法:2 SELECT position('b', 'abc'); -- 正确写法:2 SELECT strpos('b', 'abc'); -- 错误!得到 0position不支持正则,别把%\d当通配符:sqlSELECT position('%' IN 'abc'); -- 0(不是「任意」) SELECT strpos('a1', '\d'); -- 0(不是「数字」) SELECT regexp_instr('a1', '\d'); -- 正确:2空串的位置恒为 1,不能用来判空:
sqlSELECT position('' IN 'abc'); -- 1 SELECT position('' IN ''); -- 1区分大小写。要做不敏感匹配,先
lower()或改用regexp_instr(..., 'i'):sqlSELECT strpos('PostgreSQL', 'sql'); -- 0 SELECT strpos(lower('PostgreSQL'), 'sql'); -- 6 SELECT regexp_instr('PostgreSQL', 'sql', 1, 1, 0, 'i'); -- 6返回的是字符位置而非字节位置。处理多字节文本时不要用它当字节偏移:
sqlSELECT position('b' IN '中a b'); -- 5(字符)0参与算术会产生负偏移,进而触发left/right的负数语义:sqlSELECT left('abc', 0 - 1); -- 不推荐!得到 'ab'(-1 表示去掉最后 1 个字符)regexp_instr的N为0时表示「不做次数限定」,不是「第 0 次」。用0通常是想配合'g'语义,但函数本身不返回多个结果,实际等价于不限次数下的首处搜索。subexpr超出捕获组个数会返回0,不会报错:sqlSELECT regexp_instr('ab', '(a)(b)', 1, 1, 0, '', 5); -- 0(只有 2 个组)position的 bytea 版本按字节计,与 text 版本语义不同,混用会算错偏移:sqlSELECT position('b'::bytea IN convert_to('中a b', 'UTF8')); -- 6 SELECT position('b' IN '中a b'); -- 5把
position结果直接当布尔用可读性差。WHERE position('x' IN col)虽然合法(非 0 为真),但读者需要回想「0 是假」这条规则,建议显式写> 0。regexp_instr是 PostgreSQL 15 新增(与regexp_count、regexp_like同批)。在更早版本上需用substring+regexp_matches曲线实现。LIKE '%x%'与position > 0语义等价但不可互换索引策略。两者都无法用 B-tree 加速,pg_trgmGIN 索引对LIKE直接生效,对position表达式索引也可用但需显式创建。
最佳实践
团队内统一只用一种查找写法。推荐
strpos(haystack, needle)(主串在前,符合「宾语在前」的直觉),可读性优于position(needle IN haystack)。查找前先想清楚「找不到怎么办」。凡是要拿位置做算术的场景,都用
CASE或NULLIF包一层:sql-- 用 NULLIF 把 0 转成 NULL,避免负偏移 SELECT left(email, NULLIF(strpos(email, '@'), 0) - 1) AS local_part FROM users;textlocal_part ------------ Alice bob_99 carol dave能用
split_part就别手算偏移。split_part(s, '@', 2)比「找位置再substring」更短、更不易错。查找函数留给「需要知道具体位置」的场景。判断包含关系优先用
LIKE/ILIKE。语义更直白,且能与pg_trgm索引配合。需要「第 N 次出现」时用
regexp_instr,而不是嵌套substring+position。后者的偏移换算极易出错。给长文本的模糊搜索装
pg_trgm。仅靠position/LIKE '%x%'都会全表扫描:sqlCREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE INDEX products_name_trgm_idx ON products USING gin (name gin_trgm_ops);把「是否包含某关键词」固化成生成列,既省去重复计算,又能建索引:
sqlALTER TABLE products ADD COLUMN is_wireless boolean GENERATED ALWAYS AS (strpos(tags, 'wireless') > 0) STORED;不要用
position判断非空。用length(col) > 0或col <> '';position('' IN col) = 1对空串同样成立,无法区分。标记位(flag)与位置的区别要想清楚。
position返回位置(可比较大小),regexp_like/LIKE返回布尔。需要「排在最前面的那个」时才用位置。对多字节文本按字节定位时显式转换。用
convert_to(...)+ bytea 版本,并在注释里写明单位是字节。统一注释里使用「1 基」这一说法,提醒后续维护者偏移量需要
± 1:sql-- strpos 返回 1 基下标;- 1 得到 @ 之前的字符数 left(email, strpos(email, '@') - 1)监控慢查询时留意「以
%开头的LIKE」与「包裹列的函数」。这两类都是索引杀手,应作为 DBA 巡检的固定检查项。
下一节