前段时间一两年,随着微服务项目的盛行,服务项目和服务项目间的倚赖愈来愈强,初始化关系愈来愈繁杂,服务项目和服务项目间的灵活性愈来愈重要。在碰到突发性的允诺量剧增,蓄意的使用者出访,亦或允诺振幅最佳值给上游服务项目带来较大压力时,他们常常须要透过内存、开闭、TNUMBERAP降班、阻抗平衡等多种不同形式确保服务项目的灵活性。其中开闭是必不可少的劳特尔,这首诗如是说开闭相关科学知识。
1. 开闭
开闭简而言之,就是对允诺或mammalian数展开管制;透过对两个天数询问处内的允诺量展开管制来安全可靠的恒定运转。如果他们的服务项目天然资源非常有限、处置能力非常有限,就须要对初始化他们服务项目的上游允诺展开管制,以避免另一方面服务项目由于天然资源用尽而暂停服务项目。
在开闭两个基本概念须要了解。
共振振幅:在两个基层单位天数内容许的允诺量。如 QPS 管制为10,表明 1 秒内最多拒绝接受 10 次允诺。婉拒思路:超过共振振幅的允诺的婉拒思路,常用的婉拒思路有直接婉拒、排队等候等候等。2. 一般来说询问处演算法
一般来说询问处演算法又叫计时器演算法,是一种简单方便快捷的开闭演算法。主要透过两个支持氢原子操作方式的计时器来总计 1 秒内的允诺单次,当 1 秒内算数达到开闭共振振幅时促发婉拒思路。不多不少 1 秒,计时器抹除为 0 开始再次算数。
2.1. 标识符同时实现
上面是单纯的代码同时实现,QPS 管制为 2,这里的标识符做了一些强化,并没有原则上开两个内存去内要 1 秒抹除计时器,而要在每天初始化时展开天数间距排序来确认与否先抹除计时器。
/** * @author https://www.wdbyte.com */ public class RateLimiterSimpleWindow { // 共振振幅 private static Integer QPS = 2;// 天数询问处(微秒) private static long TIME_WINDOWS = 1000; // 计时器 private static AtomicInteger REQ_COUNT = newAtomicInteger();private static long START_TIME = System.currentTimeMillis(); public synchronized static boolean tryAcquire() { if ((System.currentTimeMillis() – START_TIME) > TIME_WINDOWS) { REQ_COUNT.set(0); START_TIME = System.currentTimeMillis(); }return REQ_COUNT.incrementAndGet() <= QPS; } public static void main(String[] args) throws InterruptedException { for (int i = 0; i < 10; i++) { Thread.sleep(250); LocalTime now = LocalTime.now(); if (!tryAcquire()) { System.out.println(now + ” 被开闭”); }else { System.out.println(now + ” 做点什么”); } } } }运转结果:
20:53:43.038922做点什么 20:53:43.291435 做点什么 20:53:43.543087 被开闭 20:53:43.796666 做点什么 20:53:44.050855 做点什么 20:53:44.303547 被开闭 20:53:44.555008 被开闭 20:53:44.809083 做点什么 20:53:45.063828 做点什么 20:53:45.314433 被开闭从输出结果中可以看到大概每秒操作方式 3 次,由于管制 QPS 为 2,所以平均会有一次被开闭。看起来可以了,不过他们思考一下就会发现这种单纯的开闭形式是有问题的,虽然他们管制了 QPS 为 2,但是当碰到天数询问处的临界突变时,如 1s 中的后 500 ms 和第 2s 的前 500ms 时,虽然是加起来是 1s 天数,却可以被允诺 4 次。
单纯修改测试标识符,可以展开验证:
// 先休眠 400ms,可以更快的到达天数询问处。 Thread.sleep(400); for (int i = 0; i < 10; i++) { Thread.sleep(250); if(!tryAcquire()) {System.out.println(“被开闭”); } else { System.out.println(“做点什么”); } }得到输出中可以看到连续 4 次允诺,间距 250 ms 没有却被管制。:
20:51:17.395087 做点什么 20:51:17.653114 做点什么 20:51:17.903543 做点什么 20:51:18.154104 被开闭 20:51:18.405497 做点什么 20:51:18.655885 做点什么 20:51:18.906177 做点什么 20:51:19.158113被开闭 20:51:19.410512 做点什么 20:51:19.661629 做点什么3. 滑动询问处演算法
他们已经知道一般来说询问处演算法的同时实现形式以及它所存在的问题,而滑动询问处演算法是对一般来说询问处演算法的改进。既然一般来说询问处演算法在碰到天数询问处的临界突变时会有问题,那么他们在碰到下两个天数询问处前也调整天数询问处不就可以了吗?
上面是滑动询问处的示意图。
上图的示例中,每 500ms 滑动一次询问处,可以发现询问处滑动的间隔越短,天数询问处的临界突变问题发生的概率也就越小,不过只要有天数询问处的存在,还是有可能发生天数询问处的临界突变问题。
3.1. 标识符同时实现
上面是基于以上滑动询问处思路同时实现的单纯的滑动询问处开闭工具类。
package com.wdbyte.rate.limiter;import java.time.LocalTime; importjava.util.concurrent.atomic.AtomicInteger;/** * 滑动询问处开闭工具类 * * @author https://www.wdbyte.com */ public class RateLimiterSlidingWindow { /** * 共振振幅 */ private int qps = 2; /** * 天数询问处总大小(微秒) */ private long windowSize = 1000; /** * 多少个子询问处 */ private Integer windowCount = 10; /** * 询问处列表 */ private WindowInfo[] windowArray = new WindowInfo[windowCount]; public RateLimiterSlidingWindow(int qps) { this.qps = qps; long currentTimeMillis = System.currentTimeMillis(); for (int i = 0; i < windowArray.length; i++) { windowArray[i] =new WindowInfo(currentTimeMillis, new AtomicInteger(0)); } } /** * 1. 排序当前天数询问处 * 2. 更新当前询问处算数 & 抹除过期询问处算数 * 3. 当前 QPS 与否超过管制 * * @return */ public synchronized boolean tryAcquire() { longcurrentTimeMillis = System.currentTimeMillis();// 1. 排序当前天数询问处 int currentIndex = (int)(currentTimeMillis % windowSize / (windowSize / windowCount));// 2. 更新当前询问处算数 & 抹除过期询问处算数 int sum = 0; for (int i = 0; i < windowArray.length; i++) { WindowInfo windowInfo = windowArray[i];if((currentTimeMillis – windowInfo.getTime()) > windowSize) { windowInfo.getNumber().set(0); windowInfo.setTime(currentTimeMillis); } if(currentIndex == i && windowInfo.getNumber().get() < qps) { windowInfo.getNumber().incrementAndGet(); } sum = sum + windowInfo.getNumber().get(); }// 3. 当前 QPS 与否超过管制 returnsum <= qps; }private class WindowInfo { // 询问处开始天数 private Long time; // 计时器 private AtomicInteger number; public WindowInfo(long time, AtomicInteger number) { this.time = time; this.number = number; } // get…set…} }上面是测试用例,设置 QPS 为 2,测试单次 20 次,每天间距 300 微秒,预计成功单次在 12 次左右。
public static void main(String[] args) throws InterruptedException { int qps = 2, count = 20, sleep = 300, success = count * sleep / 1000* qps; System.out.println(String.format(“当前QPS管制为:%d,当前测试单次:%d,间距:%dms,预计成功单次:%d”, qps, count, sleep, success)); success =0; RateLimiterSlidingWindow myRateLimiter = new RateLimiterSlidingWindow(qps); for (int i = 0; i < count; i++) { Thread.sleep(sleep); if (myRateLimiter.tryAcquire()) { success++; if(success % qps ==0) { System.out.println(LocalTime.now() + “: success, “); } else { System.out.print(LocalTime.now() + “: success, “); } } else { System.out.println(LocalTime.now() + “: fail”); } } System.out.println(); System.out.println(“实际测试成功单次:” + success); }上面是测试的结果。
当前QPS管制为:2,当前测试单次:20,间距:300ms,预计成功单次:12 16:04:27.077782: success, 16:04:27.380715: success, 16:04:27.684244: fail 16:04:27.989579: success, 16:04:28.293347: success, 16:04:28.597658: fail 16:04:28.901688: fail 16:04:29.205262: success, 16:04:29.507117: success, 16:04:29.812188: fail 16:04:30.115316: fail 16:04:30.420596: success, 16:04:30.725897: success, 16:04:31.028599: fail 16:04:31.331047: fail 16:04:31.634127: success, 16:04:31.939411: success, 16:04:32.242380: fail 16:04:32.547626: fail 16:04:32.847965: success, 实际测试成功单次:114. 滑动日志演算法
滑动日志演算法是同时实现开闭的另一种方法,这种方法比较单纯。基本逻辑就是记录下所有的允诺天数点,新允诺到来时先判断前段时间指定天数范围内的允诺数量与否超过指定共振振幅,由此来确认与否达到开闭,这种形式没有了天数询问处突变的问题,开闭比较准确,但是因为要记录下每天允诺的天数点,所以占用的内存较多。
4.1. 标识符同时实现
上面是单纯同时实现的 两个滑动日志演算法,因为滑动日志要每天允诺原则上存储一条记录,可能占用内存过多。所以上面这个同时实现其实不算严谨的滑动日志,更像两个把 1 秒天数切分成 1000 个天数询问处的滑动询问处演算法。
package com.wdbyte.rate.limiter; import java.time.LocalTime; import java.util.HashSet; import java.util.Set; import java.util.TreeMap;/** * 滑动日志形式开闭 * 设置 QPS 为 2. * * @author https://www.wdbyte.com */ public class RateLimiterSildingLog { /** * 共振振幅 */ private Integer qps = 2; /** * 记录允诺的天数戳,和数量 */ privateTreeMap<Long, Long> treeMap =new TreeMap<>(); /** * 清理允诺记录间距, 60 秒 */ private long claerTime = 60 * 1000; public RateLimiterSildingLog(Integer qps) { this.qps = qps; } public synchronized boolean tryAcquire() { longnow = System.currentTimeMillis();// 清理过期的数据老数据,最长 60 秒清理一次 if(!treeMap.isEmpty() && (treeMap.firstKey() – now) > claerTime) { Set<Long> keySet =new HashSet<>(treeMap.subMap(0L, now – 1000).keySet()); for(Long key : keySet) { treeMap.remove(key); } } // 排序当前允诺单次 int sum = 0; for (Long value: treeMap.subMap(now –1000, now).values()) { sum += value; } // 超过QPS管制,直接返回 false if (sum + 1 > qps) { return false; } // 记录本次允诺 if (treeMap.containsKey(now)) { treeMap.compute(now, (k, v) -> v + 1); } else { treeMap.put(now, 1L); } return sum <= qps; } public static void main(String[] args) throws InterruptedException { RateLimiterSildingLog rateLimiterSildingLog = new RateLimiterSildingLog(3);for (int i = 0; i < 10; i++) { Thread.sleep(250); LocalTime now = LocalTime.now(); if(rateLimiterSildingLog.tryAcquire()) { System.out.println(now + ” 做点什么”); } else { System.out.println(now +” 被开闭”); } } } }标识符中把共振振幅 QPS 设定为 3,运转可以得到如下日志:
20:51:17.395087 做点什么 20:51:17.653114 做点什么 20:51:17.903543 做点什么 20:51:18.154104 被开闭 20:51:18.405497 做点什么 20:51:18.655885做点什么 20:51:18.906177 做点什么 20:51:19.158113 被开闭 20:51:19.410512 做点什么 20:51:19.661629 做点什么5. 漏桶演算法
漏桶演算法中的漏桶是两个形象的比喻,这里可以用生产者消费者模式展开表明,允诺是两个生产者,每两个允诺都如一滴水,允诺到来后放到两个队列(漏桶)中,而桶底有两个孔,不断的漏出水滴,就如消费者不断的在消费队列中的内容,消费的速率(漏出的速度)等于开闭共振振幅。即假如 QPS 为 2,则每 1s / 2= 500ms 消费一次。漏桶的桶有大小,就如队列的容量,当允诺堆积超过指定容量时,会促发婉拒思路。
上面是漏桶演算法的示意图。
由如是说可以知道,漏桶模式中的消费处置总是能以恒定的速度展开,可以很好的保护另一方面系统不被突如其来的网络流量冲垮;但是这也是漏桶模式的缺点,假设 QPS 为 2,同时 2 个允诺进来,2 个允诺并不能同时展开处置响应,因为每 1s / 2= 500ms 只能处置两个允诺。
6. 令牌桶演算法
令牌桶演算法同样是同时实现开闭是一种常用的思路,最为常用的 Google 的 Java 开发工具包 Guava 中的开闭工具类 RateLimiter 就是令牌桶的两个同时实现。令牌桶的同时实现思路类似于生产者和消费间的关系。
系统服务项目作为生产者,按照指定振幅向桶(容器)中添加令牌,如 QPS 为 2,每 500ms 向桶中添加两个令牌,如果桶中令牌数量达到共振振幅,则不再添加。
允诺执行作为消费者,每个允诺都须要去桶中拿取两个令牌,取到令牌则继续执行;如果桶中无令牌可取,就促发婉拒思路,可以是超时等候,也可以是直接婉拒本次允诺,由此达到开闭目的。
上面是令牌桶开闭演算法示意图。
思考令牌桶的同时实现可以以下特点。
1s / 共振振幅(QPS) = 令牌添加天数间距。桶的容量等于开闭的共振振幅,令牌数量达到共振振幅时,不再添加。可而促发婉拒思路,不过如 RateLimiter 开闭工具已经强化了这类问题。6.1. 标识符同时实现
Google 的 Java 开发工具包 Guava 中的开闭工具类 RateLimiter 就是令牌桶的一个同时实现,日常开发中他们也不会手动同时实现了,这里直接使用 RateLimiter 展开测试。
引入倚赖:
<exclusion> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>31.0.1-jre</version> </exclusion>RateLimiter 开闭体验:
// qps 2RateLimiter rateLimiter = RateLimiter.create(2); for (int i = 0; i < 10; i++) { String time= LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_TIME); System.out.println(time + “:”+ rateLimiter.tryAcquire()); Thread.sleep(250); }17:19:06.797557:true 17:19:07.061419:false 17:19:07.316283:true 17:19:07.566746:false 17:19:07.817035:true 17:19:08.072483:false 17:19:08.326347:true 17:19:08.577661:false 17:19:08.830252:true 17:19:09.085327:false6.2. 思考
虽然演示了 Google Guava 工具包中的 RateLimiter 的同时实现,但是他们须要思考两个问题,就是令牌的添加形式,如果按照指定间距添加令牌,那么须要开两个内存去定时添加,如果有很多个接口很多个 RateLimiter 实例,内存数会随之增加,这显然不是两个好的办法。显然 Google 也考虑到了这个问题,在 RateLimiter 中,是排序令牌与否足够的
上面是 Guava 中 RateLimiter 类的子类 SmoothRateLimiter 的 resync() 方法的标识符分析,可以看到其中的令牌排序逻辑。
void resync(long nowMicros) { // 当前微秒天数 // 当前天数与否大于下两个令牌生成天数 if (nowMicros > this.nextFreeTicketMicros) { // 可生成的令牌数 newPermits = (当前天数 – 下两个令牌生成天数)/ 令牌生成天数间距。 // 如果 QPS 为2,这里的 coolDownIntervalMicros 就是 500000.0 微秒(500ms)double newPermits = (double)(nowMicros –this.nextFreeTicketMicros) / this.coolDownIntervalMicros(); // 更新令牌库存 storedPermits。 this.storedPermits = Math.min(this.maxPermits, this.storedPermits + newPermits); // 更新下两个令牌生成天数 nextFreeTicketMicros this.nextFreeTicketMicros = nowMicros; } }7. Redis 分布式开闭
Redis 是两个开源的内存数据库,可以用来作为数据库、内存、消息中间件等。Redis 是单内存的,又在内存中操作方式,所以速度极快,得益于 Redis 的各种特性,所以使用 Redis 同时实现两个开闭工具是十分方便快捷的。
上面的演示都基于Spring Boot 项目,并须要以下倚赖。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>配置 Redis 信息。
spring: redis: database: 0 password: port: 6379 host: 127.0.0.1 lettuce: shutdown-timeout: 100ms pool: min-idle: 5 max-idle: 10 max-active: 8 max-wait: 1ms7.1. 一般来说询问处开闭
Redis 中的固定询问处开闭是使用 incr 命令同时实现的,incr 命令通常用来自增算数;如果他们使用天数戳信息作为 key,自然就可以统计每秒的允诺量了,以此达到开闭目的。
这里有两点要注意。
对于不存在的 key,第一次新增时,value 始终为 1。INCR 和 EXPIRE 命令操作方式应该在两个氢原子操作方式中提交,以确保每个 key 都正确设置了过期天数,不然会有 key 值无法自动删除而导致的内存溢出。由于 Redis 中同时实现事务的繁杂性,所以这里直接只用 lua 脚本来同时实现氢原子操作方式。上面是 lua 脚本内容。
local count = redis.call(“incr”,KEYS[1]) if count == 1 then redis.call(expire,KEYS[1],ARGV[2]) end if count > tonumber(ARGV[1]) then return 0 end return 1上面是使用 Spring Boot 中 RedisTemplate 来同时实现的 lua 脚本初始化测试标识符。
/** * @author https://www.wdbyte.com */ @SpringBootTest class RedisLuaLimiterByIncr { private static String KEY_PREFIX = “limiter_”; private staticString QPS =“4”; private static String EXPIRE_TIME = “1”; @Autowired privateStringRedisTemplate stringRedisTemplate;@Test public void redisLuaLimiterTests() throws InterruptedException, IOException { for (int i = 0; i < 15; i++) { Thread.sleep(200); System.out.println(LocalTime.now() + ” “ + acquire(“user1”)); } }/** * 计时器开闭 * * @param key * @return */ public boolean acquire(String key) { // 当前秒数作为 key key = KEY_PREFIX + key + System.currentTimeMillis() / 1000; DefaultRedisScript<Long> redisScript =new DefaultRedisScript<>(); redisScript.setResultType(Long.class); //lua文件存放在resources目录下 redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(“limiter.lua”))); return stringRedisTemplate.execute(redisScript, Arrays.asList(key), QPS, EXPIRE_TIME) == 1; } }标识符中虽然管制了 QPS 为 4,但是因为这种开闭同时实现是把微秒天数戳作为 key 的,所以会有临界询问处突变的问题,上面是运转结果,可以看到因为天数询问处的变化,导致了 QPS 超过了管制值 4。
17:38:23.122044 true 17:38:23.695124 true 17:38:23.903220 true # 此处有天数询问处变化,所以上面继续 true 17:38:24.106206 true 17:38:24.313458 true 17:38:24.519431 true 17:38:24.724446 true 17:38:24.932387 false 17:38:25.137912 true 17:38:25.355595 true 17:38:25.558219 true 17:38:25.765801 true 17:38:25.969426 false 17:38:26.176220 true 17:38:26.381918 true7.3. 滑动询问处开闭
透过对上面的基于 incr 命令同时实现的 Redis 开闭形式的测试,他们已经发现了一般来说询问处开闭所带来的问题,在这首诗的第三部分已经如是说了滑动询问处开闭的优势,它可以大幅度降低因为询问处临界突变带来的问题,那么如何使用 Redis 来同时实现滑动询问处开闭呢?
这里主要使用 ZSET 有序集合来同时实现滑动询问处开闭,ZSET 集合有上面几个特点:
ZSET 集合中的 key 值可以自动值。基于上面的四点特性,可以编写出基于 ZSET 的滑动询问处开闭 lua 脚本。
–KEYS[1]: 开闭 key –ARGV[1]: 天数戳 – 天数询问处 –ARGV[2]: 当前天数戳(作为score) –ARGV[3]: 共振振幅 –ARGV[4]: score 对应的唯一value — 1. 移除天数询问处之前的数据 redis.call(zremrangeByScore, KEYS[1], 0, ARGV[1]) — 2. 统计当前元素数量 local res = redis.call(zcard, KEYS[1]) — 3. 与否超过共振振幅 if (res == nil) or(res < tonumber(ARGV[3])) then redis.call(zadd, KEYS[1], ARGV[2], ARGV[4]) return 1 else return 0 end上面是使用 Spring Boot 中 RedisTemplate 来同时实现的 lua 脚本初始化测试标识符。
@SpringBootTest class RedisLuaLimiterByZset { private String KEY_PREFIX = “limiter_”; private String QPS = “4”; @Autowired private StringRedisTemplate stringRedisTemplate; @Test public void redisLuaLimiterTests() throws InterruptedException, IOException { for (int i = 0; i < 15; i++) { Thread.sleep(200); System.out.println(LocalTime.now() + ” “ + acquire(“user1”)); } } /** * 计时器开闭 * *@param key * @return */ public boolean acquire(String key) { longnow = System.currentTimeMillis(); key = KEY_PREFIX + key; String oldest = String.valueOf(now –1_000); String score = String.valueOf(now); String scoreValue = score; DefaultRedisScript<Long> redisScript =new DefaultRedisScript<>(); redisScript.setResultType(Long.class); //lua文件存放在resources目录下 redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource(“limiter2.lua”))); returnstringRedisTemplate.execute(redisScript, Arrays.asList(key), oldest, score, QPS, scoreValue) ==1; } }标识符中管制 QPS 为 4,运转结果信息与之一致。
17:36:37.150370 true 17:36:37.716341 true 17:36:37.922577 true 17:36:38.127497 true 17:36:38.335879 true 17:36:38.539225 false 17:36:38.745903 true 17:36:38.952491 true 17:36:39.159497 true 17:36:39.365239 true 17:36:39.570572 false 17:36:39.776635 true 17:36:39.982022 true 17:36:40.185614 true 17:36:40.389469 true这里如是说了 Redis 同时实现开闭的两种形式,当然使用 Redis 也可以同时实现漏桶和令牌桶两种开闭演算法,这里就不做演示了,感兴趣的可以自己研究下。
8. 总结
这首诗如是说同时实现开闭的几种形式,主要是询问处演算法和桶演算法,两者各有优势。
询问处演算法同时实现单纯,逻辑清晰,可以很直观的得到当前的 QPS 情况,但是会有天数询问处的临界突变问题,而且不像桶一样有队列可以缓冲。桶演算法虽然稍微繁杂,不好统计 QPS 情况,但是桶演算法也有优势所在。 漏桶模式消费速率恒定,可以很好的保护另一方面系统,可以对网络流量展开整形,但是面对突发性网络流量不能快速响应。 令牌桶模式可以面对突发性网络流量,但是启动时会有缓慢加速的过程,不过常用的开源工具中已经对此强化。单机开闭与分布式开闭
上面演示的基于标识符形式的询问处演算法和桶演算法开闭都适用于单机开闭,如果须要分布式开闭可以结合注册中心、阻抗平衡排序每个服务的开闭共振振幅,但这样会降低一定精度,如果对精度要求不是太高,可以使用。
而 Redis 的开闭,由于 Redis 的单机性,本身就可以用于分布式开闭。使用 Redis 可以同时实现各种可以用于开闭演算法,如果觉得麻烦也可以使用开源工具如 redisson,已经封装了基于 Redis 的开闭。
其他开闭工具
文中已经提到了 Guava 的开闭工具包,不过它毕竟是单机的,开源社区中也有很多分布式开闭工具,如阿里开源的 Sentinel 就是不错的工具,Sentinel 以网络流量为切入点,从网络流量控制、TNUMBERAP降班、系统阻抗保护等多个维度保护服务项目的灵活性。
一如既往,文章中的标识符存放在:github.com/niumoo/Java…
作者:程序猿阿朗
链接:
https://juejin.cn/post/7075137592265539614