引言 笔者经历的一次服务雪崩
什么是服务雪崩?
雪崩一词的本意是指山地积雪由于底部溶解等原因而突然大块塌落,具有很强的破坏力;而在微服务项目中,雪崩是指因突发流量导致某个服务不可用,从而导致上游服务不可用,并产生级联效应,最终导致整个系统不可用的现象。
服务雪崩,听到这个词就能想到问题的严重性……
那一次服务雪崩,公司整条业务线的服务都中断了,从该业务线延伸出来的下游业务线也被波及。笔者连续三天两夜忙着处理问题,睡眠时间加起来不足5小时,也正因如此,对其印象非常深刻。
事发那天,我们正在进行每周的技术分享会,一位运营工作人员推开会议室的大门传来“噩耗”,画面瞬间转变,技术分享会变成了问题排查讨论会。
通过查看当时服务的负载均衡统计记录,我们发现并发请求量增长了一倍,从每分钟3万~4万个请求数,增长到每分钟约8.6万个请求数。而在事发之前,服务一直稳定运行,很显然,这次事故与并发请求量翻倍有直接关系。
这是由笔者负责技术选型与架构设计的一个分布式广告系统,也是笔者入门分布式微服务实战的第一个项目。从设计到实现该项目,遇到过很多的难题,熬了很多个夜晚,但也颇有收获。
服务的部署如下。
• 广告点击服务(RPC远程调用服务消费者):两台2核8GB的实例,每台都运行一个服务进程,下文将其统称为服务A。
• 渠道广告过滤服务(RPC远程调用服务提供者):两台2核4GB的实例,每台都运行一个服务进程,下文将其统称为服务B。
• 其他服务。
广告点击服务(服务A)通过Dubbo RPC调用渠道广告过滤服务(服务B)来验证某广告是否被批单给某渠道,如图1所示。
图1 服务A与服务B的调用关系
查看当时服务打印的日记可以发现以下3个问题。
1. 服务A的RPC远程调用大量超时
我们将服务B每个接口的超时时间都配置为3秒。服务B提供的接口的实现都是缓存级别的操作,理论上除了发生网络问题,否则耗时不可能超过这个值(3秒)。
2. 服务B的Jedis读操作超时
服务B的每个节点都配置了200个最小连接数的Jedis连接池,这是根据Netty工作线程数配置的,即就算有200个请求线程并发执行,也能为每个线程分配一个Jedis连接。但服务B的几个节点的日记全是Jedis读操作超时(Read time out)。
3. 服务A文件句柄数达到上限
SocketChannel套接字会占用一个文件句柄,并且有多少个客户端连接就占用多少个文件句柄。我们在服务的启动脚本上为每个进程配置最大文件打开数为102 400,理论上当时的并发请求量并不可能达到这个数值。服务A底层用的是自研的基于Netty实现的HTTP服务框架,没有限制最大连接数。
所以,这3个问题就是排查此次服务雪崩真正原因的突破口。
首先,怀疑Redis服务承载不了这么大的并发请求量。根据业务代码估算,处理广告的一次点击需要执行30次GET请求才能从Redis获取数据,如果每分钟有8万个并发请求数,则需要执行240万次GET请求。而Redis除了服务A和服务B在使用,还有其他两个并发量高的服务在使用,保守估计,Redis每分钟需要承受300万次的读/写请求,即每秒要承受5万次的读/写请求,与理论值10万次(Redis每秒可以处理超过10万次读/写请求)相比已经过半。
由于历史原因,我们使用的还是Redis 2.x版本,采用“一主一从”模式。Jedis配置的连接池是读/写分离的连接池,也就是一个主节点处理写请求,一个从节点处理读请求。由于写请求非常少,一般为每15分钟写一次,因此可先忽略写请求对Redis性能的影响,将每秒接近5万次的读请求交由一个Redis从节点处理。我们将Redis升级到4.x版本,并由原来的主从集群改为分布式集群,采用“两主无从”模式(使用AWS的Redis服务可以被配置为无从节点,从而节约成本)。
在Redis升级后,理论上当两个主节点分槽位后,请求会被平摊到两个节点上,性能应该会好很多。但是,服务重新上线后不到一个小时,每分钟的并发请求数又突增到了6万~7万个,这次的问题是大量的RPC远程调用超时,已经没有了Jedis读超时,相比之前好了一些,至少不用再给Redis添加节点。
虽然Redis升级后没有出现超时的情况,但某个Jedis的GET读操作仍然很耗时。Redis的命令耗时与Jedis的读操作超时不同,Jedis的读操作还受网络传输的影响,Redis响应的数据包越大,Jedis接收数据包就越耗时。
Redis执行一条命令的过程如下。
(1)接收客户端请求。
(2)进入队列等待执行。
(3)执行命令。
(4)响应结果给客户端。
在排查业务代码后发现,有一个业务处理逻辑也很耗时:在拿到缓存的value后将其分割成数组,判断请求参数是否在数组中。
复现代码如下。
这段代码是用来模拟高并发请求的,观察在200个业务线程全部耗尽的情况下,执行一个简单的判断元素是否存在的业务逻辑需要多长时间。把这段代码运行一遍,发现很多业务逻辑的执行耗时超过1500毫秒,程序输出如下。
缓存的value字符串越长,这段代码就越耗时,同时越消耗内存,再加上Jedis从发送GET请求到接收完成Redis响应的数据包的耗时,接口的执行总耗时就会超过3秒。所以,导致服务雪崩的根本原因就是这个隐藏的性能问题。
服务B接口执行超时还会导致Dubbo请求量翻倍。
当服务A向服务B发起RPC远程调用时,虽然Dubbo消费端因超时放弃了请求,但是请求已经发出,即使Dubbo消费端取消,提供端也无法感知到服务A的RPC远程调用因超时放弃了,也就无法中断当前正在执行的线程,所以服务B还是要执行完一次调用的业务逻辑。
Dubbo集群容错机制默认使用Failover,即当调用失败时,重试调用其他服务节点。由于默认最大重试次数为两次,且不算第一次调用,因此在最坏情况下,一共会发起3次RPC远程调用,如图2所示。
图2 超时重试机制
当服务A的RPC远程调用因超时放弃时,Dubbo的集群容错机制会重新选择服务B的一个节点发起RPC远程调用,所以并发8万个请求对服务B而言,在最糟糕的情况下就变成了并发24万个请求,最后导致服务B的每个节点业务线程池的线程一直被占用。此时,RPC远程调用就会收到远程服务抛出的线程池已满的异常。
对造成这次服务雪崩事故原因的分析如下。
(1)Jedis抛出Read time out的原因:由于缓存的value字符串太长,网络传输的数据包太大,因此Jedis执行GET请求的耗时较长。
(2)服务A出现RPC远程调用超时的原因:由于业务代码的缺陷、并发请求量的突增及缓存设计的缺陷,因此Jedis读操作耗时长,服务B接口执行耗时超过3秒,服务A的RPC远程调用超时。
(3)服务A出现服务B拒绝请求异常的原因:服务A的RPC远程调用超时触发Dubbo超时重试,由于原本并发量就已经很高,再加上耗时的接口调用,因此服务B业务线程池的线程全部处于工作状态,使得服务B高负荷运行,而Dubbo超时重试又导致服务B并发请求量翻倍,最后导致服务B处理不过来只能拒绝执行服务A的请求。
(4)服务A崩溃的原因:服务B的不可用导致服务A来不及处理客户端发送的请求,而服务A又没有拒绝客户端的请求,客户端便会一直发送请求,最后服务A请求堆积,导致SocketChannel占用的文件句柄达到上限,服务A就崩溃了。
服务B崩溃导致服务A崩溃,正是这种级联效应导致服务雪崩。
另外,定时任务服务会调用服务B的接口,在每次任务执行时,都会导致服务B不可用。由于该服务是内部服务,我们可以通过修改定时任务发送请求的线程数和频率来降低接口的QPS,但如果有其他第三方的定时任务服务调用这个接口,接口的QPS就不好控制了。
为了避免流量再次突增导致服务雪崩,在优化完业务代码和缓存设计后,我们也为项目引入了熔断器(也称断路器):Sentinel。Sentinel可以为接口配置熔断降级规则和系统负载保护规则,当服务器负载过高或请求失败率过高时,可自动切断请求,以确保服务能够稳定运行。
由于Sentinel支持按来源限流,因此我们也为定时任务发起的请求配置限流规则,限制服务B只能有5个线程可以同时处理定时任务发起的请求。
Sentinel是阿里巴巴集团于2018年开源的微服务熔断器组件,于同年被列入云原生全景图谱,位于编排和管理模块象限中,其商业化产品AHAS(应用高可用服务)也被列入该图谱,如图3所示。
图3 云原生全景图谱
Sentinel承接了阿里巴巴集团近10年的“双十一”促销活动流量的核心场景,截至笔者写作本书时已有15.2k的Star(Sentinel在GitHub上的Star数)。Sentinel以流量为切入点,通过流量控制、熔断降级、系统负载保护等多种服务降级方式保护服务的稳定性,并已提供对多种主流框架的适配,如Spring Cloud、Dubbo。
之所以在学习Sentinel之前跟大家分享这个服务雪崩的故事,是因为笔者想通过这次事故帮助大家更好地理解什么是服务雪崩,了解熔断器组件在分布式项目中的重要性。