从一次服务雪崩事故说起:微服务稳定性的血泪教训

图片[1]-从一次服务雪崩事故说起:微服务稳定性的血泪教训 - 速优课-速优课

前言

在微服务架构日渐普及的今天,”服务雪崩”这个词相信很多开发者都不陌生。但真正亲身经历过的人,才能深刻体会到它的破坏力。今天,我想通过一次真实的线上事故,带大家直观感受服务雪崩是如何一步步发生的,以及为什么微服务系统必须具备自我保护能力。

这是我职业生涯中印象最深刻的一次事故——连续三天两夜的排查,睡眠时间加起来不足5小时。也正是这次经历,让我对服务降级、熔断限流有了刻骨铭心的理解。

一、什么是服务雪崩

“雪崩”原本是自然界的现象:山地积雪因为底部溶解等原因,突然大块塌落,具有极强的破坏力。

在微服务领域,服务雪崩指的是:由于突发流量导致某个服务不可用,进而引发上游服务连锁故障,最终导致整个系统瘫痪的现象。 就像多米诺骨牌一样,一块倒下,全盘皆输。

二、事故现场:从技术分享会到紧急排查

事情发生在一个普通的周三下午,我们正在进行每周的技术分享会。突然,运营同事推开门,带来了一个坏消息:整条业务线的服务都挂了。

技术分享会瞬间变成了问题排查会。通过监控系统,我们很快发现了异常:

并发请求量直接翻倍! 从平时的每分钟3-4万请求,突然飙升到了8.6万。

在此之前,服务一直运行得很稳定。很显然,这次事故和并发量突增有直接关系。

系统架构背景

先简单介绍一下当时的系统部署情况。这是一个分布式广告系统,也是我接触的第一个微服务实战项目:

服务名称部署配置说明
服务A(广告点击处理)2台 2核8G实例基于Netty自研的HTTP服务框架
服务B(渠道广告过滤)2台 2核4G实例RPC服务提供者,Dubbo框架
图片[2]-从一次服务雪崩事故说起:微服务稳定性的血泪教训 - 速优课-速优课

三、三个关键异常现象

通过分析日志,我们发现了三个核心问题,它们就像三条线索,指向事故的真相。

异常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执行一条命令的完整过程是:

  1. 接收客户端请求
  2. 进入队列等待执行
  3. 执行命令
  4. 响应结果给客户端

由于Jedis的get操作耗时长,导致服务B的接口执行时间超过了3秒。

五、雪崩是如何发生的:级联失败的完整链条

找到了慢操作,那雪崩是怎么一步步扩散的呢?让我们梳理一下完整的故障链条。

第一步:Dubbo超时重试放大流量

服务A调用服务B,虽然Dubbo消费端因为超时放弃了请求,但请求已经发出去了——就像泼出去的水收不回来。服务端(服务B)感知不到消费端已经超时放弃,不会中断正在执行的线程,还是要把业务逻辑执行完。

更严重的是,Dubbo集群容错机制默认使用 Failover 策略:调用失败时,会自动重试其他服务节点。默认重试两次,加上第一次调用,最坏情况下一共会发起3次RPC调用。

图片[3]-从一次服务雪崩事故说起:微服务稳定性的血泪教训 - 速优课-速优课

这样一来,原本服务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]-从一次服务雪崩事故说起:微服务稳定性的血泪教训 - 速优课-速优课

缓存的字符串越长,这段代码就越耗时,也越消耗内存。再加上网络传输的时间,接口总耗时很容易就超过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等主流框架。

总结与思考

这次服务雪崩事故给我上了深刻的一课,也让我彻底明白了:在微服务架构中,服务降级不是锦上添花,而是必需品。 每个服务都应该具备自我保护的能力。

几个关键的思考点:

  1. 缓存设计要谨慎:大value是Redis的隐形杀手,不仅慢还占内存,能用Hash/Bitmap就别用大字符串
  2. 超时重试要合理:Dubbo默认重试2次在某些场景下会放大故障,不是所有接口都适合重试
  3. 服务要有自我保护:不能被动地接收所有请求,该拒绝的时候要拒绝,否则只会被拖垮
  4. 监控和熔断要前置:不要等雪崩了才想起要限流,提前配置熔断规则才能防患于未然

接下来的文章中,我会带大家深入理解Sentinel的核心原理,从滑动窗口指标统计到责任链模式,从限流算法到熔断降级,一步步揭开这个”流量防卫兵”的神秘面纱。

© 版权声明
THE END
喜欢就支持一下吧
点赞6
相关推荐
评论 抢沙发

请登录后发表评论

    请登录后查看评论内容

温馨提示:
1、本内容转载于网络,版权归原作者所有!
2、本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
3、本内容若侵犯到你的版权利益,请联系我们,会尽快给予删除处理!