赞
踩
最近在学习Sentinel组件需要了解限流算法相关的知识,正好在微信公众号上看到了一篇不错的文章,在此记录一下以下是原文链接。
限流算法接口
public interface RateLimiter {
/**
* 判断请求是否能够通过
* @return 能通过返回true否则false
*/
boolean tryAcquire();
}
首先维护一个计数器,将单位时间段当做一个窗口,计数器记录这个窗口接收请求的次数。
假设单位时间是1秒,限流阀值为3。在单位时间1秒内,每来一个请求,计数器就加1,如果计数器累加的次数超过限流阀值3,后续的请求全部拒绝。等到1s结束后,计数器清0,重新开始计数。如下图:
国定窗口算法伪代码实现如下:
public class CounterRateLimiter implements RateLimiter { // 每秒限制的请求数 private final long permitsPerSecond; // 上一个窗口开始的时间 private long timestamp = System.currentTimeMillis(); // 计数器 private int counter; public CounterRateLimiter(long permitsPerSecond) { this.permitsPerSecond = permitsPerSecond; } @Override public synchronized boolean tryAcquire() { long now = System.currentTimeMillis(); // 窗口内请求数量小于阈值,更新计数放行,否则拒绝请求 if (now - timestamp < 1000) { if (counter < permitsPerSecond) { counter++; return true; } else { return false; } } // 时间窗口过期,重置计数器和时间戳 counter = 0; timestamp = now; return true; } }
但是,这种算法有一个很明显的临界问题:假设限流阀值为5个请求,单位时间窗口是1s,如果我们在单位时间内的前0.8-1s和1-1.2s,分别并发5个请求。虽然都没有超过阀值,但是如果算0.8-1.2s,则并发数高达10,已经超过单位时间1s不超过5阀值的定义啦。
滑动窗口限流解决固定窗口临界值的问题。它将单位时间周期分为n个小周期,分别记录每个小周期内接口的访问次数,并且根据时间滑动删除过期的小周期。
一张图解释滑动窗口算法,如下:
我们来看下滑动窗口是如何解决临界问题的?
假设单位时间还是1s,滑动窗口算法把它划分为5个小周期,也就是滑动窗口(单位时间)被划分为5个小格子。每格表示0.2s。每过0.2s,时间窗口就会往右滑动一格。然后呢,每个小周期,都有自己独立的计数器,如果请求是0.83s到达的,0.8~1.0s对应的计数器就会加1。
TIPS: 当滑动窗口的格子周期划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。
滑动窗口算法代码实现如下:
public class SlidingWindowRateLimiter implements RateLimiter { // 每分钟限制的请求数 private final long permitsPerMinute; // 计数器,k为当前窗口的开始时间值秒,value为当前窗口的计数 private final TreeMap<Long, Integer> counters; public SlidingWindowRateLimiter(long permitsPerMinute) { this.permitsPerMinute = permitsPerMinute; this.counters = new TreeMap<>(); } @Override public synchronized boolean tryAcquire() { // 获取当前时间所在的子窗口值;10s一个窗口 long currentWindowTime = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC) / 10 * 10; // 获取当前窗口的请求总量 int currentWindowCount = getCurrentWindowCount(currentWindowTime); if (currentWindowCount >= permitsPerMinute) { return false; } // 计数器加一 counters.merge(currentWindowTime, 1, Integer::sum); return true; } /** * 获取当前窗口的所有请求数(并删除所有无效的子窗口计数器) * * @param currentWindowTime 当前子窗口时间 * @return 当前窗口中的计数 */ private int getCurrentWindowCount(long currentWindowTime) { // 计算出窗口的开始位置时间 long startTime = currentWindowTime - 50; int result = 0; // 遍历当前存储的计数器,删除无效的子窗口计数器,并累加当前窗口中的所有计数器之后 Iterator<Map.Entry<Long, Integer>> entryIterator = counters.entrySet().iterator(); while (entryIterator.hasNext()) { Map.Entry<Long, Integer> entry = entryIterator.next(); if (entry.getKey() < startTime) { entryIterator.remove(); } else { result += entry.getValue(); } } return result; } }
面对突发流量的时候,我们可以使用令牌桶算法限流。
令牌桶算法原理:
令牌桶算法代码实现如下:
public class TokenBucketRateLimiter implements RateLimiter { // 令牌桶的容量 private final long capacity; // 令牌发放速率 private final long generatedPerSecond; // 最后一个令牌发放的时间 private long lastTokenTime = System.currentTimeMillis(); // 当前令牌数量 private long currentTokens; public TokenBucketRateLimiter(long capacity, long generatedPerSecond) { this.capacity = capacity; this.generatedPerSecond = generatedPerSecond; } @Override public synchronized boolean tryAcquire() { /* 计算当前令牌的数量 请求时间在最后令牌产生的时间相差大于等于1s 1.重新计算令牌桶中的令牌数量 2. 将最后一个令牌发放时间重置为当前时间 */ long now = System.currentTimeMillis(); if (now - lastTokenTime >= 1000) { long newPermits = (now - lastTokenTime) / 1000 * generatedPerSecond; currentTokens = Math.min(currentTokens + newPermits, capacity); lastTokenTime = now; } if (currentTokens > 0) { currentTokens--; return true; } return false; } }
漏桶算法面对限流,就更加的柔性,不存在直接的粗暴拒绝。
它的原理很简单,可以认为就是注水漏水的过程。往漏桶中以任意速率流入水,以固定的速率流出水。当水超过桶的容量时,会被溢出,也就是被丢弃。因为桶容量是不变的,保证了整体的速率。
漏桶算法代码实现如下:
public class LeakyBucketRateLimiter implements RateLimiter { // 桶的容量 private final int capacity; // 漏出的速率 private final int permitsPerSecond; // 剩余水量 private long leftWater; // 上次注入水的时间 private long timeStamp = System.currentTimeMillis(); public LeakyBucketRateLimiter(int capacity, int permitsPerSecond) { this.capacity = capacity; this.permitsPerSecond = permitsPerSecond; } @Override public synchronized boolean tryAcquire() { // 计算剩余水量 long now = System.currentTimeMillis(); long timeGap = (now - timeStamp) / 1000; leftWater = Math.max(0, leftWater - timeGap * permitsPerSecond); timeStamp = now; // 如果未满,则放行;否则限流 if (leftWater < capacity){ leftWater += 1; return true; } return false; } }
这并不是一个完整的漏桶算法的实现,以上代码中只是对流量是否会被抛弃进行校验,即tryAcquire返回true表示漏桶未满,否则表示漏桶已满丢弃请求。
想要以恒定的速率漏出流量,通常还应配合一个FIFO队列来实现,当tryAcquire返回true时,将请求入队,然后再以固定频率从队列中取出请求进行处理。示例代码如下:
@Test public void testLeakyBucketRateLimiter() throws InterruptedException { ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(); ExecutorService singleThread = Executors.newSingleThreadExecutor(); LeakyBucketRateLimiter rateLimiter = new LeakyBucketRateLimiter(20, 20); // 存储流量的队列 Queue<Integer> queue = new LinkedList<>(); // 模拟请求 不确定速率注水 singleThread.execute(() -> { int count = 0; while (true) { count++; boolean flag = rateLimiter.tryAcquire(); if (flag) { queue.offer(count); System.out.println(count + "--------流量被放行--------"); } else { System.out.println(count + "流量被限制"); } try { Thread.sleep((long) (Math.random() * 1000)); } catch (InterruptedException e) { e.printStackTrace(); } } }); // 模拟处理请求 固定速率漏水 scheduledExecutorService.scheduleAtFixedRate(() -> { if (!queue.isEmpty()) { System.out.println(queue.poll() + "被处理"); } }, 0, 100, TimeUnit.MILLISECONDS); // 保证主线程不会退出 while (true) { Thread.sleep(10000); } }
漏桶算法存在目的主要是用来平滑突发的流量,提供一种机制来确保网络中的突发流量被整合成平滑稳定的额流量。
不过由于漏桶对流量的控制过于严格,在有些场景下不能充分使用系统资源,因为漏桶的漏出速率是固定的,即使在某一时刻下游能够处理更大的流量,漏桶也不允许突发流量通过。
滑动日志是一个比较“冷门”,但是确实好用的限流算法。滑动日志限速算法需要记录请求的时间戳,通常使用有序集合来存储,我们可以在单个有序集合中跟踪用户在一个时间段内所有的请求。
假设我们要限制给定T时间内的请求不超过N,我们只需要存储最近T时间之内的请求日志,每当请求到来时判断最近T时间内的请求总数是否超过阈值。
滑动日志算法代码实现如下:
public class SlidingLogRateLimiter implements RateLimiter { // 每分钟限制的请求数 private static final long PERMITS_PER_MINUTE = 60; // 请求日志计数器,k-为请求的时间(秒),value当前时间的请求数量 private final TreeMap<Long, Integer> requestLogCountMap = new TreeMap<>(); @Override public synchronized boolean tryAcquire() { // 最小时间粒度为秒 long currentTimeStamp = LocalDateTime.now().toEpochSecond(ZoneOffset.UTC); // 获取当前窗口的请求总数 int currentWindowCount = getCurrentWindowCount(currentTimeStamp); if (currentWindowCount >= PERMITS_PER_MINUTE) { return false; } // 请求成功,将当前请求日志加入到日志计数器中 requestLogCountMap.merge(currentTimeStamp, 1, Integer::sum); return false; } /** * 统计当前时间窗口内的请求数 * * @param currentTimeStamp 当前时间 * @return 请求数 */ private int getCurrentWindowCount(long currentTimeStamp) { // 计算出窗口的开始位置时间 long startTime = currentTimeStamp - 59; // 遍历当前存储的计数器,删除无效的子窗口计数器,并累加当前窗口中的所有计数器之和 return requestLogCountMap.entrySet() .stream() .filter(entry -> entry.getKey() >= startTime) .mapToInt(Map.Entry::getValue) .sum(); } }
滑动日志能够避免突发流量,实现较为精准的限流;同样更加灵活,能够支持更加复杂的限流策略,如多级限流,每分钟不超过100次,每小时不超过300次,每天不超过1000次,我们只需要保存最近24小时所有的请求日志即可实现。
灵活并不是没有代价的,带来的缺点就是占用存储空间要高于其他限流算法。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。