正则回溯爆炸:一条日志把 CPU 打满

日志校验正则含嵌套量词,一条超长失配日志触发灾难性回溯,单核 CPU 被打满、解析接口超时;重写为非回溯写法并限制输入长度后恢复。

问题背景

日志清洗服务负责消费上游应用日志,按行解析成结构化字段后写入分析库,高峰期每天要处理几百万行。解析逻辑是一组随业务迭代不断追加的正则规则,每行依次匹配,命中哪条就按哪条的格式提取。

上周为「单行 kv 校验」新增了一条规则:整行只允许由单词和空白组成。规则写成了 ^(\w+\s?)+$,上线前拿正常日志样本测过,单行匹配都在毫秒级,评审时也就按「简单正则」放行了。当时没人觉得这条规则会有什么问题——它只做校验,不提取、不落盘。

故障现象

一个工作日的上午,监控开始告警:日志解析接口的 P99 从 20ms 一路涨到 30s 以上,超时率飙升。登录机器看,gunicorn 的 5 个 worker 里有 3 个把单核 CPU 跑到了 100%,内存平稳、磁盘 IO 正常,负载均衡把流量切给剩下两个 worker,几分钟后它们也相继打满。

更让人困惑的是进程状态:健康检查连续失败,但日志里没有 traceback,只有 worker 超时后被 SIGKILL 的记录。重启进程能短暂恢复,十来分钟后又复现,像是某个环节在「算死」,而不是卡在等锁、等网络、等磁盘。

排查过程

先做排除。内存曲线平稳,排除泄漏;磁盘 IO 几乎为零,排除落盘阻塞;连接数正常,排除连接池耗尽(此前 requests 连接池那篇的教训);线程栈没有锁等待,排除 fork 继承锁那类死锁。

然后 top -H -p <pid> 看线程级占用:只有一个线程跑满单核,其余线程在排队。结合 GIL 的特性,这更像「一个线程在 C 层长跑」,把同进程其他线程一起拖住,而不是多线程各自变慢。

接着 py-spy dump --pid <pid> 抓栈,栈顶停在 re.match(),没有进入任何业务函数——时间花在正则引擎里,不是业务逻辑或网络 IO。再用 strace -c -p <pid> 观察,几乎没有系统调用,确认是纯用户态 CPU 计算。

给解析入口加临时日志,记录每行长度与耗时。规律很快出来:耗时与行长强相关,而且只在「以合法字符开头、以非法字符结尾」的超长行上出现。当天上游新增了一种导出的二进制字段,base64 字符串末尾带着 #,单行长度 8KB。

本机复现,旧正则 ^(\w+\s?)+$ 匹配「n 个 a 再补一个 #」:

输入 旧正则耗时
14 个 a + # 1.5 ms
18 个 a + # 25.7 ms
22 个 a + # 440 ms
24 个 a + # 1.8 s

每多一个字符,耗时约翻一倍;24 个字符已经要 1.8 秒,8KB 的失配行在旧正则下是天文数字。换成重写后的 ^\w+(?:\s+\w+)*$,同样 8KB 的失配行只要 0.39ms,合法行 0.07ms。

回溯为什么是 2 的幂:(\w+\s?)+ 的外层 + 与内层 \w+ 在匹配范围上重叠,同一段 a 可以被切成 1+1+…、2+1+…、3+2+… 等任意组合;结尾那个 # 让所有组合都在最后一步失败,引擎必须把所有切分方式逐一试完才能判定不匹配,路径数随长度指数增长。

解决方案

  1. 重写正则,消除嵌套量词:改为 ^\w+(?:\s+\w+)*$。词与词之间只允许「一段空白 + 一个词」这一种切分方式,没有重叠量词,回溯路径退化为线性。
  2. 能不用正则就不用:这行校验本身并不需要整行正则,line.strip() 后按空白 split、再逐 token 校验字符集即可;单 token 校验用 re.fullmatch(r'\w+', tok) 也安全,关键是不要套整行嵌套量词。
  3. 输入长度上限:解析前 len(line) > 4096 的行直接截断或跳过并计数;上游采集端同步限制单行长度,做双层兜底。
  4. 超时兜底:关键路径改用第三方 regex 模块,regex.match(pattern, line, timeout=0.05) 超时抛异常,按坏行处理并告警;标准库 re 没有超时参数。
  5. 灰度验证:新规则先用「正常短行 + 超长合法行 + 超长失配行」三组语料跑计时断言,单次匹配超过 50ms 不允许上线。

根因分析

  • 回溯型引擎的语义:正则匹配是「尝试—失败—回退」,嵌套或重叠量词会把失败路径的数量指数级放大;输入越接近失配边界,代价越高。
  • 输入长度无界:任何处理外部输入的逐行入口,都必须假设单行可能很长。长度本身就是一种资源,和内存、连接数一样需要配额。
  • 测试语料偏置:上线前只测了正常短行,既没有失配样本,也没有长度边界样本,最危险的分支被完整漏掉。
  • GIL 放大影响面:re 的匹配在 C 层执行并持有 GIL,一个线程卡住会让同进程其他线程一并排队,所以外部看到的是整个 worker 失联,而不是单个请求变慢。
  • 监控盲区:只监控了接口耗时和错误率,没有「单请求 CPU 时间」指标,事后只能从进程 CPU 反推原因。

预防措施

  • 正则评审清单:禁止 (A+)+、(A|A)*、(.*)* 这类嵌套/重叠量词;能用字符串方法解决的不用正则;复杂解析拆成多步,每步保持线性。
  • CI 性能回归:每条新增正则跑三组样本(正常 1KB、超长合法 8KB、超长失配 8KB),单次匹配超过 50ms 直接判定失败。
  • 输入侧双保险:网关限制请求体大小,日志采集端按 4KB 截断单行,应用层再做一次长度校验。
  • 监控补齐:新增「单请求 CPU 时间 P99」与「worker 超时重启次数」告警;正则匹配按规则名做耗时埋点。
  • 依赖兜底:所有处理不可信输入的匹配统一走带 timeout 的 regex 模块,标准库 re 只用于纯内部、已知长度的字符串。
  • 知识沉淀:把那张「每加一个字符耗时翻倍」的实验表放进团队 wiki,作为正则评审的反面教材。

总结

这次故障的教训不是「正则很危险」这么笼统,而是:正则的耗时不是输入长度的线性函数,嵌套量词会让它变成指数函数。线上代码只要处理外部输入,就必须同时约束「正则复杂度」和「输入长度」,两个约束缺一个,都可能在某个普通的工作日被一条 8KB 的日志打穿。

事后回看,上线前那组只含正常样本的测试,恰好把最危险的分支完完整整地漏掉了。测试语料的设计,和正则本身一样值得认真对待。

延伸阅读

使用 Hugo 构建
主题 Stack 由 Jimmy 设计