在之前的文章中介绍了 《微服务架构中的 熔断和降级》,在微服务架构中,除了 熔断和降级,限流 和 隔离 也是常见的微服务架构可用性保障措施。
限流是通过限制住流量大小来保护系统,它尤其能够 解决异常突发流量打崩系统的问题。例如常见的某个攻击者攻击你维护的系统,那么限流就能极大程度上保护住你的系统。基本问题:限流需要确定一个流量阈值,这个阈值该怎么算?
限流的算法
限流算法可以像 负载均衡 算法那样,分成 静态算法 和 动态算法 两类。
- 静态算法包含令牌桶、漏桶、固定窗口和滑动窗口。这些算法就是要求研发人员提前设置好阈值。在算法运行期间它是不会管服务器的真实负载的。
- 动态算法也叫做自适应限流算法,典型的是 BBR 算法。这一类算法利用一系列指标来判定是否应该减少流量或者放大流量。动态算法和 TCP 的拥塞控制是非常接近的,只不过 TCP 控制的是报文流量,而微服务控制的是请求流量。
- 也可以考虑参考熔断和降级里面的思路,选用一些指标来设计自己的限流算法。例如你的业务需要很多内存,那么你可以根据剩余空闲内存来判断要不要执行限流。
关于 动态算法(比如 BBR)的原理和实现有一定难度,大多数微服务框架都没提供 BBR 的限流器实现。如果有时间的话,可以了解一下 BBR 的基本原理。
令牌桶
系统会以一个恒定的速率产生令牌,这些令牌会放到一个桶里面,每个请求只有拿到了令牌才会被执行。每当一个请求过来的时候,就需要尝试从桶里面拿一个令牌。如果拿到了令牌,那么请求就会被处理;如果没有拿到,那么这个请求就被限流了。需要注意 ,本身令牌桶是可以积攒一定数量的令牌的。比如说桶的容量是 100,也就是这里面最多积攒 100 个令牌。那么当某一时刻突然来了 100 个请求,它们都能拿到令牌。
漏桶
漏桶:是指当请求以不均匀的速度到达服务器之后,限流器会以固定的速率转交给业务逻辑。某种程度上,可以将漏桶算法看作是令牌桶算法的一种特殊形态,无容量的令牌桶约等于漏桶。将令牌桶中桶的容量设想为 0,就是漏桶了。如下图所示:
在漏桶里面,令牌产生之后就需要取走,没取走的话也不会积攒下来。因此 漏桶是绝对均匀的,而令牌桶不是绝对均匀的。
固定窗口与滑动窗口
固定窗口 是指在一个固定时间段,只允许执行固定数量的请求。比如说在一秒钟之内只能执行 100 个请求。
滑动窗口 也是指在一个固定时间段内,只允许执行固定数量的请求,但是滑动窗口是平滑地挪动窗口,而不像固定窗口那样突然地挪动窗口。假设窗口大小是一分钟,此时时间是 t1,那么窗口的起始位置是 t1-1分钟。过了2秒之后,窗口大小依旧是1分钟,但是窗口的起始位置也向后挪动了2秒,变成了t1 - 1 分钟 + 2 秒。这也就是滑动的含义。
关于突发流量
不同的算法之间比较重要的一个区别是:能否处理小规模的突发流量。
假如说正常情况下 你的限流是一秒钟 100 个请求,但是如果某一秒钟来了 101 个请求,你依旧会觉得第 101 个请求应该尽可能处理掉。在这种场景下,漏桶是做不到的,因为漏桶是非常均匀的,一秒钟 100 个请求在漏桶里面就是 10 毫秒一个请求,绝对不会多也不会少。
而令牌桶就能够处理,因为令牌桶油容量。比如说令牌桶产生令牌的速率是 100 个每秒,但是桶的容量是 20 个,那么也就是说某一秒钟内,最多可以处理 120个请求。20(前一秒攒的令牌)+ 100(当下这一秒)=120
针对 固定窗口和滑动窗口则有另外一个类似的问题:请求集中再窗口的一边。
假如一个窗口大小是一分钟 1000 个请求,你预计这1000 个请求会均匀分散在这一分钟内。那么有没有可能第一秒钟就来了 1000 个请求?当然可能。那当下这一秒系统有没有可能崩溃?自然也是可能的。所以固定窗口和滑动窗口的窗口时间不能太长。比如说以秒为单位是合适的,但是以分钟作为单位就是不合适的。
总结:漏桶算法非常均匀,但是令牌桶相比之下就没那么均匀。令牌桶本身允许积攒一部分令牌,所以如果有偶发的突发流量,那么这一部分请求也能得到正常处理。但是要小心令牌桶的容量,不能设置太大。不然积攒的令牌太多的话就起不到限流效果了。例如容量设置为 1000,那么要是积攒了 1000 个令牌之后真的突然来了 1000 个请求,它们都能拿到令牌,那么系统可能撑不住这突如其来的 1000 个请求。
请求的大小
为什么使用了限流,系统还是有可能崩溃?关键词是 请求大小。
刚刚我们讨论的限流是针对请求的个数进行的,但并没有考虑到另一个非常关键的问题,就是请求的大小。和负载均衡里的一个问题类似:负载均衡算法基本上都没有考虑请求所需的资源,同理在限流算法也是如此。
限流是针对请求个数进行的,那么如果有两台实例,一台实例处理的都是小请求,另一台实例处理的都是大请求,那么都限流在每秒 100 个请求。可能第一台实例什么问题都没有,而第二台实例就崩溃了。
限流和负载均衡有点像,基本没有考虑请求的资源消耗问题。所以负载均衡不管怎么样,都会有偶发性负载不均衡的问题,限流也是如此。例如将一个实例限制在每秒 100 个请求,但是万一这个 100 个请求都是消耗资源很多的请求,那么最终这个实例也可能会承受不住负载而崩溃。动态限流算法一定程度上能够缓解这个问题,但是也无法根治,因为一个请求只有到它被执行的时候,我们才知道它是不是大请求。
如何计算阈值?
限流的阈值应该怎么确定?总体上思路有四个:看服务的观测数据、压测、借鉴、手动计算。
看服务的性能数据
这个属于常规解法,基本上就是看业务高峰期的 QPS 来确定整个集群的阈值。如果要确定单机的阈值,那就再除以实例个数。关键词是 业务性能数据。
- 通过完善的数据监控系统,可以根据观测到的性能数据来确定阈值。比如说观察线上的数据,如果在业务高峰期整个集群的 QPS 都没超过 1000,那么就可以考虑将阈值设定在 1200,多出来的 200 就是余量。
- 不过这种方式有一个要求,就是服务必须先上线,有了线上的观测数据才能确定阈值。并且整个阈值很有可能是偏低的。因为业务巅峰并不意味着是集群性能的瓶颈。如果集群本身可以承受每秒3000个请求,但是因为业务量不够,每秒只有 1000 个请求,那么这里预估出来的阈值是显著低于集群真实瓶颈 QPS 的。
压测
针对上述问题,最好的方式应该是在线上执行全链路压测,测试出瓶颈。即便不能做全链路压测,也可以考虑模拟线上环境进行压测,再差也应该在测试环境做一个压力测试。限流针对的是线上环境,那么自然要尽可能模拟线上环境。最符合这个要求的就是全链路压测了,它就是直接在线上环境执行的,因此结果也是最准的。
关于数据压测基本上需要考虑如下几个维度:
从理论上来说,可以选择 A、B、C 当中的任何一个点作为你的限流的阈值。
- A是性能最好的点。A 之前 QPS 虽然在上升,但是响应时间稳定不变。在这个时候资源利用率也在提升,所以选择 A 你可以得到最好的性能和较高的资源利用率。
- B是系统快要崩溃的临界点。很多人会选择这个点作为限流的阈值。这个点响应时间已经比较长了,但是系统还能撑住。选择这个点意味着能撑住更高的并发,但是性能不是最好的,吞吐量也不是最高的。
- C是吞吐量最高的点。实际上,有些时候你压测出来的 B 和 C 可能对应到同一个 QPS 的值。选择这个点作为限流阈值,你可以得到最好的吞吐量。
这三个点的口诀就是 性能A、并发B、吞吐量C。综合来说,如果是性能苛刻的服务,我会选择 A 点。如果是追求最高并发的服务,我会选择 B 点,如果是追求吞吐量的服务,我会选择 C 点。
如果某些场景下的压力测试特别困难,或者有些服务根本测不了,应该怎么办?
一家公司应该把压测作为提高系统性能和可用性的一个关键措施,毕竟没有压测数据,性能优化和可用性改进也不知道怎么下手。所以建议还是应该尽可能把压测搞起来,反正压测这个东西是迟早要有的。
借鉴
如果真的做不了压测的时候怎么确定阈值。关键词就是 借鉴
如果真的做不了,或者来不及,或者没资源,那么还可以考虑参考类似服务的阈值。比如说如果A、B服务是紧密相关的,也就是通常调用了A服务就会调用B服务,那么可以用 A服务 已经确定的阈值作为 B 的阈值。又或者 A 服务到 B 服务之间有一个转化关系。比如说创建订单到支付,会有一个转化率,假如说是 90%,如果创建订单的接口阈值是 100,那么支付的接口就可以设置为 90。
手动计算
如果是一个全新的业务,没有任何可以借鉴的地方,应该怎么办?这个时候就只剩下最后一招了—— 手动计算。
就是沿着整条调用链路统计出现了多少次数据库查询、多少次微服务调用、多少次第三方中间件访问,如Redis,Kafka等。但是手动计算的准确度是有很大的误差的,比如说垃圾回收类型语言,还要刨除垃圾回收的开销,开销多大又取决于你的垃圾回收频率和消耗。因此最好还是把阈值做成可以动态调整的。那么在最开始上线的时候就可以把阈值设置得比较小。后面通过观测发现系统还很健康,就可以继续上调阈值。
【问】上面关于阈值里面提到的 ABC 三个点,实际业务中应该使用哪个点?
【答】需要根据系统的整体情况来考虑。
- 如果系统很少会触发阈值,可以考虑 B 点,追求最大并发数。触发限流之后,系统一般阻塞下,扛一扛就过去了。
- 如果系统长时间在阈值附近徘徊,说明系统性能已经接近极限了,就算把请求阻塞下,最后多半也是超时。这个时候选 C 点更好,最大吞吐量,能处理掉最多的请求。
- 如果系统对性能要求苛刻,比如整条链路超时时间很短,那就只能选 A 点,最大化性能。但凡请求的响应时间长一点,可能就整体超时了。
限流的对象
限流的对象,需要我们思考 针对什么来进行限流。
从单机或者集群的角度看,可以分为 单机限流 或者 集群限流。
- 集群限流一般需要借助 Redis 之类的中间件来记录流量和阈值,也就是 需要用 Redis 等工具来实现前面提到的限流算法。
- 当然也可以不使用 Redis,通过网关来实现集群限流。
从业务对象的角度看,限流的对象会有很多,例如:
- VIP 用户不限流,而对普通用户限流。
- 针对 IP 限流。用户登录或者参与秒杀都可以使用这种限流,比方说设置一秒钟最多只能有 50 个请求,即便考虑到公共 IP 的问题,正常的用户手速也是没那么快的。
- 针对业务 ID 限流,例如针对用户 ID 进行限流。
限流后的做法
即使一个请求被限流了,那么我们也可以设计一些精巧的方案来处理。比如:
- 同步阻塞等待一段时间。如果是偶发性地触发了限流,那么稍微阻塞等待一会儿,后面就有极大的概率能得到处理。
比如说限流设置为一秒钟 100 个请求,恰好来了 101个请求。多出来的一个请求只需要等一秒钟,下一秒钟就会被处理。但是要注意控制住超时,也就是说你不能让人无限期地等待下去。
- 同步转异步。如果一个请求没被限流,那就直接同步处理;如果被限流了,那么这个请求就会被存储起来,等到业务低峰期的时候再处理。这个其实跟降级差不多。
- 调整负载均衡算法。如果某个请求被限流了,那么就相当于告诉负载均衡器,应该尽可能少给这个节点发送请求。这个和熔断里面的方案类似,只不过在熔断里面是负载均衡器后续不再发请求,而在限流这里还是会发送请求,只是会降低转发请求到该节点的概率。调整节点的权重就能达成这种效果。
总结
限流是为了保证系统可用性,防止系统因为流量过大而崩溃的一种服务治理手段。从算法上来说,有令牌桶、漏桶、固定窗口和滑动窗口算法。还有动态限流算法,或者说自适应限流算法,比较有名的就是参考了 TCP 拥塞控制算法 BBR 衍生出来的算法。这些算法之间比较重要的一个区别是 能否处理小规模的突发流量。
从限流对象上来说,可以是集群限流或者单机限流,也可以是针对具体业务来做限流。比如说在登录的时候,可以针对 IP 进行限流。又或者在一些增值服务里面,非付费用户也会被限流。
触发限流之后,具体的措施也可以非常灵活。被限流的请求可以同步阻塞一段时间,也可以考虑同步转异步。如果负载均衡算法灵活的话,也可以做一些调整,减少发到该节点的概率。
用好限流的一个重要前提是能够设置准确的阈值,例如每秒钟限制在 100 个请求还是限制在 200 个请求。如果阈值过低,那么系统资源就容易闲置浪费;如果阈值太高,那么系统可能撑不住那么多流量,导致崩溃。
- 在讨论对外的API,如HTTP 接口或者公共API时,可以使用限流来保护系统。
- 在讨论TCP拥塞控制时,可以提起在服务治理上限流也借鉴了TCP拥塞控制的一些思想。
- 在讨论Redis或者类似产品的时候,可以用 Redis 实现集群限流。
关于 IP限流 的一个案例分享:
在登录接口里面引入了限流机制。正常情况下,一个用户在一秒钟内最多点击一次登录,所以针对每一个 IP,我限制它最多只能在一秒内提交 50 次登录请求。这个 50 充分考虑到了公共 IP 的问题,正常用户是不可能触发这个阈值的。这个限流虽然很简单,但是能够有效防范一些攻击。不过限流再怎么防范,还是会出现系统撑不住流量的情况。
【问】针对 IP 限流是一个非常常见的限流方案,那么怎么获得用户的 IP 呢?尤其是在请求经过了网关的情况下,怎么避免自己拿到的是网关的 IP?
【答】可以是用 HTTP 请求头中的,X-Forwarded-For(XFF)自定有头字段。可以获取客户端的真
实地址,还可以获取整个请求链中的所有代理服务器 IP 地址。但要注意 X-Forwarded-For 头是可伪造的字段。