Spring Cloud Gateway限流实现及存在问题


写在前面

在最近工作中由于业务需要调用腾讯云短信发送的接口,而腾讯云短信发送接口的默认请求频率限制在3000次/秒,因此我们需要在业务中限流调用频率,防止超限发送失败,基于这背景后续调研现有成熟的限流组件,包括阿里的Sentinel、Bucket4j、Guava RateLimiter、Spring Cloud Gateway等等,虽然最后采用自研而没采用以上的方案,但还是有必要讲讲Spring Cloud Gateway的实现原理及存在问题。

实现原理

Spring Cloud Gateway是Spring Cloud的网关组件(简称为SCG),它定义了RateLimiter接口及RedisRateLimiter实现,在项目中通过Spring Boot的自动装配特性激活此功能,激活后通过文件配置规则或者注解配置使用限流功能。

  • GatewayRedisAutoConfiguration
 1@Configuration
 2@AutoConfigureAfter(RedisReactiveAutoConfiguration.class)
 3@AutoConfigureBefore(GatewayAutoConfiguration.class)
 4@ConditionalOnBean(ReactiveRedisTemplate.class)
 5@ConditionalOnClass({RedisTemplate.class, DispatcherHandler.class})
 6class GatewayRedisAutoConfiguration {
 7
 8	@Bean
 9	@SuppressWarnings("unchecked")
10	public RedisScript redisRequestRateLimiterScript() {
11		DefaultRedisScript redisScript = new DefaultRedisScript<>();
12		redisScript.setScriptSource(new ResourceScriptSource(new ClassPathResource("META-INF/scripts/request_rate_limiter.lua")));
13		redisScript.setResultType(List.class);
14		return redisScript;
15	}
16
17	@Bean
18	//TODO: replace with ReactiveStringRedisTemplate in future
19	public ReactiveRedisTemplate<String, String> stringReactiveRedisTemplate(
20			ReactiveRedisConnectionFactory reactiveRedisConnectionFactory) {
21		RedisSerializer<String> serializer = new StringRedisSerializer();
22		RedisSerializationContext<String , String> serializationContext = RedisSerializationContext
23				.<String, String>newSerializationContext()
24				.key(serializer)
25				.value(serializer)
26				.hashKey(serializer)
27				.hashValue(serializer)
28				.build();
29		return new ReactiveRedisTemplate<>(reactiveRedisConnectionFactory,
30				serializationContext);
31	}
32
33	@Bean
34	@ConditionalOnMissingBean
35	public RedisRateLimiter redisRateLimiter(ReactiveRedisTemplate<String, String> redisTemplate,
36											 @Qualifier(RedisRateLimiter.REDIS_SCRIPT_NAME) RedisScript<List<Long>> redisScript,
37											 Validator validator) {
38		return new RedisRateLimiter(redisTemplate, redisScript, validator);
39	}
40}
  • 注解Anotation
1@RateLimiter(base = RateLimiter.Base.IP, path=/sms/send, permits = 10, timeUnit = TimeUnit.MINUTES)

通过上述注解配置后会生成RedisRateLimiter bean,但限流功能最终是通过该bean调用Lua脚本来实现,该lua脚本基于令牌桶算法实现限流限流,可支持从服务、用户、IP或自定义等维度限流,限流的lua脚本位于spring-cloud-gateway-core模块的META-INF/scripts目录下:

 1local tokens_key = KEYS[1]  -- token对应的key
 2local timestamp_key = KEYS[2]  -- token key对应的时间key,单位:秒
 3local rate = tonumber(ARGV[1]) -- 每秒填充的速率
 4local capacity = tonumber(ARGV[2]) -- 令牌桶的容量
 5local now = tonumber(ARGV[3]) -- 当前请求的时间,单位:秒
 6local requested = tonumber(ARGV[4])  -- 请求获取令牌数
 7local fill_time = capacity/rate  -- 填满令牌桶时需要的时间
 8local ttl = math.floor(fill_time*2)  -- 设置key对应的过期时间,及时回收内存
 9
10-- 获取当前剩余的令牌,如果token对应的key不存在则创建并填满
11local last_tokens = tonumber(redis.call("get", tokens_key))
12if last_tokens == nil then
13  last_tokens = capacity
14end
15
16-- 获取上次获取令牌的时间,单位:秒
17local last_refreshed = tonumber(redis.call("get", timestamp_key))
18if last_refreshed == nil then
19  last_refreshed = 0
20end
21
22-- 计算当前请求的时间距离上一次获取令牌的时间间隔
23local delta = math.max(0, now-last_refreshed)
24-- 根据时间间隔计算当次请求需要填充的令牌数
25local filled_tokens = math.min(capacity, last_tokens+(delta*rate))
26-- 若当前剩余令牌数大于或等于当前请求令牌,则返回1
27local allowed = filled_tokens >= requested
28local new_tokens = filled_tokens
29local allowed_num = 0
30-- 扣减剩余令牌数并设置allowed_num为1
31if allowed then
32  new_tokens = filled_tokens - requested
33  allowed_num = 1
34end
35-- 更新key的过期时间
36redis.call("setex", tokens_key, ttl, new_tokens)
37redis.call("setex", timestamp_key, ttl, now)
38-- 返回获取令牌结果及剩余令牌数
39return { allowed_num, new_tokens }

调用脚本后将返回是否获取令牌成功及当前剩余的令牌数,下面从Java代码上看看是如何调用的:

 1@ConfigurationProperties("spring.cloud.gateway.redis-rate-limiter")
 2public class RedisRateLimiter extends AbstractRateLimiter<RedisRateLimiter.Config> implements ApplicationContextAware {
 3    /**
 4     * This uses a basic token bucket algorithm and relies on the fact that Redis scripts
 5     * execute atomically. No other operations can run between fetching the count and
 6     * writing the new count.
 7     */
 8    @Override
 9    @SuppressWarnings("unchecked")
10    public Mono<Response> isAllowed(String routeId, String id) {
11        //...
12        // 每秒允许多少个用户允许通过,即令牌生成速率
13        int replenishRate = routeConfig.getReplenishRate();
14        // 令牌最大数量
15        int burstCapacity = routeConfig.getBurstCapacity();
16        try {
17            List<String> keys = getKeys(id);
18            // The arguments to the LUA script. time() returns unixtime in seconds.
19            List<String> scriptArgs = Arrays.asList(replenishRate + "", burstCapacity + "",
20                    Instant.now().getEpochSecond() + "", "1");
21            // allowed, tokens_left = redis.eval(SCRIPT, keys, args)
22            Flux<List<Long>> flux = this.redisTemplate.execute(this.script, keys, scriptArgs);
23            ...
24        }
25    }
26    static List<String> getKeys(String id) {
27        // use `{}` around keys to use Redis Key hash tags
28        // this allows for using redis cluster
29        // Make a unique key per user.
30        String prefix = "request_rate_limiter.{" + id;
31        // You need two Redis keys for Token Bucket.
32        String tokenKey = prefix + "}.tokens";
33        String timestampKey = prefix + "}.timestamp";
34        return Arrays.asList(tokenKey, timestampKey);
35    }
36}

从代码里可看到isAllowed方法是通过6个参数调用redis lua脚本,分别是:

  • tokenKey :限流的key
  • timestampKey:限流key对应的时间key
  • rate:限流的频率,单位为秒
  • capacity:令牌桶算法支持的最大突发量
  • now:当前时间(unix Epoch),单位为秒
  • requested:当前请求令牌数

其中tokenKey使用{}占位符是为了使不同的key能路由到redis集群中的不同机器,它表示限流的维度,即是限流是针对用户还是服务、IP地址等等,当然这个规则使用者是可以自定义的,下面是常用的限流维度:

  • 基于用户维度:此维度是耗Redis内存的,因为需要存储每个用户的数据,数量级别相当于是系统活跃用户数,如果每个接口都基于用户维度来限流,那么需要存储的数据量就是M*N 量级。

  • 基于请求路径、服务维度:该维度限流可以提供服务级别的限流能力,但该脚本实现存在热点数据问题,即无论以何种方式部署redis,最终都会读写tokenKey这个热点数据,也就是最终操作都会聚集到一台redis机器上。

  • 基于IP维度:这个维度没有服务/业务意义,可防止羊毛党或部分爬虫恶意刷接口等。

  • 时间回退问题:在计算可用token时严重依赖时间,因为令牌桶算法是把时间平均到每一段需要放入多少令牌的,再上述lua脚本里now参数就是java的unix Epoch time,如果对应机器时间发生校正前置/回退,那么上述计算结果就是不准确的,事实上像Nginx实现的漏桶限流算法,阿里的Sentinel实现的令牌桶算法都存在时间回退问题,而Guava的SmoothRatelimiter实现最终用的是nanoTime就不存在这个问题,但Guava的RateLimiter却只能支持单机限流。

存在问题

上面提到令牌桶数量的计算依赖于时间,而这个时间是由调用脚本侧传入的,假如分布式系统中每台的服务器时间不一样,有一台机器慢或快了一秒或N秒等,那调用该lua脚本限流会出现什么问题?

这里需要读懂上述lua脚本代码才更好的理解此问题,假设某次请求时间正常(10:00:01),但该秒內已达限流无法获取令牌,此时慢几秒的机器(09:59:58)发送获取令牌请求则会被限流,虽然被限流了但还是需要执行后续的redis.call(“setex”, timestamp_key, ttl, now)代码,因此会把当前时间更新为慢时钟机器对应的请求时间了。

之后其他机器请求进来(10:00:02),基于令牌桶算法以秒为单位时,我们知道当前秒数大于上次秒数时请求会立即放行,并且会冲洗计算剩余令牌数,因此在时间10:00:01后的请求应被限但却被放行,即限流失效。

解决方案

  • 拆分限流Key:个人觉得比较好的办法是将一个热点数据拆分成16个或更多可以提高性能,然后通过设置机器相关的key将同一台机器请求路由至同一台redis,但该方案需要些hash改进,且需要解决分布式调用均衡的问题。
  • 业务限流隔离:根据业务的特性分别连接不同的redis来实现限流,对于请求量级特别大的可多部署redis
  • 修改lua脚本:将now时间由调用侧传递改为lua脚本自己获取时间,将脚本里的local now = tonumber(ARGV[3])改为tonumber(redis.call(“time”)[1])即可。

注意:上述改进并非必要,正如阿里Sentinel限流实现所说:只要求保证实现限流的效果,不要求准确性,但如果涉及重要业务的,例如金融相关的,建议还是需要做优化处理的。