本文主要讨论Spring Cloud Gateway的基于Redis分布式限频存在的失效/不准确的可能性及解决方法,同时适用于所有参考request_rate_limiter.lua实现的基于redis限频组件.
阅读本文不需要了解Spring Cloud Gateway(下简称SCG)怎么使用或具体实现,本文只是基于限频角度讨论下常规的组件使用问题。
主要讨论SCG提供的基于Redis分布式限频存在的失效/不准确的可能性及解决方法,同时适用于所有参考request_rate_limiter.lua实现的基于redis限频组件,其次也讨论该方案其他不足。如果你对SCG RedisRateLimit有所了解或已知道其存在的几个问题或觉得TLDR;,可以直接跳到本文最后。
Spring Cloud Gateway定义了RateLimiter接口来达到限频效果,通过RedisRateLimiterFactory生成这个bean,借助于基于spring的各种扩展,我们可以通过诸如:
GatewayRedisAutoConfiguration
name: RequestRateLimiter args: key-resolver: "#{@remoteAddrKeyResolver}" redis-rate-limiter.replenishRate: 1 redis-rate-limiter.burstCapacity: 5
注解
@RateLimiter(base = RateLimiter.Base.IP, path=”/xxx”, permits = 4, timeUnit = TimeUnit.MINUTES)
上面不需要理解,总之,通过上述等,最后生成RedisRateLimiter类型的bean,而限频最终就是通过该bean调用一段 Redis的Lua脚本来实现,该lua脚本基于令牌桶(Token Bucket)算法实现限频限流,支持从服务、用户、IP或自定义等维度限流。
我们来看下基于redis的限频逻辑和SCG的调用逻辑。
这里lua脚本是本文主要讨论的内容,它位于SCG的spring-cloud-gateway-core模块的 META-INF/scripts/request_rate_limiter.lua 下面:
local tokens_key = KEYS[1] local timestamp_key = KEYS[2] redis.log(redis.LOG_WARNING, "tokens_key " .. tokens_key) local rate = tonumber(ARGV[1]) local capacity = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local requested = tonumber(ARGV[4]) local fill_time = capacity/rate local ttl = math.floor(fill_time*2) --redis.log(redis.LOG_WARNING, "rate " .. ARGV[1]) --redis.log(redis.LOG_WARNING, "capacity " .. ARGV[2]) --redis.log(redis.LOG_WARNING, "now " .. ARGV[3]) --redis.log(redis.LOG_WARNING, "requested " .. ARGV[4]) --redis.log(redis.LOG_WARNING, "filltime " .. fill_time) --redis.log(redis.LOG_WARNING, "ttl " .. ttl) local last_tokens = tonumber(redis.call("get", tokens_key)) if last_tokens == nil then last_tokens = capacity end redis.log(redis.LOG_WARNING, "last_tokens " .. last_tokens) local last_refreshed = tonumber(redis.call("get", timestamp_key)) if last_refreshed == nil then last_refreshed = 0 end redis.log(redis.LOG_WARNING, "last_refreshed " .. last_refreshed) 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 = filled_tokens - requested allowed_num = 1 end redis.log(redis.LOG_WARNING, "delta " .. delta) redis.log(redis.LOG_WARNING, "filled_tokens " .. filled_tokens) redis.log(redis.LOG_WARNING, "new_tokens " .. new_tokens) --redis.log(redis.LOG_WARNING, "allowed_num " .. allowed_num) --redis.log(redis.LOG_WARNING, "new_tokens " .. new_tokens) redis.log(redis.LOG_WARNING, "--------") redis.call("setex", tokens_key, ttl, new_tokens) redis.call("setex", timestamp_key, ttl, now) return { allowed_num, new_tokens }
在介绍下该脚本之前,我们先看下SCG怎么调用的,这里简单贴下代码:
@ConfigurationProperties("spring.cloud.gateway.redis-rate-limiter") public class RedisRateLimiterextends AbstractRateLimiter<RedisRateLimiter.Config>implements ApplicationContextAware{ //...... /** * This uses a basic token bucket algorithm and relies on the fact that Redis scripts * execute atomically. No other operations can run between fetching the count and * writing the new count. */ @Override @SuppressWarnings("unchecked") public Mono<Response> isAllowed(String routeId, String id){ //... // How many requests per second do you want a user to be allowed to do? int replenishRate = routeConfig.getReplenishRate(); // How much bursting do you want to allow? int burstCapacity = routeConfig.getBurstCapacity(); try { List<String> keys = getKeys(id); // The arguments to the LUA script. time() returns unixtime in seconds. List<String> scriptArgs = Arrays.asList(replenishRate + "", burstCapacity + "", Instant.now().getEpochSecond() + "", "1"); // allowed, tokens_left = redis.eval(SCRIPT, keys, args) Flux<List<Long>> flux = this.redisTemplate.execute(this.script, keys, scriptArgs); ... } } static List<String> getKeys(String id){ // use `{}` around keys to use Redis Key hash tags // this allows for using redis cluster // Make a unique key per user. String prefix = "request_rate_limiter.{" + id; // You need two Redis keys for Token Bucket. String tokenKey = prefix + "}.tokens"; String timestampKey = prefix + "}.timestamp"; return Arrays.asList(tokenKey, timestampKey); } //...... }
为便于理解,我把大部分代码都省略了,只保留调用部分,如上可看到,isAllowed方法判断是否限频时,通过6个参数调用上文的redis lua脚本,分别是:
其中 tokenKey 就是限频的维度,即是限频是针对用户还是服务等,SCG默认支持的有: General(统一控制,通常是request path),IP(用户IP),User(按用户控制) ,此外也可自定义,诸如上文配置里的 key-resolver。
上面介绍SCG redis限频实现,不过不是必须理解的,上述归纳起来只是要说明一点:SCG redis限频就是通过 Redis Lua脚本实现,上文代码贴出,并且,这段代码也是国内一些公司自研的基于redis Lua实现限频功能的组件常参考的代码。
一段题外话,如果读者可能已经知道限频常用算法分为漏桶(Leaky Bucket)和令牌桶(Token Bucket),Spring Cloud 定义是该Lua脚本是基于 Token bucket算法实现,这里可以简单了解下,不过笔者认为没必要纠结Leaky Bucket和Token Bucket的区别,事实上guava官方doc/代码没有说自己是Token Bucket实现甚至没有提及,Nginx认为自己是基于Leaky Bucket实现,但核心代码类似上述,Ali Sentinel还认为Guava的实现更接近Leaky,如果你用guava和toekn bucket搜索得到的是中文结果或英文但作者中文名,不过不是本文重点,笔者会在自己的下一篇博客讨论下这个问题,总之不要认为leaky和token是不同的限频/限流算法即可。
好了,看几个小问题:
以下讨论都是针对 同一个 tokenKey 的情况,即tokenkey为general/api/path时现象明显,uid时存在可能性但不明显。
任何参考上述SCG通过Redis Lua脚本实现的基于Redis分布式限频,最大问题就是限频功能可能会失效。
为什么这么说?
首先,Token Bucket计算依赖于时间,这个时间是脚本参数传入的,假设我们的分布式系统中服务器的时间不一样,比如有一台机器慢了一秒或任何秒数,我相信该现象时间可能极短,但是应该频繁发生。
其次,我们看下有一台时间慢一秒,该lua脚本会发生什么。
这里需要读懂上述lua脚本代码,不过也可只看下面推理即可
上面分析结论就是 21:00:09秒 限频临界后的请求本该被限频,但是却被放行了,即限频失效。
下面这个脚本可以验证上述想法:
#!/bin/bash sleep 4; date snds=`date +"%s"` for i in {1..4} do redis-cli --eval ~/git/spc_ratelimit.lua uuid1 uuid1.tmp , 2 4 $snds 1; done redis-cli --eval ~/git/spc_ratelimit.lua uuid1 uuid1.tmp , 2 4 $snds 1; echo "--reset---" redis-cli --eval ~/git/spc_ratelimit.lua uuid1 uuid1.tmp , 2 4 $((snds-10)) 1 echo "--after reset---" for i in {1..4} do redis-cli --eval ~/git/spc_ratelimit.lua uuid1 uuid1.tmp , 2 4 $snds 1; done redis-cli --eval ~/git/spc_ratelimit.lua uuid1 uuid1.tmp , 2 4 $snds 1; date
上述 spc_ratelimit.lua uuid1 uuid1.tmp , 2 4 $snds 1;即是调用该脚本,uuid1即为tokenkey,2是频率,4是capacity,$snds是当前秒,1是请求量,redis ttl时间是4秒,所以开头sleep 4秒来清空历史数据。
开启redis-server,运行上述shell,本人机器输出:
➜ 2019 sh test.sh Wed Apr 22 23:59:57 CST 2020 1) (integer) 1 2) (integer) 3 1) (integer) 1 2) (integer) 2 1) (integer) 1 2) (integer) 1 1) (integer) 1 2) (integer) 0 1) (integer) 0 2) (integer) 0 --reset--- 1) (integer) 0 2) (integer) 0 --after reset--- 1) (integer) 1 2) (integer) 3 1) (integer) 1 2) (integer) 2 1) (integer) 1 2) (integer) 1 1) (integer) 1 2) (integer) 0 1) (integer) 0 2) (integer) 0 Wed Apr 22 23:59:57 CST 2020
可以看到,在调用四次被限频后,通过模拟一次慢10秒($((snds-10)))的请求调用后,请求又被放行了,即 在 “Wed Apr 22 23:59:57 CST 2020至Wed Apr 22 23:59:57 CST 2020”这段时间內,接口被访问了8次(本该4次)。
1,拆分
个人觉得比较好的办法是将一个热点数据拆分成16个或更多,可以提高性能,然后通过设置机器相关的key将同一台机器请求路由至同一台redis,但该方案需要些hash改进,且需要解决分布式调用均衡的问题。
2,改lua脚本
该方法是将 now 这个时间由服务传参方式,改为 lua脚本自己获取时间,lua本身有 os.time 可以获取时间,但是redis安全原因 禁止lua调用系统函数,所以想到了 redis本身有个 time 指令,所以将 request_rate_limiter.lua 脚本里的
local now = tonumber(ARGV[3])
改为
now = tonumber(redis.call(“time”)[1])
即可,上述改完后再次sh test.sh 就会发现限频生效,仅放行四次,但需要指出的是,该改动多了一次redis调用(但无需重新路由)。
需要指出的是,上述改进并非必要,正如 阿里 Sentinel的限频实现所说,只要求保证实现限频的效果,不要求准确性。