<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>灾难性回溯 on Zero Day Notes</title>
        <link>https://blog.5772447.xyz/tags/%E7%81%BE%E9%9A%BE%E6%80%A7%E5%9B%9E%E6%BA%AF/</link>
        <description>Recent content in 灾难性回溯 on Zero Day Notes</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>zh</language>
        <lastBuildDate>Sun, 15 Nov 2026 09:00:00 +0800</lastBuildDate><atom:link href="https://blog.5772447.xyz/tags/%E7%81%BE%E9%9A%BE%E6%80%A7%E5%9B%9E%E6%BA%AF/index.xml" rel="self" type="application/rss+xml" /><item>
            <title>正则回溯爆炸：一条日志把 CPU 打满</title>
            <link>https://blog.5772447.xyz/posts/7a52358c/</link>
            <pubDate>Sun, 15 Nov 2026 09:00:00 +0800</pubDate>
            <guid>https://blog.5772447.xyz/posts/7a52358c/</guid>
            <description>&lt;h2 id=&#34;问题背景&#34;&gt;&lt;a href=&#34;#%e9%97%ae%e9%a2%98%e8%83%8c%e6%99%af&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;问题背景&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;日志清洗服务负责消费上游应用日志，按行解析成结构化字段后写入分析库，高峰期每天要处理几百万行。解析逻辑是一组随业务迭代不断追加的正则规则，每行依次匹配，命中哪条就按哪条的格式提取。&lt;/p&gt;&#xA;&lt;p&gt;上周为「单行 kv 校验」新增了一条规则：整行只允许由单词和空白组成。规则写成了 &lt;code&gt;^(\w+\s?)+$&lt;/code&gt;，上线前拿正常日志样本测过，单行匹配都在毫秒级，评审时也就按「简单正则」放行了。当时没人觉得这条规则会有什么问题——它只做校验，不提取、不落盘。&lt;/p&gt;&#xA;&lt;h2 id=&#34;故障现象&#34;&gt;&lt;a href=&#34;#%e6%95%85%e9%9a%9c%e7%8e%b0%e8%b1%a1&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;故障现象&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;一个工作日的上午，监控开始告警：日志解析接口的 P99 从 20ms 一路涨到 30s 以上，超时率飙升。登录机器看，gunicorn 的 5 个 worker 里有 3 个把单核 CPU 跑到了 100%，内存平稳、磁盘 IO 正常，负载均衡把流量切给剩下两个 worker，几分钟后它们也相继打满。&lt;/p&gt;&#xA;&lt;p&gt;更让人困惑的是进程状态：健康检查连续失败，但日志里没有 traceback，只有 worker 超时后被 SIGKILL 的记录。重启进程能短暂恢复，十来分钟后又复现，像是某个环节在「算死」，而不是卡在等锁、等网络、等磁盘。&lt;/p&gt;&#xA;&lt;h2 id=&#34;排查过程&#34;&gt;&lt;a href=&#34;#%e6%8e%92%e6%9f%a5%e8%bf%87%e7%a8%8b&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;排查过程&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;先做排除。内存曲线平稳，排除泄漏；磁盘 IO 几乎为零，排除落盘阻塞；连接数正常，排除连接池耗尽（此前 requests 连接池那篇的教训）；线程栈没有锁等待，排除 fork 继承锁那类死锁。&lt;/p&gt;&#xA;&lt;p&gt;然后 &lt;code&gt;top -H -p &amp;lt;pid&amp;gt;&lt;/code&gt; 看线程级占用：只有一个线程跑满单核，其余线程在排队。结合 GIL 的特性，这更像「一个线程在 C 层长跑」，把同进程其他线程一起拖住，而不是多线程各自变慢。&lt;/p&gt;&#xA;&lt;p&gt;接着 &lt;code&gt;py-spy dump --pid &amp;lt;pid&amp;gt;&lt;/code&gt; 抓栈，栈顶停在 &lt;code&gt;re.match()&lt;/code&gt;，没有进入任何业务函数——时间花在正则引擎里，不是业务逻辑或网络 IO。再用 &lt;code&gt;strace -c -p &amp;lt;pid&amp;gt;&lt;/code&gt; 观察，几乎没有系统调用，确认是纯用户态 CPU 计算。&lt;/p&gt;&#xA;&lt;p&gt;给解析入口加临时日志，记录每行长度与耗时。规律很快出来：耗时与行长强相关，而且只在「以合法字符开头、以非法字符结尾」的超长行上出现。当天上游新增了一种导出的二进制字段，base64 字符串末尾带着 &lt;code&gt;#&lt;/code&gt;，单行长度 8KB。&lt;/p&gt;&#xA;&lt;p&gt;本机复现，旧正则 &lt;code&gt;^(\w+\s?)+$&lt;/code&gt; 匹配「n 个 a 再补一个 #」：&lt;/p&gt;&#xA;&lt;table&gt;&#xA;  &lt;thead&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;th&gt;输入&lt;/th&gt;&#xA;          &lt;th&gt;旧正则耗时&lt;/th&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/thead&gt;&#xA;  &lt;tbody&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;14 个 a + #&lt;/td&gt;&#xA;          &lt;td&gt;1.5 ms&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;18 个 a + #&lt;/td&gt;&#xA;          &lt;td&gt;25.7 ms&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;22 个 a + #&lt;/td&gt;&#xA;          &lt;td&gt;440 ms&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;      &lt;tr&gt;&#xA;          &lt;td&gt;24 个 a + #&lt;/td&gt;&#xA;          &lt;td&gt;1.8 s&lt;/td&gt;&#xA;      &lt;/tr&gt;&#xA;  &lt;/tbody&gt;&#xA;&lt;/table&gt;&#xA;&lt;p&gt;每多一个字符，耗时约翻一倍；24 个字符已经要 1.8 秒，8KB 的失配行在旧正则下是天文数字。换成重写后的 &lt;code&gt;^\w+(?:\s+\w+)*$&lt;/code&gt;，同样 8KB 的失配行只要 0.39ms，合法行 0.07ms。&lt;/p&gt;&#xA;&lt;p&gt;回溯为什么是 2 的幂：&lt;code&gt;(\w+\s?)+&lt;/code&gt; 的外层 &lt;code&gt;+&lt;/code&gt; 与内层 &lt;code&gt;\w+&lt;/code&gt; 在匹配范围上重叠，同一段 a 可以被切成 1+1+…、2+1+…、3+2+… 等任意组合；结尾那个 &lt;code&gt;#&lt;/code&gt; 让所有组合都在最后一步失败，引擎必须把所有切分方式逐一试完才能判定不匹配，路径数随长度指数增长。&lt;/p&gt;&#xA;&lt;h2 id=&#34;解决方案&#34;&gt;&lt;a href=&#34;#%e8%a7%a3%e5%86%b3%e6%96%b9%e6%a1%88&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;解决方案&#xD;&#xA;&lt;/h2&gt;&lt;ol&gt;&#xA;&lt;li&gt;&lt;strong&gt;重写正则，消除嵌套量词&lt;/strong&gt;：改为 &lt;code&gt;^\w+(?:\s+\w+)*$&lt;/code&gt;。词与词之间只允许「一段空白 + 一个词」这一种切分方式，没有重叠量词，回溯路径退化为线性。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;能不用正则就不用&lt;/strong&gt;：这行校验本身并不需要整行正则，&lt;code&gt;line.strip()&lt;/code&gt; 后按空白 split、再逐 token 校验字符集即可；单 token 校验用 &lt;code&gt;re.fullmatch(r&#39;\w+&#39;, tok)&lt;/code&gt; 也安全，关键是不要套整行嵌套量词。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;输入长度上限&lt;/strong&gt;：解析前 &lt;code&gt;len(line) &amp;gt; 4096&lt;/code&gt; 的行直接截断或跳过并计数；上游采集端同步限制单行长度，做双层兜底。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;超时兜底&lt;/strong&gt;：关键路径改用第三方 &lt;code&gt;regex&lt;/code&gt; 模块，&lt;code&gt;regex.match(pattern, line, timeout=0.05)&lt;/code&gt; 超时抛异常，按坏行处理并告警；标准库 &lt;code&gt;re&lt;/code&gt; 没有超时参数。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;灰度验证&lt;/strong&gt;：新规则先用「正常短行 + 超长合法行 + 超长失配行」三组语料跑计时断言，单次匹配超过 50ms 不允许上线。&lt;/li&gt;&#xA;&lt;/ol&gt;&#xA;&lt;h2 id=&#34;根因分析&#34;&gt;&lt;a href=&#34;#%e6%a0%b9%e5%9b%a0%e5%88%86%e6%9e%90&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;根因分析&#xD;&#xA;&lt;/h2&gt;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;回溯型引擎的语义&lt;/strong&gt;：正则匹配是「尝试—失败—回退」，嵌套或重叠量词会把失败路径的数量指数级放大；输入越接近失配边界，代价越高。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;输入长度无界&lt;/strong&gt;：任何处理外部输入的逐行入口，都必须假设单行可能很长。长度本身就是一种资源，和内存、连接数一样需要配额。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;测试语料偏置&lt;/strong&gt;：上线前只测了正常短行，既没有失配样本，也没有长度边界样本，最危险的分支被完整漏掉。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;GIL 放大影响面&lt;/strong&gt;：&lt;code&gt;re&lt;/code&gt; 的匹配在 C 层执行并持有 GIL，一个线程卡住会让同进程其他线程一并排队，所以外部看到的是整个 worker 失联，而不是单个请求变慢。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;监控盲区&lt;/strong&gt;：只监控了接口耗时和错误率，没有「单请求 CPU 时间」指标，事后只能从进程 CPU 反推原因。&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h2 id=&#34;预防措施&#34;&gt;&lt;a href=&#34;#%e9%a2%84%e9%98%b2%e6%8e%aa%e6%96%bd&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;预防措施&#xD;&#xA;&lt;/h2&gt;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;正则评审清单&lt;/strong&gt;：禁止 &lt;code&gt;(A+)+&lt;/code&gt;、&lt;code&gt;(A|A)*&lt;/code&gt;、&lt;code&gt;(.*)*&lt;/code&gt; 这类嵌套/重叠量词；能用字符串方法解决的不用正则；复杂解析拆成多步，每步保持线性。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;CI 性能回归&lt;/strong&gt;：每条新增正则跑三组样本（正常 1KB、超长合法 8KB、超长失配 8KB），单次匹配超过 50ms 直接判定失败。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;输入侧双保险&lt;/strong&gt;：网关限制请求体大小，日志采集端按 4KB 截断单行，应用层再做一次长度校验。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;监控补齐&lt;/strong&gt;：新增「单请求 CPU 时间 P99」与「worker 超时重启次数」告警；正则匹配按规则名做耗时埋点。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;依赖兜底&lt;/strong&gt;：所有处理不可信输入的匹配统一走带 &lt;code&gt;timeout&lt;/code&gt; 的 &lt;code&gt;regex&lt;/code&gt; 模块，标准库 &lt;code&gt;re&lt;/code&gt; 只用于纯内部、已知长度的字符串。&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;知识沉淀&lt;/strong&gt;：把那张「每加一个字符耗时翻倍」的实验表放进团队 wiki，作为正则评审的反面教材。&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;h2 id=&#34;总结&#34;&gt;&lt;a href=&#34;#%e6%80%bb%e7%bb%93&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;总结&#xD;&#xA;&lt;/h2&gt;&lt;p&gt;这次故障的教训不是「正则很危险」这么笼统，而是：正则的耗时不是输入长度的线性函数，嵌套量词会让它变成指数函数。线上代码只要处理外部输入，就必须同时约束「正则复杂度」和「输入长度」，两个约束缺一个，都可能在某个普通的工作日被一条 8KB 的日志打穿。&lt;/p&gt;&#xA;&lt;p&gt;事后回看，上线前那组只含正常样本的测试，恰好把最危险的分支完完整整地漏掉了。测试语料的设计，和正则本身一样值得认真对待。&lt;/p&gt;&#xA;&lt;h2 id=&#34;延伸阅读&#34;&gt;&lt;a href=&#34;#%e5%bb%b6%e4%bc%b8%e9%98%85%e8%af%bb&#34; class=&#34;header-anchor&#34;&gt;&lt;/a&gt;延伸阅读&#xD;&#xA;&lt;/h2&gt;&lt;ul&gt;&#xA;&lt;li&gt;&lt;a class=&#34;link&#34; href=&#34;https://blog.5772447.xyz/posts/15f31637/&#34; &gt;Popen 未读管道：备份脚本卡在 64KB&lt;/a&gt;&lt;/li&gt;&#xA;&lt;li&gt;&lt;a class=&#34;link&#34; href=&#34;https://blog.5772447.xyz/posts/1112ecdc/&#34; &gt;fork 撞上多线程：子进程启动即死锁&lt;/a&gt;&lt;/li&gt;&#xA;&lt;li&gt;&lt;a class=&#34;link&#34; href=&#34;https://blog.5772447.xyz/posts/6bd44f06/&#34; &gt;email.py 撞名标准库：smtplib 报错&lt;/a&gt;&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;</description>
        </item></channel>
</rss>
