做了这么多年数据库开发,我越来越觉得SQL里的REGEXP正则表达式是个被低估的技能。平时大家写查询,用到最多的就是LIKE加几个百分号,但真遇到“手机号格式是否合法”“这个字段里有没有连续重复的标点”“能不能从日志串里把IP和状态码抠出来”这类需求,LIKE那套模糊匹配根本不够用。REGEXP就是专门解决这类“结构未知但模式可描述”的匹配问题的,它能让一条SQL干完原本要写好几层CASE WHEN或者拉出去用脚本处理的活。这篇文章我把REGEXP在SQL里的使用从语法、数据库差异到实战场景都过一遍,适合正在搞数据清洗、接口校验、报表开发和慢SQL优化的人参考,不管你是MySQL、PostgreSQL还是Oracle用户,都能找到能直接抄的方案。
1. 为什么SQL查询需要正则表达式
1.1 从LIKE的痛点说起
LIKE的优点是简单,缺点就是匹配能力太粗。百分号和下划线两个通配符,只能表达“任意一串字符”和“任意单个字符”,根本没法表达“至少一个数字”“连续4到8个小写字母”“以1开头且第二位是3到9”这种规则。实际开发中,做手机号校验时经常看到有人写 phone LIKE '1%',结果一堆“10086客服”“1号店会员”之类根本不是手机号的数据也匹配进去了。原因很简单,LIKE只看前后缀,没法约束每一位到底是什么字符。正则就不一样,^1[3-9][0-9]{9}$ 表达得非常精确:1开头、第二位3到9、后面9位数字,一共11位。这种精度是LIKE做不到的。
还有一点,LIKE在匹配“内容包含某类字符但位置不固定”的情况时特别痛苦。比如要从备注字段里找“包含金额”的记录,金额可能是1.5、20、300.25,你不可能用LIKE把每种情况都写出来,但正则 [0-9]+(\\.[0-9]{1,2})? 一条就搞定。LIKE擅长的是“前后缀明确的模糊查询”,一旦规则复杂一点,SQL就会写得又臭又长,性能还不一定好。
1.2 REGEXP的真正用武之地
正则表达式在SQL里的价值,主要体现在几个场景。第一是数据清洗,从脏数据里识别格式异常的记录,比如身份证号位数不对、邮箱少了@、金额字段混入了字母,这种规则用LIKE表达非常费劲,用正则几秒钟就能写出来。第二是日志分析,从一段混合文本中提取IP、时间、状态码、SQL语句片段,这算是REGEXP_SUBSTR的看家本领。第三是接口和入库校验,在INSERT之前用正则做白名单校验,防止非法格式进入库,这个在数据仓库和接口对接中很常见。第四是ETL流程中的标准化,把各种写法的手机号、电话、日期统一成标准格式,用REGEXP_REPLACE一步搞定。
概括一下,凡是“规则可以用模式描述、但值本身千变万化”的校验和提取需求,都是正则的主场。数据量越大,字段内容越杂乱,正则带来的收益越明显。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 各数据库的正则能力差异
2.1 MySQL:从REGEXP到完整的正则函数族
MySQL是最早把REGEXP当成常用操作符的数据库之一。老版本里只有 REGEXP / RLIKE,作用是返回0或1,配合WHERE使用,比如 SELECT * FROM user WHERE name REGEXP '^张'。注意MySQL的REGEXP是“部分匹配”语义,只要字符串某个位置能匹配上就返回真,不像LIKE那样多数情况下在要求全串匹配。所以做格式校验时,必须自己加头部锚点 ^ 和尾部锚点 $,这个细节很多人第一次用都踩过。
MySQL 8.0补齐了 REGEXP_LIKE、REGEXP_REPLACE、REGEXP_SUBSTR、REGEXP_INSTR 一套完整函数,做提取和替换就方便多了。REGEXP_LIKE本质上和REGEXP一样,只是函数风格更接近Oracle。REGEXP_SUBSTR可以从字符串里直接截取匹配片段,REGEXP_REPLACE可以做替换,REGEXP_INSTR返回匹配位置。MySQL 8.0还有个变化,正则引擎从Henry Spencer换成了ICU,默认行为也变了。5.7时代REGEXP默认不区分大小写(受排序规则影响),8.0起默认区分大小写,需要忽略大小写时得指定 'i' 参数。比如 REGEXP_REPLACE(name, 'abc', 'x', 'i') 里的第四个参数,就是8.0新增的匹配参数。
2.2 PostgreSQL:POSIX风格正则
PostgreSQL的正则支持在开源数据库里算是最完整的,走的是POSIX风格。操作符有四个:~、~*、!~、!~*,分别对应“匹配”“不区分大小写匹配”“不匹配”“不区分大小写不匹配”,用起来比函数简洁很多。同时PostgreSQL还提供了 REGEXP_MATCHES、REGEXP_REPLACE、REGEXP_SPLIT_TO_ARRAY、REGEXP_SPLIT_TO_TABLE 等函数,能在各种场景下切片、提取、拆分。
另外PostgreSQL还有个 SIMILAR TO 操作符,介于LIKE和正则之间,兼容SQL标准。但实际工作中用的人不多,因为它的语法既不像LIKE那么直观,又没有POSIX正则的表达能力强。我建议直接学 ~ 操作符和 REGEXP_ 系列函数,没必要为了“标准”去学SIMILAR TO,很多情况下SIMILAR TO写完还得到处查文档。
2.3 Oracle:强大但命名独立的REGEXP家族
Oracle从10g开始提供正则支持,常用的是 REGEXP_LIKE、REGEXP_SUBSTR、REGEXP_REPLACE、REGEXP_INSTR、REGEXP_COUNT。语法上比MySQL更偏向SQL风格,匹配参数用一个字符串传入,i 是忽略大小写,c 是区分大小写,n 是让点号匹配换行,m 是多行模式,x 是忽略空白。这里有个比较坑的地方:Oracle的正则基于POSIX标准,不直接支持 \d 这种Perl转义,想匹配数字得写 [[:digit:]] 或者 [0-9],写 \d 反而会被当成字面反斜杠加字母d,结果什么都不匹配。
在实际应用中,REGEXP_SUBSTR(column, '[^,]+', 1, 2) 这种写法在取第几个分隔字段时非常实用,很多做数据迁移的老手靠它拆分逗号分隔的字符串。Oracle的REGEXP_SUBSTR还支持第6个参数指定返回第几个捕获组,这是MySQL和PG默认没有的能力,提取身份证号里的出生日期等场景会用到。
2.4 SQL Server:天生没有正则怎么办
SQL Server是个尴尬的存在,它没有原生REGEXP函数。有个办法是用LIKE的增强功能,SQL Server的LIKE支持 [abc]、[a-z]、[^abc] 这类字符集匹配,能做一部分正则的事,但量词、分组、锚点这些都没有。要真正用正则,常见做法是引入CLR自定义函数,把.NET的Regex类包装成SQL函数,比如封装一个 dbo.RegExMatch 和一个 dbo.RegExReplace,平时用起来跟MySQL的REGEXP_LIKE差不多。CLR集成会引入部署和权限问题,很多公司不愿意开这个口子,毕竟部署一个带CLR的数据库需要更高的安全审批等级。
如果CLR走不通,就只能靠 CHARINDEX、PATINDEX、REPLACE、STRING_SPLIT 这些函数组合硬绕。我的经验是,SQL Server场景下如果真要频繁做正则,先评估一下是不是应该把这类逻辑放到应用层,用Python或Java来处理。数据库里没有正则能力,就别硬在SQL层面做复杂的文本清洗,架构上该切出去的逻辑要果断切出去。
2.5 其他数据库:达梦、SQLite、Hive等
国内用达梦数据库的项目越来越多,它兼容Oracle的正则函数族,REGEXP_LIKE、REGEXP_SUBSTR、REGEXP_REPLACE 语法基本一致,从Oracle迁移过来的SQL可以直接跑,这一点对很多正在做国产化替换的团队来说省了大事。SQLite默认不带REGEXP支持,但它预留了 REGEXP 操作符位,需要自己用C扩展或load_extension加载自定义函数,否则直接写 REGEXP 会报“no such function”。Hive和Spark SQL里有 RLIKE、REGEXP_EXTRACT、REGEXP_REPLACE,做数仓清洗时很常用,例如 WHERE url RLIKE '^https?://...' 比LIKE组合写法简洁得多。
跨数据库做迁移时,最怕的就是正则语法差异。一个常见的方案是,把正则表达式统一写成“大部分引擎都支持”的POSIX风格,比如用 [[:digit:]] 而不是 \d,这样在Oracle、PG、MySQL 8里都能跑通,虽然看起来啰嗦一点,但至少不会踩转义和语法兼容的坑。
3. 核心语法与SQL中的转义陷阱
3.1 元字符与字符类速览
先把SQL正则里常用的元素过一遍。下面这张表列的是最常用的语法,配合具体示例,基本覆盖日常80%的匹配需求。
| 语法 | 含义 | SQL示例 |
|---|---|---|
. |
匹配换行外的任意字符 | name REGEXP '张.' |
^ |
匹配字符串开头 | name REGEXP '^张' |
$ |
匹配字符串结尾 | name REGEXP '张$' |
[abc] |
匹配集合中任意字符 | name REGEXP '[abc]' |
[^abc] |
匹配不在集合中的字符 | phone REGEXP '[^0-9]' |
[a-z] |
匹配范围内的字符 | code REGEXP '[a-z]' |
* |
前一项出现0次或多次 | a REGEXP 'ab*c' |
+ |
前一项出现1次或多次 | a REGEXP 'ab+c' |
? |
前一项出现0次或1次 | a REGEXP 'ab?c' |
{n,m} |
前一项出现n到m次 | a REGEXP '\\d{5,8}' |
| |
或关系 | a REGEXP 'ab|cd' |
(...) |
分组 | a REGEXP '(ab)+' |
这张表里最容易出问题的就是反斜杠。在MySQL的普通字符串里,反斜杠本身是转义字符,所以你要匹配一个真正的数字 \d,SQL里得写两个反斜杠 \\d,写一个反斜杠会被字符串解析层吞掉。PostgreSQL如果开启了标准字符串(默认开启),写 '\d' 也是两个字符反斜杠加d,但为了保险我习惯写 E'\\d' 显式用转义字符串。Oracle则完全不认Perl转义,老老实实用 [[:digit:]]。这个转义差异,是我见过最多的REGEXP踩坑原因。
3.2 量词的贪婪与懒惰
正则里的量词默认是贪婪的,会尽可能多地匹配。比如字符串 abc123def,正则 \\d+ 会匹配整段 123,而不是只匹配 1。大多数时候贪婪是符合预期的,但如果在复杂表达式里叠加多个量词,贪婪会带来额外交互,最典型的问题就是“灾难性回溯”。比如 (a+)+b 这种嵌套量词模式,在输入全是a的超长文本上,会让匹配引擎尝试海量路径,最坏情况能把数据库CPU打满。
应对办法有两个。一是写正则时避免出现两个带量词的分组嵌套,比如 (a+)+ 改成 a+,([\\s\\S]*)* 改成 [\\s\\S]*,表达意思几乎一样,但性能天差地别。二是限制被匹配字段的长度,比如先写 WHERE LENGTH(remark) < 500,再叠加正则条件。懒惰量词的写法是在量词后加问号,比如 \\d+?,表示尽可能少匹配,SQL场景下用得少,但解析日志时会派上用场。
3.3 分组、捕获与反向引用
分组的作用有两个:一是把多个字符打包,比如 (ab)+ 表示ab可以连续重复多次;二是捕获匹配内容,供后续提取或替换使用。REGEXP_REPLACE 里经常用捕获组做重新拼接,比如手机号脱敏,MySQL 8.0的写法是:
sql复制SELECT REGEXP_REPLACE(phone, '^(\\d{3})\\d{4}(\\d{4})$', '\\1****\\2')
FROM user;
替换字符串里 \\1 和 \\2 分别引用第一个和第二个括号捕获的内容。这里注意,不同数据库的反向引用写法不一致,MySQL用 \\1,PostgreSQL里写成 \1 或 \\1 取决于是否开启标准字符串,Oracle里也是 \1。写跨库SQL时,这个细节一定要事先确认。
PostgreSQL和MySQL 8.0还支持命名捕获组 (?<name>...),但兼容性不如数字捕获组,我一般不建议在图省事时用命名捕获组。如果只是匹配而不需要提取内容,尽量用非捕获分组 (?:...),虽然对性能影响不大,但语义更清晰。
3.4 锚点与边界匹配
^ 和 $ 在正则里表示字符串开头和结尾。前面反复提到,SQL里的REGEXP默认是部分匹配,所以校验“整个字符串”时必须加锚点。比如你要查“字段全是数字”的记录,写 WHERE col REGEXP '\\d+' 会把包含数字的记录全查出来,正确写法是 WHERE col REGEXP '^\\d+$'。这个细节非常重要,几乎所有初学REGEXP的人都在这里栽过跟头。
\\b 是单词边界,在PostgreSQL和MySQL 8的ICU引擎里可用,但MySQL 5.7的Henry Spencer引擎不支持。写跨版本SQL时要留意,5.7上跑 \\b 可能会直接报错或者行为不符合预期,保险做法是用字符类和锚点代替。
3.5 大小写敏感与排序规则的关系
正则是否区分大小写,在不同数据库中受不同因素影响,这是最容易被忽略的“隐藏规则”。MySQL 5.7的REGEXP大小写行为跟随排序规则,比如utf8_general_ci下不区分大小写;MySQL 8.0的REGEXP默认区分大小写,需要忽略时加 'i' 参数。PostgreSQL的 ~ 区分大小写,~* 不区分。Oracle默认区分,match_param不写就是区分大小写。SQL Server虽然没原生正则,但LIKE是否区分大小写取决于数据库排序规则。
写跨库代码时,最稳的做法是显式写匹配参数,不依赖默认值。比如在PG里要用不区分大小写就直接写 ~*,在MySQL 8里就加 'i',不要想当然认为“默认不区分”。
4. 实操案例:数据校验、提取与清洗
4.1 手机号格式校验
先看最经典的手机号校验。需求:检查phone字段是否是合法手机号,规则是1开头、第二位3到9、共11位。MySQL里这样写:
sql复制SELECT * FROM user
WHERE phone REGEXP '^1[3-9][0-9]{9}$';
这里 ^ 和 $ 必须加,否则像 12345678901abc 这种值也会被匹配出来。如果表里已有脏数据,要找异常记录就取反:
sql复制SELECT * FROM user
WHERE phone NOT REGEXP '^1[3-9][0-9]{9}$';
实际操作时,我习惯先跑一个统计,看看脏数据占比再动手清理:
sql复制SELECT
CASE WHEN phone REGEXP '^1[3-9][0-9]{9}$' THEN 'ok' ELSE 'bad' END AS flag,
COUNT(*)
FROM user
GROUP BY flag;
这样能立刻掌握数据质量,避免闷头改数据改到一半发现规则写错。
4.2 邮箱与金额字段校验
邮箱校验规则可以写得比较简单务实:用户名部分包含字母数字及 ._%+-,域名部分包含字母数字和 .-,顶级域至少两个字母。一个能直接用的表达式:
sql复制SELECT email
FROM customer
WHERE email REGEXP '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$';
这只能做基础格式校验,不用追求RFC822那套完整规范,因为完整规范的正则复杂到没人能维护,业务上能拦截明显错误就够了。金额校验常用 ^[0-9]+(\\.[0-9]{1,2})?$,表示整数位至少一位、小数位最多两位且可选。这个表达式如果忘记加 $,就会误匹配 12.345 这种三位小数的数据,脏数据就这样悄悄混进库了。
4.3 从日志文本中提取关键字段
假设access_log表里存着整段日志,字段叫raw_text,需要从里面提取状态码和请求路径。MySQL 8.0可以这样写:
sql复制SELECT
REGEXP_SUBSTR(raw_text, 'HTTP/1\\.[01]" [0-9]{3}') AS status_part,
REGEXP_SUBSTR(raw_text, '"(GET|POST|PUT|DELETE) [^"]+"') AS request_part
FROM access_log
WHERE raw_text REGEXP 'request_time=[0-9.]+';
PostgreSQL里等价写法是用 substring 函数:
sql复制SELECT
substring(raw_text from 'HTTP/1\\.[01]" [0-9]{3}') AS status_part,
substring(raw_text from '"(GET|POST|PUT|DELETE) [^"]+"') AS request_part
FROM access_log
WHERE raw_text ~ 'request_time=[0-9.]+';
实际项目里,我曾用一条REGEXP_SUBSTR把几百万条混合日志中的user_id、order_id、error_code一次性抽出来,比写三层SUBSTRING嵌套拼接快得多,而且不容易数错位置导致错位。日志提取这种场景,正则的效率和准确性优势非常明显。
4.4 数据清洗:替换、去重与拆分
连续空格压缩是数据清洗里的高频需求。MySQL 8.0里 REGEXP_REPLACE(remark, '\\s+', ' ') 默认会替换所有匹配到的子串,所以连续空格会全部被压成一个空格。但PostgreSQL不一样,它的 REGEXP_REPLACE 默认只替换第一个匹配,要全部替换必须加 'g' 标志:
sql复制SELECT REGEXP_REPLACE(remark, '\\s+', ' ', 'g')
FROM table_name;
这个差异很容易坑人,从MySQL迁移到PG或者反过来写脚本时,经常出现“替换了一半”的情况。类似的还有去重连续标点,比如把多个逗号、句号、空格统一成一个:
sql复制SELECT REGEXP_REPLACE(content, '[,,\\.\\s]+', ' ', 'g')
FROM table_name;
至于拆分逗号分隔字段,Oracle里用 REGEXP_SUBSTR(column, '[^,]+', 1, 2) 取第二个值,PostgreSQL用 SPLIT_PART(column, ',', 2),MySQL用 SUBSTRING_INDEX(column, ',', 2),各有各的写法。如果数据量不大,正则拆分通用性更强,但大数据量下还是要考虑有没有更简单的字符串函数。
4.5 用正则做输入白名单校验,而不是过滤关键字
这里说点安全相关的正面实践。网上流传的各种所谓“SQL注入万能密码”,本质都是利用拼接SQL时未做参数化和输入校验。防御的核心一定是参数化查询,但正则白名单可以当第二道防线。在入库之前,对关键字段用正则做格式校验,比如用户名只允许字母、数字和下划线:
sql复制SELECT * FROM user
WHERE username NOT REGEXP '^[A-Za-z0-9_]+$';
这种白名单方式比黑名单过滤安全得多。千万不要试图用正则去“过滤”SQL关键字,比如把 select、union 替换掉,因为绕过手法太多太多,正则根本做不了安全边界。正则在这里的角色只是“格式校验工具”,真正的安全边界要靠参数化查询和最小权限账号。
5. 正则查询的性能优化与慢SQL排查
5.1 正则查询为什么慢:索引失效与全表扫描
数据库对REGEXP的常规实现是遍历每一行,把字段值逐个送给正则引擎匹配。这意味着除非查询里还有其他普通条件能走索引,否则正则条件必然触发全表扫描或全索引扫描。数据量在百万级以上时,一条复杂正则可能跑好几秒。
比如 SELECT * FROM orders WHERE note REGEXP '退款|售后' 在千万级表上,基本就是全表扫,而且字符串匹配比B+Tree索引的等值查找慢几个数量级。排查慢SQL时,用EXPLAIN看到type是ALL或index,Extra列带有Using where,基本就能推断正则条件没有可用的索引。
5.2 优化手段:生成列、表达式索引与双层过滤
第一优先策略是先缩小扫描范围。加上create_time时间范围、status状态过滤、租户ID过滤等普通条件,让数据库先走索引把候选集砍到最小,再对候选集做正则。第二是用生成列把正则结果预计算并建索引。MySQL 5.7以上可以这样做:
sql复制ALTER TABLE user ADD COLUMN phone_valid TINYINT
GENERATED ALWAYS AS (phone REGEXP '^1[3-9][0-9]{9}$') STORED;
CREATE INDEX idx_phone_valid ON user(phone_valid);
之后查询 WHERE phone_valid = 1 就能直接走索引,不需要每行都跑一次正则。PostgreSQL里更简单,直接用表达式索引:
sql复制CREATE INDEX idx_user_phone_valid ON user ((phone ~ '^1[3-9][0-9]{9}$'));
对应查询 WHERE phone ~ '^1[3-9][0-9]{9}$' 就可能走上索引扫描。注意PostgreSQL表达式索引要求查询表达式和索引表达式完全一致,包括正则字符串本身,差一个空格都可能失效。
如果正则只匹配前缀,比如 ^abc,那可以先让普通索引的range scan过滤前缀,比如先写 WHERE col LIKE 'abc%',再接正则做完整校验,正则引擎需要处理的行数会大幅下降。
5.3 正则本身要防灾难性回溯
复杂正则引擎的匹配开销可能成指数级增长。典型模式是 (a+)+$、(x+x+)+y 这类嵌套量词。在MySQL 8.0的ICU引擎下,超长文本配合这种模式,CPU会直接飙高。我遇到过一条线上订单备注匹配,因为写了 ([\\s\\S]*)* 这种模式,把数据库节点CPU打满,最后只能杀掉会话排查。
现在的规矩是,正则里尽量不要出现两个重叠的带量词分组。如果确实需要用,就先截断字符串长度再匹配,或者用LENGTH限制字段长度:
sql复制SELECT * FROM remark_table
WHERE LENGTH(remark) < 500
AND remark REGEXP '...';
把正则引擎的输入规模控制住,灾难性回溯的影响就会被限制在小范围内。
5.4 慢SQL排查工具与实验方法
MySQL 8.0.18以上可以用 EXPLAIN ANALYZE 直接看到每个条件消耗的时间,对定位正则条件耗时特别有用。旧版本可以用 SET profiling = 1; 然后执行查询,最后 SHOW PROFILES; 看整体耗时分布。PostgreSQL用 EXPLAIN (ANALYZE, BUFFERS) 观察actual time。达梦也支持类似EXPLAIN。
实际优化时,我建议做一个对比实验:去掉正则条件,看查询返回行数和耗时;加上正则条件,再看耗时增量。如果增量太大,说明正则过滤掉的行很少,或者正则本身太贵,需要优先处理数据问题,而不是继续优化SQL写法。比如先看看是不是表里有大量不规范的脏数据,把脏数据清理了,正则条件性能自然就上来了。
6. 常见问题速查与个人实操心得
6.1 高频问题速查表
| 问题现象 | 大概率原因 | 解决办法 |
|---|---|---|
| REGEXP明明有数据却匹配不到 | 反斜杠被字符串转义吞掉,或锚点写错 | MySQL里写\\d,PG建议E'\\d',Oracle用[[:digit:]] |
| 只想完全匹配却返回了部分匹配 | REGEXP默认部分匹配 | 加上^和$锚点 |
| 替换只替换了第一处 | PG的REGEXP_REPLACE默认只替换首个匹配 | PG里加'g'标志 |
| 大小写行为跟预期不一致 | 各库默认规则不同 | MySQL 8默认区分大小写,PG用~*,Oracle显式传'i' |
| 数据量大时正则查询超慢 | 无法走索引,全表扫描 | 先用普通索引条件缩小范围,或用生成列预计算 |
| SQL Server写REGEXP报错 | SQL Server无原生正则 | CLR自定义函数,或把逻辑移到应用层 |
| 匹配中文总出问题 | 字符集或Unicode转义不支持 | 数据库字符集设为utf8mb4,直接用字面中文,别用\u转义 |
| 正则报invalid regular expression | 括号未闭合、转义错误、语法不支持 | 先在外部工具测试,再逐段排查 |
6.2 我的几个实操心得
每次在SQL里写正则之前,我习惯先在Python或者在线正则工具里验证一遍表达式,确认逻辑没问题再搬到SQL里。但要注意,在线工具能过不代表SQL里能过,因为SQL字符串的转义规则和数据库正则引擎的语法子集可能会带来额外的坑。这个“先验证再迁移”的流程,能省下大量调试时间。
正则表达式写好后,正式跑大批量UPDATE之前,一定先写一条COUNT统计,确认匹配范围符合预期,再上生产。很多人因为少看了一个锚点或者多写了一个字符,把整表数据改坏,这个代价太高了。另外,正则不适合做复杂的跨行或递归解析,遇到JSON嵌套、HTML标签这种结构,别硬用正则,数据库的JSON函数或者应用层解析才是正路。
最后再分享一个小技巧:SQL里做正则替换或清洗时,先把替换后的结果SELECT出来,肉眼对比一眼前后差异,再改成UPDATE。数据库没有后悔药,多看一眼结果,很多误操作都能避免。
