Skip to content

POSITION / STRPOS ​

查找子串位置(Substring Search)是判断「是否包含」「从哪开始截」「分割点在哪」的通用基础操作。PostgreSQL 提供 position()(SQL 标准语法)、strpos()(函数式写法)与 regexp_instr()(正则版,15+)三条路径。三者的返回值语义、参数顺序、是否接受正则都不同,混用极易写出「看似能跑、结果反了」的查询。本节讲清它们的对应关系与性能差异。

前置知识

阅读本节前,建议先了解:REPLACE / TRANSLATE / REGEXP_REPLACE

基础概念 ​

概念术语说明
子串查找Substring Search在主串中定位目标子串,返回下标
1 基索引1-Based IndexingPostgreSQL 字符串位置从 1 开始,不是 0
未找到Not Found返回 0,而非 NULL 或 -1
参数顺序差异Argument Orderposition(要找 IN 主串) 与 strpos(主串, 要找) 相反
正则定位Regex Positionregexp_instr() 支持模式匹配与第 N 次出现
截取Extraction用查找结果配合 substring / left 取片段
存在性判断Existence Checkposition(...) > 0 或直接用 LIKE / strpos
大小写敏感Case Sensitivityposition 区分大小写,不区分需 lower() 或 ILIKE

四个定位手段的对照:

表达式参数顺序支持正则返回说明
position('b' IN 'abc')要找先写否integerSQL 标准语法,关键字 IN
position('b', 'abc')要找先写否integer逗号形式,等价于上者
strpos('abc', 'b')主串先写否integer内部实现为 textpos,性能同 position
regexp_instr('abc', 'b')主串先写是integerPostgreSQL 15+ 新增

返回值语义:

sql
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(区分大小写)
text
 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,所以不能用它判断「有没有填内容」。

选择流程:

text
你要「找位置」还是「判断有没有」?
  ├─ 只要判断包含关系 → 用 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 是正则加强版。

语法与用法 ​

sql
-- 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

最小可运行示例:

sql
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;
text
 f_strpos | f_position_in | f_position_comma | f_regexp_instr | f_not_found | total_len
----------+---------------+------------------+----------------+-------------+-----------
        5 |             5 |                5 |              5 |           0 |        10

与截取函数配合是最常见的用法——查出分隔符位置,再取左半或右半:

sql
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;
text
        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):

sql
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');
text
INSERT 0 5

详细说明 ​

position 与 strpos 的等价性 ​

strpos(string, substring) 与 position(substring IN string) 内部调用同一个函数,只是参数顺序相反。选择哪个取决于可读性与团队习惯:

sql
-- 下面两个查询返回完全相同的结果
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;
text
 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 参数,写反了不会报错,只会得到错误的位置:

sql
SELECT
  strpos('abc', 'bc')            AS correct,     -- 2
  strpos('bc', 'abc')            AS reversed;    -- 0(找 'abc' 在 'bc' 里,当然没有)

建议团队内统一只用一种写法,并在 code review 时重点检查。

位置从 1 开始:三个连锁陷阱 ​

陷阱一:left / substring 的长度参数直接相减

因为位置是 1 基,取「分隔符之前的部分」要 - 1:

sql
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'
text
 raw | at_pos | wrong | right
-----+--------+-------+-------
 a@b |      2 | a@    | a

陷阱二:substring 的 FROM 语义

substring(str FROM start) 的 start 也按字符计数,所以取分隔符之后的部分要 + 1(否则会包含分隔符本身):

sql
SELECT
  substring('a@b' FROM strpos('a@b', '@'))       AS include_at,  -- '@b'
  substring('a@b' FROM strpos('a@b', '@') + 1)   AS exclude_at;  -- 'b'
text
 include_at | exclude_at
------------+------------
 @b         | b

陷阱三:0 参与算术会「歪掉」

position 找不到时返回 0,如果直接拿去运算,会得到 -1:

sql
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)
text
 not_found | minus_one | left_neg
-----------+-----------+----------
         0 |        -1 |

left(str, -1) 在 PostgreSQL 中的语义是「去掉最后 1 个字符」,而不是「取前 -1 个」!所以在 not_found 情形下 left('abc', -1) 会返回 'ab'。这类静默错误非常隐蔽,正确做法是先判断:

sql
SELECT
  CASE WHEN strpos(s, '@') > 0
       THEN left(s, strpos(s, '@') - 1)
       ELSE s
  END
FROM (VALUES ('a@b'), ('no-at')) AS t(s);
text
 case
------
 a
 no-at

regexp_instr 的参数逐个说明 ​

sql
regexp_instr(string, pattern [, start [, N [, endoption [, flags [, subexpr]]]]])
参数含义默认值
string主串必填
patternPOSIX 正则必填
start从第几个字符开始扫描1
N要第几次匹配(0 表示不做次数限定)1
endoption0 = 返回匹配起点;1 = 返回匹配终点之后的位置0
flags与 regexp_replace 相同的 flag 字符''
subexpr返回第几个捕获组的位置(0 = 整个匹配)0
sql
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
text
 first_digit | second_digit | third_digit | end_after | group2 | case_insensitive
-------------+--------------+-------------+-----------+--------+------------------
           2 |            4 |           6 |         3 |      6 |                2

endoption = 1 常用来配合 substring 取「匹配结束后的剩余部分」:

sql
-- 取第一个数字之后的所有内容
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);
text
      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+
sql
-- 三种「包含」写法,语义完全一致,性能也接近
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');
text
                                 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
text
                                 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
text
                                 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 索引:

sql
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%';
text
                                       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 ms

pg_trgm 的适用范围

pg_trgm 把字符串切成三元组(trigram)建索引,支持 LIKE '%x%'、ILIKE '%x%'、正则 ~ 的加速,但要求关键词长度至少 3 个字符,短于 3 字符的搜索仍会退化为全表扫描。

多字节字符与 position 的计数单位 ​

position 与 strpos 的返回值是字符位置,不是字节位置。这在 UTF-8 下处理中文时很关键:

sql
SELECT
  position('世' IN 'hello 世界')     AS cjk_pos,
  octet_length('hello ')            AS prefix_bytes,
  octet_length('hello 世')          AS incl_cjk_bytes;
text
 cjk_pos | prefix_bytes | incl_cjk_bytes
---------+--------------+----------------
       7 |            6 |             9
  • '世' 是第 7 个字符(hello 共 6 个字符),但它的字节起点是第 7 个字节(前缀 hello 是 6 字节 ASCII)——此例巧合相同。
  • 若前缀含中文,字符位与字节位就会分叉:
sql
SELECT
  position('b' IN '中a b')      AS char_pos,    -- 5
  octet_length('中a ')          AS byte_prefix; -- 5(3 + 1 + 1)
text
 char_pos | byte_prefix
----------+-------------
        5 |           5

要按字节定位(如处理二进制协议、截断带外编码的存储),应改用 convert_to(str, 'UTF8') 转成 bytea 后用 position 的 bytea 版本:

sql
SELECT
  position('b'::bytea IN convert_to('中a b', 'UTF8'))    AS byte_pos,
  position('b' IN '中a b')                               AS char_pos;
text
 byte_pos | char_pos
----------+----------
        6 |        5

bytea 版本返回第 6 个字节,text 版本返回第 5 个字符,差异来自 中 占 3 字节。

性能:position 是简单扫描 ​

position / strpos 在 C 层使用 Boyer-Moore-Horspool 类算法做子串搜索,复杂度近似 O(n + m),不需要编译正则。与 regexp_instr 相比,在长文本上差距明显:

sql
\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%';
text
 count
-------
     0
Time: 7.214 ms

 count
-------
     0
Time: 41.876 ms

 count
-------
     0
Time: 7.031 ms

regexp_instr 比 strpos 慢约 5~6 倍;LIKE '%x%' 与 strpos 基本持平(内部都走同一类匹配器)。

表达式索引对 strpos 有效,对 regexp_instr 也有条件有效

strpos、position、regexp_instr、regexp_like 都是 IMMUTABLE(当 flags 为常量时),理论上可建表达式索引,但以位置为输出的索引几乎没有查询价值(WHERE strpos(...) = 5 这种条件太少见)。真正有价值的是把「是否存在」固化成生成列:

sql
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,需要拆出国家码、品类码、序号:

sql
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;
text
        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 |          4

second_rel 是相对于子串的位置(4),要换算成原串的绝对位置需要加上偏移 first_dash:

sql
SELECT
  sku,
  strpos(sku, '-') + position('-' IN substring(sku FROM strpos(sku, '-') + 1)) AS second_dash_abs
FROM products
ORDER BY id;
text
        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:

sql
SELECT sku, regexp_instr(sku, '-', 1, 2) AS second_dash FROM products ORDER BY id;
text
        sku        | second_dash
-------------------+-------------
 CN-ELEC-20260001  |           8
 CN-ELEC-20260002  |           8
 US-HOME-20260003  |           8
 CN-BOOK-20260004  |           8
 CN-ELEC-20260005  |           8

场景二:拆分邮箱与统计域名分布 ​

sql
-- 拆分邮箱为本地部分与域名,并按域名聚合
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;
text
    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 都区分大小写。真实业务中应先归一:

sql
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;
text
   domain    | users_count
-------------+-------------
 example.com |           4
 test.org    |           1

场景三:判断标签是否存在并分类 ​

sql
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;
text
 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, ',')):

sql
SELECT id, sku
FROM products
WHERE 'usb' = ANY (string_to_array(tags, ','))
ORDER BY id;
text
 id |        sku
----+-------------------
  1 | CN-ELEC-20260001
  2 | CN-ELEC-20260002
  5 | CN-ELEC-20260005

string_to_array 会把 'usb-c' 当作一个完整元素,从而避免误命中。

注意事项 ​

  1. 位置从 1 开始,不是 0。这是从 0 基语言(JavaScript、Python、C)迁移过来最容易犯的错:

    sql
    SELECT strpos('abc', 'a');   -- 1(不是 0)
  2. 找不到返回 0,不是 NULL、不是 -1:

    sql
    SELECT strpos('abc', 'z'), position('z' IN 'abc');   -- 0 | 0
  3. strpos 与 position 参数顺序相反,写反不报错:

    sql
    SELECT strpos('abc', 'b');             -- 正确写法:2
    SELECT position('b', 'abc');           -- 正确写法:2
    SELECT strpos('b', 'abc');             -- 错误!得到 0
  4. position 不支持正则,别把 % \d 当通配符:

    sql
    SELECT position('%' IN 'abc');                      -- 0(不是「任意」)
    SELECT strpos('a1', '\d');                          -- 0(不是「数字」)
    SELECT regexp_instr('a1', '\d');                    -- 正确:2
  5. 空串的位置恒为 1,不能用来判空:

    sql
    SELECT position('' IN 'abc');            -- 1
    SELECT position('' IN '');               -- 1
  6. 区分大小写。要做不敏感匹配,先 lower() 或改用 regexp_instr(..., 'i'):

    sql
    SELECT strpos('PostgreSQL', 'sql');                  -- 0
    SELECT strpos(lower('PostgreSQL'), 'sql');           -- 6
    SELECT regexp_instr('PostgreSQL', 'sql', 1, 1, 0, 'i'); -- 6
  7. 返回的是字符位置而非字节位置。处理多字节文本时不要用它当字节偏移:

    sql
    SELECT position('b' IN '中a b');   -- 5(字符)
  8. 0 参与算术会产生负偏移,进而触发 left / right 的负数语义:

    sql
    SELECT left('abc', 0 - 1);   -- 不推荐!得到 'ab'(-1 表示去掉最后 1 个字符)
  9. regexp_instr 的 N 为 0 时表示「不做次数限定」,不是「第 0 次」。用 0 通常是想配合 'g' 语义,但函数本身不返回多个结果,实际等价于不限次数下的首处搜索。

  10. subexpr 超出捕获组个数会返回 0,不会报错:

    sql
    SELECT regexp_instr('ab', '(a)(b)', 1, 1, 0, '', 5);   -- 0(只有 2 个组)
  11. position 的 bytea 版本按字节计,与 text 版本语义不同,混用会算错偏移:

    sql
    SELECT position('b'::bytea IN convert_to('中a b', 'UTF8'));   -- 6
    SELECT position('b' IN '中a b');                              -- 5
  12. 把 position 结果直接当布尔用可读性差。WHERE position('x' IN col) 虽然合法(非 0 为真),但读者需要回想「0 是假」这条规则,建议显式写 > 0。

  13. regexp_instr 是 PostgreSQL 15 新增(与 regexp_count、regexp_like 同批)。在更早版本上需用 substring + regexp_matches 曲线实现。

  14. LIKE '%x%' 与 position > 0 语义等价但不可互换索引策略。两者都无法用 B-tree 加速,pg_trgm GIN 索引对 LIKE 直接生效,对 position 表达式索引也可用但需显式创建。

最佳实践 ​

  1. 团队内统一只用一种查找写法。推荐 strpos(haystack, needle)(主串在前,符合「宾语在前」的直觉),可读性优于 position(needle IN haystack)。

  2. 查找前先想清楚「找不到怎么办」。凡是要拿位置做算术的场景,都用 CASE 或 NULLIF 包一层:

    sql
    -- 用 NULLIF 把 0 转成 NULL,避免负偏移
    SELECT left(email, NULLIF(strpos(email, '@'), 0) - 1) AS local_part
    FROM users;
    text
     local_part
    ------------
     Alice
     bob_99
     carol
     dave
  3. 能用 split_part 就别手算偏移。split_part(s, '@', 2) 比「找位置再 substring」更短、更不易错。查找函数留给「需要知道具体位置」的场景。

  4. 判断包含关系优先用 LIKE / ILIKE。语义更直白,且能与 pg_trgm 索引配合。

  5. 需要「第 N 次出现」时用 regexp_instr,而不是嵌套 substring + position。后者的偏移换算极易出错。

  6. 给长文本的模糊搜索装 pg_trgm。仅靠 position / LIKE '%x%' 都会全表扫描:

    sql
    CREATE EXTENSION IF NOT EXISTS pg_trgm;
    CREATE INDEX products_name_trgm_idx ON products USING gin (name gin_trgm_ops);
  7. 把「是否包含某关键词」固化成生成列,既省去重复计算,又能建索引:

    sql
    ALTER TABLE products
      ADD COLUMN is_wireless boolean
      GENERATED ALWAYS AS (strpos(tags, 'wireless') > 0) STORED;
  8. 不要用 position 判断非空。用 length(col) > 0 或 col <> '';position('' IN col) = 1 对空串同样成立,无法区分。

  9. 标记位(flag)与位置的区别要想清楚。position 返回位置(可比较大小),regexp_like / LIKE 返回布尔。需要「排在最前面的那个」时才用位置。

  10. 对多字节文本按字节定位时显式转换。用 convert_to(...) + bytea 版本,并在注释里写明单位是字节。

  11. 统一注释里使用「1 基」这一说法,提醒后续维护者偏移量需要 ± 1:

    sql
    -- strpos 返回 1 基下标;- 1 得到 @ 之前的字符数
    left(email, strpos(email, '@') - 1)
  12. 监控慢查询时留意「以 % 开头的 LIKE」与「包裹列的函数」。这两类都是索引杀手,应作为 DBA 巡检的固定检查项。

下一节

继续学习:SPLIT_PART / STRING_AGG

参考链接 ​