
凡是写过多年代码的人几乎都绕不开一个又爱又恨的工具正则表达式。说它强大是因为几行看似晦涩的符号就能完成一整套文本匹配、提取、校验和替换逻辑说它折磨人是因为一个漏掉的转义符、一个过度贪婪的星号就可能让你的程序在线上静默地跑偏好几个小时。这篇文章我不打算给你抄一堆网上随处可见的公式而是从实际应用角度聊聊正则表达式——语法怎么理解、在 Java 和 PHP 里怎么落地、写爬虫时怎么用它从网页里高效抠数据以及那些坑了我很多次、大概率也会坑到你的问题。如果你刚开始接触正则这篇文章能帮你把零散的知识点串成一条线如果你已经用过一段时间那重点看第三节之后的部分尤其是踩坑实录应该能省下不少排查时间。1. 正则表达式到底解决什么问题1.1 本质一门描述文本结构的微型语言很多人把正则表达式当成“一堆奇怪符号”其实换个角度就好理解得多它是一门专门用来描述“文本长什么样”的小型语言。你告诉它目标文本的结构规则它去目标字符串里找出所有符合规则的片段。举个生活化的例子。你在一堆杂物里找钥匙如果只知道“钥匙大概十厘米长、金属材质”那找起来很慢。但如果你知道“钥匙头是圆形的、上面有三个齿”你就能快速过滤掉大部分无关物件。正则表达式干的就是这事——它用字符、量词、位置关系这些“特征描述符”在文本里精确圈定目标。它解决的核心问题主要是三类匹配校验判断一段文本是否符合预期格式比如邮箱、手机号、身份证号、日期格式。数据提取从一段杂乱文本里捞出特定内容比如从日志里抓 IP、从网页里抓链接。批量替换按规则把文本中的某些部分统一改掉比如把 Markdown 里的图片链接改成 CDN 地址。这三件事几乎覆盖了日常开发里绝大多数文本处理需求。所以正则是那种“平时不起眼一旦用起来就回不去”的技能。1.2 适合用正则的场景以及不该碰的场景我见过不少新人走两个极端要么什么都想用正则要么看到正则就慌。其实判断标准很朴素——目标文本是否有清晰、局部的结构特征。适合用正则的场景表单字段校验手机号、邮箱、身份证号、银行卡号、日期时间。日志分析提取错误码、IP、耗时数据、特定关键字。文本清洗去掉多余空格、HTML 标签、特殊符号。爬虫定向提取从 HTML 源码中抓取 URL、图片地址、标题。不适合用正则的场景嵌套结构比如 HTML 里套着一层又一层 div、JSON 里嵌着多层对象。正则本质是线性匹配处理嵌套容易失控这时候应该用专门的解析器如 BeautifulSoup、json.load 等。语法级解析比如写一个完整的表达式计算器或编译器需要词法分析和语法树正则只擅长其中“分词”这一小步。超长且复杂的匹配正则在极端情况下可能产生灾难性回溯后面详细讲性能和稳定性都容易出问题。记住一个经验法则正则做“由粗到细”的初筛很好用但不要试图用一个正则搞定所有事。拆成几步分步处理通常比攒一个巨型正则更可靠、更好维护。2. 语法核心拆解从元字符到进阶技巧2.1 元字符与字符类搭积木的起点正则的“字母表”里除了普通字符还有一批有特殊含义的元字符。理解它们就相当于拿到了搭积木的基本块。先背熟这几个最常用的元字符含义示例.匹配任意一个字符默认不包含换行a.c能匹配abc、a1c、a_c\d匹配一个数字等价于[0-9]\d{3}匹配三位数字\w匹配一个字母、数字或下划线\w匹配一个单词\s匹配一个空白符空格、制表符、换行name:\s*匹配冒号后的空格^匹配字符串开头^abc只匹配开头的abc$匹配字符串结尾abc$只匹配结尾的abc[abc]字符类匹配方括号中任意一个字符[Pp]ython匹配Python或python[^abc]否定字符类匹配除括号内字符外的任意字符[^0-9]匹配非数字字符类是个很好用的东西。比如要匹配一个十六进制颜色值#ffaabb就能写成#[0-9a-fA-F]{6}。这里的{6}就是量词指重复 6 次。2.2 量词贪婪、懒惰与独占量词控制“前面的元素重复多少次”。三个基础量词是*0 次或多次、1 次或多次、?0 次或 1 次配合花括号{n,m}可以精确控制次数。这里有一个非常关键、也是新手最容易踩坑的概念贪婪匹配。默认情况下量词是贪婪的它会尽可能多地匹配字符。比如字符串a123b456b用a.*b去匹配正则引擎会从第一个a开始一路吃到最后一个b把a123b456b整段都吞进去。很多人的正则“匹配过头了”原因就在这。解决办法是让量词变成懒惰模式在量词后面加一个?写成.*?。这样它会尽量少地匹配找到第一个b就停结果是a123b。如果追求极致性能还有独占模式在量词后面加如.*它不允许回溯性能最好但用不好会直接匹配失败。日常开发中优先用懒惰模式独占模式少碰除非你非常清楚自己在做什么。2.3 分组与引用给匹配结果做标记用小括号()可以把一段表达式包成一个整体并捕获匹配到的内容这就是分组。分组的价值有两个一是控制作用范围。比如(ab)匹配的是ab、abab、ababab而ab匹配的是abbbbb只有b重复。这个区别在实战中经常决定结果的对错。二是提取信息。比如从日志行2025-03-12 14:30:22 ERROR timeout中提取日期和时间可以写成(\d{4}-\d{2}-\d{2})\s(\d{2}:\d{2}:\d{2})第一个括号捕获日期第二个捕获时间后续就能直接引用。捕获的内容除了在代码里通过group(1)、group(2)取还可以在正则内部用反向引用。比如匹配重复单词the the可以写\b(\w)\s\1\b这里的\1引用了第一个分组的内容。另外还有非捕获分组(?:...)它只用来组合不捕获内容。如果你的分组不需要在外部引用就用它能省一点内存语义也更清楚。2.4 断言不消费字符的位置条件断言是个进阶但也非常实用的概念。它匹配的是“位置”而不是“字符”。最常见的两个是前瞻断言(?...)表示“后面必须跟着什么”(?!...)表示“后面不能是什么”。后顾断言(?...)表示“前面必须是什么”(?!...)表示“前面不能是什么”。举个例子想从一段文本中提取“后面跟着数字”的单词可以用\b\w(?\d)。断言的好处是匹配结果里不包含断言部分的内容方便精确提取。它在密码强度校验里也很常用。比如要求密码必须同时包含字母和数字可以用前瞻断言组合^(?.*[a-zA-Z])(?.*\d).{8,}$。这个表达式的思路是先“偷看”整串文本确认里面有字母再“偷看”确认有数字最后才真正匹配 8 位以上的任意字符。用断言做“多重条件校验”是非常典型的实战技巧。3. 多语言实战Java 与 PHP 里的正则落地3.1 Java 中的 Pattern 与 MatcherJava 里使用正则的标准姿势是java.util.regex包下的两个类Pattern和Matcher。基本流程分三步先用Pattern.compile()编译正则得到一个模式对象再用模式对象对目标字符串创建匹配器Matcher最后通过匹配器执行操作。import java.util.regex.Pattern; import java.util.regex.Matcher; String text 订单号BK20250312001金额299.00 元; Pattern pattern Pattern.compile(BK\\d{11}); Matcher matcher pattern.matcher(text); if (matcher.find()) { System.out.println(找到订单号 matcher.group()); }这里有两个方法经常被搞混matcher.matches()要求整个字符串完全匹配正则一般用于格式校验。matcher.find()在字符串中查找符合规则的子串用于提取。写校验逻辑时习惯用matches()写提取逻辑时用find()配合循环。提取多个结果时可以这样Pattern pattern Pattern.compile(BK\\d{11}); Matcher matcher pattern.matcher(text); while (matcher.find()) { System.out.println(找到 matcher.group()); }如果正则里写了分组提取分组内容用matcher.group(1)、matcher.group(2)。这个特性在日志解析里非常实用一行就能把一个日志条目拆成多个字段。3.2 案例用正则校验身份证号码身份证号码是很多业务系统绕不开的字段也是正则校验的经典场景。先明确规则18 位身份证由 17 位本体码和 1 位校验码组成其中前 6 位是地区码中间 8 位是出生日期最后 4 位含顺序码和校验码。所以正则校验应该分层做不能只数位数。第一步先用正则校验基本格式private static final Pattern ID_CARD_18 Pattern.compile( ^[1-9]\\d{5}(?:18|19|20)\\d{2}(?:0[1-9]|1[0-2]) (?:0[1-9]|[12]\\d|3[01])\\d{3}[0-9Xx]$ );拆开看这个正则^[1-9]\d{5}首位不能是 0后面 5 位数字组成 6 位地区码。(?:18|19|20)\d{2}出生年份前两位限定在 18、19、20后面接任意两位数字组成 4 位年份。(?:0[1-9]|1[0-2])月份01 到 09或者 10、11、12。(?:0[1-9]|[12]\d|3[01])日期01 到 31。\d{3}[0-9Xx]3 位顺序码加 1 位校验码允许 X 或 x。格式校验通过后再单独校验校验码。18 位身份证的校验算法是把前 17 位分别乘以固定权重累加后对 11 取模根据余数映射到对应的校验码。这个逻辑用代码实现比用正则更合适而且能排除“格式对但号码不对”的伪号码。public static boolean isValidIdCard(String idCard) { if (idCard null || !ID_CARD_18.matcher(idCard).matches()) { return false; } char[] chars idCard.toUpperCase().toCharArray(); int[] weights {7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2}; char[] checkCodes {1, 0, X, 9, 8, 7, 6, 5, 4, 3, 2}; int sum 0; for (int i 0; i 17; i) { sum (chars[i] - 0) * weights[i]; } return checkCodes[sum % 11] chars[17]; }我的实际建议是正则只负责“长得很像”的粗校验业务合法性的细校验交给代码逻辑。两者结合既不会漏掉明显错误也不会放行伪造号码。3.3 PHP 里的 preg 系列函数PHP 操作正则主要用preg_match、preg_match_all、preg_replace、preg_split这几个函数。和 Java 不同PHP 的正则语法是直接写在字符串里并且要求带定界符最常见的定界符是正斜杠/。先看一个校验邮箱的例子$email testexample.com; if (preg_match(/^[\w.-][\w-]\.[\w.]$/, $email)) { echo 邮箱格式合法; }再看用preg_match_all批量提取文本中的手机号$text 联系人张三 13812345678李四 13987654321; preg_match_all(/1[3-9]\d{9}/, $text, $matches); // $matches[0] 是全部匹配结果 print_r($matches[0]);preg_replace很适合做文本清洗。比如把用户输入里所有连续空白压缩成单个空格$clean preg_replace(/\s/, , $input);PHP 正则还有一个常见坑是定界符冲突。如果正则里包含/字符比如匹配 URL 路径/api/v1/order直接写/\/api\/v1\/order/就需要到处转义很丑。这时候可以换定界符比如用#或~preg_match(#/api/v1/order#, $url);这个技巧能省掉大量反斜杠可读性好很多。3.4 跨语言时的几个差异提醒正则的语法核心在各语言里是通用的但细节上有差异迁移时要注意转义方式Java 字符串里反斜杠本身需要转义所以\d要写成\\dPHP 单引号字符串里\d可以直接写但双引号字符串里有些转义序列会被解析尽量用单引号写正则。匹配模式修饰符PHP 用/i表示忽略大小写、/s让.匹配换行、/U切换为懒惰模式Java 则是在编译时传参数比如Pattern.compile(regex, Pattern.CASE_INSENSITIVE | Pattern.DOTALL)。后顾断言支持度Java 支持变长后顾断言但 PHP 的 PCRE 对变长后顾支持有限跨语言复用同一正则时尽量把断言写成定长的避免迁移后莫名其妙报错。差异虽多但核心匹配逻辑是一致的。只要在一种语言里把正则调通换语言时重点检查转义和修饰符就行。4. 爬虫场景下的正则应用4.1 为什么爬虫经常和正则绑在一起写爬虫的人几乎都会用到正则原因很现实爬虫拿到的是 HTML 源码而 HTML 本质就是一大段结构松散的文本。虽然正式解析 HTML 应该用 DOM 解析器但在很多“只想要几个特定字段”的轻量场景里用正则从源码里直接抠反而是最快、最省事的方式。更重要的是正则提取不依赖 DOM 结构是否完整。有些网页的 HTML 写得极不规范标签嵌套错误、属性缺引号解析器可能直接报错但正则只看字符模式一样能提取到目标内容。这也是正则至今在爬虫领域无法被完全替代的原因。4.2 从网页源码里提取链接、邮箱和手机号提取页面里所有链接是爬虫最常见的需求。链接结构一般是a href...可以这样写import re html a hrefhttps://example.com/page1页面1/a a href/page2页面2/a a hrefjavascript:void(0)空链接/a links re.findall(ra\shref[\]([^\])[\], html) print(links)这个正则的几个细节值得说\s允许a和href之间有空格或其他空白。[\]同时兼容单引号和双引号。([^\])是核心捕获组匹配除引号外的任意字符保证提取到完整的 URL不会被多余的引号截断。javascript:void(0)这类伪链接也会被提取出来后续需要二次过滤这是个很常见的清洗步骤。再比如从一段混杂文本里抓邮箱text 商务合作请联系 csexample.com 或 supportdemo.org.cn emails re.findall(r[\w.-][\w-]\.[\w.], text) print(emails)注意[\w.-]里把点、加号、减号都放进了字符类这样能覆盖first.nametagexample.com这类带特殊字符的邮箱前缀。字符类内部的点号不需要转义这也是很多人容易写错的地方。4.3 HTML 实体与换行的坑用正则处理 HTML 时我踩过几个值得说的坑。第一个是HTML 实体。网页里经常出现nbsp;、amp;、lt;之类的转义实体直接按可见字符匹配是匹配不到的。比如要提取文章正文里的价格源码里可能写的是299nbsp;元而不是299 元。处理方法是先做实体解码或者把正则写成兼容实体形式。第二个是换行问题。默认情况下.不匹配换行符。如果目标内容跨行比如div classcontent 这是一段很长的 跨行文本 /div用data.*?content这种点号去匹配就会失败。解决办法是先把 HTML 里的换行去掉或者在正则开头加忽略模式把.变成匹配所有字符。在 Python 里是re.S标志PHP 里是/s修饰符Java 里是Pattern.DOTALL。第三个是贪婪匹配吞噬页面。提取div classitem.../div中的内容时如果页面里有多个同类 div贪婪的量词会把整个页面从第一个 div 一直吃到最后一个 div 的结尾。这时候一定要用非贪婪.*?并且最好加上明确的边界标签。4.4 什么时候该放弃正则改用解析器正则虽好但有个界限当你要提取的内容依赖于未知深度的嵌套结构时正则不是工具而是陷阱。比如要提取 HTML 表格里每一行的数据表格内部还有嵌套表格这时候正则连“从哪里开始、到哪里结束”都很难界定更别提正确分层。这种情况应该用 BeautifulSoupPython、jsoupJava、DOMDocumentPHP这类解析器它们能把 HTML 转成树状结构按标签和层级关系精确取数。我的判断标准很简单如果页面结构稳定、目标字段有清晰边界用正则如果结构复杂、依赖嵌套层级用解析器。实际项目中两者经常混用——先用解析器定位到某个容器节点再用正则从节点文本里提取具体字段这样既快又稳。5. 常见问题与排查技巧实录5.1 灾难性回溯正则卡死、CPU 飙高这是正则最隐蔽、也最危险的问题。它的原理是当复杂量词嵌套在一起、匹配失败时正则引擎会反复回溯尝试所有可能的分支次数呈指数级增长导致程序长时间卡死。一个典型例子是(a)$去匹配一串没有a结尾的长字符串。正则引擎会尝试a的所有切分方式字符串越长尝试次数越多最后几乎等于死循环。排查技巧遇到“程序好像卡住了”的情况优先怀疑正则层面的性能问题而不是业务代码死循环。用(?:...)替代不必要的捕获分组减少回溯状态。用贪婪量的占有模式*、Java、PHP 都支持禁止回溯。给量词加合理上限比如{1,20}而不是*从根源限制可能性数量。5.2 转义地狱反斜杠套反斜杠很多初学者写正则时最困扰的就是反斜杠数量。在 Java 字符串里\d要写成\\d在 Python 里推荐用r\d原始字符串在 PHP 单引号里写\d没问题但双引号里可能出问题。我的建议是写正则时先按正则语法写再按宿主语言的转义规则补反斜杠。先在在线工具里把正则调通再往代码里粘粘完至少跑一个用例验证。阶段性地“只验证语法、不验证结果”是特别容易犯的错误。5.3 贪婪匹配越界前面反复提到贪婪匹配这里再说一个实用排查方法当你发现提取结果“多了一大截”第一反应不是去改量词而是先明确边界字符。比如提取两个标签之间的内容title(.*?)/title比title(.*)/title安全得多但更稳妥的做法是把尾部边界也写进正则比如要求匹配结果必须以/title收尾。如果目标文本里边界字符本身不唯一比如要提取两个div之间的内容而中间还夹着多层div这时候单靠正则很难处理回到上一节的建议换解析器。5.4 中文与 Unicode 的匹配正则不仅能匹配英文数字也能处理中文。最直接的方式是把中文字符的 Unicode 范围写进字符类[\u4e00-\u9fa5]匹配常用汉字。Java、Python、PHP 都支持这种写法。比如提取一段话里的所有中文人名text 项目经理王小明开发李小红 names re.findall(r[\u4e00-\u9fa5]{2,3}, text) print(names)注意这里有个坑如果目标文本里混着日文假名或生僻字\u4e00-\u9fa5可能覆盖不全。如果需要更广的 CJK 支持可以用\p{scriptHan}这类按 Unicode 属性匹配的写法不过它对正则引擎的版本有要求不是所有环境都支持。5.5 换行与边界问题除了点号不匹配换行外^和$的边界定义在不同模式下也会变化。默认情况下^匹配整个字符串的开头$匹配整个字符串的结尾。但如果开启了多行模式Java 的Pattern.MULTILINE、PHP 的/m、Python 的re.M它们就变成匹配“每一行”的开头和结尾。这个差异在日志分析时特别重要。假设要提取每行日志里以ERROR开头的行就必须开启多行模式否则^ERROR只会匹配整个日志的第一行。我见过因为忘开多行模式线上日志统计漏掉 90% 错误记录的案例。5.6 调试建议把正则当成程序来测正则再短它也是一段“逻辑”。我的调试习惯是三层递进先在线验证去 Regex101、RegExr 这类工具里输入样例文本实时看匹配高亮和分组捕获结果比在代码里反复跑快得多。再在本地写单元测试用三五个典型用例覆盖“应该匹配”“应该拒绝”“边界情况”三类场景确认正则行为稳定。最后做性能压力测试用超长字符串、大量重复文本跑一遍观察响应时间和 CPU 占用避免灾难性回溯在线上爆发。正则表达式的调试和业务代码有个共同点不要在真实数据上“边猜边试”先用小样本把边界摸清楚再放到全量数据上跑。这样排查效率最高也最不容易被数据里千奇百怪的格式带偏。6. 写在最后的一点个人体会玩了这么多年正则我最大的体会是正则不是背出来的是“养”出来的。你不需要记住每一个语法细节但必须建立起“先分析文本结构再拆表达式”的思考方式。遇到一个提取需求先问自己三个问题——目标内容长什么样前后边界是什么可能出现哪些变体这三个问题想清楚了正则基本就写对了一半。另一条切身体会是能写简单的绝不写复杂的。一个 100 字符的超长正则看着很酷但三个月后你自己都未必能读懂后续维护更是灾难。遇到复杂的匹配需求优先拆成多个简单正则分步处理每一步都单独测试最后合起来的效果往往比一个巨型正则好得多。代码里给正则加上注释也很重要哪怕只是简单标注“这里在匹配身份证出生日期段”未来接手的同事都会感谢你。最后分享一个小技巧把你自己项目中反复用到的正则收集起来整理成一个工具类或配置文件比如手机号、邮箱、URL、中文姓名、金额这些统一管理。下次新项目要用时直接引用既保证一致性又省去重复调试的时间。正则这个技能属于那种“平时不值一提关键时候能救命”的类型值得你花一个下午系统梳理一遍。