我们经常把服务的“高可用”“可靠性”“健壮性”挂在嘴边, 可见,一个服务的不可靠性是每个程序员都希望解决的问题。要解决问题,首先要先拆解并认清问题。那么服务的不可靠性来源于哪里呢?从来源上可以分为内部和外部。
内部:程序代码的BUG、引用的三方库有BUG、硬件问题。
外部:本服务与外部服务进行的交互,即:发到本服务的网络请求、本服务发出到外部的网络请求。
其中“外部”这一部分就是本文所讨论的内容——流量治理。
八股式的面试官往往会干巴巴地问:请说一下服务限流/服务熔断是怎么做的,有哪几种方法,优劣是什么。但本文先不聊具体的限流、熔断等具体的解决方法,而是先要认识我们面对的问题。
不要信任外部流量和服务
我们都知道这么一句话:不要相信前端返回的任何信息。同理,作为一个服务,不能信任与本服务交互的任何一个其它服务,无论是内部还是外部的;也不能信任任何打给本服务的请求,或者本服务请求外部服务得到的返回。
外部服务
应该假设这个外部服务是不稳定的, 随时可能会挂掉的或者出问题的,不要完全相信对方给出的任何担保(比如对方担保99.9999%的请求会在50ms内返回)。
一个外部的服务,有可能短时间内无法提供服务、服务耗时突增、返回错误格式的内容, 也有可能长时间挂掉。
请注意,上面我用的描述是“不要完全相信”, 是因为虽然我们不能信任对方,但出于现实的成本和效率考量,往往也要假设对方能达到它所担保的服务水平。
举个例子:服务A需要高频调用服务B提供的一个API,不难想像得到,服务A的内存占用情况与调用API的时延直接相关。假设服务B担保了在99.9999%的时间内可以在50ms内返回, 我们就可以基于这个数据对服务A做压测计算硬件资源容量,再提供一些冗余。最终算出来的硬件资源绝对与“服务B在99%的时间内返回时延都在5s左右”算出来的结果是不同的。
另外,对于不同“可信度”的服务,我们需要进行的应对也是不同的。比如许多互联网公司会把 AWS S3 作为数据的最终存储,就是因为他们都信任 S3 这个产品的稳定性。而如果一个服务的 API 三天两头报502,那就要加更多的重试、兜底等逻辑了。
consul/etcd 这类服务发现组件经常被默认为100%可用,但也不绝对。首先要看这个服务的集群是否足够高可用,比如单机节点的 etcd server 照样没法提供多少高可用能力;二是注意这些集群的负载情况,它们可能不会崩溃,但当QPS过高时,它们一样会无法对你的请求正常服务;三是看是否需要考虑跨AZ甚至跨云的多活容灾。
同时,也要换位思考,注意自己的服务对外部服务所能产生的影响。同样以上面的服务A调用服务B的API为例,平时服务A发送请求的QPS是10K,由于一个新业务上线,QPS量级,暴涨到100K,对方的集群可能会撑不住。轻则短时间拒绝服务,重则对方服务直接压垮。
正确的做法是:事先与对方沟通请求量级,请对方做好扩容准备;分批逐步放量。
外部流量
对于打到本服务的请求 :
- 不要相信当前的或者假设的请求量级,要做好随时有突发流量甚至被DDOS的准备。也不要低估突发流量的量级大小和爆发力度。
- 要考虑到请求的path、url长度、body长度等不按常理出牌的情况。例1:请求里的body平均100KB左右,业务场景会把请求的body落日志,现突然出现一批流量,每个body都有几M大小,导致日志文件过大(写入慢影响系统性能,或硬盘占满);例2:prometheus里使用请求的path作为label之一,突然收到一批被恶意构建的请求,每个path都不一样,导致prometheus的label维度爆炸,内存吃满。
- 要对请求里的参数进行充分检查。有必要的话就增加签名校验
而对于本服务调用外部服务时的返回,类似的,也不能信任里面的任何数据,一定要充分校验或判断。
具体的流量治理方法
终于讲到八股里老生常谈的这些东西了。
限流
更时尚的叫法是“流量控制”
限流是为了避免流量过大把服务压垮。如果是在接收请求时用,就是避免自己被压垮;如果是在发出请求时用,就是避免外部服务被我们压垮。
常见的有计数法、令牌桶、漏桶。 现在更常用的是后两者。(具体实现本文不讨论,相关文章太多了)
计数法
计数法有三种:
-
每个时间窗口统计一次数。问题是精度不高,无法处理流量突刺和临界情况。
- 先说流量突刺。因为这个方法实现的限制,导致使用的时间窗口不会太小,比如每秒统计一次数。假如这一秒内,前10ms的突发流量就把这一秒的额度全用完了,后面990ms就不会接收任何请求,导致服务器处理到的请求数也是一个一个的“脉冲”,假如这个“脉冲”足够大,还是有可能在不触发限流的前提下把服务器冲垮。
- 临界情况。还是以上面的秒级计数器为例,假设限流设置为1000qps, 在1s的最后10ms内发出1000个请求,又在第二s的前10ms内发出1000个请求。没有触发限流,但在20ms内发出了2000个请求,平均下来相当于10万QPS。
-
在1的基础上,改用滑动时间窗口。解决了临界问题,但精度还是不够高。
-
直接对请求连接数进行计数。这个方法比较简单且准确,但只能用在单机情况下。
令牌桶&漏桶
令牌桶和漏桶的区别是漏桶多了一个“流量整形”的作用,可以在保持qps的同时,使流量之间的“时间间隔”相等,也就是使每个请求在时间上均匀分布;而代价是对于偶尔的突发流量的处理上,会比令牌桶浪费一些请求。
令牌桶的代码实现我所见到的有两种:1. hystrix-go的实现,每一个令牌是一个token结构体,每次取令牌还令牌的操作就是真正的从pool里拿到或放回对应的token结构体;高并发的业务如果要使用hystrix,请注意关注它带来的性能损失。2. golang官方标准库(golang.org/x/time/rate)里的实现比较巧妙,由于令牌桶是每秒向桶内均匀地放N个令牌,那么当前令牌数的计算就可以转换为对时间的计算,并没有产生实体的token结构体,性能高得多。
注意:令牌桶在产生令牌时,时间上是均匀的。例如限制1000qps, 即令牌的产生是每秒产生1000个,不是每过一秒一次性生成1000个,而是每1ms产生一个,或者每10ms产生10个。
分布式限流
在分布式场景下,如果需要比较准确地限流,一般需要在内网提供一个专门的限流器服务。每个请求都要调用一次这个服务,开销不小。
较为粗糙的方式是定时获取当前服务的总机器数,将总QPS限流转化为单机的QPS限流。这在精度要求不时够用。如果限流只是为了避免外部服务被流量打崩,不需要太精确,可考虑此方法。
熔断与降级
如果说限流关注的是流量大小,那么熔断和降级机制关注的则是负载本身。
流量是导致系统负载大的原因之一,但不是唯一原因。
其它可能导致系统负载增大的原因,试列举几个:
- 底层机器老化
- 调用外部API的时延增加
- 比较慢的数据库查询被高频调用
- 。。。
但无论原因是什么,此时我们都需要为系统降载了。所以只有限流器是不够的,需要有另外的机制来关注系统本身的负载情况,如果系统本身负载过高了,就需要自动降载。
熔断器关注直接的负载指标,如果该指标超过了阈值,就拒绝服务一段时间。这个直接负载指标可能是CPU,也可能是内存、流量大小、请求失败率等。因此一般可以使用通用的熔断器组件。
熔断器大致有两种思路:
- 触发熔断时完全限流一段时间。时间过后,发少量探测流量观察系统负载情况,再决定是否恢复。
- 分级熔断,不同级别限流比例不同。在触发到限流时,定时检查以更新限流比例。
降级则关注具体的业务实现,需自行开发。如果某个服务的负载过高,可以转为提供效果差一些但性能更高的服务。举例:假设有个服务,每次被调用都会实时计算1000位圆周率并返回,现在我要为它提供一个降级服务,当监测到系统负载比较高时,改为只计算10位圆周率。
重试
为了确保对一个外部接口的调用一定成功,会多重试几次。有这几个注意点:
- 最好进行封装
- 注意重试的频率,以免对被调用方造成过大压力
- 重试的间隔可以递进,比如第一次等待5秒后重试, 第二次等待30秒后重试,第三次等待10min后重试。