01 开篇词:一次服务雪崩问题排查经历 笔者想跟大家分享笔者经历的一次服务雪崩事故,分析导致此次服务雪崩事故的原因。或许大多数读者都有过这样的经历,这是项目给我们上的一次非常宝贵的实战课程。

什么是服务雪崩?

雪崩一词指的是山地积雪由于底部溶解等原因而突然大块塌落的现象,具有很强的破坏力,在微服务项目中指由于突发流量导致某个服务不可用,从而导致上游服务不可用,并产生级联效应,最终导致整个系统不可用,使用雪崩这个词来形容这一现象最合适不过。

服务雪崩,听到这个词就能想到问题的严重性。是的,当时公司整条业务线的服务都挂了,从该业务线延伸出来的下游业务线也被波及。笔者当时是连续三天两夜的忙着处理问题,加起来睡眠时间不足 5 小时,正是如此,印象非常深刻。

其实这一天的到来我是有预感的,但我以为会是数据量上升导致,实际却是并发量先上升,而严重程度超出我的预料。问题出现那天,我们还在进行每周的技术分享会,结果一运营小姐姐推开会议室的大门传来噩耗,画面瞬间转变,技术分享会变成了问题排查讨论会。

当时看了服务的负载均衡统计,发现并发请求量增长了一倍,从每分钟 3 到 4 万的请求数,增长到 8.6 万。在事发之前,服务一直稳定运行,很显然,这次事故与并发量翻倍有直接的关系。

这是由笔者负责技术选型与架构设计的一个分布式广告系统,也是笔者入门分布式微服务实战的第一个项目,从设计到实现,期间遇到过很多的难题,被项目推着走,熬了很多个夜,但也颇有收获。

关于服务的部署:

  • 处理广告点击的服务:2 台 2 核 8g 的实例,每台都运行一个服务进程,下文统称服务 A;
  • 渠道广告过滤服务(RPC 远程调用服务提供者):2 台 2 核 4g 实例,每台都运行一个服务进程,下文统称服务 B;
  • 还有其它的服务提供者,但不是影响本次服务雪崩的凶手,因此这里就不列举了。

服务部署

从当时查看服务打印的日记可以看出三个问题。

1. 服务 A:RPC 远程调用大量超时

我们配置服务 B 每个接口的超时时间都是 3 秒。服务 B 提供的接口的实现都是缓存级别的操作,3 秒的超时时间,理论上除了网络问题,调用不可能会超过这个值。

2. 服务 B:Jedis 读操作超时,服务 B 几个节点的日记全是 Jedis 读超时(Read time out!)

服务 B 每个节点配置了 200 个最小连接数的 Jedis 连接池,这是根据 Netty 工作线程数配置的,即读写操作就算 200 个线程并发执行,也能为每个线程分配一个 Jedis 连接。

3. 服务 A:文件句柄数达到上限

SocketChannel 套接字会占用一个文件句柄,有多少个客户端连接就占用多少个文件句柄。我们在服务的启动脚本上为每个进程配置 102400 的最大文件打开数,理论上当时的并发量并不可能会达到这个数值。服务 A 底层用的是自研的基于 Netty 实现的 HTTP 服务框架,没有限制最大连接数。

所以,这三个问题就是排查此次服务雪崩真正原因的突破口。

首先是怀疑 Redis 服务扛不住这么大的并发请求。根据业务代码估算,处理广告的一次点击需要执行 30 次 get 操作从 Redis 获取数据,那么每分钟 8w 并发,就需要执行 240w 次 get 请求,而 Redis 除了本文提到的服务 A 和服务 B 用到外,还有其它两个并发量高的服务在用,保守估计,Redis 每分钟需要承受 300w 的读写请求。转为每秒就是 5w 的读写请求,与理论值 Redis 每秒可以处理超过 10 万次读写操作已经过半。

由于历史原因,Redis 使用的还是 2.x 的版本,用的一主一从,Jedis 配置连接池是读写分离的连接池,也就是写请求打到主节点,读请求打到从节点。由于写请求非常的少,大多都是定时 15 分钟写一次,因此可先忽略写请求对 Redis 性能的影响,那么就是每秒接近 5w 读请求只有一个 Redis 从节点处理。所以我们将 Redis 升级到 4.x 版本,并由主从集群改为分布式集群,两主无从(使用 AWS 的 Redis 服务可以配置无从节点,还是节约成本的问题)。

Redis 升级后,理论上,两个主节点分槽位后请求会平摊到两个节点上,性能应该会好很多。但好景不长,服务重新上线一个小时不到,并发又突增到了六七万每分钟,这次是大量的 RPC 远程调用超时,已经没有 Jedis 的读超时(Read time out)了,相比之前好了点,至少不用再给 Redis 加节点,排除掉 Redis 性能瓶颈。

虽然升级后没有“Read time out!”,但某个 Jedis 的 Get 读操作还是很耗时,这才是罪魁祸首。Redis 的命令耗时与 Jedis 的读操作 Read time out 不同,Jedis 的读操作还受网络传输的影响,Redis 响应的数据包越大,Jedis 接收数据包就越耗时。Redis 执行一条命令的过程分为:

  • 接收客户端请求
  • 进入队列等待执行
  • 执行命令
  • 响应结果给客户端

Jedis 的 get 耗时长导致服务 B 接口执行耗时超过设置的 3s。服务 A 向服务 B 发起 RPC 调用,虽然 dubbo 消费端超时放弃请求,但是请求已经发出,就算消费端取消,提供者无法感知服务 A 超时放弃了,没有中断当前正在执行的线程,所以服务 B 还是要执行完一次调用的业务逻辑,这与说出去的话收不回来一样的道理。

Dubbo 集群容错机制默认使用 Failover,即当调用出现失败时,重试其它服务节点。默认会重试两次,不算第一个调用,所以最坏情况下,一共会发起三次 RPC 调用,如下图所示。

超时重试

当服务 A 超时放弃时,Dubbo 的集群容错处理会重新选择服务 B 的一个节点发起调用,所以并发 8w 对于服务 B 而言,最糟糕的情况下就变成了并发 24w。最后导致服务 B 的每个节点业务线程池的线程一直被占用,RPC 远程调用又多出了一个异常,就是远程服务线程池已满,服务 B 直接响应失败。

问题最终还是要回到 Jedis 的 Read time out 上,就是 key 对应的 value 太大导致传输耗时,业务代码拿到 value 后将 value 分割成数组,判断请求参数是否在数组中也非常耗时,就会导致服务 B 处理接口调用耗时超过 3s,从而导致服务 B 不可用,服务 B 不可用直接拖垮服务 A。

模拟服务 B 接口的业务代码如下: public class Match { static class Task implements Runnable { private String value; public Task(String value) { this.value = value; } @Override public void run() { for (; ; ) { // 模拟 jedis get 耗时 try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } // =====> 实际业务代码 long start = System.currentTimeMillis(); List 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()); // 开启 200 个线程执行 Task 的 run 方法 for (int i = 0; i < 200; i++) { new Thread(new Task(strValue)).start(); } } }

这段代码很简单,就是模拟高并发,观察在 200 个业务线程全部耗尽的情况下,一个简单的判断元素是否存在的业务逻辑执行需要多长时间。把这段代码跑一遍,发现很多执行耗时超过 1500ms,如下图所示。

测试结果

缓存的 value 字符串越长,这段代码就越耗时,同时也越消耗内存。如果再加上 Jedis 从发送 get 请求到接收完成 Redis 响应的数据包的耗时,接口的执行总耗时就会超过 3000ms。所以,导致服务雪崩的根本原因就是这个隐藏的性能问题。

代码层面的优化就是将 id 拼接成字符串的存储方式改为使用 hash 结构存储,直接 hget 方式判断一个元素是否存在,不需要将这么大的数据读取到本地,即避免了网络传输消耗,也优化了接口的执行速度。当然,最好使用 bitmap 存储,但由于该缓存还有其它用途,因此才选用 hash。

造成这次服务雪崩事故的原因分析总结:

  • Jedis 抛出 Read time out 的原因:由于缓存的 value 字符串太长,网络传输数据包大,导致 Jedis 执行 get 命令耗时长。
  • 服务 A 出现 RPC 调用超时的原因:业务代码的缺陷、并发量的突增,以及缓存设计缺陷导致 Jedis 读操作耗时长,导致服务 B 接口执行耗时超过 3 秒,从而导致服务 A 远程 RPC 调用超时。
  • 服务 A 出现服务 B 拒绝请求异常的原因:服务 A 调用超时触发 dubbo 超时重试,原本并发量就已经很高,加上耗时的接口调用,服务 B 业务线程池的线程全部处于工作状态,服务 B 已经高负荷运行,而 Dubbo 的超时重试又导致服务 B 并发量翻倍,简直雪上加霜,服务 B 处理不过来只能拒绝执行服务 A 的请求。
  • 服务 A 奔溃的原因:服务 B 的不可用导致服务 A 处理不过来客户端发来的请求,而服务 A 又没有拒绝客户端的请求,客户端请求源源不断,最后服务 A 请求堆积,导致 SocketChannel 占用的文件句柄达到上限,服务 A 就奔溃了。

服务 B 的奔溃导致服务 A 奔溃,正是这种级联效应导致服务雪崩。

另外,由定时任务服务调用服务 B 的接口,在每次任务执行时,都会导致服务 B 变得不可用。由于是内部服务,我们可以通过修改定时任务发送请求的线程数和频率来降低接口的 QPS,一开始我们也是这么做的。但如果有其它第三方的定时任务服务调用这个接口就不好控制了。

为避免流量再次突增导致服务雪崩,在优化完业务代码和缓存设计后,我们也为项目引入了断路器:Sentinel,为接口配置熔断降级规则、系统负载保护规则,当服务器负载过高或者请求失败率过高时,自动熔断上游服务的请求,以确保服务能够稳定运行。由于 Sentinel 支持按来源限流,我们也为定时任务发起的请求配置限流规则,限制服务 B 同时只能有五个线程处理定时任务发起的请求。

Sentinel 是阿里于 2018 年开源的微服务断路器组件,意义为流量防卫兵,承接了阿里巴巴近 10 年的双十一大促流量的核心场景,目前已有 13.3k 的 Star。Sentinel 以流量为切入点,实现流量控制、熔断降级、系统负载保护等多种服务降级方式保护服务的稳定性,并已提供对多种主流框架的适配,例如 Spring Cloud、Dubbo。

之所以在学习 Sentinel 之前跟大家分享这个服务雪崩故事,是想通过这次事故帮助读者更好的理解什么是服务雪崩。这次服务雪崩事故,让笔者明白了服务降级在分布式系统中的重要性。可以这么说,微服务项目不能缺少服务降级,每个服务都需要有自我保护的能力。

专栏大纲

了解 Sentinel 首先要攻克其基于滑动窗口实现的指标数据统计、以及基于责任链模式实现的服务降级过滤器链,在掌握这两点之后,整个 Sentinel 的框架源码将不难理解。Sentinel 实现的冷启动限流效果算法与匀速限流效果的算法算是限流模块中最难理解的一部份,在介绍这部分内容时,我们会结合 Guava 的限流算法分析,降低理解难度。

本专栏内容安排如下:

  • 第一部分(01-03):服务雪崩与服务降级介绍。从一个服务雪崩故事开始了解服务雪崩,进而理解为什么需要服务降级、服务降级的实现方式有哪些,以及为什么选择 Sentinel。
  • 第二部分(04-07):理解 Sentinel 的核心实现原理。我们将了解指标数据的统计与框架的整个骨架,深入理解 Sentinel 中的重要概念和核心类,介绍 Java SPI 在 Sentinel 中的使用。
  • 第三部分(08-15):分析 Sentinel 的核心功能实现原理。内容包括限流的实现与流量效果控制,熔断降级与系统自适应限流、黑白名单与热点参数限流,最后通过自定义 ProcessorSlot 实现开关降级。
  • 第四部分(16-20):Sentinel 对主流框架的适配和扩展功能。我们将了解动态数据源,以及分析 Sentinel 集群限流的实现原理,最后使用 JMH 压测 Sentinel 对应用性能的影响。

关于源码分析,笔者选择的是 Sentinel 1.7.1 版本。

为什么写这个专栏

我第一次看 Sentinel 源码也感觉无从下手,特别是关于节点树这些概念的理解,也是硬着头片去啃源码,结合官方文档去揣摩代码背后的设计思想。我想要研究 Sentinel 的源码一开始只是好奇 Sentinel 是怎么统计每个接口的 QPS 的,并且也模仿 Sentinel 实现了一个基于滑动窗口的 QPS 统计工具,但后来又不满足于这搁浅的认识,于是深入探索 Sentinel 整个框架的核心实现原理,在对 Sentinel 有一定的了解后,也基于 Sentinel 做过一些扩展,例如,笔者在最近的一个新项目中,在网关层实现请求的熔断(项目从单体迁移的一些原因)、抛弃 AOP+Redis 实现开关降级的方式,基于 Sentinel 实现开关降级提高了开关降级的灵活度。

基于 Sentinel 自学难度高、分析 Sentinel 原理细节的资料零零散散且不全、官方文档介绍得不够深入,笔者下定决心完成此专栏,希望能够帮助到想要深入学习了解 Sentinel 的读者。由于笔者的表达能力有限,如果有表达不够清晰或者表达错误的地方,还恳请大家帮忙指出。

作者简介

吴就业,洋葱集团,后端架构师、Java 开发工程师。主要负责新项目的技术选型与架构设计、旧项目的重构,以及订单服务、支付中心的需求开发与维护。在微服务领域有丰富的实战经验,如:广告系统重构的分布式架构设计、支付中心的技术选型与架构设计、基于 xxl-job 二次开发的分布式定时任务调度平台、自研微服务监控系统。喜欢研究优秀的开源框架源码,擅长 Spring Cloud、Dubbo、Netty、Java 虚拟机字节码等技术。

适宜人群

  • 想深入了解 Sentinel 的使用者
  • 正在实践微服务的开发者/组织
  • Spring Cloud、Dubbo 微服务初学者
  • 想要了解如何统计接口 QPS 的开发者
  • 想要了解匀速限流、冷启动限流算法的开发者

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/%e6%b7%b1%e5%85%a5%e7%90%86%e8%a7%a3%20Sentinel%ef%bc%88%e5%ae%8c%ef%bc%89/01%20%e5%bc%80%e7%af%87%e8%af%8d%ef%bc%9a%e4%b8%80%e6%ac%a1%e6%9c%8d%e5%8a%a1%e9%9b%aa%e5%b4%a9%e9%97%ae%e9%a2%98%e6%8e%92%e6%9f%a5%e7%bb%8f%e5%8e%86.md