上周帮一个做运营的朋友处理数据,他从旧系统导出的客户表里,手机号那一列简直是灾难:有的用横线分隔,有的带着中英文括号和空格,还有几行直接把两个号码挤在一个单元格里。手动整理的话,大几百条数据至少要一整天。当时我用了一条正则表达式配合脚本批量清洗,半小时不到全部规整完。这件事让我觉得,正则表达式的应用范围远比大多数人想象得广,它不只在程序员写代码时出现,日常文本处理、数据校验、爬虫提取、日志分析里都离不开它。今天我想从实际应用出发,聊聊正则表达式的语法核心、高频表达式、跨语言差异和性能陷阱,希望能帮刚接触正则的朋友少走弯路,也给老手提供一份随手能用的参考。
1. 正则表达式到底解决什么问题:从一次数据清洗说起
1.1 一个真实案例:几百条混乱手机号的处理
那批数据里,手机号是以各种形式混在备注文本中的。比如:
13812345678 138-1234-5678 (010) 88888888 138 1234 5678 13911112222/13733334444需求是把里面所有手机号单独提取成一行,座机号和多余符号全部不要。我当时的做法是先定义一条手机号正则:1[3-9]\d{9},然后用Python的re.findall把文本里所有符合这个模式的数字串提出来。核心代码很简单:
import re raw = open('customers.txt', encoding='utf-8').read() pattern = re.compile(r'1[3-9]\d{9}') phones = pattern.findall(raw) with open('phones.txt', 'w', encoding='utf-8') as f: f.write('\n'.join(phones))这段正则的含义是:以1开头,第二位是3到9,后面跟着9位数字。findall会返回文本中所有不重叠的匹配项,所以第五行那种两个人同一个单元格的,也能一次性拆出两个手机号。这里的关键点是第二位用[3-9],它自然而然地排除了(010) 88888888这类座机号。如果写成1\d{10},就会把座机区号和号码里的部分片段误抓出来。
实操中有个细节我吃过亏:re.sub是静默替换,如果正则写得不严谨,它会悄悄把不该改的内容也改掉。所以我建议先备份原数据,用findall提取结果写进新文件,确认无误后再做替换类操作。另外,如果文本里混有138 1234 5678这种带空格的号码,直接匹配会失败,需要先re.sub(r'\s+', '', raw)把空格去掉,或者把正则改成1[3-9]\d{1}\s?\d{4}\s?\d{4}来兼容;但后者容易误伤,尽量先做规范化。
1.2 正则的思维模型:用"模式"代替"逻辑"
刚接触正则的朋友最容易犯的错,是想用写代码的思路去套。比如碰到一段文本,第一反应是先split、再判断长度、再检查前缀。这种命令式的思路不是不可以,但程序会很长,而且边界条件特别多。正则的核心思维方式是"描述你想要的,而不是一步步告诉程序怎么做"。
打个比方:你让家政阿姨打扫房间,不需要告诉她"先拿起抹布、再去卫生间、然后弯下腰擦第三个抽屉",你只需要说"把所有带污渍的表面擦干净"。正则是基于模式匹配的声明式工具。你写\d{11},就是在说"我要11位连续数字"。至于它出现在哪一行、前面是什么、后面是不是汉字,都不用管。这种思维方式在文本处理领域很好使,尤其是面对一堆格式不统一、来源复杂的数据时,正则往往是性价比最高的方案。
不过,正则并不是万能的,它没有变量、没有循环、没有函数调用,真正复杂的业务逻辑还是要靠宿主语言(Python、PHP、Java、Perl等)去实现。理解这一点非常重要,后面我会专门讲怎么把正则和代码配合起来。
2. 语法核心:字符、量词、断言这三块基石
正则语法看起来五花八门,但核心无非三件事:匹配什么样的字符(字符)、匹配多少个(量词)、在什么位置匹配(断言)。把这三块吃透,大部分表达式都能看懂,也能自己写出来。
2.1 字符类与转义:别小看\d和[]
字符是最基本的概念。\d表示数字,等价于[0-9];\w表示字母、数字、下划线,等价于[A-Za-z0-9_];\s表示空白符,包括空格、Tab、换行。它们都是转义字符,在绝大多数语言里都有对应。
这里有一个常见误区:\d只匹配ASCII数字0-9,匹配不了全角数字(123)。如果数据里混入了全角字符,正则就找不到,必须先做归一化转换。另一些语言里可以用\p{N}加上unicode属性去匹配各种数字,但普通场景下直接用[0-9]更可控。
中括号[]用来定义字符集,比如[a-zA-Z]匹配所有字母,[^0-9]匹配非数字。字符集里有一些小坑:连字符-放在开头或结尾表示字面意义上的减号,比如[-a]只匹配减号或字母a;如果放在中间如[a-z]就是范围。同样,点号.在正则里是"任意字符"的意思,想匹配字面上的点必须写成\.。这些转义在字符串里还要注意,比如PHP字符串里写正则,反斜线本身要先转义,所以\.在代码里要写成"\.",Java里则要写成"\\.",很多人第一次用Java写正则就是被这个双层转义搞晕的。
2.2 量词与贪婪模式:性能问题的根源
量词决定前面那个字符出现多少次。*表示0次或多次,+表示1次或多次,?表示0次或1次,{m,n}表示m到n次。比如\d{3,4}匹配3到4位数字。
| 量词 | 含义 | 示例 |
|---|---|---|
* | 0次或多次 | ab*匹配 a、ab、abb |
+ | 1次或多次 | ab+匹配 ab、abb,不匹配 a |
? | 0次或1次 | colou?r匹配 color、colour |
{m,n} | m次到n次 | \d{3,4}匹配3或4位数字 |
这里的关键问题是贪婪匹配:默认情况下,*和+都是尽量多吃字符,直到整个表达式无法匹配时才退回。这就是回溯的起点。
经典的例子是匹配HTML标签。<.+>的本意是匹配一对尖括号,但因为+是贪婪的,它会把<div>content</div>整个都吃掉,直到最后一个>才停。用非贪婪写法<.+?>才能匹配到<div>这种最短片段。在大多数正则引擎里,在量词后面加一个?就是非贪婪模式:<.+?>。
贪婪与懒惰不只是一个语义差别,它对性能影响非常大。如果模式写成<.*>.*,加上输入文本很长,引擎会不断尝试各种划分方式,一旦某次匹配失败,回溯次数会成倍增长。这一点我在后面"灾难性回溯"那一章会专门展开。写正则时,能用字符类限制范围就尽量别用.*,能用锚点就尽量别裸奔,这是避免性能问题的最基本招数。
2.3 断言与捕获:不消费字符的匹配
断言这个东西,新手觉得抽象,老手用起来是真香。它用来判断"当前位置是否符合条件",本身不消耗字符。常见的四个:
(?=...)正向先行断言,比如\d(?=元)匹配"元"前面的数字。(?!...)负向先行断言,比如\d(?!元)匹配后面不是"元"的数字。(?<=...)正向后行断言,比如(?<=@)\w+匹配@符号后面的单词。(?<!...)负向后行断言,比如(?<!@)\w+匹配前面不是@的单词。
后行断言在部分语言里有长度限制,Java里要求固定长度,Perl和PHP相对宽松一些。使用断言可以免去事后从结果里再去掉前缀后缀的麻烦。捕获分组和断言经常一起出现。小括号()有两个作用:一是改变优先级或组合,二是把匹配到的内容捕获到分组里。(?:)是非捕获分组,只用来组合,不占分组号。命名分组(?<name>...)让代码可读性提升很多,比如在Python里用m.group('phone')取结果。
举个例子:从"联系人:张三,电话:13812345678"这行文本里提取手机号,可以直接写(?<=电话:)1[3-9]\d{9},这样匹配结果就天然只包含号码,不包含"电话:"这几个字。比起先匹配整个1[3-9]\d{9}再去代码里切字符串,这种方式更直接,也更不容易出错。
3. 高频表达式拆解:身份证号、邮箱、URL 的实战写法
很多人收藏了一堆正则表达式,直接复制粘贴却用不了,原因多半是没理解表达式背后的边界条件。这一章我从三个最常被搜索的场景出发,讲讲怎么写出能用的表达式。
3.1 身份证号码:不只是 18 位数字那么简单
搜索"java 身份证号码如何用正则表达式校验"的人很多,因为这是许多注册系统的刚需。身份证号是18位,由6位地区码、8位出生日期、3位顺序码和1位校验码组成。最基本的正则只是限制位数和结尾,比如^\d{17}[\dXx]$,这个只能挡住明显不像是身份证的输入,但挡不住123456789012345678。
稍微严格一点,可以用^[1-9]\d{5}(19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$。这个表达式把出生日期的月份限制在01-12,日期限制在01-31,地区码首位不能为0。但是注意,它仍然允许2月30日这种不存在的日期。正则本质上是文本模式的校验,现实世界的业务规则比如闰年、大小月、校验码,必须靠代码配合。
我实际写身份证校验时的做法是:先用正则做格式初审,再用代码做日期范围和校验码校验。Java里可以这样写:
public static boolean isIdCard(String id) { String regex = "^[1-9]\\d{5}(19|20)\\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\\d|3[01])\\d{3}[\\dXx]$"; if (!id.matches(regex)) return false; // 这里再做日期合法性检查与最后一位校验码验证 return checkIdCardDate(id) && checkIdCardChecksum(id); }Java的String.matches()要求字符串整体匹配,所以不需要额外加^...$;但如果你用Pattern.compile(regex).matcher(id).find(),那就必须自己加首尾锚点,否则它会匹配字符串的一部分,校验就会失效。这个区别特别容易踩坑。
校验码的计算是:前17位每位乘以固定权重,求和后模11,得到0-10的数字,分别对应1 0 X 9 8 7 6 5 4 3 2。最后一位校验码是X的,通常用户输入大小写都要接受。这块代码逻辑不复杂,但和正则配合起来,才能形成一个真正可信的校验工具。
3.2 邮箱与URL:写完记得测试边界
邮箱正则也是重灾区。网上流传最广的版本^[\w.+-]+@[\w-]+(\.[\w-]+)+$,能用,但不算严谨。比如它允许a@b这种没有顶级域名的形式;反过来,有些真正合法的邮箱又可能被拦掉。如果只是给普通用户注册用,我建议用一个"够用且不误杀"的版本:
^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$这个要求域名中至少有一个点,顶级域名至少两个字母。它仍然不是100%严格,但注册场景已经够了。重要的是边界测试:空字符串、user@domain、user+tag@domain.com、结尾带空格、大写邮箱。很多问题不是出在正则本身,而是出在没做trim就用了matches()。
URL正则稍微复杂一点,因为要同时支持带协议、可选端口、查询参数和锚点。我常用的一个版本是:
^https?://[^\s/$.?#].[^\s]*$这个是宽松版,只用来从文本中快速判断"这一段像不像URL",不适合做严格域名校验。如果真要解析URL,建议用各语言自带的URL类,正则负责匹配提取,解析交给标准库,分工明确。比如在Java里,先Pattern.compile(regex).matcher(text).find()找到URL片段,再用new URL(foundString)去解析协议、主机、端口,这样远比用一条超长正则试图覆盖所有边界可靠。
4. 爬虫里的正则应用:HTML提取的正确姿势
搜"spider爬虫正则表达式"的人,多半是想从网页里抠数据。正则确实能干这个活,但直接用正则去解析完整的HTML文档,通常是一场灾难。
4.1 为什么直接匹配 HTML 经常翻车
HTML不是正则语言,它有嵌套结构、属性顺序可以随意变化、标签大小写不固定,还有换行和多余空格。比如你想匹配<div class="content">,但实际页面里可能是<div class = "content">,或者<DIV class="content">,甚至属性顺序完全反过来。写正则去照顾所有可能情况,复杂度会迅速失控。
所以我的经验是分层处理:先用解析器(Python的BeautifulSoup/lxml、Java的Jsoup)把HTML变成DOM树,按结构定位到你感兴趣的区域,再用正则去提取单个字段、清理文本、匹配数字、识别URL参数。比如目标是在一个商品列表页提取所有商品ID,可以先用find_all('div', class_='item')拿到每个商品块,再对每个块用re.search(r'id=(\d+)', block)提取ID。这样正则只处理小块文本,翻车概率小得多,性能也好很多。
4.2 链接、图片和正文的正则提取实战
但有些场景直接用正则反而更方便,比如从一个大文本里快速抽出所有链接或图片地址。基础的链接提取可以写:
href="([^"]+)"配合soup.find_all('a'),再取href属性,比正则更稳。可如果你手头没有解析器,必须直接在HTML源码里抓,那可以配合多种引号情况:
<[aA][^>]+href=["']?([^"' >]+)["']?这个表达式允许单引号、双引号或者没有引号的属性值,但它也会误抓一些JavaScript里的字符串。所以在爬虫场景里,我更推荐结构优先:先用选择器定位到<a>标签,再用正则从href里提取URL并拼接绝对地址。图片提取同理,<img[^>]+src=["']([^"']+)["']>,注意有些站点用的是>$line =~ s{(\d{4})-(\d{2})-(\d{2})}{$1/$2/$3}g;
替换操作可以换成花括号定界符,这样替换部分里就不需要转义斜线,代码更清爽。在开发环境中,Perl正则调试更直观,模式匹配出错往往第一时间能发现。不过到了大型系统里,Perl的正则因为过于灵活,可读性是个大问题,高强度的Perl代码久了连作者自己都要翻文档。
6. 正则的性能陷阱与调试方法:别再让表达式打垮服务
正则用得好是瑞士军刀,用得不好就是性能炸弹。这一章聊两个问题:为什么有些正则在数据量一大就卡死,以及怎么有效调试和优化。
6.1 灾难性回溯是怎么发生的
考虑一个表达式:^(\d+)+$,本意是判断一串字符是否全是数字。如果输入是123456789,引擎很快匹配成功。可如果输入是123456789a呢?引擎会尝试各种方式:(\d+)+可以先让内层吃掉9位,外层再尝试匹配,发现后面是a,然后回溯,让内层少吃一位,外层补一位,继续失败,再回溯,再尝试。随着位数增加,尝试组合数呈指数级增长。我一个朋友曾经在生产环境遇到过这种情况:用户输入了一个很长的"数字加字母"字符串,请求直接超时,CPU打满,就是因为接口里的正则^(\d+)+$触发了灾难性回溯。
类似的高危模式还有(a+)+、(\w*)*、(.*)*这类嵌套量词。这里的本质是正则引擎(尤其是传统NFA)在遇到"可以有很多种划分方式,但整体匹配失败"时,被迫遍历所有可能路径。避免办法很简单:不要写嵌套的量词,能用字符类就别用.*,能加锚点就加锚点。
6.2 优化思路与工具
如果已经遇到性能问题,我一般按这个顺序排查:
- 先看是不是有嵌套量词或
.*滥用,改成更精确的字符类。 - 再看能不能加锚点。匹配失败时,锚点能让引擎快速放弃不可能的位置。
- 能用非贪婪就非贪婪,但注意非贪婪不一定更快;真正要避免的是"匹配失败时的大量回溯"。
- 某些引擎支持原子组
(?>...)和占有量词++,它们会放弃回溯,能显著降低回溯次数,但语义要自己确认好。 - 在大批量数据处理前,先用几千行样本做性能测试,别拿全量数据直接跑。
调试工具方面,regex101.com 是我用得最多的。它能实时高亮分组、给出匹配步骤和回溯次数,能直观看出一个表达式在什么位置开始卡壳。另一个debuggex.com可以把正则转换成可视化状态图,适合理解复杂模式。正则调优不是玄学,你只要盯住"匹配失败时走了多少步",就知道表达式健不健壮。
最后再分享一个小技巧:我在处理文本时,永远会准备一个包含正常值、边界值、非法值三个维度的测试文本。正常值用来验证功能正常,边界值是空字符串、超长字符串、带Unicode的字符串,非法值专门用来触发最坏情况。正则这东西,收藏再多不如动手跑几轮,知道了它的脾气,你才能真正把它用好。