内部原因:包括程序bug导致死循环,某个接口导致数据库慢查询,程序逻辑不完善导致耗尽内存等。
外部原因:包括黑客攻击,促销或者抢购引入了超出平时几倍甚至几十倍的用户,第三方系统大量请求,第三方系统响应缓慢等。
解决接口级故障的核心思想和异地多活基本类似,都是优先保证核心业务和优先保证绝大部分用户。常见的应对方法有四种,降级、熔断、限流和排队,下面我会一一讲解。
1. 降级
降级指系统将某些业务或者接口的功能降低,可以是只提供部分功能,也可以是完全停掉所有功能。
例如,论坛可以降级为只能看帖子,不能发帖子;也可以降级为只能看帖子和评论,不能发评论;而App的日志上传接口,可以完全停掉一段时间,这段时间内App都不能上传日志。
降级的核心思想就是丢车保帅,优先保证核心业务。
例如,对于论坛来说,90%的流量是看帖子,那我们就优先保证看帖的功能;对于一个App来说,日志上传接口只是一个辅助的功能,故障时完全可以停掉。
常见的实现降级的方式有两种:
简单来说,就是系统预留了后门用于降级操作。例如,系统提供一个降级URL,当访问这个URL时,就相当于执行降级指令,具体的降级指令通过URL的参数传入即可。这种方案有一定的安全隐患,所以也会在URL中加入密码这类安全措施。
系统后门降级的方式实现成本低,但主要缺点是如果服务器数量多,需要一台一台去操作,效率比较低,这在故障处理争分夺秒的场景下是比较浪费时间的。
为了解决系统后门降级方式的缺点,我们可以将降级操作独立到一个单独的系统中,实现复杂的权限管理、批量操作等功能。
其基本架构如下:
2. 熔断
熔断是指按照规则停掉外部接口的访问,防止某些外部接口故障导致自己的系统处理能力急剧下降或者出故障。
熔断和降级是两个比较容易混淆的概念,因为单纯从名字上看,好像都有禁止某个功能的意思。但它们的内涵是不同的,因为降级的目的是应对系统自身的故障,而熔断的目的是应对依赖的外部系统故障的情况。
假设一个这样的场景:A服务的X功能依赖B服务的某个接口,当B服务的接口响应很慢的时候,A服务的X功能响应肯定也会被拖慢,进一步导致A服务的线程都被卡在X功能处理上,于是A服务的其他功能都会被卡住或者响应非常慢。
这时就需要熔断机制了:A服务不再请求B服务的这个接口,A服务内部只要发现是请求B服务的这个接口就立即返回错误,从而避免A服务整个被拖慢甚至拖死。
实现熔断机制有两个关键点:
一是需要有一个统一的API调用层,由API调用层来进行采样或者统计。如果接口调用散落在代码各处,就没法进行统一处理了。
二是阈值的设计,例如1分钟内30%的请求响应时间超过1秒就熔断,这个策略中的“1分钟”“30%”“1秒”都对最终的熔断效果有影响。实践中,一般都是先根据分析确定阈值,然后上线观察效果,再进行调优。
3. 限流
降级是从系统功能优先级的角度考虑如何应对故障,而限流则是从用户访问压力的角度来考虑如何应对故障。限流指只允许系统能够承受的访问量进来,超出系统访问能力的请求将被丢弃。
虽然“丢弃”这个词听起来让人不太舒服,但保证一部分请求能够正常响应,总比全部请求都不能响应要好得多。
限流一般都是系统内实现的,常见的限流方式可以分为两类:基于请求限流和基于资源限流。
基于请求限流指从外部访问的请求角度考虑限流,常见的方式有两种。
第一种是限制总量,也就是限制某个指标的累积上限,常见的是限制当前系统服务的用户总量,例如:某个直播间限制总用户数上限为100万,超过100万后新的用户无法进入;某个抢购活动商品数量只有100个,限制参与抢购的用户上限为1万个,1万以后的用户直接拒绝。
第二种是限制时间量,也就是限制一段时间内某个指标的上限,例如1分钟内只允许10000个用户访问;每秒请求峰值最高为10万。
无论是限制总量还是限制时间量,共同的特点都是实现简单,但在实践中面临的主要问题是比较难以找到合适的阈值。例如系统设定了1分钟10000个用户,但实际上6000个用户的时候系统就扛不住了;或者达到1分钟10000用户后,其实系统压力还不大,但此时已经开始丢弃用户访问了。
即使找到了合适的阈值,基于请求限流还面临硬件相关的问题。例如一台32核的机器和64核的机器处理能力差别很大,阈值是不同的,可能有的技术人员以为简单根据硬件指标进行数学运算就可以得出来,实际上这样是不可行的,64核的机器比32核的机器,业务处理性能并不是2倍的关系,可能是1.5倍,甚至可能是1.1倍。
为了找到合理的阈值,通常情况下可以采用性能压测来确定阈值,但性能压测也存在覆盖场景有限的问题,可能出现某个性能压测没有覆盖的功能导致系统压力很大;另外一种方式是逐步优化:先设定一个阈值然后上线观察运行情况,发现不合理就调整阈值。
基于上述的分析,根据阈值来限制访问量的方式更多的适应于业务功能比较简单的系统,例如负载均衡系统、网关系统、抢购系统等。
基于请求限流是从系统外部考虑的,而基于资源限流是从系统内部考虑的,也就是找到系统内部影响性能的关键资源,对其使用上限进行限制。常见的内部资源包括连接数、文件句柄、线程数和请求队列等。
例如,采用Netty来实现服务器,每个进来的请求都先放入一个队列,业务线程再从队列读取请求进行处理,队列长度最大值为10000,队列满了就拒绝后面的请求;也可以根据CPU的负载或者占用率进行限流,当CPU的占用率超过80%的时候就开始拒绝新的请求。
基于资源限流相比基于请求限流能够更加有效地反映当前系统的压力,但实际设计时也面临两个主要的难点:如何确定关键资源,以及如何确定关键资源的阈值。
通常情况下,这也是一个逐步调优的过程:设计的时候先根据推断选择某个关键资源和阈值,然后测试验证,再上线观察,如果发现不合理,再进行优化。
为了更好地实现前面描述的各种限流方式,通常情况下我们会基于限流算法来设计方案。常见的限流算法有两大类四小类,它们的实现原理和优缺点各不相同,在实际设计的时候需要根据业务场景来选择。
第一大类是时间窗算法,它会限制一定时间窗口内的请求量或者资源消耗量,根据实现方式又可以细分为“固定时间窗”和“滑动时间窗”。
固定时间窗算法的实现原理是,统计固定时间周期内的请求量或者资源消耗量,超过限额就会启动限流,如下图所示:
它的优点是实现简单,缺点是存在临界点问题。例如上图中的红蓝两点只间隔了短短10秒,期间的请求数却已经达到200,超过了算法规定的限额(1分钟内处理100)。但是因为这些请求分别来自两个统计窗口,从单个窗口来看还没有超出限额,所以并不会启动限流,结果可能导致系统因为压力过大而挂掉。
为了解决临界点问题,滑动时间窗算法应运而生,它的实现原理是,两个统计周期部分重叠,从而避免短时间内的两个统计点分属不同的时间窗的情况,如下图所示:
总体上来看,滑动时间窗的限流效果要比固定时间窗更好,但是实现也会稍微复杂一些。
第二大类是桶算法,用一个虚拟的“桶”来临时存储一些东西。根据桶里面放的东西,又可以细分为“漏桶”和“令牌桶”。
漏桶算法的实现原理是,将请求放入“桶”(消息队列等),业务处理单元(线程、进程和应用等)从桶里拿请求处理,桶满则丢弃新的请求,如下图所示:
我们可以看到漏桶算法的三个关键实现点:
漏桶算法的技术本质是总量控制,桶大小是设计关键,具体的优缺点如下:
漏桶算法主要适用于瞬时高并发流量的场景(例如刚才提到的0点签到、整点秒杀等)。在短短几分钟内涌入大量请求时,为了更好的业务效果和用户体验,即使处理慢一些,也要做到尽量不丢弃用户请求。
令牌桶算法和漏桶算法的不同之处在于,桶中放入的不是请求,而是“令牌”,这个令牌就是业务处理前需要拿到的“许可证”。也就是说,当系统收到一个请求时,先要到令牌桶里面拿“令牌”,拿到令牌才能进一步处理,拿不到就要丢弃请求。
它的实现原理是如下图所示:
我们可以看到令牌桶算法的三个关键设计点:
令牌桶算法的技术本质是速率控制,令牌产生的速率是设计关键,具体的优缺点如下:
令牌桶算法主要适用于两种典型的场景,一种是需要控制访问第三方服务的速度,防止把下游压垮,例如支付宝需要控制访问银行接口的速率;另一种是需要控制自己的处理速度,防止过载,例如压测结果显示系统最大处理TPS是100,那么就可以用令牌桶来限制最大的处理速度。
刚才介绍漏桶算法的时候我提到漏桶算法可以应对瞬时高并发流量,现在介绍令牌桶算法的时候,我又说令牌桶允许突发流量。
你可能会问,这两种说法好像差不多啊,它们到底有什么区别,到底谁更适合做秒杀呢?
其实,令牌桶的“允许突发”实际上只是“允许一定程度的突发”,比如系统处理能力是每秒100 TPS,突发到120 TPS是可以的,但如果突发到1000 TPS的话,系统大概率就被压垮了。所以处理秒杀时高并发流量,还是得用漏桶算法。
令牌桶的算法原本是用于网络设备控制传输速度的,而且它控制的目的是保证一段时间内的平均传输速度。之所以说令牌桶适合突发流量,是指在网络传输的时候,可以允许某段时间内(一般就几秒)超过平均传输速率,这在网络环境下常见的情况就是“网络抖动”。
但这个短时间的突发流量并不会导致雪崩效应,网络设备也能够处理得过来。对应到令牌桶应用到业务处理的场景,就要求即使有突发流量来了,系统自己或者下游系统要真的能够处理的过来,否则令牌桶允许突发流量进来,结果系统或者下游处理不了,那还是会被压垮。
因此,令牌桶在实际设计的时候,桶大小不能像漏桶那样设计很大,需要根据系统的处理能力来进行仔细的估算。例如,漏桶算法的桶容量可以设计为100万,但是一个每秒30 TPS的令牌桶,桶的容量可能只能设计成40左右。海外有的银行给移动钱包提供的接口TPS上限是30,压测到了40就真的挂了。
4. 排队
排队实际上是限流的一个变种,限流是直接拒绝用户,排队是让用户等待一段时间,全世界最有名的排队当属12306网站排队了。
排队虽然没有直接拒绝用户,但用户等了很长时间后进入系统,体验并不一定比限流好。
由于排队需要临时缓存大量的业务请求,单个系统内部无法缓存这么多数据,一般情况下,排队需要用独立的系统去实现,例如使用Kafka这类消息队列来缓存用户请求。
下图是1号店的“双11”秒杀排队系统架构:
它的基本实现摘录如下:
【排队模块】 负责接收用户的抢购请求,将请求以先入先出的方式保存下来。每一个参加秒杀活动的商品保存一个队列,队列的大小可以根据参与秒杀的商品数量(或加点余量)自行定义。 【调度模块】 负责排队模块到服务模块的动态调度,不断检查服务模块,一旦处理能力有空闲,就从排队队列头上把用户访问请求调入服务模块,并负责向服务模块分发请求。这里调度模块扮演一个中介的角色,但不只是传递请求而已,它还担负着调节系统处理能力的重任。我们可以根据服务模块的实际处理能力,动态调节向排队系统拉取请求的速度。 【服务模块】 负责调用真正业务来处理服务,并返回处理结果,调用排队模块的接口回写业务处理结果。