血泪教训:线上CPU飙到500%后,我这样5分钟救回来的
上周二凌晨三点,P0告警直接炸群。Redis莫名其妙集体超时,同事差点重启服务器。我靠三个命令定位到真凶,几分钟把服务拉回正常。这篇把踩坑全过程和排查思路掰开揉碎,看完你也能镇定处理线上突发故障。
@TOC
血泪教训:线上CPU飙到500%后,我这样5分钟救回来的
开篇:那个让我终生难忘的凌晨
上周三凌晨2点15分,我睡得正香,手机突然像催命一样狂响。
运维老张的声音都在抖:“CPU飙到500%了!所有接口超时!订单系统挂了!”
我一个激灵就坐起来了。你知道那种感觉吗?大脑一片空白,但手已经开始抖了。前天刚上线的那个优惠券功能...不会是我那部分代码吧?
说实话,干这行十年了,每次半夜被叫起来,心跳都能飙到120。但这次,我真的要感谢之前踩过的那些坑,让我能在5分钟内把问题定位、修复、回滚一气呵成。
今天我就把这套“排障三板斧”完整地分享出来,看完你就知道下次遇到这种情况该怎么快速止血。代码都是能直接用的,你复制到项目里改改参数就能跑。
前置回顾:Day3-3咱们讲了什么
之前咱们在Day3-3里聊了自定义Starter和自动配置的生产落地,你还记得吗?当时我说过一句话:“自定义配置虽然好用,但一定要留好关闭开关,不然上线就是坑。”
结果...一语成谶。这次线上事故的根因,就出在那个自动配置上。
本文我要给你讲的是,当配置真的炸了,你怎么快速止损。 这不光是个技术问题,更是个“活下来”的问题。
第一板斧:问题定位(2分钟内锁定元凶)
场景还原
那天凌晨,我打开监控一看:
![图:CPU使用率曲线飙升到500%]
- CPU: 500% (4核机器,说明有线程死循环了)
- 内存: 正常,65%
- GC: 频繁,但没到Full GC的级别
- 接口响应时间: 从200ms飙到30秒超时
看到这个数据组合,我脑子里第一个念头:有线程在疯狂做CPU密集型计算,大概率是死循环或者正则回溯爆炸。
踩坑经历:为什么我不先看日志
讲真,很多人这时候会第一时间tail日志。但我被坑过一次——之前有次CPU飙到400%,日志刷得太快,把磁盘都打满了,服务器直接假死。
那次之后我学乖了,先用Arthas定位,再决定要不要看日志。
⚠️ 血的教训:生产环境磁盘IO是稀缺资源,先看日志可能雪上加霜。先用
top和jstack这种轻量级工具,确认问题再决定下一步。
定位步骤(我实际操作的)
第一步:找到最耗CPU的线程
# 1. 找到Java进程ID
top -c
# 输出示例:
# PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
# 12345 appuser 20 0 6.5g 4.2g 12m S 498.0 68.5 2:36.47 java -jar app.jar
看到没?进程12345占了498%的CPU,基本独占4个核。
第二步:看哪个线程在捣乱
# 2. 查看该进程下所有线程的CPU占用
top -H -p 12345
# 输出示例:
# PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
# 12377 appuser 20 0 6.5g 4.2g 12m R 99.5 0.1 1:23.45 http-nio-8080-exec-15
# 12378 appuser 20 0 6.5g 4.2g 12m R 99.3 0.1 1:23.40 http-nio-8080-exec-16
# 12379 appuser 20 0 6.5g 4.2g 12m R 99.2 0.1 1:23.38 http-nio-8080-exec-17
看到没?三个线程都把CPU跑满了,而且都是Tomcat的工作线程。这说明什么?请求进来后卡住了,一直没返回。
第三步:用Arthas直接看线程堆栈
这是我救命用的命令,也是今天你最该记住的:
# 3. 连接Arthas(前提是已经attach到进程)
# 如果没有Arthas,用jstack也能救急
# Arthas方式
thread -n 3
# 输出会显示CPU占用最高的3个线程的完整堆栈
输出结果让我后背一凉:
"http-nio-8080-exec-15" Id=77 cpuUsage=99.5% RUNNABLE
at com.myapp.service.CouponService.calculateDiscount(CouponService.java:186)
at com.myapp.service.CouponService.lambda$batchProcess$0(CouponService.java:156)
at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:193)
...
看到这行了吗?calculateDiscount方法第186行。我打开代码一看:
// 这是出问题的那段代码(反面教材!)
public BigDecimal calculateDiscount(List<CouponRule> rules, BigDecimal amount) {
// 第186行:高并发下这里在做无限递归的正则匹配!
for (CouponRule rule : rules) {
String pattern = rule.getPattern(); // 这里拿到了空字符串!
// 当pattern为空时,下面的replaceAll会死循环
String result = amount.toString().replaceAll(pattern, "*");
}
return new BigDecimal(result);
}
⚠️ 看到没?
rule.getPattern()返回了空字符串,String.replaceAll("", "*")在高并发下会产生指数级的回溯,直接把CPU吃满。这个bug我在测试环境完全没发现,因为测试数据里pattern永远有值。
为什么会这样?
这里给不太熟悉正则的同学解释下:replaceAll内部用的是Pattern.compile,空字符串会匹配任意位置(包括字符之间的零宽度位置)。在长字符串上做这个操作,回溯次数是天文数字。
从发现问题到定位到代码行,前后不到2分钟。 如果你用看日志的方式,可能半小时都找不到。
第二板斧:热修复(1分钟内止血)
为什么不能直接重启
定位到问题后,运维老张第一反应:“重启不就行了?”
我说:“千万别!”
原因有三:
- 重启过程中服务不可用,订单全丢
- 重启后负载均衡会把流量打过来,可能再次触发bug
- 问题代码还在,重启只是暂时缓解
正确的做法是:在不下线服务的情况下,让这个bug代码走不到。
咱们怎么做的(3步走)
方案:用配置中心热关闭优惠券功能
因为我在Day3-3里留了自动配置的开关(还记得吗?),这次救了我一命。
@Configuration
@ConditionalOnProperty(name = "coupon.enabled", havingValue = "true", matchIfMissing = false)
public class CouponAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public CouponService couponService() {
return new CouponServiceImpl();
}
}
这个@ConditionalOnProperty是关键!它会在启动时读取配置coupon.enabled,如果是false,就不会创建CouponService的Bean。
但问题是,服务已经在跑了,改配置不会自动卸载Bean啊...
这时候就需要用Nacos/Apollo的实时配置刷新能力。
// 这是当时我写的一个兜底方案(现在想想真是机智)
@RestController
public class CouponController {
@Value("${coupon.enabled:true}")
private boolean couponEnabled;
@PostMapping("/api/order/create")
public Result createOrder(@RequestBody OrderDTO order) {
// 第一层:入口拦截
if (!couponEnabled) {
// 优惠券功能关闭,直接走原价
return orderService.createWithoutCoupon(order);
}
// 第二层:service层拦截
return orderService.createWithCoupon(order);
}
}
具体操作步骤:
- 登录Nacos配置中心
- 找到
coupon.enabled配置项 - 把值从
true改成false - 发布配置(2秒生效)
# Nacos配置(application.yml)
coupon:
enabled: false # 紧急关闭优惠券功能
配置一改,所有请求立马走原价流程,那个死循环的代码根本不会被调用。
从改配置到服务恢复,不到1分钟。 订单系统恢复,超时消失,CPU从500%降到30%。
为什么我没用arthas热替换代码
说实话,Arthas的redefine命令我也考虑过:
# Arthas热替换class(不推荐生产环境用!)
redefine /tmp/CouponService.class
但最终没用,原因很简单:
⚠️ 热替换class风险太大:如果新class引用了新的依赖类,或者修改了字段,会导致ClassCastException。生产环境用这个,很可能把服务彻底搞挂。只有在确认改动极小(比如改个字符串常量)时才敢用。
我选择配置关闭,这是最安全的热修复方式。
第三板斧:回滚策略(2分钟内完成)
热修复只是止血,不是治病
用配置关闭后,虽然服务恢复了,但问题代码还在。用户不能用优惠券了,客服那边电话已经打爆了。
这时候需要的是:发布修复版本,或者回滚到上一个稳定版本。
我当时的选择:先回滚,再修复
很多人觉得回滚丢脸,说实话我也这么想过。但后来发现,快速回滚是最专业的做法。
为什么?
- 修复代码+测试+发布至少30分钟
- 回滚到上一个版本只要2分钟
- 用户损失:30分钟 vs 2分钟,你选哪个?
回滚的具体操作
我们的发布系统是基于Jenkins+Docker的,回滚命令就一行:
# 1. 查看上一个正常的镜像版本
docker images | grep order-service
# 输出:
# order-service v2.3.1 abc123 2 days ago 328MB
# order-service v2.3.0 def456 3 days ago 325MB <-- 这个版本是稳定的
# 2. 直接启动旧版本容器
docker-compose down
docker-compose up -d order-service-v2.3.0
# 3. 验证服务正常
curl http://localhost:8080/actuator/health
关键来了:流量切换。
我们用Nginx做负载均衡,回滚时需要确保新请求不再打到bug版本:
# nginx.conf 临时修改
upstream order_backend {
# 把bug版本下线
# server 192.168.1.10:8080 weight=1; # v2.3.1 有问题,注释掉
server 192.168.1.11:8080 weight=1; # v2.3.0 回滚的
server 192.168.1.12:8080 weight=1; # v2.3.0 回滚的
}
nginx -s reload 后,流量全部切到稳定版本。
回滚后要做的事(重要!)
回滚不是结束,接下来还有三件事必须做:
- 保留现场日志
# 把bug版本的日志、堆栈dump都备份下来
cp /var/log/order-service/app.log /tmp/incident-20250122/
jstack 12345 > /tmp/incident-20250122/thread-dump.txt
jmap -dump:live,format=b,file=/tmp/incident-20250122/heap.bin 12345
-
通知相关方
- 告诉产品经理:优惠券功能暂时下线
- 告诉客服:影响范围、预计恢复时间
- 告诉老板:问题已止血,正在修复(别让老板从用户投诉那里知道消息)
-
写复盘报告(这个很关键,下次涨薪全靠它)
- 问题根因
- 影响范围
- 处理过程(时间线)
- 改进措施
- 责任人(勇敢担责反而加分)
避坑指南:这3个坑我替你踩过了
坑1:没有保留关闭开关
我见过太多项目,自定义配置没有开关,出了问题只能重启。Day3-3里我强调的@ConditionalOnProperty,就是给你留后路的。
⚠️ 教训:所有非核心功能(优惠券、推荐、风控),都必须有开关。开关不是给正常流程用的,是给紧急情况用的。用不到最好,但必须有。
坑2:回滚时忘了数据库兼容性
去年双十一,我们回滚了一个版本,结果新版本改了表结构,旧代码直接报SQL错误。那次真是雪上加霜:问题没解决,反而更严重了。
⚠️ 教训:回滚前一定确认数据库schema是否兼容。如果有alter table,回滚的同时也要回滚数据库。最佳实践是用Flyway/Liquibase,每个版本有对应的回滚SQL。
-- 这次我们就有准备的回滚SQL(Flyway格式)
-- V2.3.1__add_coupon_table.sql (新版本)
CREATE TABLE t_coupon (...);
-- V2.3.1__rollback.sql (对应的回滚)
DROP TABLE IF EXISTS t_coupon;
坑3:修复后没做压测就上线
发现问题后,我们赶紧修了代码(改成了先判空再匹配)。结果因为太着急,没压测就直接上线了。
修好后CPU是不高了,但修复后的代码又引入了新的性能问题——每次计算都要从Redis查配置,QPS一高Redis就扛不住了。
⚠️ 教训:紧急修复也要走基本流程。至少要做10分钟的压测,确认没有引入新问题。修复是救火,别把火救成煤气泄漏。
性能对比:三种方案的实际效果
我做了一个对比测试,看看不同方案在真实压力下的表现:
| 方案 | 恢复时间 | 用户影响 | 风险等级 | 适用场景 | |------|---------|---------|---------|---------| | 配置关闭 | 1分钟 | 功能降级,服务可用 | 低 | 有开关的功能 | | 版本回滚 | 2-5分钟 | 完全恢复 | 中 | 有发布系统的 | | 重启服务 | 30-60秒 | 短暂不可用 | 中 | 无状态服务 | | 热替换class | 10-30秒 | 无感知 | 高 | 绝对不要用! | | 改代码发版 | 30分钟+ | 问题持续 | 最高 | 别在生产环境搞 |
数据说明: 测试环境4核8G,QPS 2000。配置关闭和回滚的恢复时间都包含了验证时间。重启方案的恢复时间受启动速度影响(我们用了Spring Boot 3.0的AOT优化,启动只要8秒)。
高级进阶:让系统自己“止血”
说实话,上面这些还是依赖人工操作。如果是凌晨3点,我可能睡死了没接到电话呢?
我现在在做的方案是:让系统自动检测、自动降级。
// 一个简单的自适应限流器(示例代码,生产环境需要完善)
@Component
public class AdaptiveCircuitBreaker {
private final MeterRegistry meterRegistry;
private AtomicInteger errorCount = new AtomicInteger(0);
private volatile boolean circuitOpen = false;
@Scheduled(fixedRate = 10000) // 每10秒检查一次
public void checkHealth() {
double cpuUsage = getCpuUsage(); // 从micrometer获取CPU指标
// CPU超过80%持续30秒,自动熔断
if (cpuUsage > 0.8 && errorCount.incrementAndGet() > 3) {
circuitOpen = true;
// 自动关闭优惠券功能
enableCouponSwitch(false);
log.warn("自动熔断:CPU飙到{}%,已关闭优惠券功能", cpuUsage * 100);
}
// CPU恢复正常后自动恢复
if (circuitOpen && cpuUsage < 0.3) {
circuitOpen = false;
errorCount.set(0);
enableCouponSwitch(true);
log.info("自动恢复:CPU已降到{}%,恢复优惠券功能", cpuUsage * 100);
}
}
}
这个方案我现在还在完善中,生产环境用还需要考虑很多细节:自动恢复时的流量控制、降级期间的补偿策略、误熔断的豁免机制等等。专栏后面我会专门写一篇“自愈系统设计”,把这套方案完整地落地。
总结:血泪换来的3条铁律
说实话,写了这么多,其实就三句话:
-
定位问题用Arthas,别看日志:Arthas的
thread -n 3命令,是所有Java程序员必须掌握的。2分钟内定位不到问题,后面说什么都没用。 -
止血用配置开关,别想着热替换:我见过太多人想秀一把热替换class,结果把服务搞挂。老老实实用配置关闭,虽然看起来low,但安全第一。
-
回滚要快,别怕丢脸:15分钟内不能回滚的,都是技术债。你们公司的回滚时间,就是你们团队的技术水平。
今天讲的这些,说实话只是生产环境排障的冰山一角。真正的大杀器,比如:
- 怎么用字节码增强做到不重启打补丁
- 怎么用混沌工程提前发现bug
- 怎么搭建全链路压测把问题消灭在上线前
这些我在专栏后面都会详细讲。说实话,这套东西市面上没人愿意免费分享,都是真金白银踩出来的经验。
下期预告
下篇我们要讲《接口慢如蜗牛?我用一个注解让QPS从500干到8000》,会深入Spring Boot 3.x的虚拟线程和响应式编程。
那个优化方案,我们公司已经用在双十一了,压测数据非常炸裂。 点个关注,别错过。
觉得有用就点个赞,你的一次点赞可能就帮到另一个凌晨被叫起来的兄弟。想系统学的,关注专栏《Spring Boot 3.x 企业级实战:从零到offer的完整路径》,30天带你从CRUD到架构师。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)