本章我们会对限流算法做个简单介绍,包括常用的限流算法(计数器、漏桶算法、令牌桶案发、滑动窗口)的概述、实现方式、典型场景做个说明。
什么是限流算法
限流是对系统的一种保护措施。即限制流量请求的频率(每秒处理多少个请求)。一般来说,当请求流量超过系统的瓶颈,则丢弃掉多余的请求流量,保证系统的可用性。即要么不放进来,放进来的就保证提供服务。
计数器
概述
计数器采用简单的计数操作,到一段时间节点后自动清零
实现
package com.ls.cloud.sys.alg.limit;import com.ls.cloud.common.core.util.DateUtil;import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.concurrent.*;public class Counter {public static void main(String[] args) {//计数器,这里用信号量实现final Semaphore semaphore = new Semaphore(1);//定时器,到点清零ScheduledExecutorService service = Executors.newScheduledThreadPool(1);service.scheduleAtFixedRate(new Runnable() {@Overridepublic void run() {semaphore.release(1);}},3000,3000,TimeUnit.MILLISECONDS);//模拟无限请求从天而降降临while (true) {try {//判断计数器semaphore.acquire();} catch (InterruptedException e) {e.printStackTrace();}//如果准许响应,打印一个okDate date = new Date();SimpleDateFormat dateFormat= new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");System.out.println("执行------------"+ dateFormat.format(date));}}
}
结果分析
执行------------2023-12-05 02:17:33
执行------------2023-12-05 02:17:36
执行------------2023-12-05 02:17:39
执行------------2023-12-05 02:17:42
执行------------2023-12-05 02:17:45
优缺点
- 优点:实现起来非常简单。
- 缺点:控制力度太过于简略,假如1s内限制3次,那么如果3次在前100ms内已经用完,后面的900ms将只能处于阻塞状态,白白浪费掉
典型场景
使用计数器限流的场景较少,因为它的处理逻辑不够灵活。最常见的是登录验证码倒计时,60秒接收一次,如果在限流场景使用计数器,可能导致前面100ms进入全部流程,系统可能依然会出现宕机的情况。
漏桶算法
概述
漏桶算法将请求缓存在桶中,服务流程匀速处理。超出桶容量的部分丢弃。漏桶算法主要用于保护内部的处理业务,保障其稳定有节奏的处理请求,但是无法根据流量的波动弹性调整响应能力。现实中,类似容纳人数有限的服务大厅开启了固定的服务窗口。
实现
可以基于队列进行实现。
package com.ls.cloud.sys.alg.limit;import java.util.concurrent.*;public class Barrel {public static void main(String[] args) {//桶,用阻塞队列实现,容量为3final LinkedBlockingQueue<Integer> que = new LinkedBlockingQueue(3);//定时器,相当于服务的窗口,2s处理一个ScheduledExecutorService service = Executors.newScheduledThreadPool(1);service.scheduleAtFixedRate(new Runnable() {@Overridepublic void run() {// 删除队首元素int v = que.poll();System.out.println("处理:"+v);}},2000,2000,TimeUnit.MILLISECONDS);//无数个请求,i 可以理解为请求的编号int i=0;while (true) {i++;try {System.out.println("put:"+i);//如果是put,会一直等待桶中有空闲位置,不会丢弃
// que.put(i);//等待1s如果进不了桶,就溢出丢弃que.offer(i,1000,TimeUnit.MILLISECONDS);} catch (Exception e) {e.printStackTrace();}}}}
结果
put:1
put:2
put:3
put:4
put:5
处理:1
put:6
put:7
处理:2
put:8
put:9
处理:3
put:10
put:11
处理:5
put:12
put:13
- put任务号按照顺序入桶
- 执行任务匀速的2s一个被处理
- 因为桶的容量只有3,所以1-3完美执行,4被溢出丢弃,5正常执行
优缺点
- 优点:有效的挡住了外部的请求,保护了内部的服务不会过载
- 内部服务匀速执行,无法应对流量洪峰,无法做到弹性处理突发任务
- 任务超时溢出时被丢弃。现实中可能需要缓存队列辅助保持一段时间
典型场景
nginx中的限流是漏桶算法的典型应用,配置案例如下:
http { #$binary_remote_addr 表示通过remote_addr这个标识来做key,也就是限制同一客户端ip地址。 #zone=one:10m 表示生成一个大小为10M,名字为one的内存区域,用来存储访问的频次信息。 #rate=1r/s 表示允许相同标识的客户端每秒1次访问 limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s; server { location /limited/ { #zone=one 与上面limit_req_zone 里的name对应。 #burst=5 缓冲区,超过了访问频次限制的请求可以先放到这个缓冲区内,类似代码中的队列长度。#nodelay 如果设置,超过访问频次而且缓冲区也满了的时候就会直接返回503,如果没有设置,则所有请求会等待排队,类似代码中的put还是offer。 limit_req zone=one burst=5 nodelay; }}
令牌桶
概述
令牌桶算法可以认为是漏桶算法的一种升级,它不但可以将流量做一步限制,还可以解决漏桶中无法弹性伸缩处理请求的问题。体现在现实中,类似服务大厅的门口设置门禁卡发放。发放是匀速的,请求较少时,令牌可以缓存起来,供流量爆发时一次性批量获取使用。而内部服务窗口不设限。
实现
package com.ls.cloud.sys.alg.limit;import java.util.concurrent.*;public class Token {public static void main(String[] args) throws InterruptedException {//令牌桶,信号量实现,容量为3final Semaphore semaphore = new Semaphore(3);//定时器,1s一个,匀速颁发令牌ScheduledExecutorService service = Executors.newScheduledThreadPool(1);service.scheduleAtFixedRate(new Runnable() {@Overridepublic void run() {if (semaphore.availablePermits() < 3){semaphore.release();}
// System.out.println("令牌数:"+semaphore.availablePermits());}},1000,1000,TimeUnit.MILLISECONDS);//等待,等候令牌桶储存Thread.sleep(5);//模拟洪峰5个请求,前3个迅速响应,后两个排队for (int i = 0; i < 5; i++) {semaphore.acquire();System.out.println("洪峰:"+i);}//模拟日常请求,2s一个for (int i = 0; i < 3; i++) {Thread.sleep(1000);semaphore.acquire();System.out.println("日常:"+i);Thread.sleep(1000);}//再次洪峰for (int i = 0; i < 5; i++) {semaphore.acquire();System.out.println("洪峰:"+i);}//检查令牌桶的数量for (int i = 0; i < 5; i++) {Thread.sleep(2000);System.out.println("令牌剩余:"+semaphore.availablePermits());}}
}
结果
洪峰:0
洪峰:1
洪峰:2
洪峰:3
洪峰:4
日常:0
日常:1
日常:2
洪峰:0
洪峰:1
洪峰:2
洪峰:3
洪峰:4
令牌剩余:2
令牌剩余:3
令牌剩余:3
令牌剩余:3
令牌剩余:3
- 洪峰0-2迅速被执行,说明桶中暂存了3个令牌,有效应对了洪峰
- 洪峰3,4被间隔性执行,得到了有效的限流
- 日常请求被匀速执行,间隔均匀
- 第二波洪峰来临,和第一次一样
- 请求过去后,令牌最终被均匀颁发,积累到3个后不再上升
典型场景
springcloud中gateway可以配置令牌桶实现限流控制,案例如下:
cloud: gateway: routes: ‐ id: limit_route uri: http://localhost:8080/test filters: ‐ name: RequestRateLimiter args: #限流的key,ipKeyResolver为spring中托管的Bean,需要扩展KeyResolver接口 key‐resolver: '#{@ipResolver}' #令牌桶每秒填充平均速率,相当于代码中的发放频率 redis‐rate‐limiter.replenishRate: 1 #令牌桶总容量,相当于代码中,信号量的容量 redis‐rate‐limiter.burstCapacity: 3
滑动窗口
概述
滑动窗口可以理解为细分之后的计数器,计数器粗暴的限定1分钟内的访问次数,而滑动窗口限流将1分钟拆为多个段,不但要求整个1分钟内请求数小于上限,而且要求每个片段请求数也要小于上限。相当于将原来的计数周期做了多个片段拆分,更为精细。
实现
package com.ls.cloud.sys.alg.limit;import java.util.LinkedList;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;public class Window {//整个窗口的流量上限,超出会被限流final int totalMax = 5;//每片的流量上限,超出同样会被拒绝,可以设置不同的值final int sliceMax = 5;//分多少片final int slice = 3;//窗口,分3段,每段1s,也就是总长度3sfinal LinkedList<Long> linkedList = new LinkedList<>();//计数器,每片一个key,可以使用HashMap,这里为了控制台保持有序性和可读性,采用TreeMapMap<Long,AtomicInteger> map = new TreeMap();//心跳,每1s跳动1次,滑动窗口向前滑动一步,实际业务中可能需要手动控制滑动窗口的时机。ScheduledExecutorService service = Executors.newScheduledThreadPool(1);//获取key值,这里即是时间戳(秒)private Long getKey(){return System.currentTimeMillis()/1000;}public Window(){//初始化窗口,当前时间指向的是最末端,前两片其实是过去的2sLong key = getKey();for (int i = 0; i < slice; i++) {linkedList.addFirst(key-i);map.put(key-i,new AtomicInteger(0));}//启动心跳任务,窗口根据时间,自动向前滑动,每秒1步service.scheduleAtFixedRate(new Runnable() {@Overridepublic void run() {Long key = getKey();//队尾添加最新的片linkedList.addLast(key);map.put(key,new AtomicInteger());//将最老的片移除map.remove(linkedList.getFirst());linkedList.removeFirst();System.out.println("step:"+key+":"+map);;}},1000,1000,TimeUnit.MILLISECONDS);}//检查当前时间所在的片是否达到上限public boolean checkCurrentSlice(){long key = getKey();AtomicInteger integer = map.get(key);if (integer != null){return integer.get() < totalMax;}//默认允许访问return true;}//检查整个窗口所有片的计数之和是否达到上限public boolean checkAllCount(){return map.values().stream().mapToInt(value -> value.get()).sum() < sliceMax;}//请求来临....public void req(){Long key = getKey();//如果时间窗口未到达当前时间片,稍微等待一下//其实是一个保护措施,放置心跳对滑动窗口的推动滞后于当前请求while (linkedList.getLast()<key){try {Thread.sleep(200);} catch (InterruptedException e) {e.printStackTrace();}}//开始检查,如果未达到上限,返回ok,计数器增加1//如果任意一项达到上限,拒绝请求,达到限流的目的//这里是直接拒绝。现实中可能会设置缓冲池,将请求放入缓冲队列暂存if (checkCurrentSlice() && checkAllCount()){map.get(key).incrementAndGet();System.out.println(key+"=通过:"+map);}else {System.out.println(key+"=拒绝:"+map);}}public static void main(String[] args) throws InterruptedException {Window window = new Window();//模拟10个离散的请求,相对之间有200ms间隔。会造成总数达到上限而被限流for (int i = 0; i < 10; i++) {Thread.sleep(200);window.req();}//等待一下窗口滑动,让各个片的计数器都置零Thread.sleep(3000);//模拟突发请求,单个片的计数器达到上限而被限流System.out.println("---------------------------");for (int i = 0; i < 10; i++) {window.req();}}
}
结果
1701766769=通过:{1701766767=0, 1701766768=0, 1701766769=1}
1701766769=通过:{1701766767=0, 1701766768=0, 1701766769=2}
1701766769=通过:{1701766767=0, 1701766768=0, 1701766769=3}
1701766769=通过:{1701766767=0, 1701766768=0, 1701766769=4}
step:1701766770:{1701766768=0, 1701766769=4, 1701766770=0}
1701766770=通过:{1701766768=0, 1701766769=4, 1701766770=1}
1701766770=拒绝:{1701766768=0, 1701766769=4, 1701766770=1}
1701766770=拒绝:{1701766768=0, 1701766769=4, 1701766770=1}
1701766770=拒绝:{1701766768=0, 1701766769=4, 1701766770=1}
1701766770=拒绝:{1701766768=0, 1701766769=4, 1701766770=1}
step:1701766771:{1701766769=4, 1701766770=1, 1701766771=0}
1701766771=拒绝:{1701766769=4, 1701766770=1, 1701766771=0}
step:1701766772:{1701766770=1, 1701766771=0, 1701766772=0}
step:1701766773:{1701766771=0, 1701766772=0, 1701766773=0}
step:1701766774:{1701766772=0, 1701766773=0, 1701766774=0}
---------------------------
1701766774=通过:{1701766772=0, 1701766773=0, 1701766774=1}
1701766774=通过:{1701766772=0, 1701766773=0, 1701766774=2}
1701766774=通过:{1701766772=0, 1701766773=0, 1701766774=3}
1701766774=通过:{1701766772=0, 1701766773=0, 1701766774=4}
1701766774=通过:{1701766772=0, 1701766773=0, 1701766774=5}
1701766774=拒绝:{1701766772=0, 1701766773=0, 1701766774=5}
1701766774=拒绝:{1701766772=0, 1701766773=0, 1701766774=5}
1701766774=拒绝:{1701766772=0, 1701766773=0, 1701766774=5}
1701766774=拒绝:{1701766772=0, 1701766773=0, 1701766774=5}
1701766774=拒绝:{1701766772=0, 1701766773=0, 1701766774=5}
step:1701766775:{1701766773=0, 1701766774=5, 1701766775=0}
step:1701766776:{1701766774=5, 1701766775=0, 1701766776=0}
step:1701766777:{1701766775=0, 1701766776=0, 1701766777=0}
step:1701766778:{1701766776=0, 1701766777=0, 1701766778=0}
典型场景
滑动窗口算法,在tcp协议发包过程中被使用。在web现实场景中,可以将流量控制做更细化处理,解决计数器模型控制力度太粗暴的问题。