1. 正则表达式到底在解决什么麻烦
第一次接触正则表达式(regex)的人,八成都有过这种体验:在网上搜到一串^1[3-9]\d{9}$,贴进代码里能跑,但需求稍微一变——比如要提取日志里的 IP 和时间戳,或者要判断一个字符串里有没有连续重复的词——就完全不知道从哪里下手改了。问题不在于语法难,而在于大多数人接触正则的方式是"背答案",而不是理解它在描述什么样的文本形状。
正则表达式本质上是一门极小的、专门用来描述字符串模式的领域语言。你写下一串模式,等于给文本画了一张"通缉画像":开头是什么、中间可以出现多少次、结尾在哪里停、哪些部分要单独拎出来用。Python 有re模块,JavaScript 有RegExp字面量,很多编辑器、命令行工具、数据库查询、接口参数校验也都内置了自己的正则引擎。也就是说,学会这一套写法,你能在很多完全不同的环境里反复用同一份技能,这是它性价比高的地方。
这篇内容适合三类人:完全没碰过正则、想从零建立一套能自洽的认知的新手;用过一点但每次都要现搜、改不动别人写的长正则的"复制粘贴型用户";以及需要在 Python 里做批量文本清洗、在 SQL Server 里做模糊查询、或者要给接口写字段校验规则的中级使用者。我会从"哪些活该用正则、哪些活别交给它"讲起,把语法骨架拆清楚,再拿手机号、邮箱、日志提取这些真实场景逐个字符地过一遍,最后讲讲性能坑和排查手段。看完你至少能做到:拿到一条陌生正则能读懂它想干什么,自己写出能上生产的匹配规则。
2. 先想清楚:哪些活该用正则,哪些别用
2.1 正则真正擅长的三类任务
我把日常遇到的正则任务归成三类。第一类是格式校验,判断一个字符串整体符不符合某个形状,比如手机号是不是 11 位、日期是不是2024-05-01这种写法、订单号是不是"两个大写字母加八位数字"。这类任务的特点是:待匹配的字符串短、结构固定、要求整串匹配。
第二类是批量提取,从一大段杂乱文本里把符合特征的部分抠出来。典型场景是解析服务器日志,一行里塞着时间、来源地址、请求方法、路径、状态码,你想把状态码和路径分别装进变量。这种活正则做起来非常轻快,几行代码就能处理上百万行。
第三类是批量替换与清洗,比如把文本里所有多余的空格压成一个、把不同写法的日期统一成同一种格式、在一堆 CSV 里删掉看不见的控制字符。这类任务里正则的"查找—替换"能力比手写字符串循环靠谱得多,因为边界情况通常是它先想到的,而不是你调试三天之后才想到。
这三类任务有一个共同点:目标文本的形状是可以用有限状态描述的。只要满足这个前提,正则就是最省事的工具。
2.2 三个明确的劝退信号
知道什么时候不用,比知道怎么用更值钱。我踩过几次之后总结了三个信号,一旦出现,就该换工具了。
第一个信号是结构嵌套。HTML、JSON、XML、带括号嵌套的表达式,这些文本的形状不是"有限状态",而是"递归结构"。正则引擎(除了少数支持递归的 PCRE 变体)没法记住"我已经开了三层括号还没关"。所以用正则抓 HTML 标签,遇到嵌套标签立刻翻车。这类活请交给专门的解析器:Python 里用html.parser、lxml、json模块,别跟自己过不去。
第二个信号是需要计算或校验位运算。银行卡号的 Luhn 校验、身份证最后一位的加权校验、ISBN 校验码,这些都要做算术。正则只能看"长得像不像",不能算"对不对"。你能用它筛出"18 位数字加一个可能是 X 的字符",但没法确认这串号码合不合法。合理的做法是正则先做形式初筛,再写代码做实际校验,两层配合。
第三个信号是规则复杂到正则自己都看不懂了。我见过有人为了校验一个带业务规则的券码,写了两百多个字符的正则,中间塞了一堆环视断言,三个月后他自己都改不动。这时候拆成几个简单正则加几行判断代码,可读性和可维护性都会好得多。正则不是越长越专业,能短就短才是本事。
3. 语法骨架:字符类、量词、分组、锚点
3.1 字符类:先说清"匹配什么"
字符类解决的是"这一位上允许出现哪些字符"。最基础的写法是方括号,[abc]表示这一位可以是 a、b 或 c 中的任意一个;[a-z0-9_]表示小写字母、数字或下划线;[^abc]加了脱字符,表示"除了这三个之外的任意字符"。方括号里的脱字符只在开头才有"取反"的含义,写在中间就是个普通字符,这点经常被搞混。
为了方便,正则提供了几组简写:\d是数字,\w是"单词字符",\s是空白字符(空格、制表符、换行等),它们的大写形式\D、\W、\S分别表示取反。这里有个非常容易踩的坑:\w的含义在不同引擎、不同模式下并不一样。Python 3 里对str做匹配时默认走 Unicode 语义,\w会匹配中文、日文等各类文字,所以re.findall(r'\w+', '你好 world')会拿到['你好', 'world']。而 JavaScript 里如果不加u标志,\w只认 ASCII 字母数字下划线,中文匹配不到。同一条正则换个环境结果不同,八成就是这里出的问题。
点号.是另一个高频误解源。默认情况下它匹配"除换行符以外的任意一个字符",也就是说它不匹配\n。想让它跨行匹配,Python 里要加re.DOTALL,JavaScript 里要用s标志。我早期写日志解析,模式里用了.*却怎么也跨不过行,折腾了很久才想起这个默认行为。
3.2 量词与贪婪:接着定"匹配多少"
量词决定前面那个单元重复几次:*是零次或多次,+是一次或多次,?是零次或一次,{3}是正好三次,{2,5}是两到五次,{2,}是至少两次。
真正需要理解的是贪婪与懒惰。默认所有量词都是贪婪的,意思是"能多拿就多拿"。比如对字符串<a>1</a><b>2</b>用<.*>去匹配,结果是整串<a>1</a><b>2</b>,因为.*一路吃到行尾才回头。在量词后面加个问号变成<.*?>,就切换成懒惰模式,"够用就停",于是第一次匹配到<a>就收手。
这里有个很实用的判断方法:如果你要提取两个标记之间的内容,几乎总是该用.*?而不是.*。比如提取引号里的内容,用"(.*?)";如果写成"(.*)",一行里有多个引号对时,它会从第一个引号吃到最后一个引号,把中间的分隔也算进去。我自己在解析"键=值;键=值"这类配置串时,被这个行为坑过不止一次。
还要注意一个细节:.+?至少匹配一个字符,而.*?允许匹配空字符串。做提取时如果目标可能为空,就要用后者,否则会漏掉那些"冒号后面什么都没有"的行。
3.3 分组、捕获与命名分组
圆括号(...)有两个作用:把一段模式打包成一个整体,以及把它匹配到的内容捕获下来,供后续使用。比如(\d{4})-(\d{2})-(\d{2})匹配日期时,三个括号分别捕获年、月、日,Python 里用m.group(1)、m.group(2)就能取到。
如果只是想分组、不想捕获,用(?:...),也就是非捕获分组。它的好处一是语义清楚,二是减少引擎的记账开销,长正则里能省一点性能。我在写复杂模式时习惯把所有纯粹为了组织量词的括号都写成非捕获形式,只保留真正需要取值的那些。
命名分组是可读性的关键。Python 写法是(?P<year>\d{4}),JavaScript 写法是(?<year>\d{4}),取用时m.group('year')。当一条正则里有五六个捕获组,靠数字下标group(5)去取是很危险的,改动一下模式顺序就全乱套了。命名分组能让代码自解释,这个投入非常值。
还有反向引用,\1表示"和第一个捕获组匹配到的内容一模一样"。经典用途是找重复词,比如\b(\w+)\s+\1\b能匹配"这个 这个"这种重复。它的机制是引用匹配结果,不是引用模式本身,所以(\w)\1匹配的是两个相同字符,比如aa、bb。
3.4 锚点与零宽断言:站在原地看着前后
^和$是最常用的锚点,分别表示字符串开头和结尾。这里有个坑:默认情况下^和$在"多行模式"下才会匹配每行的行首行尾,Python 里要加re.MULTILINE(简写re.M),JavaScript 里用m标志。不加这个标志时,^只认整段文本的最开头。
\b是单词边界,它不是字符,而是"位置的判断":左边是单词字符、右边不是,或者反过来,这个位置就是边界。写\bcat\b可以避免匹配到concatenate里的cat。
零宽断言是初学者觉得最玄的部分,但其实直觉很简单:它不消费字符,只是站在原地朝某个方向看一眼,看看那一段是否符合条件。(?=...)是正向先行断言,往后看;(?!...)是负向先行断言,往后看不该出现什么;(?<=...)和(?<!...)分别是正向后行和负向后行断言,往前面看。举个例子,想匹配"后面跟着元"的数字,写成\d+(?=元),这样100元会匹配出100,但结果的字符组成里不含"元",因为那个位置只是被"看了一眼",没被吃掉。
后行断言有个额外限制:很多引擎要求括号里的长度是固定的,Python 的re就属于这一类,(?<=\d+)会直接报错。变长后行断言的替代方案是用捕获组加切片,或者换用支持变长后行的引擎(PCRE、.NET、新版 JavaScript)。这个限制我在做金额提取时被卡过一次,记得提前躲开。
3.5 转义与反斜杠层数:最容易被忽视的坑
正则里\是转义字符,而绝大多数编程语言的字符串字面量里\也是转义字符,两层转义叠在一起,就是各种诡异报错的源头。Python 里我强烈建议一律用原始字符串:写r"\d+"而不是"\d+",前者原样传给引擎,后者在字符串层面先把\d处理一遍,出来的东西可能和你以为的不一样。
这个坑在跨层传输时会被放大。有些接口在参数校验层用正则约束字段格式,当它拿到一个格式不合法、或者转义层数不对的模式时,会直接返回类似"给到的不是合法正则"的报错,让人一脸茫然。遇到这种情况,排查顺序是:先确认这个模式在字符串层面是不是已经变成了别的字符(打印出来看真实内容),再确认是否被 JSON 转义又翻了一倍(JSON 里\要写成\\),最后再检查语言层面的写法。我的一般做法是:能用原始字符串就绝不用普通字符串,能少一层转义就绝不多加一层。
4. 高频实战:把常用正则拆到字符级
4.1 手机号:从 11 位到 13 位带前缀的写法演进
先说最常见的 11 位手机号。很多教程给的版本是^1[3-9]\d{9}$,逐段拆:^锚定开头,1要求第一位是 1,[3-9]要求第二位是 3 到 9 之间的数字,\d{9}要求后面还有 9 个数字,$锚定结尾。合起来 1+1+9 正好 11 位。
为什么第二位不写成[0-9]?因为号段是有规划的,第二位是 0、1、2 的情况在实际分配里基本不会出现。校验类的正则宁可稍微收紧一点,也不要放太宽——把明显不可能的值挡在外面,能减少后面业务逻辑的分支判断。当然也要意识到,号段会随着时间变化,写成[3-9]也是一种权衡,需要在准确性和长期稳定性之间选。
现在很多人问的是"13 位数字的手机号码正则怎么写"。这里其实是两种不同的需求混在一起了。一种是要匹配"纯 13 位数字",比如某些内部编号或带国家码的完整号码,那就是^\d{13}$;如果还想卡住号段特征,比如要求是 86 开头的完整形式,就写^861[3-9]\d{9}$。另一种是"11 位号码可以带也可以不带 86 前缀",写成^(?:86)?1[3-9]\d{9}$,其中(?:86)?用非捕获分组加问号表示"这一段可有可无"。
再进一步,实际业务里用户输入的号码经常带空格、短横线、括号,比如138-1234-5678或(86) 13812345678。这时候我通常分两步走:先用re.sub(r'[\s\-()]', '', raw)把噪音字符全清掉,再用严格正则在干净字符串上校验。把"清洗"和"校验"分开,比写一条兼容所有书写形式的正则要可靠得多,报错信息也更容易定位。
4.2 邮箱、日期、IP:三条经典模式的取舍
邮箱正则流传最广的是^[\w.+-]+@[\w-]+\.[\w.-]+$。它由三部分组成:本地部分(允许字母数字下划线点加号减号)、@、域名部分(允许字母数字减号,至少一个点,后面还能跟更多段)。这条能挡住绝大多数明显错误的输入,比如没有@、@后面没有点的情况。
但要清醒地知道它的边界:它挡不住a@b.c这种顶级域名只有一位的情况,也挡不住连续两个点。能不能写得更严格?可以,但收益递减。业界普遍的做法是正则做形式初筛,真正的合法性靠发验证邮件确认。这比死抠正则划算得多。
日期正则要注意顺序陷阱。^\d{4}-\d{2}-\d{2}$只能保证形状对,2024-99-99照样通过。想收紧一点可以写^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$,月限定在 01 到 12,日限定在 01 到 31。但 2 月 30 号这种问题,正则还是管不了,得交给日期库去解析。我自己的习惯是:正则卡形状,datetime卡真实性。
IP 地址正则^(?:(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)\.){3}(?:25[0-5]|2[0-4]\d|1\d{2}|[1-9]?\d)$看起来很长,但拆开就是同一个"0 到 255"的分支重复了四次。这个 0-255 分支值得记一下:25[0-5]管 250 到 255,2[0-4]\d管 200 到 249,1\d{2}管 100 到 199,[1-9]?\d管 0 到 99。四段拼起来刚好覆盖全部取值,不重不漏,这是正则里少见的"枚举得干干净净"的漂亮例子。
4.3 从日志里提取字段:命名分组的实战用法
来看一段典型的访问日志,形如:
10.12.3.45 - - [12/May/2024:09:31:02 +0800] "GET /api/orders?page=2 HTTP/1.1" 200 1532我要抓出 IP、时间、方法、路径、状态码。写出来是这样:
import re LOG_RE = re.compile( r'(?P<ip>\d{1,3}(?:\.\d{1,3}){3})\s+-\s+-\s+' r'\[(?P<time>[^\]]+)\]\s+' r'"(?P<method>[A-Z]+)\s+(?P<path>\S+)\s+[^"]*"\s+' r'(?P<status>\d{3})\s+(?P<size>\d+)' ) line = '10.12.3.45 - - [12/May/2024:09:31:02 +0800] "GET /api/orders?page=2 HTTP/1.1" 200 1532' m = LOG_RE.search(line) if m: print(m.groupdict())几个设计上的取舍值得说。IP 部分用了\d{1,3}(?:\.\d{1,3}){3},这里没有用前面那条严格的 0-255 版本,因为日志里的地址是可信来源,严格校验反而浪费性能。时间部分用[^\]]+而不是.+?,因为"方括号里不会有方括号"这个前提成立,用取反字符类比懒惰匹配更精准,也更快。路径用\S+表示"非空白字符连成一串",避开了空格分隔的问题。
这里再强调一遍命名分组的好处:m.groupdict()直接返回一个字典,字段名自带语义,后面写业务逻辑时不用回头翻正则数括号。团队协作时,这一点能省掉大量沟通成本。
5. 落地差异:Python、JavaScript 与 SQL Server 各有脾气
5.1 Python 的 re 模块:几个必须知道的用法
Python 里我建议养成re.compile()的习惯,尤其是正则在循环里反复使用时。编译后的模式对象可以复用,省掉重复解析的开销。取匹配结果有四个常用入口:match()从字符串开头尝试,search()扫描整个字符串找第一处,findall()返回所有匹配的列表,finditer()返回迭代器。
findall()和finditer()的区别很多人踩过:当模式里有捕获组时,findall()返回的是元组列表而不是完整匹配串。比如用(\d{4})-(\d{2})去做findall,拿到的是一堆('2024', '05'),如果本意是想要2024-05,那就得把括号改成非捕获形式或者换finditer。我见过同事在这上面查了半小时。
替换用re.sub(),第三个参数是替换内容,想引用捕获组可以用\1或者函数形式。用函数形式更灵活,比如要把文本里所有数字加一:
import re text = "订单号 A100 和 B205 已发货" result = re.sub(r'([A-Z])(\d+)', lambda m: f"{m.group(1)}{int(m.group(2)) + 1}", text) print(result) # 订单号 A101 和 B206 已发货另外re.VERBOSE标志值得推荐。加上它以后,正则里的空白和换行会被忽略(除非放进字符类或用\转义),还能写#注释。长正则在生产代码里几乎必须这么写,否则半年后没人看得懂。
5.2 SQL Server 没有原生正则,别硬上
这是很多人会误解的一点。SQL Server 的 T-SQL 本身不提供正则表达式函数,LIKE和PATINDEX用的是通配符语法,不是正则。这是必须先说清楚的前提,否则你会一直在文档里找一个不存在的函数。
但它也不是完全没辙。T-SQL 的LIKE支持一套比标准 SQL 更丰富的通配符:%匹配任意长度字符串,_匹配单个字符,[abc]匹配字符集中的一个,[a-z]匹配范围,[^abc]表示取反。有了这套,简单的格式判断是可以做的。比如在用户表里筛出手机号形如 1 开头、第二位 3 到 9 的 11 位记录:
SELECT UserId, Phone FROM Users WHERE Phone LIKE '1[3-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]';一定要写满 10 个[0-9],因为%是"任意长度",写LIKE '1[3-9]%'会把 20 位、50 位的脏数据也捞出来。想要严格长度,_和[0-9]是唯一的选择。PATINDEX用来找位置,返回第一次出现的位置索引,找不到返回 0,可以配合WHERE PATINDEX('%[^0-9]%', Field) = 0来判断"整列全是数字"。
真的需要完整正则能力时,常见路径有两条:一是通过 CLR 集成把 .NET 的正则能力挂进数据库(需要开启相应配置并部署程序集,属于运维层面的事,得和 DBA 沟通);二是把过滤逻辑挪到应用层,用 Python、Java 这类语言处理。我个人的倾向是后者,尤其是数据量不大或者可以做分页处理的场景——数据库专心做它擅长的事,文本处理交给应用层的正则,边界清晰,排查也方便。
5.3 引擎口味对照表
跨语言用正则时,下面这些差异建议先扫一眼再动手:
| 特性 | Python re | JavaScript | 说明 |
|---|---|---|---|
| 命名分组语法 | (?P<name>...) | (?<name>...) | 取用方式也不同 |
| 变长后行断言 | 不支持 | 较新版本支持 | Python 需绕行 |
\w是否含中文 | 默认包含(str) | 加u才包含 | 最容易踩的差异 |
| 点号跨行 | re.DOTALL | s标志 | 默认都跨不过换行 |
| 多行锚点 | re.MULTILINE | m标志 | 不加只认整串首尾 |
| 默认替换范围 | 全部替换 | 需加g标志 | JS 容易漏掉全局 |
最后一行特别值得留意。JavaScript 的str.replace(/a/g, 'b')必须带g才是全局替换,不带就只换第一处。我见过线上数据只被替换了一部分,排查半天最后发现是漏了个标志位。
6. 性能与调试:别让一条正则在生产上打满 CPU
6.1 回溯是怎么一步步吃掉 CPU 的
正则引擎的回溯机制是个双刃剑。它让正则写起来灵活,但也会在特定模式下产生指数级的时间消耗。经典的危险模式是嵌套量词,比如(a+)+b。拿它去匹配一长串a后面跟着一个其他字符的文本时,引擎会尝试各种把a分组的方式,组合数量随长度指数增长。字符串长度到 30 左右,执行时间可能就上天了。
更贴近实际的危险模式是^(\w+\s?)*$这类,本意是校验"由单词和空格组成的行"。看着挺合理,但如果输入末尾有个非单词非空格的特殊字符,就会触发大规模回溯。
避免的原则有三条。第一,能用具体字符类就别用点号,[^\]]+比.*?既快又准。第二,避免在量词里嵌套量词,尤其别让内层和外层能匹配同一批字符。第三,能用锚点就加锚点,开头加上^能让引擎尽早失败,而不是在每个位置都试一遍。另外,如果只是判断"有没有",用re.search()配合简单模式,别用捕获组做多余的工作,这在处理大批量数据时差距很明显。
6.2 我的调试流程和工具组合
拿到一条不好使的正则,我的排查流程基本固定:先确定目标文本的真实样子,把待匹配的字符串打印出来看有没有隐藏的换行、制表符、零宽字符,很多"明明看着对"的问题都出在这里。然后把正则从长到短拆着测,先确认外层框架能匹配上,再往里逐层收缩。
工具上,交互式验证是效率最高的方式。regex101和RegExr这类在线工具能高亮匹配结果,还能逐步骤展示回溯过程,对理解"为什么没匹配上"帮助极大。要留意的是,这些工具可以切换语言(PCRE、Python、JavaScript 等)和标志位,测的时候记得选成和你运行环境一致的选项,否则在工具里能过、到代码里失败,白折腾。
编辑器内置的查找替换也是很好的日常练习场。VS Code、Notepad++ 都支持正则搜索,做文件批量改名、日志清理时非常顺手。我现在习惯先用编辑器把模式调通,再搬到 Python 里写成代码,这样能避开代码—运行—看报错这个慢循环。
7. 常见问题与排查速查
7.1 症状对照表
这些年被问到的问题里,下面这些重复率最高,整理成表方便对照:
| 症状 | 典型原因 | 处理方式 |
|---|---|---|
| 明明看着能匹配,返回 None | 锚点不匹配、隐藏字符、^$未开多行模式 | 打印原始串 repr,检查标志位 |
| 匹配结果比预期长 | 用了贪婪量词 | 改成*?或+? |
| 中文匹配不到 | \w语义差异 | Python 直接用,JS 加u标志或改字符类 |
findall返回元组 | 模式里有捕获组 | 改非捕获(?:...)或用finditer |
| 后行断言报错 | 引擎不支持变长后行 | 换写法或用捕获组切片 |
| 替换只生效一次 | JS 漏了g标志 | 补上g |
| 模式传到接口被判为非法 | 转义层数叠加出错 | 打印真实字符串,逐层核对反斜杠 |
| 匹配瞬间卡死 | 嵌套量词引发回溯 | 拆分模式、加锚点、缩短量词范围 |
7.2 我自己的避坑清单
最后分享几条从实际项目里攒下来的经验,都不是文档里会专门写的:
第一,正则一定要写注释和测试用例。我会在测试文件里放一组"应该匹配"和"不应该匹配"的样本,改动模式后跑一遍。这比靠脑子记可靠得多,尤其在多人协作的项目里。
第二,校验类正则不要互相套用。手机号、邮箱、身份证各有各的规则,别想着写一条"通用校验"。规则独立、失败信息明确,排查成本会低很多。
第三,注意长度上限。像^1[3-9]\d{9}$这类模式,如果忘了$,13812345678999999也能匹配上。凡是校验整串的,两端锚点一个都不能少。
第四,别在校验里塞业务逻辑。正则只回答"形状对不对","这个号码有没有被占用""这个邮箱是不是企业域名"属于业务规则,该查库就查库。把这两层混在一起,早晚会出问题。
第五,慢正则比错正则更危险。错正则至少会报错让你发现,慢正则会安安静静地在生产上吃掉 CPU。上线前用接近真实长度的数据跑一次压测,这一步别省。
我自己用了这么多年正则,最深的感受是:它不难,难的是养成"先想清楚文本形状,再动手写模式"的习惯。大部分人卡住,不是因为语法记不住,而是因为跳过了这一步,直接开始试。把待处理的数据先看几遍,把边界情况列出来,模式往往一次就能写对。至于那些长到吓人的生产级正则,其实也不过是若干个小片段拼起来的,拆开看,每一段都很朴素。