![图片[1]-从一次服务雪崩事故说起:微服务稳定性的血泪教训 - 速优课-速优课](https://www.suyouke.com/wp-content/uploads/2026/08/doubao_img_2304x1728_20260803_094414-1024x768.png)
前言
在微服务架构日渐普及的今天,”服务雪崩”这个词相信很多开发者都不陌生。但真正亲身经历过的人,才能深刻体会到它的破坏力。今天,我想通过一次真实的线上事故,带大家直观感受服务雪崩是如何一步步发生的,以及为什么微服务系统必须具备自我保护能力。
这是我职业生涯中印象最深刻的一次事故——连续三天两夜的排查,睡眠时间加起来不足5小时。也正是这次经历,让我对服务降级、熔断限流有了刻骨铭心的理解。
一、什么是服务雪崩
“雪崩”原本是自然界的现象:山地积雪因为底部溶解等原因,突然大块塌落,具有极强的破坏力。
在微服务领域,服务雪崩指的是:由于突发流量导致某个服务不可用,进而引发上游服务连锁故障,最终导致整个系统瘫痪的现象。 就像多米诺骨牌一样,一块倒下,全盘皆输。
二、事故现场:从技术分享会到紧急排查
事情发生在一个普通的周三下午,我们正在进行每周的技术分享会。突然,运营同事推开门,带来了一个坏消息:整条业务线的服务都挂了。
技术分享会瞬间变成了问题排查会。通过监控系统,我们很快发现了异常:
并发请求量直接翻倍! 从平时的每分钟3-4万请求,突然飙升到了8.6万。
在此之前,服务一直运行得很稳定。很显然,这次事故和并发量突增有直接关系。
系统架构背景
先简单介绍一下当时的系统部署情况。这是一个分布式广告系统,也是我接触的第一个微服务实战项目:
| 服务名称 | 部署配置 | 说明 |
|---|---|---|
| 服务A(广告点击处理) | 2台 2核8G实例 | 基于Netty自研的HTTP服务框架 |
| 服务B(渠道广告过滤) | 2台 2核4G实例 | RPC服务提供者,Dubbo框架 |
![图片[2]-从一次服务雪崩事故说起:微服务稳定性的血泪教训 - 速优课-速优课](https://www.suyouke.com/wp-content/uploads/2026/08/image-1024x440.png)
三、三个关键异常现象
通过分析日志,我们发现了三个核心问题,它们就像三条线索,指向事故的真相。
异常1:服务A的RPC调用大量超时
我们给服务B的每个接口都配置了3秒超时时间。按理说,服务B的接口都是缓存级别的操作,除非网络出问题,否则不可能超过3秒。但现实是,大量调用都超时了。
异常2:服务B的Jedis读操作超时
服务B的所有节点日志里,全是 Read time out! 异常。
这里有个背景:服务B每个节点配置了200个最小连接数的Jedis连接池,这是根据Netty工作线程数来配的。理论上,就算200个线程同时读写,每个线程也能分到一个Jedis连接,不应该出现连接不够的情况。
异常3:服务A的文件句柄数达到上限
我们在启动脚本里给每个进程配置了102400的最大文件打开数,按当时的并发量来看,根本不可能达到这个上限。但事实是,文件句柄数真的被打满了。
每个SocketChannel套接字都会占用一个文件句柄,有多少客户端连接就占用多少个。服务A底层用的是自研的Netty HTTP框架,没有限制最大连接数——这为后面的崩溃埋下了伏笔。
四、排查过程:从Redis到业务代码
第一阶段:怀疑Redis扛不住
一开始我们怀疑是Redis顶不住这么大的并发。
按业务代码估算,处理一次广告点击需要执行30次Redis get操作。按每分钟8万请求算,就是240万次get请求。再加上其他服务的使用,保守估计Redis每分钟要承受300万次读写,换算成每秒就是5万次。
而Redis单节点理论上每秒可以处理超过10万次读写,看起来还没到瓶颈,但当时我们用的还是Redis 2.x的一主一从架构,读请求都打到从节点上。也就是说,一个从节点要扛每秒近5万的读请求。
于是我们做了第一次优化:
- 将Redis从2.x升级到4.x
- 从主从架构改为分布式集群(两主无从)
升级后,理论上请求会平摊到两个节点上,性能应该好很多。
第二阶段:排除Redis,但问题还在
然而好景不长,服务重新上线不到一小时,并发又冲到了每分钟六七万。
这次的变化是:Jedis读超时(Read time out)消失了,但大量RPC调用超时依然存在。至少排除了Redis本身的性能瓶颈,但真正的凶手还没找到。
第三阶段:发现罪魁祸首
虽然没有了 Read time out!,但某个Jedis的get操作依然很慢——这才是真正的元凶。
这里要区分两个概念:
- Redis命令执行耗时:Redis服务器执行命令的时间
- Jedis读操作耗时:还包括网络传输时间,响应数据包越大,接收就越耗时
Redis执行一条命令的完整过程是:
- 接收客户端请求
- 进入队列等待执行
- 执行命令
- 响应结果给客户端
由于Jedis的get操作耗时长,导致服务B的接口执行时间超过了3秒。
五、雪崩是如何发生的:级联失败的完整链条
找到了慢操作,那雪崩是怎么一步步扩散的呢?让我们梳理一下完整的故障链条。
第一步:Dubbo超时重试放大流量
服务A调用服务B,虽然Dubbo消费端因为超时放弃了请求,但请求已经发出去了——就像泼出去的水收不回来。服务端(服务B)感知不到消费端已经超时放弃,不会中断正在执行的线程,还是要把业务逻辑执行完。
更严重的是,Dubbo集群容错机制默认使用 Failover 策略:调用失败时,会自动重试其他服务节点。默认重试两次,加上第一次调用,最坏情况下一共会发起3次RPC调用。
![图片[3]-从一次服务雪崩事故说起:微服务稳定性的血泪教训 - 速优课-速优课](https://www.suyouke.com/wp-content/uploads/2026/08/image-1.png)
这样一来,原本服务B需要处理的并发是8万,最坏情况下就变成了24万!
第二步:服务B线程池耗尽
在重试机制的放大下,服务B的业务线程池很快被占满,开始出现”远程服务线程池已满”的异常,直接拒绝请求。
第三步:服务A被拖垮
服务B不可用后,服务A的请求开始堆积。由于服务A没有拒绝客户端请求的机制,客户端的请求还在源源不断地涌来。
最终,服务A的SocketChannel占用的文件句柄数达到上限,服务A彻底崩溃。
这就是典型的服务雪崩:服务B的崩溃拖垮了服务A,级联效应导致整个系统瘫痪。
六、根因分析:一个隐藏的性能坑
说了这么多,那导致Jedis读操作慢的根本原因是什么呢?
答案是:缓存的value太大了。
业务代码里,把大量ID用逗号拼接成一个字符串存在Redis里。每次取的时候,先把这个大字符串读出来,然后分割成数组,再判断某个ID是否在数组中。
我写了一段模拟代码来复现这个问题:
public class Match {
static class Task implements Runnable {
private String value;
public Task(String value) {
this.value = value;
}
@Override
public void run() {
for (; ; ) {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
long start = System.currentTimeMillis();
List<String> ids = Arrays.stream(value.split(",")).collect(Collectors.toList());
boolean exist = ids.contains("4029000");
System.out.println("exist:" + exist + ",time:" + (System.currentTimeMillis() - start));
}
}
};
public static void main(String[] args) {
StringBuilder value = new StringBuilder();
for (int i = 4000000; i <= 4029000; i++) {
value.append(String.valueOf(i)).append(",");
}
String strValue = value.toString();
System.out.println(strValue.length());
for (int i = 0; i < 200; i++) {
new Thread(new Task(strValue)).start();
}
}
}
这段代码模拟了200个业务线程全部耗尽的场景,跑起来后发现很多执行耗时都超过了1500ms:
![图片[4]-从一次服务雪崩事故说起:微服务稳定性的血泪教训 - 速优课-速优课](https://www.suyouke.com/wp-content/uploads/2026/08/image-2.png)
缓存的字符串越长,这段代码就越耗时,也越消耗内存。再加上网络传输的时间,接口总耗时很容易就超过3秒。
代码层面的优化
找到根因后,优化方案就很明确了:
- 将字符串拼接的存储方式改为 Hash结构,直接用
hget判断元素是否存在 - 这样既避免了大数据量的网络传输,也优化了执行速度
- (理论上用Bitmap更好,但由于该缓存还有其他用途,最终选择了Hash)
七、事故原因完整总结
让我们把整个事故链条完整地梳理一遍:
| 异常现象 | 根本原因 |
|---|---|
| Jedis Read time out | 缓存value字符串太长,网络传输数据包大,导致get命令耗时长 |
| 服务A RPC调用超时 | 业务代码缺陷 + 并发突增 + 缓存设计缺陷,导致服务B接口执行超3秒 |
| 服务B拒绝请求 | 超时触发Dubbo重试,并发量被放大,服务B线程池耗尽 |
| 服务A崩溃 | 服务B不可用导致请求堆积,文件句柄数达到上限 |
八、引入Sentinel:给服务装上保险丝
代码和缓存优化完了,但谁敢保证下次不会有其他性能问题?谁敢保证流量不会再突增?
为了避免重蹈覆辙,我们为项目引入了 Sentinel——阿里开源的微服务流量防卫兵。
我们做了这些配置:
- 为接口配置熔断降级规则:请求失败率过高时自动熔断
- 配置系统负载保护规则:服务器负载过高时自动限流
- 利用按来源限流功能:给定时任务的请求单独配置限流规则,限制同时只能有5个线程处理定时任务请求
Sentinel是阿里巴巴2018年开源的微服务断路器组件,承接了阿里近10年双十一大促流量的核心场景。它以流量为切入点,提供流量控制、熔断降级、系统负载保护等多种能力,并且天然适配Spring Cloud、Dubbo等主流框架。
总结与思考
这次服务雪崩事故给我上了深刻的一课,也让我彻底明白了:在微服务架构中,服务降级不是锦上添花,而是必需品。 每个服务都应该具备自我保护的能力。
几个关键的思考点:
- 缓存设计要谨慎:大value是Redis的隐形杀手,不仅慢还占内存,能用Hash/Bitmap就别用大字符串
- 超时重试要合理:Dubbo默认重试2次在某些场景下会放大故障,不是所有接口都适合重试
- 服务要有自我保护:不能被动地接收所有请求,该拒绝的时候要拒绝,否则只会被拖垮
- 监控和熔断要前置:不要等雪崩了才想起要限流,提前配置熔断规则才能防患于未然
接下来的文章中,我会带大家深入理解Sentinel的核心原理,从滑动窗口指标统计到责任链模式,从限流算法到熔断降级,一步步揭开这个”流量防卫兵”的神秘面纱。










请登录后查看评论内容