限流工具调研
常用限流方法
- 令牌桶:针对业务有短时间较高并发场景
- 漏桶:针对限定入口速率,保证下游服务安全
- 滑动窗口:针对不会出现极大的突增流量的场景,平滑并发情况,对于短时间的高并发(恶意调用)情况能够及时响应
- 计数器:有毛刺现象,不推荐,滑动窗口为其升级版
常用限流产品
- Guava RateLimiter。优:简单易用 缺:单机
- Sentinel。优:大厂背书,社区活跃,提供分布式方案,提供控制台,支持接口配置
- Spring Cloud Gateway。
- Hystrix。缺:不再维护
- Resilience4j。取代Hystrix
- 阿里云AHAS。优:简单易用,提供控制台 收费
背景
Sentinel是一款很优秀的限流产品,公司内部使用也非常广泛。但由于申请Sentinel资源比较麻烦,所以先暂时自己实现了简单的分布式限流算法。在实际项目中华选择了更贴合业务场景的滑动窗口算法。
手写限流工具
令牌桶
场景:商品秒杀场景,例如秒杀100台电脑,秒杀开始前向内存中预热110个token,拿到token的请求才能执行后面的流程
-- 限流目标
local key = KEYS[1]
-- 上一次令牌生成时间
local time_key = KEYS[2]
local capacity = tonumber(ARGV[1])
local qps = tonumber(ARGV[2])
local cur_time = tonumber(ARGV[3])
local one_week = 60 * 60 * 24 * 7
-- 获取桶中令牌
local token = redis.call('get', key)
if token then
token = tonumber(token)
else
token = capacity
end
-- 计算时间段生成的令牌
local last_time = redis.call('get', time_key)
if not last_time then
last_time = cur_time
else
last_time = tonumber(last_time)
end
-- todo token生成时间粒度可以更细
local gen_token = math.floor((cur_time - last_time) / 1000) * qps
token = token + gen_token
if token > capacity then
token = capacity
end
-- 没有令牌,拒绝
if token == 0 then
return -1;
end
-- 使用token
redis.call('set', key, token - 1)
redis.call('set', time_key, cur_time)
redis.call('expire', time_key, one_week)
redis.call('expire', key, one_week)
return 1
滑动窗口
场景:可以承接突然发生的较高并发,对于流量攻击可以进行熔断防护
-- 限流目标
local key = KEYS[1]
local window_start_time = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local cur_time = tonumber(ARGV[3])
local uuid = ARGV[4]
-- 以当前时间戳作为 score
local count = redis.call('zcount', key, window_start_time, cur_time)
if count >= limit then
return -1
end
-- 删除窗口外的数据
redis.call('zremrangebyscore', key, 0, window_start_time)
redis.call('zadd', key, cur_time, uuid)
return 1
@Aspect
@Component
public class RateLimiterAspect {
@Autowired
private JedisClientUtil jedisClientUtil;
@Pointcut("@annotation(cn.xxx.web.anno.RateLimiter)")
public void check() {}
public static final String SCRIPT =
"-- 限流目标\n" +
"local key = KEYS[1]\n" +
"local window_start_time = tonumber(ARGV[1])\n" +
"local limit = tonumber(ARGV[2])\n" +
"local cur_time = tonumber(ARGV[3])\n" +
"local uuid = ARGV[4]\n" +
"-- 以当前时间戳作为 score\n" +
"local count = redis.call('zcount', key, window_start_time, cur_time)\n" +
"if count >= limit then\n" +
" return -1\n" +
"end\n" +
"-- 删除窗口外的数据\n" +
"redis.call('zremrangebyscore', key, 0, window_start_time)\n" +
"redis.call('zadd', key, cur_time, uuid)\n" +
"return 1";
/**
* 滑动窗口 key
*/
private static final String LIMITER_KEY = "xxx:limiter:%s";
@Before("check() && @annotation(rateLimiter)")
public void checkLimit(JoinPoint joinPoint, RateLimiter rateLimiter) {
if (rateLimiter.window() <= 0) {
throw new IllegalArgumentException("限流器配置错误");
}
// qps = 限制数 / 窗口大小
int qps = rateLimiter.qps();
int window = rateLimiter.window();
int limit = qps * window;
String signature = joinPoint.getSignature().toString();
String key = String.format(LIMITER_KEY, signature);
Long windowStartTime = System.currentTimeMillis() - window * 1000L;
List<String> keyList = Lists.newArrayList(key);
List<String> argList = Lists.newArrayList(String.valueOf(windowStartTime), String.valueOf(limit), String.valueOf(System.currentTimeMillis()), UUID.randomUUID().toString());
Long res = (Long) jedisClientUtil.eval(SCRIPT, keyList, argList);
if (Objects.equals(res, -1L)) {
throw new xxxBizException("系统繁忙,请稍后重试");
}
}
}