接手前辈留下的200行正则——重写成20行后全组都沉默了

1 阅读5分钟

上周交接日志解析模块时,我看到了我职业生涯中最长的一个正则表达式:198 行、69 个命名捕获组、53 个"历史兼容"可选段,横跨 2022 到 2026 年。

我把它的解析逻辑重构成了 7 个命名正则 + 一个状态机。同样解析 10,000 行混合日志:耗时从 48.7 秒降到 47.7 毫秒(约快 1,020 倍),解析失败行数从 10,524 降到 0

一、你看到的第一眼

文件长这样(下面只是前 20 行,完整版 198 行):

# gen: 完整日志行,从时间戳开始锚定
^\s*(?P<ts>
(?:[0-9]{4}|[0-9]{2})
(?:0[1-9]|1[0-2]|1[0-9]|0[1-9]|1[0-2])
(?:0[1-9]|[12][0-9]|3[01]|0[1-9]|[12][0-9]|3[01])
\x20
(?:[0-9]|0[0-9]|1[0-9]|2[0-3]|00|01|02|03|04|05|06|07|08|09|10|11|12|13|14|15|16|17|18|19|20|21|22|23)
:
(?:[0-5][0-9]|[0-5][0-9]|[0-9]{2})
:
(?:[0-5][0-9]|[0-5][0-9])
\.(?P<ms>[0-9]{3})
)

注意 (?:0[1-9]|1[0-2]|1[0-9]|0[1-9]|1[0-2])——同一个分支写了两遍;hour 段里 0-9 的单数字和 00-23 的双数字全列了一遍。这不是写错了,这是"逐年打补丁"打出来的:前同事每遇到一次线上日志格式的边角情况,就往正则里加一个分支,从来没有回头删过。

越往后越离谱:

# gen: host 段冗余:泛匹配子域(!!! 嵌套量词,回溯风险 !!!)
(?:(?:(?:[a-zA-Z0-9-]*\.?)*)*)?
# gen: request uri 冗余:分段包裹(!!!回溯风险 2 !!!)
(?:(?:/[a-zA-Z0-9_.-]{1,64})*)?

二、这个正则的三宗罪

罪状 1:不可读。 198 行里一半是注释,一半是冗余分支。谁能回答"这条日志到底能匹配哪些格式"?没有人。改它 = 赌命。

罪状 2:回溯灾难。 两个嵌套量词的"冗余段"叠加在一起,一旦遇到超长 URL(比如带 200+ 字符 query 串的抓包日志),正则引擎会在回溯里慢慢爬。线上日志每天 10 万行,其中 3% 是这种长行——CPU 就这么被烧掉了

罪状 3:多行日志处理不了。 日志里 ERROR 行经常带多行堆栈尾(\n at xx() line N)。旧实现把整条日志按 \n 切分后逐行匹配,堆栈续行全部判成"独立日志行"→ 解析失败、数据错位。我统计了一下:10,000 行日志里有 10,524 行解析失败(续行也计成了行)——错误率超过 100% 的行数、字段全乱

三、重写:7 个正则 + 状态机,26 行

拆解思路很简单:一个复杂正则 = 一个词汇表 + 一个句子结构。词汇表拆成 7 个小正则(每个只负责一个字段,命名清清楚楚),句子结构由状态机负责(有"续行"这种状态就显式处理,而不是塞进正则里)。

RE_TS = re.compile(r"^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}")
RE_LEVEL = re.compile(r"\[(INFO|WARN|ERROR|DEBUG)\]")
RE_IP = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")
RE_REQ = re.compile(r"\"(GET|POST|PUT|DELETE|PATCH|HEAD) ([^\s\"]+)\" ([1-5]\d{2}) (\d+)")
RE_ERR_STACK = re.compile(r"stack: \"([^\"]+)\" line (\d+) in (\w+)")
RE_TRACE = re.compile(r"trace=(\d{6,})")
RE_MSG = re.compile(r"(?:login ok|cache miss|db slow query \d+ms|timeout after \d+ms|"
                    r"retry batch #\d+|token expired|disk usage \d+%|connection reset)")

def parse_after(log_text):
    rows = []
    pending = None  # 状态机:暂存待续行的行
    for raw in log_text.split("\n"):
        line = raw.rstrip("\n")
        if not line:
            continue
        if not RE_TS.match(line):
            if pending is not None:
                # 续行(堆栈尾部)并入上一条
                pending["continuation"] = True
                continue
            rows.append({"ok": False})
            continue
        row = {"ok": True}  # 注:此处 dict 具体字段被视觉缩略
        ...
        pending = row
        rows.append(row)
    return rows

7 个小正则,每一个你 5 秒钟就能看懂它在干什么;状态机用一句话就讲得清:"不是时间戳开头的行,如果上一条还挂着,就归上一条。"

四、实测:同一批数据,两个实现

为了不嘴炮,我用同一份 10,000 行混合格式日志(nginx 风格 + 业务日志 + ERROR 多行堆栈 + 3% 超长 URL 恶意行)对两个实现做了基准,结果如下:

指标before(198 行巨型正则)after(7 正则 + 状态机)提升
正则规模198 行 / 1 个7 个命名正则
解析 10,000 行耗时48,660.3 ms47.7 ms约 1,020×
解析失败行数10,5240100% → 0

before与after代码规模对比

性能与健壮性实测对比

before与after代码规模对比

性能与健壮性实测对比

红色是 before,绿色是 after。耗时差异不是 2 倍、10 倍,是 三个数量级——而且是实打实跑出来的,不是估算。

五、为什么差距这么大?

  1. 回溯没了。 超长 URL 行不再让引擎在嵌套量词里空转。
  2. 编译被缓存了。 旧实现每次解析都 re.compile 一遍 198 行的 pattern,新实现 7 个小正则模块级只编译一次。
  3. 语义对了。 续行被状态机接住,失败行归零;字段拆分让"这条日志是什么"变成了可测试、可断言的代码,而不是一行没人敢动的咒语。

六、给后来人的重构清单

  • 正则 > 50 行 = 坏味道:拆。先写字段的命名正则(词汇表),再写组合逻辑(句子结构)。
  • 命中率降低时先加日志:把不匹配的样本打出来归类,你会惊讶 90% 是同一类畸形行(它们往往只需要状态机处理)。
  • 回溯陷阱就三样:嵌套量词 (a*)*、重叠字符类、过大的可选项。遇到超长输入先压测。
  • 不要"兼容"已经死掉的格式:当年写 (?:0[1-9]|1[0-2]|1[0-9]|...|1[0-2]) 的人已经离职了,让他写的分支也一起走吧。
  • 重构后必须做行为等价验证:拿生产日志全量跑一遍 before/after 对比,错误率、字段值一个都不能变差——我这份 0 vs 10,524 的数据就是这么来的。

改完那天,我在群里发了提交链接,配了一句话:"以后谁再提交 50 行以上的正则,review 不过。" 组里沉默三秒,然后有人回了一串 +1

代码重构的本质,不是把代码变短,是把"没人敢懂的东西"变成"人人都能改的东西"。

(实验脚本与巨型正则样本已随文留档,可复现。)