高并发场景下的限流策略:
在开发高并发系统时,有很多手段来保护系统:缓存、降级、限流。
当访问量快速增长、服务可能会出现一些问题(响应超时),或者会存在非核心服务影响到核心流程的性能时, 仍然需要保证服务的可用性,即便是有损服务。所以意味着我们在设计服务的时候,需要一些手段或者关键数据进行自动降级,或者配置人工降级的开关。
缓存的目的是提升系统访问速度和增大系统处理的容量,可以说是抗高并发流量的银弹;降级是当服务出问题或者影响到核心流程的性能则需要暂时屏蔽掉某些功能,等高峰或者问题解决后再打开;而有些场景并不能用缓存和降级来解决,比如秒杀、抢购;写服务(评论、下单)、频繁的复杂查询,因此需要一种手段来限制这些场景的并发/请求量性能调优是针对于代码本身的不规范性和系统资源的瓶颈的,当计算机的硬件资源达到瓶颈的时间已经无法调优了。高并发场景下一方面通过缓存,异步化,服务化,集群去增加整个系统的吞吐量,另一方面通过限流,降级来保护系统。
限流的作用:
在各大节假日旅游高峰期,各大旅游景点都是人满为患。所有有些景点为了避免出现踩踏事故,会采取限流措施。那在架构场景中,是不是也能这么做呢?针对这个场景,能不能够设置一个最大的流量限制,如果超过这个流量,我们就拒绝提供服务,从而使得我们的服务不会挂掉。当然,限流虽然能够保护系统不被压垮,但是对于被限流的用户,就会很不开心。所以限流其实是一种有损的解决方案。但是相比于全部不可用,有损服务是最好的一种解决办法。
限流的设计还能防止恶意请求流量、恶意攻击。所以,限流的基本原理是通过对并发访问/请求进行限速或者一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务(定向到错误页或者告知资源没有了)、排队或等待(秒杀、下单)、降级(返回兜底数据或默认数据,如商品详情页库存默认有货)一般互联网企业常见的限流有:
- 限制总并发数(如数据库连接池、线程池)
- 限制瞬时并发数(nginx的limit_conn模块,用来限制瞬时并发连接数)
- 限制时间窗口内的平均速率(如Guava的RateLimiter、nginx的limit_req模块,限制每秒的平均速率)
- 其他的还有限制远程接口调用速率、限制MQ的消费速率。
- 另外还可以根据网络连接数、网络流量、CPU或内存负载等来限流。
有了限流,就意味着在处理高并发的时候多了一种保护机制,不用担心瞬间流量导致系统挂掉或雪崩,最终做到有损服务而不是不服务;但是限流需要评估好,不能乱用,否则一些正常流量出现一些奇怪的问题而导致用户体验很差造成用户流失。
常见的限流算法:
常见的限流算法有:滑动窗口、令牌桶、漏桶。计数器也可以进行粗暴限流实现。
滑动窗口协议:
是传输层进行流控的一种措施,接收方通过通告发送方自己的窗口大小,从而控制发送方的发送速度,从而达到防止发送方发送速度过快而导致自己被淹没的目的。发送和接受方都会维护一个数据帧的序列,这个序列被称作窗口。发送方的窗口大小由接受方确定,目的在于控制发送速度,以免接受方的缓存不够大,而导致溢出,同时控制流量也可以避免网络拥塞。
下面图中的0,1,2,3,4号数据帧已经被发送出去,但是1,2,3,4未收到关联的ACK,6,7,8....帧则是等待发送,由于0号数据帧已经接收到服务端的ACK,导致了窗口滑动,这里5号数据帧处于可发送状态。可以看出发送端的窗口大小为5,这是由接受端告知的。此时如果发送端收到1号ACK,则窗口的左边缘向右收缩,窗口的右边缘则向右扩展,此时窗口就向前“滑动了”,即数据帧6也可以被发送。Alibaba Sentinel 中间件就使用了该协议。
滑动窗口协议动画演示地址:https://media.pearsoncmg.com/aw/ecs_kurose_compnetwork_7/cw/content/interactiveanimations/selective-repeat-protocol/index.html
漏桶算法:(控制传输速率Leaky bucket)
漏桶算法思路是,不断的往桶里面注水,无论注水的速度是大还是小,水都是按固定的速率往外漏水;如果桶满了,水会溢出;桶本身具有一个恒定的速率往下漏水,而上方时快时慢的会有水进入桶内。当桶还未满时,上方的水可以加入。一旦水满,上方的水就无法加入。桶满正是算法中的一个关键的触发条件(即流量异常判断成立的条件)。而此条件下如何处理上方流下来的水,有两种方式,在桶满水之后,常见的两种处理方式为:
- 暂时拦截住上方水的向下流动,等待桶中的一部分水漏走后,再放行上方水。
- 溢出的上方水直接抛弃。
令牌桶(能够解决突发流量):
令牌桶算法是网络流量整形(Traffic Shaping)和速率限制(Rate Limiting)中最常使用的一种算法。典型情况下,令牌桶算法用来控制发送到网络上的数据的数目,并允许突发数据的发送。令牌桶是一个存放固定容量令牌(token)的桶,按照固定速率往桶里添加令牌; 令牌桶算法实际上由三部分组成:两个流和一个桶,分别是令牌流、数据流和令牌桶
令牌流与令牌桶
系统会以一定的速度生成令牌,并将其放置到令牌桶中,可以将令牌桶想象成一个缓冲区(可以用队列这种数据结构来实现),当缓冲区填满的时候,新生成的令牌会被扔掉。这里有两个变量很重要:第一个是生成令牌的速度,一般称为 rate 。比如,我们设定 rate = 2 ,即每秒钟生成 2 个令牌,也就是每 1/2 秒生成一个令牌;第二个是令牌桶的大小,一般称为 burst 。比如,我们设定 burst = 10 ,即令牌桶最大只能容纳 10 个令牌。
有以下三种情形可能发生:
- 数据流的速率 等于 令牌流的速率。这种情况下,每个到来的数据包或者请求都能对应一个令牌,然后无延迟地通过队列;
- 数据流的速率 小于 令牌流的速率。通过队列的数据包或者请求只消耗了一部分令牌,剩下的令牌会在令牌桶里积累下来,直到桶被装满。剩下的令牌可以在突发请求的时候消耗掉。
- 数据流的速率 大于 令牌流的速率。这意味着桶里的令牌很快就会被耗尽。导致服务中断一段时间,如果数据包或者请求持续到来,将发生丢包或者拒绝响应。
我们可以通过 guava 所提供的工具类进行令牌桶算法的初体验:
1.导入依赖:
com.google.guava guava 23.0
2.编写案例:
public class RateLimiterMain { //令牌桶的实现 RateLimiter rateLimiter=RateLimiter.create(10); //qps public void doTest(){ if(rateLimiter.tryAcquire()){ //这里就是获得一个令牌,成功获得了一个令牌 System.out.println("允许通过进行访问"); }else{ System.out.println("被限流了"); } } public static void main(String[] args) throws IOException { RateLimiterMain rateLimiterMain=new RateLimiterMain(); CountDownLatch countDownLatch=new CountDownLatch(1); Random random=new Random(); for(int i=0;i<20;i++){ new Thread(()->{ try { countDownLatch.await(); Thread.sleep(random.nextInt(1000)); rateLimiterMain.doTest(); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); } countDownLatch.countDown(); System.in.read(); } }
运行结果一目了然。