正则表达式进阶:优雅停机 匹配技巧
最近在给一个 Python 服务补优雅停机逻辑,发现 SIGTERM 收进来以后,旧 worker 还在处理任务,日志里一堆 state=RUNNING,关起来特别不干净。先想用 if "RUNNING" in line 凑合一下,结果误杀了几条历史日志,然后就开始用正则匹配任务状态,发现这玩意儿是真的难搞啊,随便一个 task.*RUNNING 就能匹配一大片,为什么很多教程只讲元字符,不拿生产日志练手呢?可能是我看的例子都太干净吧...
下面是实测可用的几种写法,最好配上脏数据测试,不然你就慢慢看日志猜吧。
别一上来就用 .*:先给匹配加刹车
最常见的写法是:
re.search(r"task-(\d+).*RUNNING", line)
看起来能用,但 .* 贪婪得很,一行日志里只要有 RUNNING,前面 task-123 之后的内容全被它吞了。如果一行里同时有 task-111 state=DONE 和 task-222 state=RUNNING,它很可能还是返回 task-111,因为贪婪匹配会尽量往前找。
改成非贪婪,并且限定不要跨行:
re.search(r"task-(\d+).*?RUNNING", line)
这样至少知道要“尽量短”。但在处理多行日志的时候,最好用 [^ \r]* 替代 .,因为 Python 里 . 默认不匹配换行,但有些场景会被标志位影响,不如显式写死。
re.search(r"task-(\d+)[^\r\n]*?state=RUNNING", line)
这句话的意思比较直白:找 task-数字,然后在同一行内,尽量少吞点东西,直到遇到 state=RUNNING。
用边界:别让 task-id 匹配到 task-identity
日志里字段一多,子串匹配最容易翻车。
task-identity=abc task-id=job-42 state=RUNNING
如果你写:
re.search(r"task-id=(\w+)", line)
task-id 可能被 task-identity 影响。这里加单词边界:
re.search(r"\btask-id=(\w+)\b", line)
更稳的写法是把字段名、值、状态分开:
task_re = re.compile(
r"\btask-id=(?P<task_id>[A-Za-z0-9_.-]{3,64})\b"
r"[^\r\n]*?\bstate=RUNNING\b"
)
(?P<task_id>...) 是命名捕获组,后面读代码的时候不用猜 m.group(1) 到底是什么。生产代码里这种小可读性很值。
前瞻匹配:只判断,不吞字符
优雅停机经常关心“这一行是不是正在运行的任务”,不一定要把中间内容拿出来。这时可以用 lookahead:
running_re = re.compile(
r"\btask-id=(?P<task_id>[A-Za-z0-9_.-]{3,64})\b"
r"(?=.*\bstate=RUNNING\b)"
)
但是这里 .* 有跨行风险,换成:
running_re = re.compile(
r"\btask-id=(?P<task_id>[A-Za-z0-9_.-]{3,64})\b"
r"(?=[^\r\n]*\bstate=RUNNING\b)"
)
前瞻组 (?=...) 会检查后面是否有 state=RUNNING,但不会消耗这些字符。匹配结果里直接拿 task_id,干净很多。
实际取所有运行中的任务:
def running_task_ids(log_lines: list[str]):
ids = []
for line in log_lines:
m = running_re.search(line)
if m:
ids.append(m.group("task_id"))
return ids
原子组:给回溯一个紧急刹车
正则的“优雅停机”不只是服务停机,匹配本身也要少灾难。比如有这么一段配置匹配:
re.match(r"(a+)+$", text)
遇到一长串 a,引擎会反复回溯,CPU 直接起飞。这种写法在日志过滤、接口校验里很容易写出 ReDoS。
部分引擎支持原子组,比如 PCRE、.NET,较新的 Python 也可以试试:
re.compile(r"(?>a+)")
或者占有量词:
re.compile(r"a*+")
它的含义是:一旦匹配了,就别把字符吐回去重算了。对“只校验,不解析”的场景很友好。比如判断一个变量名是不是停机开关:
name_re = re.compile(r"(?i)^(?>graceful[-_]?)?(?>shutdown|stop|drain)$")
这里用 (?i) 忽略大小写,用 (?>...) 减少回溯。当然如果你用 Go 的 RE2,本来就没有回溯,这类写法更没必要纠结,但正则思想还是一样的:尽量写窄。
实战:从一段脏日志里捞出最后任务
比如日志里有:
2026-09-10T02:11:00Z [app] task-id=job-123 state=RUNNING payload={"type":"resize"}
2026-09-10T02:11:01Z [app] task-id=job-456 state=DONE
2026-09-10T02:11:02Z [app] task-id=job-789 state=RUNNING msg="waiting for drain"
你想在停机前找最后一个还在跑的任务,可以这么写:
last_running_re = re.compile(
r"^(?P<ts>\S+).*\btask-id=(?P<task_id>[A-Za-z0-9_.-]{3,64})\b"
r"(?=[^\r\n]*\bstate=RUNNING\b)",
re.MULTILINE,
)
lines = log_text.strip().splitlines()
last = None
for line in lines:
m = last_running_re.search(line)
if m:
last = m.group("task_id")
如果日志可能多行,别用 re.DOTALL 硬吞;优雅停机最怕误匹配,宁可拆行。
对应一个简单 YAML 配置:
shutdown:
enabled: true
signal: SIGTERM
filter: '(?i)^(graceful[-_]?)?(shutdown|stop|drain)$'
exclude:
- healthcheck
- metrics-push
读配置的时候用 re.fullmatch,比 re.search 更安全:
pattern = re.compile(r"(?i)^(graceful[-_]?)?(shutdown|stop|drain)$")
if pattern.fullmatch(value):
allow_shutdown_signal(value)
最后:正则别只写一次,多喂几个反例
我一般至少准备这几类数据:
task-id=ok-1 state=RUNNING
task-id=ok-2 state=DONE task-id=bad-3 state=RUNNING
TASK-ID=UPPER state=running
task-id=very-long-name-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa state=RUNNING
line
第一行测基本,第二行测多个字段,第三行测大小写,第四行测边界,第五行测脏空行。正则这种东西,写出来只是开始,真正优雅不优雅,得让它在乱七八糟的输入面前别乱咬。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。