AI 写的正则把服务卡死:灾难性回溯怎么判、怎么改写、怎么加上限

2026-07-29

数据截至 2026-07。各语言运行时对匹配超时、原子组、占有量词的支持差异较大,且随版本变化,文中不写具体版本号与开关名,请以你所用运行时的官方最新说明为准,并在本地跑一个最小用例确认。

这类故障最常被归错因:你看到 CPU 打满、请求堆积、线程池耗尽,第一反应是流量涨了或者下游变慢了,于是去扩容、去调连接池,结果扩出去的机器一台台按同样的姿势倒下。 灾难性回溯的特征恰恰相反——它不是负载问题,是单条输入引发的算法复杂度爆炸。一个请求就能让一个工作线程永久性地转在那里,扩容只是给它更多可以吃掉的核。AI 生成的正则特别容易踩,因为模型倾向于写”看起来能覆盖所有情况”的宽松模式,而宽松正好是回溯爆炸的温床。

站内还有两篇相邻的文章,分工是这样的:改完功能接口变慢 讲的是 AI 改完代码后整体性能变慢的定位方法,从指标到火焰图,覆盖面广但不深挖某一类;生成到一半就断了 讲的是输出被截断这种”结果不对”的排查。本篇只干一件事:把”某条正则在特定输入下把线程卡死”这一类故障从头到尾讲透,包括怎么和普通的慢区分开、怎么改写、以及改不动的时候怎么设上限止损。

一、先分清:是回溯,还是别的慢

在动手之前,你要先确认自己面对的是哪一种”慢”。这三种表现看着像,机制完全不同。

第一种是整体变慢。所有接口的耗时都抬了一截,P50 和 P99 一起往上走,CPU 使用率高但不是单核打满。这通常是 GC、数据库、下游依赖或者机器本身的问题,走常规性能定位那条路。

第二种是部分请求慢。大多数请求正常,少数特别慢,而且慢的那些有共同点——同一个租户、同一批数据、同一个上传的文件。这时候要看的是数据倾斜或者热点。

第三种才是回溯爆炸。它的指纹很好认:请求不是慢,是根本不返回;对应的线程 CPU 占用稳定在单核 100%;线程栈反复采样几次,栈顶始终在正则引擎内部;没有明显的 GC 压力,内存曲线平的;把那条请求的输入原样再发一次,必定复现,换个短一点的输入立刻正常。

回溯为什么会爆炸,机制值得花两句话说清楚,因为理解了机制你才知道该改哪儿。回溯型引擎在遇到量词时会先贪心地吞掉尽可能多的字符,如果后面匹配不上就吐回来一个再试。当模式里出现嵌套量词,或者相邻的两个量词能匹配同一批字符时,同一段输入就存在指数级多种切分方式。只要最终结果是匹配失败,引擎就得把这些切分方式挨个试完才敢下结论。三个条件缺一不可:切分歧义、最终失败、输入长度可控。这里有个反直觉的地方值得记住:能匹配成功的超长输入通常一点也不慢,因为引擎第一次尝试就命中,压根不用回溯;真正把机器打死的,是长度并不夸张、却”差一点就匹配上、最后一个字符不对”的那条。所以按”最长的输入”去找凶手往往找不到,按”失败的输入”去找才准。

一个最小例子是 (a+)+b 遇到一串 a 后面跟着感叹号。外层量词和内层量词都能吃 a,一串 a 有多少种分组方式,引擎就试多少次,试完才发现根本没有 b。AI 写的正则里,这个结构常常伪装成 ^(\w+\s?)*$ 这样人畜无害的样子——\w+ 能吃字母,\s? 可以为空,两个量词嵌在一起,含义上”一串以空格分隔的词”,实现上就是一颗定时炸弹。

二、判别表:从现象到动作

先按现象对号入座,再决定下一步做什么。表里的”怎么验证”都是可以在几分钟内做完的动作,不要跳过验证直接改代码。

现象大概率成因怎么验证处置动作
单个线程 CPU 稳定占满一核,请求永不返回灾难性回溯隔几秒连续抓三次线程栈,栈顶始终在正则引擎的匹配函数里定位到具体那条模式,进第三节
线程池逐步耗尽,新请求排队,老线程不释放回溯把工作线程钉死了统计卡住线程的栈,看是不是同一条模式先在入口加输入长度限制止血,再改写
只有某个接口慢,且和输入长度强相关正则复杂度超线性但还没到指数用长度递增的输入测耗时,看是否超线性增长按第四节收窄模式,或换成分步解析
所有接口一起变慢,CPU 分散在多核不是回溯,是通用性能问题看 GC 日志和下游耗时,回溯不产生 GC 压力走通用性能定位,见文中链接的那篇
偶发卡死,重启后好一阵子又犯特定输入触发,输入来源不固定把请求体落盘留样,复现时回放加输入采样落盘,同时上匹配超时兜底
改了配置或规则文件之后开始卡用户可配置的模式本身有问题对新加的规则逐条跑压力输入把配置来的正则当不可信输入对待

抓栈的动作在各语言里都有现成工具。JVM 上用 jstack <pid>,连抓几次落到文件里对比:

PID=12345          # 换成你的进程号
for i in 1 2 3; do
  jstack "$PID" > /tmp/stack.$i.txt
  sleep 3
done
diff /tmp/stack.1.txt /tmp/stack.3.txt | head -40

注意别把占位符直接写成尖括号形式敲进 shell——<pid> 在 bash 里会被当成输入重定向,报的是找不到文件,跟 JVM 一点关系没有。Python 服务把上面的 jstack 换成 py-spy dump --pid "$PID",同样连抓几次。抓之前先用 top -H -p "$PID" 找到那个吃满一核的线程号,再拿它去栈里对(JVM 里线程栈打印的是十六进制 nid,top 给的是十进制,需要换算一下)。三次栈顶都停在同一个匹配函数里、几乎没有位移,基本就可以定案了。这里用 diff 而不是逐份人眼看,是因为正常忙碌的线程每隔三秒栈形状会明显变化,而卡在回溯里的线程三份栈几乎逐行相同——差异为空本身就是最强的证据。

三、定案之后,按这个顺序动手

顺序很重要,因为你现在有一个正在流血的线上服务,改正则是最慢的那一步。

第一步是止血,不是修复。 在正则执行之前加一道输入长度闸门,超长直接拒绝或者截断。这一步几分钟能上线,效果立竿见影,因为回溯的代价随长度指数增长,砍掉长尾就砍掉了绝大部分风险。长度阈值不要拍脑袋写个大数,去查你业务里这个字段的真实长度分布,取上分位再留一点余量。如果这个字段本来就该是短的(用户名、订单号、手机号),那就按格式硬限制。

第二步是定位到具体哪一条模式。 栈里能看到调用点,但如果这个方法里有好几条正则,就用版本历史缩范围:

git log -S '(\w+\s?)*' --oneline -- src/

-S 会列出所有引入或删除这段字面量的提交。AI 批量改代码之后出的问题,这一招通常一下就命中。找到提交之后顺便看看同一批还改了哪些正则,这类问题很少只有一处。

第三步才是改写模式。 改完必须有测试证明它不再爆炸,具体写法见下一节末尾。

第四步是加运行时兜底。 就算这条改好了,下一次 AI 提交还会引入新的。运行时层面的保护是长期收益,但这里有一个很多人踩过的误区:把匹配丢到另一个线程里、主线程等超时就”放弃”,这只是让调用方不再阻塞,那个线程仍然在原地烧 CPU 且通常无法从外部中断——绝大多数运行时的正则引擎在匹配过程中不检查中断标志,你发的取消信号要等它自己算完才生效,而它算完可能是几个小时以后。结果是线程一个个泄漏,故障从”卡一个请求”变成”慢慢耗死整个进程”,比不加兜底更难查。

真正能回收资源的兜底只有两类:一是运行时自身提供的匹配超时或非回溯引擎选项,由引擎在匹配循环内部主动检查并中止;二是放进独立进程执行,超时就杀掉进程,操作系统会把 CPU 收回来,代价是跨进程通信开销。还有一类折中做法是在引擎读取输入字符的路径上做手脚,用一个带截止时间的输入包装类,时间一到就在读字符时抛异常,把控制权从引擎里”顶”出来——这招在部分语言可行,取决于它的匹配 API 接不接受自定义字符序列。选哪种之前,先在你的运行时里跑一个最小验证:故意用一条爆炸模式加长输入,看超时机制是不是真的在秒级把 CPU 降下来了,而不是只让调用方提前返回。别照抄其他语言的经验,这块的差异特别大。

四、改写:四种手法,按代价从低到高

手法一:消除切分歧义。 这是治本的做法。检查相邻或嵌套的量词,问一句”同一个字符能不能被这两个量词都吃掉”。如果能,就重写成只有唯一切分方式的形式。(\w+\s?)* 这种写法的问题在于分隔符可选,导致边界不确定;改成 \w+(?:\s\w+)* 之后,空格必须存在于两组词之间,切分方式唯一,回溯空间瞬间塌缩。同样的道理适用于分支:让每个分支的首字符集互不相交,引擎就不需要挨个试。

手法二:展开循环。 处理”引号包裹、内部允许转义”这类结构时,常见写法是把两种可能放进交替里再套量词,两个分支都能匹配同一批字符,典型的爆炸结构。展开写法是先匹配一段”绝不含特殊字符”的正常部分,再匹配”一个特殊部分加一段正常部分”的重复:

"[^"\\]*(?:\\.[^"\\]*)*"

这个形式里每个位置归属唯一,引擎不需要猜。它写起来比交替版本别扭,但性能是另一个量级。

手法三:切断回溯。 原子组和占有量词的作用是告诉引擎”这一段吃进去就不要吐出来了”,直接砍掉回溯分支。各语言对这两个语法的支持程度不一致,有的运行时压根编译不过,有的行为有细微差别。写之前在你自己的运行时里跑一个最小用例确认能编译、能生效,别凭印象照搬。

手法四:别用一条正则解决所有事。 很多爆炸模式是因为想一次性把校验、提取、分段全做完。拆开:先用普通字符串操作定位分隔符,把长串切成短片段,再对每个片段跑一条小而确定的正则。片段短,就算模式不完美也炸不起来。这个改法代码行数会变多,可读性反而更好,出问题也更好定位。

改完怎么证明有效?写一个长度递增的耗时测试,跑进单元测试里。这里有个坑要先说:不能一上来就用几百字符的输入去试。如果模式还在爆炸,几百字符意味着这个测试永远不会结束,你只会得到一个卡死的 CI 任务,而不是一条失败信息。正确的做法是分两段——先用很短的输入小步探测,确认没有指数特征之后,再用长输入验证线性:

import re, time

p = re.compile(r'^\w+(?:\s\w+)*$')       # 换成你要验的那条模式

def cost(n):
    s = 'a' * n + '!'                    # 尾部放个破坏匹配的字符,强制走失败路径
    t = time.perf_counter()
    p.match(s)
    return time.perf_counter() - t

for n in range(12, 41, 4):               # 先小步探测,爆炸模式在这个区间就会露头
    c = cost(n)
    print('probe', n, round(c, 4))
    assert c < 0.1, f'n={n} 就要 {c:.2f}s,指数级回溯'

for n in (2000, 4000, 8000, 16000):      # 探测通过,再用长输入确认是线性
    print('scale', n, round(cost(n), 6))

探测段的读法是看增长倍率而不是绝对值:每次只加 4 个字符,耗时却翻好几倍,就是指数。拿一条真正的爆炸模式跑这段,通常在 n 到二十几的时候单次耗时就跨过 0.1 秒把断言打掉,整个测试一秒左右结束,不会拖住 CI。规模段则相反,输入翻倍耗时也大致翻倍才算过关;如果 16000 那档比 2000 那档涨了几十上百倍,说明只是把爆炸推后了,没有真正消除。

另外别把规模段的长度定得太小。像一百来个字符这种量级,线性模式的耗时只有几十微秒,完全淹没在计时噪声里,你会看到耗时忽上忽下,得不出任何结论。要么把长度拉到几千上万,要么把每档重复跑很多次取总时间。把这两段断言写进测试,以后谁再提交一条危险模式,CI 会拦下来。这类回归测试比人工审查可靠,相关思路在 AI 代码评审工具怎么选 里有更完整的展开。

五、什么情况下别再折腾正则

工程上最贵的不是修不好,是在一条错误的路上磨太久。下面几个信号出现时,停手换实现。

信号一:改写超过两轮还在爆炸。 每改一版都要重新构造攻击输入验证,如果两轮之后仍有新的爆炸样本被找出来,说明这个模式的语法结构本身就不适合用回溯引擎表达。止损点就在这里,换成分步解析或者专用解析器。

信号二:模式是用户可配置的。 规则从配置文件、管理后台、数据库里读进来,你永远不知道下一条长什么样。审查单条模式没有意义,正确的做法是换一个复杂度有保证的引擎——有些语言的标准正则库天生保证匹配耗时与输入长度成线性关系,代价是不支持反向引用和环视。如果你的规则用不到这些高级特性,这是最省心的换法。

信号三:要解析的其实是结构化格式。 JSON、HTML、URL、日志格式、邮件地址,这些都有成熟的解析器。用正则解析嵌套结构本来就是错的,爆炸只是这个错误的第一个症状。看到 AI 生成了一条几百字符的正则来解析某种格式,别去优化它,直接换库。

信号四:这条正则的业务价值撑不起风险。 有些校验其实可有可无——比如前端已经校过一遍、后端再用一条复杂正则做”严格校验”,而校验失败的后果只是提示用户格式不对。这种情况下,把严格校验降级成宽松检查,或者干脆挪到异步链路里去,比起继续优化正则划算得多。

回滚点在哪里。 如果这条正则是最近一次 AI 批量改动引入的,而你在半小时内没能改出一个通过压力测试的版本,直接回滚那次提交,恢复到旧实现,然后从容地重做。线上稳定优先于代码美观,这个判断不需要犹豫。AI 改动引发的回滚判断,AI 把能跑的代码改坏了 里讲得更细。

六、避坑清单

坑一:以为加了非贪婪量词就安全了。 会踩是因为很多人把 +? 理解成”少回溯”。实际上非贪婪只是改变了尝试顺序,先试短的再试长的,总的搜索空间一点没变,匹配失败时照样要试完。怎么避:判断危险与否只看有没有切分歧义,不看贪婪与否。把 (a+?)+b 拿去跑一下就知道了。

坑二:把长度上限写在正则里就以为完事。 会踩是因为 {1,64} 看起来像是限制了规模。但量词的上界只限制单个量词的重复次数,嵌套结构的组合数依然可以很大,而且引擎在失败时仍要遍历。怎么避:长度限制要加在进入正则之前的输入检查上,是一个 if 判断,不是正则的一部分。

坑三:只测了正常输入。 会踩是因为正常输入几乎都能匹配成功,成功路径上引擎第一次尝试就命中,根本不回溯,测出来飞快。怎么避:测试必须构造匹配失败的输入,而且要在字符串尾部放一个破坏匹配的字符。这是这类问题唯一有效的测法。

坑四:在正则里塞用户可控的片段。 会踩是因为想做动态匹配,就把用户输入拼进模式字符串。这不只是回溯风险,还是注入风险——用户可以直接构造出爆炸模式。怎么避:用户输入只能作为被匹配的文本,需要拼进模式时必须先做转义。多数语言的标准库都提供了转义函数(名字各不相同,查一下你那门语言的正则模块),少数语言没有内建的,就找成熟库,别自己写字符替换糊弄过去——转义字符集漏一个就等于没做。更省事的做法是先问一句这里是不是真的需要正则,很多”动态匹配”其实一个普通字符串包含判断就够了。这条和其他输入面风险一起,可以参考 AI 写的代码不敢直接上线 的检查项。

坑五:全局替换和循环匹配的隐性放大。 会踩是因为你只测了单次匹配的耗时,觉得可以接受,但线上代码是在循环里对每一行日志跑这条正则。单次几毫秒乘以行数就是几秒。怎么避:看调用点的量级,把正则编译移出循环,能预筛就先用普通字符串包含判断挡掉大部分不可能匹配的行。

坑六:把这条模式当孤例修完就走。 会踩是因为定位过程消耗了太多精力,修好那一条就想收工。但 AI 生成正则的偏好是一致的,一批代码里往往有好几条同源写法。怎么避:修完之后用简单的搜索把代码库里所有形如”量词紧跟着量词”的模式扫一遍,逐条过一下有没有切分歧义。这个动作十几分钟,能省掉下一次半夜起床。

收束

灾难性回溯是少数几种”一条输入干掉一台机器”的故障,好处是它的指纹非常清晰,认出来之后修复路径也很确定。真正的风险在于第一步归错因——把它当成容量问题去扩容,就会在错误方向上浪费掉最宝贵的那半小时。

AI 生成的代码要不要直接进生产,本质上是这类问题的放大器,AI 生成的代码能上生产吗?producti 里的判断框架同样适用于正则这种”看起来只有一行”的改动。

下次再遇到,按这个清单走:

  • 有没有线程稳定占满一核、且请求永不返回?
  • 连抓三次栈,栈顶是不是都在正则引擎里?
  • 入口有没有输入长度闸门?没有先加上,这是止血。
  • git log -S 找到这条模式是哪次提交引入的,同批还改了什么?
  • 相邻或嵌套的量词能不能吃同一批字符?能就是它。
  • 改写后有没有用”匹配失败的长输入”做过耗时递增测试?
  • 运行时层面的兜底是引擎内建超时或独立进程隔离吗?只靠”另起线程等超时”不算,线程杀不掉。
  • 两轮改写还在爆炸的话,回滚提交、换解析器,别再磨。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。