先交代背景:某个周四下午,我正端着咖啡准备摸鱼,钉钉群里“叮”地弹出一条告警——订单服务 CPU 使用率 800%(8 核机器,满载)。接着是第二条:接口 P99 超时。第三条:产品经理的“在吗”。
这类问题我后来总结成一句话:CPU 高不可怕,可怕的是你不知道是哪个线程在烧。这篇把排查过程完整记下来,既当备忘录,也给以后的自己抄作业。
一、现象:机器在“发烧”,但没人认领
先别急着重启。重启是个好按钮,但按下去之后现场就没了,下次它还会再烧。我的第一个动作是:保留现场,再决定要不要摘流量。登上机器,先看整体:
$ top
top - 14:32:07 up 41 days, 3:12, 1 user, load average: 9.87, 8.42, 5.10
%Cpu(s): 96.3 us, 1.2 sy, 0.0 ni, 2.1 id, 0.0 wa, 0.0 hi, 0.4 si, 0.0 st
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
8231 app 20 0 8123456 3.1g 24812 S 798.3 19.6 41:33.12 java两个信息:us(用户态)96%,说明是应用代码在算,不是系统调用、也不是 IO 等待(wa 是 0);元凶是 PID 8231 这个 Java 进程。这一步已经排除了一大票“磁盘慢”“网络抖”的怀疑。
二、排查路线图
Java 进程 CPU 高,我固定走下面这条路,背下来能省很多时间:
三、动手:从进程一路钻到代码行
第 1 步:找出烧得最凶的线程
$ top -Hp 8231 -n 1 | head -15
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
8479 app 20 0 8123456 3.1g 24812 R 99.9 19.6 6:12.44 http-nio-8080-e
8483 app 20 0 8123456 3.1g 24812 R 99.7 19.6 6:11.90 http-nio-8080-e
8490 app 20 0 8123456 3.1g 24812 R 99.7 19.6 6:10.31 http-nio-8080-e
...(一共 8 个 http-nio-8080-exec 线程,全部 99%)注意,不是一个线程烧,而是 8 个 Tomcat 工作线程同时满载,而且 TIME+ 都是 6 分钟左右——它们不是“忙”,是卡在某个计算里出不来了。这个细节很重要:线程时间累计和“开始出问题的时间”对得上,说明是同一批请求把线程一个个拖进去的。
第 2 步:线程号转十六进制
jstack 里线程 id 叫 nid,是十六进制。top 里显示的是十进制,要转一下(这一步我每次都忘,所以写进备忘录):
$ printf '%x\n' 8479
211f第 3 步:jstack 找栈
$ jstack 8231 | grep -A 25 'nid=0x211f'
"http-nio-8080-exec-3" #41 daemon prio=5 os_prio=0 tid=0x00007f3c2c0a8800 nid=0x211f runnable [0x00007f3c0d7f5000]
java.lang.Thread.State: RUNNABLE
at java.util.regex.Pattern$Loop.match(Pattern.java:4785)
at java.util.regex.Pattern$GroupTail.match(Pattern.java:4717)
at java.util.regex.Pattern$BranchConn.match(Pattern.java:4568)
at java.util.regex.Pattern$CharProperty.match(Pattern.java:3777)
at java.util.regex.Pattern$Loop.match(Pattern.java:4785)
at java.util.regex.Pattern$GroupTail.match(Pattern.java:4717)
... (上面这几行反复出现几十次)
at java.util.regex.Matcher.matches(Matcher.java:604)
at com.demo.order.util.AddressParser.isValid(AddressParser.java:37)
at com.demo.order.service.OrderService.create(OrderService.java:118)看到 java.util.regex.Pattern$Loop.match 一层套一层,脑子里应该立刻响起警报:正则灾难性回溯(Catastrophic Backtracking)。业务栈帧在最下面:AddressParser.isValid,第 37 行。
顺手多抓几次栈(间隔 5 秒,抓 3 份),确认 8 个线程都停在同一处:一份 dump 只是快照,连续几份都卡在同一行,才叫“证据”。
四、根因:一条看起来人畜无害的正则
翻代码,第 37 行是这样的(已脱敏改写):
// 校验收货地址:由汉字、字母、数字、括号、连字符组成
private static final Pattern ADDR = Pattern.compile("^([\\u4e00-\\u9fa5A-Za-z0-9()()\\-]+\\s?)+$");
public static boolean isValid(String addr) {
return ADDR.matcher(addr).matches();
}问题出在 (X+\s?)+ 这个结构:内层 + 和外层 + 都能匹配同一段字符。当输入是合法的还好,一旦输入的末尾有个不匹配的字符(比如一个 emoji 或全角感叹号),引擎会把“一长串字符怎么切分给内外两层”的所有组合都试一遍——组合数是指数级的。
那天的“肇事”订单,收货地址是这样的:
浙江省杭州市西湖区文三路XXXX号XXXX大厦XXXX室XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX😀一个 40 多字符的地址加个笑脸,正则引擎就要试几十亿种切法。而 Java 的正则引擎是回溯型的,没有超时机制,一个请求线程就能被它无限期霸占。Tomcat 默认 200 线程,我们这个服务配了 8 个 worker 相当紧张;用户还很勤奋地点了好几次“提交订单”——于是线程一个个陷进去,CPU 800%。
顺便一提,这不是我们独创的姿势。Stack Overflow 在 2016 年因为一个帖子里多余的空白字符触发了正则回溯,站点故障了 34 分钟;Cloudflare 在 2019 年也因为 WAF 规则里的一条正则,让全球边缘节点 CPU 耗尽——踩这个坑的人,从来都不孤单。
五、止血与修复
止血(先让业务活过来)
- 网关层临时把该接口限流到很低,让剩余线程别再被拖进去;
- 已经陷进去的线程不会自己恢复,正则不响应中断——只能滚动重启该实例。重启前,我把 3 份 jstack 和
top -Hp的输出保存到了文件里; - 重启后 CPU 立刻回落到 12%,接口恢复。
根治(三选一,我全用了)
// 方案 1:改写正则,避免嵌套量词(最好,直接消灭问题)
// 字符集本身就允许空白,不需要再套一层 (...\s?)+
Pattern ADDR = Pattern.compile("^[\\u4e00-\\u9fa5A-Za-z0-9()()\\-\\s]{1,100}$");
// 方案 2:正则前先做长度校验,把输入规模卡住
if (addr == null || addr.length() > 100) return false;
// 方案 3:给正则加“超时保险丝”——CharSequence 包一层,超时抛异常
static class TimeLimitedCharSequence implements CharSequence {
private final CharSequence inner; private final long deadline;
TimeLimitedCharSequence(CharSequence inner, long timeoutMs) {
this.inner = inner; this.deadline = System.currentTimeMillis() + timeoutMs;
}
public char charAt(int i) {
if (System.currentTimeMillis() > deadline) throw new IllegalStateException("regex timeout");
return inner.charAt(i);
}
public int length() { return inner.length(); }
public CharSequence subSequence(int s, int e) { return new TimeLimitedCharSequence(inner.subSequence(s, e), deadline - System.currentTimeMillis()); }
public String toString() { return inner.toString(); }
}方案 3 利用了一个冷门事实:正则引擎每读一个字符都会调用 charAt,所以从这里抛异常,就能中断回溯。JDK 21+ 也可以考虑换用带超时能力的三方库(如 RE2/J)。
六、复盘:这类坑怎么提前躲开
| 做法 | 为什么 |
|---|---|
| 用户输入进正则前先限长度 | 指数级复杂度在小输入下不显眼,长度是最便宜的保险 |
Code Review 盯住嵌套量词 (a+)+ (a|a)+ (.*)* | 这三种写法几乎必然存在回溯风险 |
| 所有“外部输入 → 正则”的地方,用长文本 + 尾部非法字符做一次压测 | 回溯只在“匹配失败”的路径上爆炸,正常用例永远测不出来 |
| 线程池做隔离,别让一个接口拖死整个 Tomcat | 问题永远会发生,重要的是别扩散 |
| 把“top -Hp → printf → jstack”写进值班手册 | 凌晨三点没人记得住十六进制转换 |
一句话备忘:CPU 高 → 先分清 us / sy / wa →
top -Hp找线程 →printf '%x'→jstack看栈顶。栈顶是 regex,别怀疑,直接看正则。
最后说句掏心窝子的:正则这东西,写的时候觉得自己是天才,出问题的时候发现自己是段子手。从那以后,我看到正则里有两层加号,眼睛都会自己抽搐一下。
参考资料
- Stack Overflow 2016-07-20 故障复盘:一个帖子里多余的空白字符触发正则回溯,站点 CPU 被吃满,官方给出了原因与修复。
- Cloudflare 2019-07-02 故障详情:WAF 新规则里的一条正则发生灾难性回溯,全球边缘节点 CPU 耗尽,是同类问题的另一个著名案例。
- JDK 8 Pattern 文档:Java 正则的语法与量词说明(贪婪、懒惰、占有量词)。
一起聊聊