ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

正则表达式全解析:语法骨架、手机号/日志实战与性能避坑

正则表达式全解析:语法骨架、手机号/日志实战与性能避坑 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.DOTALLJavaScript 里要用s标志。我早期写日志解析模式里用了.*却怎么也跨不过行折腾了很久才想起这个默认行为。3.2 量词与贪婪接着定匹配多少量词决定前面那个单元重复几次*是零次或多次是一次或多次?是零次或一次{3}是正好三次{2,5}是两到五次{2,}是至少两次。真正需要理解的是贪婪与懒惰。默认所有量词都是贪婪的意思是能多拿就多拿。比如对字符串a1/ab2/b用.*去匹配结果是整串a1/ab2/b因为.*一路吃到行尾才回头。在量词后面加个问号变成.*?就切换成懒惰模式够用就停于是第一次匹配到a就收手。这里有个很实用的判断方法如果你要提取两个标记之间的内容几乎总是该用.*?而不是.*。比如提取引号里的内容用(.*?)如果写成(.*)一行里有多个引号对时它会从第一个引号吃到最后一个引号把中间的分隔也算进去。我自己在解析键值;键值这类配置串时被这个行为坑过不止一次。还要注意一个细节.?至少匹配一个字符而.*?允许匹配空字符串。做提取时如果目标可能为空就要用后者否则会漏掉那些冒号后面什么都没有的行。3.3 分组、捕获与命名分组圆括号(...)有两个作用把一段模式打包成一个整体以及把它匹配到的内容捕获下来供后续使用。比如(\d{4})-(\d{2})-(\d{2})匹配日期时三个括号分别捕获年、月、日Python 里用m.group(1)、m.group(2)就能取到。如果只是想分组、不想捕获用(?:...)也就是非捕获分组。它的好处一是语义清楚二是减少引擎的记账开销长正则里能省一点性能。我在写复杂模式时习惯把所有纯粹为了组织量词的括号都写成非捕获形式只保留真正需要取值的那些。命名分组是可读性的关键。Python 写法是(?Pyear\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.MJavaScript 里用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 个数字$锚定结尾。合起来 119 正好 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.-]$。它由三部分组成本地部分允许字母数字下划线点加号减号、、域名部分允许字母数字减号至少一个点后面还能跟更多段。这条能挡住绝大多数明显错误的输入比如没有、后面没有点的情况。但要清醒地知道它的边界它挡不住ab.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 到 2552[0-4]\d管 200 到 2491\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?page2 HTTP/1.1 200 1532我要抓出 IP、时间、方法、路径、状态码。写出来是这样import re LOG_RE re.compile( r(?Pip\d{1,3}(?:\.\d{1,3}){3})\s-\s-\s r\[(?Ptime[^\]])\]\s r(?Pmethod[A-Z])\s(?Ppath\S)\s[^]*\s r(?Pstatus\d{3})\s(?Psize\d) ) line 10.12.3.45 - - [12/May/2024:09:31:02 0800] GET /api/orders?page2 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 reJavaScript说明命名分组语法(?Pname...)(?name...)取用方式也不同变长后行断言不支持较新版本支持Python 需绕行\w是否含中文默认包含str加u才包含最容易踩的差异点号跨行re.DOTALLs标志默认都跨不过换行多行锚点re.MULTILINEm标志不加只认整串首尾默认替换范围全部替换需加g标志JS 容易漏掉全局最后一行特别值得留意。JavaScript 的str.replace(/a/g, b)必须带g才是全局替换不带就只换第一处。我见过线上数据只被替换了一部分排查半天最后发现是漏了个标志位。6. 性能与调试别让一条正则在生产上打满 CPU6.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。上线前用接近真实长度的数据跑一次压测这一步别省。我自己用了这么多年正则最深的感受是它不难难的是养成先想清楚文本形状再动手写模式的习惯。大部分人卡住不是因为语法记不住而是因为跳过了这一步直接开始试。把待处理的数据先看几遍把边界情况列出来模式往往一次就能写对。至于那些长到吓人的生产级正则其实也不过是若干个小片段拼起来的拆开看每一段都很朴素。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进