Java单机限流可使用AtomicInteger,RateLimiter或Semaphore来实现,可是上述方案都不支持集群限流。集群限流的应用场景有两个,一个是网关,经常使用的方案有Nginx限流和Spring Cloud Gateway,另外一个场景是与外部或者下游服务接口的交互,由于接口限制必须进行限流。html
本文的主要内容为:redis
在上篇Guava RateLimiter的分析文章中,咱们学习了令牌桶限流算法的原理,下面咱们就探讨一下,若是将RateLimiter
扩展,让它支持集群限流,会遇到哪些问题。算法
RateLimiter
会维护两个关键的参数nextFreeTicketMicros
和storedPermits
,它们分别是下一次填充时间和当前存储的令牌数。当RateLimiter
的acquire
函数被调用时,也就是有线程但愿获取令牌时,RateLimiter
会对比当前时间和nextFreeTicketMicros
,根据两者差距,刷新storedPermits
,而后再判断更新后的storedPermits
是否足够,足够则直接返回,不然须要等待直到令牌足够(Guava RateLimiter的实现比较特殊,并非当前获取令牌的线程等待,而是下一个获取令牌的线程等待)。数组
因为要支持集群限流,因此nextFreeTicketMicros
和storedPermits
这两个参数不能只存在JVM的内存中,必须有一个集中式存储的地方。并且,因为算法要先获取两个参数的值,计算后在更新两个数值,这里涉及到竞态限制,必需要处理并发问题。安全
集群限流因为会面对相比单机更大的流量冲击,因此通常不会进行线程等待,而是直接进行丢弃,由于若是让拿不到令牌的线程进行睡眠,会致使大量的线程堆积,线程持有的资源也不会释放,反而容易拖垮服务器。服务器
分布式限流本质上是一个集群并发问题,Redis单进程单线程的特性,自然能够解决分布式集群的并发问题。因此不少分布式限流都基于Redis,好比说Spring Cloud的网关组件Gateway。网络
Redis执行Lua脚本会以原子性方式进行,单线程的方式执行脚本,在执行脚本时不会再执行其余脚本或命令。而且,Redis只要开始执行Lua脚本,就会一直执行完该脚本再进行其余操做,因此Lua脚本中不能进行耗时操做。使用Lua脚本,还能够减小与Redis的交互,减小网络请求的次数。架构
Redis中使用Lua脚本的场景有不少,好比说分布式锁,限流,秒杀等,总结起来,下面两种状况下可使用Lua脚本:并发
可是使用Lua脚本也有一些注意事项:app
redis.call()
Redis虽然以单进程单线程模型进行操做,可是它的性能却十分优秀。总结来讲,主要是由于:
因此,在集群限流时使用Redis和Lua的组合并不会引入过多的性能损耗。咱们下面就简单的测试一下,顺便熟悉一下涉及的Redis命令。
# test.lua脚本的内容 local test = redis.call("get", "test") local time = redis.call("get", "time") redis.call("setex", "test", 10, "xx") redis.call("setex", "time", 10, "xx") return {test, time} # 将脚本导入redis,以后调用不需再传递脚本内容 redis-cli -a 082203 script load "$(cat test.lua)" "b978c97518ae7c1e30f246d920f8e3c321c76907" # 使用redis-benchmark和evalsha来执行lua脚本 redis-benchmark -a 082203 -n 1000000 evalsha b978c97518ae7c1e30f246d920f8e3c321c76907 0 ====== 1000000 requests completed in 20.00 seconds 50 parallel clients 3 bytes payload keep alive: 1 93.54% <= 1 milliseconds 99.90% <= 2 milliseconds 99.97% <= 3 milliseconds 99.98% <= 4 milliseconds 99.99% <= 5 milliseconds 100.00% <= 6 milliseconds 100.00% <= 7 milliseconds 100.00% <= 7 milliseconds 49997.50 requests per second
经过上述简单的测试,咱们能够发现本机状况下,使用Redis执行Lua脚本的性能极其优秀,一百万次执行,99.99%在5毫秒如下。
原本想找一下官方的性能数据,可是针对Redis + Lua的性能数据较少,只找到了几篇我的博客,感兴趣的同窗能够去探索。这篇文章有Lua和zadd的性能比较(具体数据请看原文,连接缺失的话,请看文末)。
以上lua脚本的性能大概是zadd的70%-80%,可是在可接受的范围内,在生产环境可使用。负载大概是zadd的1.5-2倍,网络流量相差不大,IO是zadd的3倍,多是开启了AOF,执行了三次操做。
Gateway
是微服务架构Spring Cloud
的网关组件,它基于Redis和Lua实现了令牌桶算法的限流功能,下面咱们就来看一下它的原理和细节吧。
Gateway
基于Filter模式,提供了限流过滤器RequestRateLimiterGatewayFilterFactory
。只需在其配置文件中进行配置,就可使用。具体的配置感兴趣的同窗自行学习,咱们直接来看它的实现。
RequestRateLimiterGatewayFilterFactory
依赖RedisRateLimiter
的isAllowed
函数来判断一个请求是否要被限流抛弃。
public Mono<Response> isAllowed(String routeId, String id) { //routeId是ip地址,id是使用KeyResolver获取的限流维度id,好比说基于uri,IP或者用户等等。 Config routeConfig = loadConfiguration(routeId); // 每秒可以经过的请求数 int replenishRate = routeConfig.getReplenishRate(); // 最大流量 int burstCapacity = routeConfig.getBurstCapacity(); try { // 组装Lua脚本的KEY List<String> keys = getKeys(id); // 组装Lua脚本须要的参数,1是指一次获取一个令牌 List<String> scriptArgs = Arrays.asList(replenishRate + "", burstCapacity + "", Instant.now().getEpochSecond() + "", "1"); // 调用Redis,tokens_left = redis.eval(SCRIPT, keys, args) Flux<List<Long>> flux = this.redisTemplate.execute(this.script, keys, scriptArgs); ..... // 省略 } static List<String> getKeys(String id) { String prefix = "request_rate_limiter.{" + id; String tokenKey = prefix + "}.tokens"; String timestampKey = prefix + "}.timestamp"; return Arrays.asList(tokenKey, timestampKey); }
须要注意的是getKeys
函数的prefix包含了"{id}",这是为了解决Redis集群键值映射问题。Redis的KeySlot算法中,若是key包含{},就会使用第一个{}内部的字符串做为hash key,这样就能够保证拥有一样{}内部字符串的key就会拥有相同slot。Redis要求单个Lua脚本操做的key必须在同一个节点上,可是Cluster会将数据自动分布到不一样的节点,使用这种方法就解决了上述的问题。
而后咱们来看一下Lua脚本的实现,该脚本就在Gateway项目的resource文件夹下。它就是如同Guava
的RateLimiter
同样,实现了令牌桶算法,只不过不在须要进行线程休眠,而是直接返回是否可以获取。
local tokens_key = KEYS[1] -- request_rate_limiter.${id}.tokens 令牌桶剩余令牌数的KEY值 local timestamp_key = KEYS[2] -- 令牌桶最后填充令牌时间的KEY值 local rate = tonumber(ARGV[1]) -- replenishRate 令令牌桶填充平均速率 local capacity = tonumber(ARGV[2]) -- burstCapacity 令牌桶上限 local now = tonumber(ARGV[3]) -- 获得从 1970-01-01 00:00:00 开始的秒数 local requested = tonumber(ARGV[4]) -- 消耗令牌数量,默认 1 local fill_time = capacity/rate -- 计算令牌桶填充满令牌须要多久时间 local ttl = math.floor(fill_time*2) -- *2 保证时间充足 local last_tokens = tonumber(redis.call("get", tokens_key)) -- 得到令牌桶剩余令牌数 if last_tokens == nil then -- 第一次时,没有数值,因此桶时满的 last_tokens = capacity end local last_refreshed = tonumber(redis.call("get", timestamp_key)) -- 令牌桶最后填充令牌时间 if last_refreshed == nil then last_refreshed = 0 end local delta = math.max(0, now-last_refreshed) -- 获取距离上一次刷新的时间间隔 local filled_tokens = math.min(capacity, last_tokens+(delta*rate)) -- 填充令牌,计算新的令牌桶剩余令牌数 填充不超过令牌桶令牌上限。 local allowed = filled_tokens >= requested local new_tokens = filled_tokens local allowed_num = 0 if allowed then -- 若成功,令牌桶剩余令牌数(new_tokens) 减消耗令牌数( requested ),并设置获取成功( allowed_num = 1 ) 。 new_tokens = filled_tokens - requested allowed_num = 1 end -- 设置令牌桶剩余令牌数( new_tokens ) ,令牌桶最后填充令牌时间(now) ttl是超时时间? redis.call("setex", tokens_key, ttl, new_tokens) redis.call("setex", timestamp_key, ttl, now) -- 返回数组结果 return { allowed_num, new_tokens }
Redis的主从异步复制机制可能丢失数据,出现限流流量计算不许确的状况,固然限流毕竟不一样于分布式锁这种场景,对于结果的精确性要求不是很高,即便多流入一些流量,也不会影响太大。
正如Martin在他质疑Redis分布式锁RedLock文章中说的,Redis的数据丢弃了也无所谓时再使用Redis存储数据。
I think it’s a good fit in situations where you want to share some transient, approximate, fast-changing data between servers, and where it’s not a big deal if you occasionally lose that data for whatever reason
接下来咱们回来学习阿里开源的分布式限流组件sentinel
,但愿你们持续关注。
我的博客: Remcarpediem