记录一次分布式限流器实现

限流工具调研

常用限流方法

  1. 令牌桶:针对业务有短时间较高并发场景
  2. 漏桶:针对限定入口速率,保证下游服务安全
  3. 滑动窗口:针对不会出现极大的突增流量的场景,平滑并发情况,对于短时间的高并发(恶意调用)情况能够及时响应
  4. 计数器:有毛刺现象,不推荐,滑动窗口为其升级版

常用限流产品

  1. Guava RateLimiter。优:简单易用 缺:单机
  2. Sentinel。优:大厂背书,社区活跃,提供分布式方案,提供控制台,支持接口配置
  3. Spring Cloud Gateway。
  4. Hystrix。缺:不再维护
  5. Resilience4j。取代Hystrix
  6. 阿里云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("系统繁忙,请稍后重试");
        }
    }
}
  • 13
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值