限流算法小记
限流算法:时间窗口简单计数、最近时间滑动窗口计数、漏桶、令牌桶等。
本质上看,并发访问控制(同步,例如加锁) 是一种限流,流量大小为1且是排他的;限流也是一种并发访问控制。
总结:
1 What
高并发系统中保护系统的三把利器:缓存、降级、限流
缓存:缓存的目的是提升系统访问速度和增大系统处理容量
降级:降级是当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务和页面有策略的降级,以此释放服务器资源以保证核心任务的正常运行
限流:限流的目的是通过对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理
限流的应用场景通常有两种:业务限流和接口限流。前者比如同一账号1min内登录连续错3次密码则要求输验证码、同一账号1min内连续3次验证码输错则禁用验证码申请5min;后者比如同一用户1min内最多调10次验证码申请接口。后者是更常见的场景。
可看出这个例子里它们的区别主要在于用户操作是否“连续”
2 限流算法
对于系统中的接口调用,如果不考虑限流,会成系统的连锁反应,轻者响应缓慢,重者系统宕机,整个业务线崩溃。限流算法通常是为了限制qps。
主要有如下几种:
2.1 基于信号量Semaphore
只有数量维度,没有时间维度。普通的并发访问控制策略如锁也属于这种。
2.2 基于固定窗口(fixed window)计数器
带上了时间维度,但在两个窗口的临界点容易出现超出限流的情况,比如限制每分钟10个请求,在00:59请求了10次,在01:01又请求了10次,而从00:30-01:30这个时间窗口来看,这一分钟请求了20次,没有控制好。为了避免这种情况,可以要求在最后一个请求到来后的1/qps时间内不接受请求或让请求等待延后,不过此法导致这个等待时间内资源浪费。
实现示例(Go语言):
package main import ( "fmt" "sync" "time" ) type RatelimitCounter struct { lock sync.Mutex interval time.Duration //时间窗口长度 capacity uint //时间窗口内允许的最大次数 begin time.Time //时间窗口起始时间 cnt uint //时间窗口内实际次数 } func NewRatelimitCounter(interval time.Duration, rate uint) *RatelimitCounter { return &RatelimitCounter{ lock: sync.Mutex{}, interval: interval, capacity: rate, begin: time.Now(), cnt: 0, } } func (r *RatelimitCounter) Allow() bool { r.lock.Lock() defer r.lock.Unlock() if r.cnt < r.capacity { r.cnt++ return true } else { now := time.Now() if now.Sub(r.begin) <= r.interval { //时间窗口左闭右开 return false } else { r.begin = now r.cnt = 1 return true } } } func (r *RatelimitCounter) Reset() { r.lock.Lock() defer r.lock.Unlock() r.begin = time.Now() r.cnt = 0 } func main() { // 限速3请求/s,则在每200ms来一个请求时,只有第1、2、3, 6、7、8,11、12、13 ... 个请求可被处理 var lock = NewRatelimitCounter(time.Second, 3) var wg sync.WaitGroup for i := 1; i <= 10; i++ { wg.Add(1) fmt.Printf("add goroutine %d\n", i) go func(id int) { if lock.Allow() { fmt.Printf("goroutine %d got lock\n", id) } wg.Done() }(i) time.Sleep(200 * time.Millisecond) } wg.Wait() fmt.Println("all goroutines done") }
2.3 基于滑动窗口(rolling window)计数器
解决了fixed window没解决的窗口临界问题。可借助 Redis zset + key ttl 功能 来配合实现。
实现思路:借助Redis ZSet来实现:key为请求的id、value为空值、score为请求发生的时间戳,这样滑动时间窗口就是一个score范围——终点是当前时间戳、起点是当前时间戳减去窗口长度。当校验是否要限流时即查询滑动时间窗口内key的个数。为免数据无限增长,每次有新请求时都为key设置ttl,值为窗口时间长度,故若一个窗口时间长度内无请求则数据会被自动删掉。
笔者自己实现的代码:(可以作为限流工具、Timed Lock、并发访问控制工具等使用)
import java.util.stream.Collectors; import java.util.stream.Stream; import com.ss.sensestudy.jedispool_client.JedisPoolClientUtil; import lombok.Getter; import lombok.ToString; import lombok.extern.slf4j.Slf4j; /** * 限流器,用于记录一个滑动窗口时间内事件发生的次数,该窗口表示最近一段时间的时长,该时间段的右端点始终是"现在"。<br> * <br> * 可借此实现接口限流等功能(例如“同一个用户最近十分钟内连续三次登录错误则要求输入验证码”、"同一个客户端最近一分钟内最多调用50次验证码查询接口"、"同一客户端在最近一分钟内连续输错十次验证码则锁定该验证码功能一分钟"),甚至可将此工具作为一个Timed * Lock或并发量控制器来使用。<br> * <br> * <b>注:各方法线程安全</b><br> * <br> */ @Slf4j @ToString public class RateLimitUtil { // TODO 多进程部署场景下数据的并发修改问题 // 本工具的演进过程:简单的LoginFailCountUtil(借助Redis ZSet实现)-> // 更通用的EventCountInPeriodRecoder -> 完整的RateLimiter private static final String keyPrefixForCount = "EvtCnt_"; private static final String keyPrefixForLock = "EvtLK_"; /** 特殊值以表示事件不做最大次数的限制 */ public static final int MAX_COUNT_LIMIT_DISABLED = -1; /** 特殊值以表示事件到最大次数时不执行禁用操作 */ public static final int LOCK_OPT_DISABLED = -1; /** * 根据给定的配置创建一个实例 * * @param periodSeconds 滑动窗口时长,表示最近一段时间的时长 * @param maxCountInPeriod 滑动窗口内允许的事件发生的最大次数。值为0或负数时表示不限制 * @param lockSecondsIfReachMax 禁用时长,表示当达到最大次数时,将进入禁用状态以不允许继续增加事件次数,该状态的时长。值为0或负数表示不禁用 * @param eventType 事件类型,表示此事件记录器记录的是哪种事件。在内部将被作为keyPrefix * @param eventTypeDesc 事件类型描述,如"获取验证码"、"校验验证码"、"加锁"等 * @return */ public static RateLimitUtil with(int periodSeconds, int maxCountInPeriod, int lockSecondsIfReachMax) { // 参数校验 if (periodSeconds <= 0) { throw new RuntimeException("periodSeconds should be positive"); } if (maxCountInPeriod <= 0) { maxCountInPeriod = MAX_COUNT_LIMIT_DISABLED; } if (lockSecondsIfReachMax <= 0) { lockSecondsIfReachMax = LOCK_OPT_DISABLED; } RateLimitUtil instance = new RateLimitUtil(); instance.periodSeconds = periodSeconds; instance.maxCountInPeriod = maxCountInPeriod; instance.lockSeconds = lockSecondsIfReachMax; log.info("init done: {}", instance.toString()); return instance; } @Getter private int periodSeconds; @Getter private int maxCountInPeriod; @Getter private int lockSeconds; private RateLimitUtil() { } /** 增加一次指定事件在最近一段指定时长内的发生次数。若该时长内该事件次数已达上限则添加失败,否则添加成功,返回值表示是否添加成功 */ public synchronized boolean addCountInPeriod(String... eventId) { if (reachCountLimitInPeriod(eventId)) { return false; } long nowScore = System.currentTimeMillis(); String keyForCount = generateRedisKey(keyPrefixForCount, eventId); JedisPoolClientUtil.zadd(keyForCount, nowScore + "", nowScore);// 增加事件 JedisPoolClientUtil.zremrangeByScore(keyForCount, 0, nowScore - periodSeconds * 1000);// 将早于统计时间段的事件删除 JedisPoolClientUtil.expire(keyForCount, periodSeconds);// 借此实现"整个时间段内无该事件时数据自动清除"的效果 if (lockSeconds != LOCK_OPT_DISABLED && reachCountLimitInPeriod(eventId)) {// 锁定 String keyForLock = generateRedisKey(keyPrefixForLock, eventId); JedisPoolClientUtil.set(keyForLock, nowScore + "", "nx", "ex", lockSeconds); } return true; } /** 删除指定事件在最近一段指定时长内的发生次数的记录 */ public synchronized long clearCountInPeriod(String... eventId) { if (inDisabled(eventId)) {// 被禁用时不允许操作 return 0; } else { String keyForCount = generateRedisKey(keyPrefixForCount, eventId); return JedisPoolClientUtil.del(keyForCount); } } /** * 修改滑动时间窗口长度。若新长度比原长度短则对于减少的时间区间(区间右端点对齐进行比较)内的事件将被丢弃,否则不对已有的事件做任何修改。<br> * 当将本工具作为一个Timed Lock使用时,可借此方法实现修改Lock时长的目的。 */ public synchronized void updatePeriod(int newPeriodSeconds) { if (newPeriodSeconds <= 0) { throw new RuntimeException("newPeriodSeconds should be positive"); } if (newPeriodSeconds < this.periodSeconds) { // 移除位于减少的时间区域内的事件 boolean lazyRemove = false; if (lazyRemove) { // do nothing。延迟更新:当使用者调用上面的的add方法时达到此效果 } else { double nowScore = System.currentTimeMillis(); String keyPattern = generateRedisKey(keyPrefixForCount, "*"); JedisPoolClientUtil.keys(keyPattern).forEach(keyForCount -> { JedisPoolClientUtil.zremrangeByScore(keyForCount, 0, nowScore - periodSeconds * 1000);// 将早于统计时间段的事件删除 }); } } this.periodSeconds = newPeriodSeconds; } /** 获取指定事件在最近一段指定时长内的发生次数 */ public long getCountInPeriod(String... eventId) { if (inDisabled(eventId)) {// 被禁用时不返回真实值而是固定的最大值 return maxCountInPeriod; } else { String keyForCount = generateRedisKey(keyPrefixForCount, eventId); // return JedisPoolClientUtil.zcard(keyForCount);因时间长度可能动态改变,故用下面的方式 double nowScore = System.currentTimeMillis(); return JedisPoolClientUtil.zcount(keyForCount, nowScore - periodSeconds * 1000, nowScore); } } /** * 判断指定事件在最近一段指定时长内的发生次数是否达到上限。达到次数上限时可能会将该事件置为禁用状态一段时间,见 * {@link #inDisabled(String...eventId) } */ public boolean reachCountLimitInPeriod(String... eventId) { return ((maxCountInPeriod != MAX_COUNT_LIMIT_DISABLED) && getCountInPeriod(eventId) >= maxCountInPeriod); } /** * 判断指定事件是否处于禁用状态(若启用了禁用功能且事件发生次数达到上限则会被设为该状态。达到次数上限是变成禁用状态的必要不充分条件)。<br> * 处于该状态时将无法继续增加事件次数(即 {@link #addCountInPeriod(String...eventId)}) */ public boolean inDisabled(String... eventId) { if (lockSeconds != LOCK_OPT_DISABLED) { String keyForLock = generateRedisKey(keyPrefixForLock, eventId); return JedisPoolClientUtil.exists(keyForLock); } else { return false; } } /** 获取指定事件禁用状态的剩余时长,单位为妙。参考{@link #inDisabled(String...eventId)} */ public long ttlOfDisabled(String... eventId) { long res = 0; if (lockSeconds != LOCK_OPT_DISABLED) { String keyForLock = generateRedisKey(keyPrefixForLock, eventId); long tmpRes = JedisPoolClientUtil.ttl(keyForLock); res = res < tmpRes ? tmpRes : res; } return res; } /** 生成key,将会将各元素值拼接在一起 */ private String generateRedisKey(String keyPrefix, String... eventId) { if (null == eventId) { throw new RuntimeException("eventId should not be null"); } for (String s : eventId) { if (null == s) { throw new RuntimeException("element in eventId should not be null"); } } StringBuilder builder = new StringBuilder(); builder.append(keyPrefix); builder.append(Stream.of(eventId).collect(Collectors.joining("_"))); return builder.toString(); }
特点:
实现上,滑动窗口、某事件次数限制、某事件锁定一定时长。
功能上,不仅可用于业务限流、接口限流,还可用于并发访问控制比如:作为 timed 分布式锁使用,且锁可重入、可排他或不排他(即具有类似信号量的功能)。
2.4 流量整形算法(漏桶和令牌通)
主要有leak bucket算法、token bucket算法,前者不能应对突发流量(流量突发时对于超发请求只能丢弃或让之排队)。两种算法具体可参阅:https://blog.csdn.net/tianyaleixiaowu/article/details/74942405
漏桶(出水恒速):出水速度恒定,意味着对于短时大流量将有大部分请求被丢弃掉(即所谓的溢出)。出水速度恒定,所以更适合用于整流(Traffic Shaping)场景,使流向下游的流量整体稳定。
实现思路:核心就是实现水出桶速度恒定的特点,取token时从出桶水滴取。故可记录签发token的时间间隔(作为频率)interval、记录最近一次签发token时间戳 t。当请求来时,判断当前时间戳 c与 t 的差值 c-t,若≤interval则拒绝、否则签发成功并更新t值为 t+ floor( (c-t)/interval ) 即更新为最近滴出的水滴的时间
实现示例(Go语言):
package main import ( "fmt" "math" "sync" "time" ) type RatelimitLeakBucket struct { lock sync.Mutex interval time.Duration //从桶里滴出水滴的时间间隔 lastWaterDropTime time.Time //最近一次滴水时间 } func NewRatelimitLeakBucket(interval time.Duration) *RatelimitLeakBucket { res := &RatelimitLeakBucket{ lock: sync.Mutex{}, interval: interval, } res.Reset() return res } func (r *RatelimitLeakBucket) Allow() bool { r.lock.Lock() defer r.lock.Unlock() past := time.Now().Sub(r.lastWaterDropTime) //fmt.Println(past) if past <= r.interval { //时间区间旧闭新开(或下闭上开) return false } else { var n int64 = int64(math.Floor(float64(past) / float64(r.interval))) r.lastWaterDropTime = r.lastWaterDropTime.Add(r.interval * time.Duration(n)) return true } } func (r *RatelimitLeakBucket) Reset() { r.lock.Lock() defer r.lock.Unlock() //r.lastWaterDropTime = time.Now().Add(-(r.interval * time.Duration(2))) // 确保早于一个interval之前即可 r.lastWaterDropTime = time.Now() } func main() { // 限速500ms一个请求,则在每200ms来一个请求时,只有第4、6、9、11 ... 个请求可被处理 var lock = NewRatelimitLeakBucket(time.Millisecond * 500) var wg sync.WaitGroup for i := 1; i <= 10; i++ { wg.Add(1) fmt.Printf("add goroutine %d\n", i) go func(id int) { if lock.Allow() { fmt.Printf("goroutine %d got lock\n", id) } wg.Done() }(i) time.Sleep(200 * time.Millisecond) } wg.Wait() fmt.Println("all goroutines done") }
令牌桶(入水恒速):生成令牌的速度恒定,而请求去拿令牌没有速度限制。意味着面对瞬时大流量可以在短时间内请求拿到大量令牌(除非桶空了,此时只能暂时等),而且拿令牌的过程并不是消耗很大的事情。
实现思路:核心就是实现水入桶速度恒定的特点,取token时从桶里取水滴。故实现上与漏桶类似,只不过漏桶是出桶生成token且从出桶获得token、而令牌桶是往桶里生成token且从桶里获得token。实现上非常类似。
实现示例(Go语言):
package main import ( "fmt" "math" "sync" "time" ) type RatelimitTokenBucket struct { // 实现上与固定窗口计数器、漏桶的都有点像 lock sync.Mutex interval time.Duration //多久一次往桶里生成水滴 capacity uint //桶的水滴容量 lastWaterPutTime time.Time //最近一次生成水滴到桶里的时间 cnt uint //桶内已有水滴个数 } func NewRatelimitTokenBucket(capacity uint, interval time.Duration) *RatelimitTokenBucket { res := &RatelimitTokenBucket{ lock: sync.Mutex{}, interval: interval, capacity: capacity, } res.Reset() return res } func (r *RatelimitTokenBucket) Allow() bool { r.lock.Lock() defer r.lock.Unlock() past := time.Now().Sub(r.lastWaterPutTime) //fmt.Println(past) var n int64 = int64(math.Floor(float64(past) / float64(r.interval))) r.lastWaterPutTime = r.lastWaterPutTime.Add(r.interval * time.Duration(n)) r.cnt += uint(n) if r.cnt > r.capacity { r.cnt = r.capacity } if r.cnt > 0 { r.cnt-- return true } else { return false } } func (r *RatelimitTokenBucket) Reset() { r.lock.Lock() defer r.lock.Unlock() //r.lastWaterPutTime = time.Now().Add(-(r.interval * time.Duration(2))) // 确保早于一个interval之前即可 r.lastWaterPutTime = time.Now() r.cnt = 0 } func main() { // 每100s产生一个token,则在每200ms来一个请求时,第2、3、4、5... 个请求可被处理 var lock = NewRatelimitTokenBucket(10, time.Millisecond*100) var wg sync.WaitGroup for i := 1; i <= 10; i++ { wg.Add(1) fmt.Printf("add goroutine %d\n", i) go func(id int) { if lock.Allow() { fmt.Printf("goroutine %d got lock\n", id) } wg.Done() }(i) time.Sleep(200 * time.Millisecond) } wg.Wait() fmt.Println("all goroutines done") }
leak bucket、token bucket 在桶满时都停止生成token,它们都是单机下的限流算法,对于分布式环境下不能直接适用,需要进行实现上的改造例如借助Redis。
参考资料:
https://segmentfault.com/a/1190000015967922
https://blog.wangqi.love/articles/Java/%E9%99%90%E6%B5%81%E6%8A%80%E6%9C%AF%E6%80%BB%E7%BB%93.html
https://mp.weixin.qq.com/s/C83VPj9A6DmMBD6m5d6dUg - 四种限流算法