在开发高并发系统时,有很多手段来保护系统,如缓存、降级和限流等。缓存目的是提升系统访问速度和增大系统处理能力,可谓是抗高并发流量的银弹。
而降级是当服务出问题或者影响到核心流程的性能,需要暂时屏蔽掉,待高峰过去或者问题解决后再打开的场景。
而有些场景并不能用缓存和降级来解决,比如稀缺资源(秒杀、抢购)、写服务(如评论、下单)、频繁的复杂查询(评论的最后几页)等。
因此,需有一种手段来限制这些场景下的并发/请求量,这种手段就是限流。
限流的目的是通过对并发访问/请求进行限速或者一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务(定向到错误页或告知资源没有了)、排队或等待(比如秒杀、评论、下单)、降级(返回兜底数据或默认数据,如商品详情页库存默认有货)。
在压测时我们能找出每个系统的处理峰值,然后通过设定峰值阈值,来防止当系统过载时,通过拒绝处理过载的请求来保障系统可用。另外,也应根据系统的吞吐量、响应时间、可用率来动态调整限流阈值。
一般开发高并发系统常见的限流有:限制总并发数(比如数据库连接池、线程池)、限制瞬时并发数(如Nginx的limit_conn模块,用来限制瞬时并发连接数)、限制时间窗口内的平均速率(如Guava的RateLimiter、Nginx的limit_req模块,用来限制每秒的平均速率),以及限制远程接口调用速率、限制MQ的消费速率等。
另外,还可以根据网络连接数、网络流量、CPU或内存负载等来限流。
先有缓存这个银弹,后有限流来应对618、双11高并发流量,在处理高并发问题上可以说是如虎添翼,不用担心瞬间流量导致系统挂掉或雪崩,最终做到有损服务而不是不服务。限流需要评估好,不可乱用,否则正常流量会出现一些奇怪的问题,而导致用户抱怨。
限流算法
常见的限流算法有:令牌桶、漏桶。计数器也可以用来进行粗暴限流实现。
令牌桶算法
是一个存放固定容量令牌的桶,按照固定速率往桶里添加令牌。令牌桶算法的描述如下。
- 假设限制2r/s,则按照500毫秒的固定速率往桶中添加令牌。
- 桶中最多存放b个令牌,当桶满时,新添加的令牌被丢弃或拒绝。
- 当一个n个字节大小的数据包到达,将从桶中删除n个令牌,接着数据包被发送到网络上。
- 如果桶中的令牌不足n个,则不会删除令牌,且该数据包将被限流(要么丢弃,要么在缓冲区等待)。
漏桶算法
漏桶作为计量工具(The Leaky Bucket Algorithm as a Meter)时,可以用于流量整形(Traffic Shaping)和流量控制(Traffic Policing),漏桶算法的描述如下。
- 一个固定容量的漏桶,按照常量固定速率流出水滴。
- 如果桶是空的,则不需流出水滴。
- 可以以任意速率流入水滴到漏桶。
- 如果流入水滴超出了桶的容量,则流入的水滴溢出了(被丢弃),而漏桶容量是不变的。
另外,有时我们还使用计数器来进行限流,主要用来限制总并发数,比如数据库连接池大小、线程池大小、秒杀并发数都是计数器的用法。
只要全局总请求数或者一定时间段的总请求数达到设定阈值,则进行限流。这是一种简单粗暴的总数量限流,而不是平均速率限流。
应用级限流
限流总并发/连接/请求数
对于一个应用系统来说,一定会有极限并发/请求数,即总有一个TPS/QPS阈值,如果超了阈值,则系统就会不响应用户请求或响应得非常慢,因此我们最好进行过载保护,以防止大量请求涌入击垮系统。
限流总资源数
如果有的资源是稀缺资源(如数据库连接、线程),而且可能有多个系统都会去使用它,那么需要加以限制。可以使用池化技术来限制总资源数,如连接池、线程池。假设分配给每个应用的数据库连接是100,那么本应用最多可以使用100个资源,超出则可以等待或者抛异常。
限流某个接口的总并发/请求数
如果接口可能会有突发访问情况,但又担心访问量太大造成崩溃,如抢购业务,那么这个时候就需要限制这个接口的总并发/请求数总请求数了。因为粒度比较细,可以为每个接口都设置相应的阈值。
可以使用Java中的AtomicLong或者Semaphore进行限流。
try {if(atomic.incrementAndGet()>限流数)(//拒绝请求//处理请求
}finally {atomic.decrementAndGet();
}
这种方式适合对可降级业务或者需要过载保护的服务进行限流,如抢购业务,超出限额,要么让用户排队,要么告诉用户没货了,这对用户来说是可以接受的。而一些开放平台也会限制用户调用某个接口的试用请求量,这时就可以用这种计数器方式实现。这种方式也是简单粗暴的限流,没有平滑处理。
限流某个接口的时间窗请求数
即一个时间窗口内的请求数,如想限制某个接口/服务每秒/每分钟/每天的请求数/调用量。如一些基础服务会被很多其他系统调用,比如商品详情页服务会调用基础商品服务调用,但是更新量比较大有可能将基础服务打挂。这时,我们要对每秒/每分钟的调用量进行限速。
平滑限流某个接口的请求数
之前的限流方式都不能很好地应对突发请求,即瞬间请求可能都被允许,从而导致一些问题。因此,在一些场景中需要对突发请求进行整形,整形为平均速率请求处理(比如5r/s,则每隔200毫秒处理一个请求,平滑了速率)。这个时候有两种算法满足我们的场景:令牌桶和漏桶算法。Guava框架提供了令牌桶算法实现,可直接拿来使用。
Guava RateLimiter提供的令牌桶算法可用于平滑突发限流(SmoothBursty)和平滑预热限流(SmoothWarmingUp)实现。
分布式限流
分布式限流最关键的是要将限流服务做成原子化,而解决方案可以使用Redis+Lua或者Nginx+Lua技术进行实现,通过这两种技术可以实现高并发和高性能。
首先,我们来使用Redis+Lua实现时间窗内某个接口的请求数限流,实现了该功能后可以改造为限流总并发/请求数和限制总资源数。Lua本身就是一种编程语言,也可以使用它实现复杂的令牌桶或漏桶算法。
对于分布式限流,目前遇到的场景是业务上的限流,而不是流量入口的限流。流量入口限流应该在接入层完成,而接入层笔者一般使用Nginx。
接入层限流
接入层通常指请求流量的入口,该层的主要目的有:负载均衡、非法请求过滤、请求聚合、缓存、降级、限流、A/B测试、服务质量监控等,可以参考《使用OpenResty开发高性能Web应用》。
对于Nginx接入层限流可以使用Nginx自带的两个模块:连接数限流模块ngx_http_limit_connmodule和漏桶算法实现的请求限流模块ngx_htp_limit_req_module。还可以使用OpenResty提供的Lua限流模块lua-resty-limit-traffic应对更复杂的限流场景。
limit conn用来对某个key对应的总的网络连接数进行限流,可以按照如IP、域名维度进行限流。
limit_req用来对某个key对应的请求的平均速率进行限流,有两种用法:平滑模式(delay)和允许突发模式(nodelay)。
节流
有时候我们想在特定时间窗口内对重复的相同事件最多只处理一次,或者想限制多个连续相同事件最小执行时间间隔,那么可使用节流(Throttle)实现,其防止多个相同事件连续重复执行。节流主要有如下几种用法:throttleFirst、throttleLast、throttleWithTimeout。
throttleFirst/throttleLast
throtleFirst/throttleLast是指在一个时间窗口内,如果有重复的多个相同事件要处理,
则只处理第一个或最后一个。其相当于一个事件频率控制器,把一段时间内重复的多个相同事件变为一个,减少事件处理频率,从而减少无用处理,提升性能。
throttleFirst在一个时间窗口内只会处理该时间窗口内的第一个事件。而throttleLast会处理该时间窗口内的最后一个事件。
一个场景是网页中的resize、scroll和mousemove事件,当我们改变浏览器大小时会触发resize事件,而滚动页面元素时会触发scroll事件。当我们快速连续执行这些操作时会连续触发这些事件,那么可能因此造成UI反应慢、浏览器卡顿,因此节流就派上用场了。对于前端开发,可以使用jquery-throttle-debounce-plugin实现,而Android开发可以使用RxAndroid实现。
throttleWithTimeout
throttleWithTimeout也叫作debounce(去抖),限制两个连续事件的先后执行时间不得小于某个时间窗口。
throttleWithTimeout限制两个连续事件的最小间隔时间窗口。
throttleFirst/throttleLast是基于决定时间做的处理,是以固定时间窗口为基准,对同一个固定时间窗口内的多个连续事件最多只处理一个。而throttleWithTimeout是基于两个连续事件的相对时间,当两个连续事件的间隔时间小于最小间隔时间窗口,就会丢弃上一个事件,而如果最后一个事件等待了最小间隔时间窗口后还没有新的事件到来,那么会处理最后一个事件。
如搜索关键词自动补全,如果用户每录入一个字就发送一次请求,而先输入的字的自动补全会被很快到来的下一个字符覆盖,那么会导致先期的自动补全是无用的。throttleWithTimeout就是来解决这个问题的,通过它来减少频繁的网络请求,避免每输入一个字就导致一次请求。