注:记录开发,自己总结,随便写写,不喜勿喷。

问题描述

之前出现过调三方接口qps异常,我还记录过日记:,这种问题经常出现,出现的原因还不止一种,有时候产品放量,有时候集中缓存失效,不同场景用同一appkey等等(三方是根据请求的appkey限制QPS的)。

我主要负责这块业务,只能去寻找解决方案,百度了一波,主要是采用分布式限流来解决。

解决方案

常见的分布式限流方案有滑动窗口算法、漏桶算法、令牌桶算法等等,接下来先简单介绍一下。

滑动窗口算法:

(1)将整个时间划分为更小的多个时间区间

(2)一个时间窗口占用固定的多个时间区间,每有一次请求,就给一个时间区间计数

(3)每经过一个时间区间,就抛弃最老的一个时间区间,加入一个最新的时间区间

(4)如果当前窗口内区间的请求计数总和超过了限制数量,则本窗口内所有请求都会被丢弃




Qps限制令牌 java 接口限制qps_经验分享


如上图,整个窗口内的时间长度是固定的,记录好每个窗口的请求数并求和,将和与限流量比较,判断请求是否继续。整个窗口在我们场景就是一秒,更小的窗口按1毫秒划分,也可以考虑划的更细。

漏桶算法:

(1)将每个请求视为“水滴”放入漏桶进行存储

(2)漏桶以固定速率漏出水滴(处理请求)

(3)漏桶满了,多余的水滴就丢弃


Qps限制令牌 java 接口限制qps_分布式_02


简单说来就是:如果当前速率小于阈值则直接处理请求,否则不直接处理请求,进入缓冲区,并增加当前水位

漏桶算法的缺陷也很明显,当短时间内有大量的突发请求时,即便此时服务器没有任何负载,每个请求也都得在队列中等待一段时间才能被响应。

这种方案在我们的场景里好像不是很好用。

令牌桶算法:

(1)令牌以固定速率生成

(2)生成的令牌放入令牌桶中存放,如果令牌桶满了则多余的令牌直接丢弃,当请求到达时,会尝试从令牌桶中取令牌,得到令牌的请求可以执行

(3)如果桶空了,则丢弃取令牌的请求


Qps限制令牌 java 接口限制qps_限流_03


令牌桶的容量大小理论上就是程序需要支撑的最大并发数。令牌桶算法既能够将所有的请求平均分布到时间区间内,又能接受服务器能够承受范围内的突发请求,因此是目前使用较为广泛的一种限流算法。

方案落地

思考一下,在分布式场景,我们要计数的话,就需要在同一个服务或者中间件计数。计数用个服务属实没必要,而且涉及高可用集群以及集群间计数信息同步的问题。这样的话就只能考虑中间件了,redis是一个值得依赖的中间件。

不管是那种算法实现都会有一个问题,这里会产生多个redis命令的执行。如滑动窗口中需要获取单位时间内的请求数、删除之前窗口的记录、添加请求计数等等;令牌桶也需要获取令牌数,扣令牌、恢复令牌数等等,在并发场景下都需要保证原子性。

因此考虑采用redis+lua+aop方案解决分布式限流的问题,接下来看代码。

以滑动窗口为例:

lua脚本如下

-- 获取zset的key
local key = KEYS[1]
-- 脚本传入的限流大小
local limit = tonumber(ARGV[1])
-- 脚本传入的限流起始时间戳
local start = tonumber(ARGV[2])
-- 脚本传入的限流当前时间戳
local now = tonumber(ARGV[3])
-- 脚本传入的限流当前时间戳
local uuid = ARGV[4]
-- 获取当前流量总数
local count = tonumber(redis.call('zcount',key, start, now))
--是否超出限流值
if count + 1 >limit then
    return false
-- 不需要限流
else
    -- 添加当前访问时间戳到zset
    redis.call('zadd', key, now, uuid)
    -- 移除时间区间以外不用的数据,不然会导致zset过大
    redis.call('zremrangebyscore',key, 0, start)
    return true
end

定义注解:

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {

    // 限流大小
    int limit();

    // 限流资源名称
    String rateName() default "";
}

aop代码如下:

/**
 * @author shunsheng
 * @Title: RatelimitAspect.java
 * @Description
 * @date 2023 02-08 17:24.
 */
@Aspect
@Component
public class RatelimitAspect {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private RedisScript<Boolean> rateLimitScript;


    @Pointcut("@annotation(com.shunsheng.study.anno.RateLimit)")
    public void pointCut(){}

    @Before("pointCut() && @annotation(rateLimit)")
    public void before(RateLimit rateLimit) throws Throwable {
        //注解上的参数信息
        int limit = rateLimit.limit();
        String name = rateLimit.rateName();
        //当前时间戳
        long now = System.currentTimeMillis();
        //调用lua脚本获取限流结果
        Boolean isAccess = stringRedisTemplate.execute(
                //lua限流脚本
                rateLimitScript,
                //限流资源名称
                Collections.singletonList(name),
                //限流大小                
                String.valueOf(limit),
                //限流窗口的左区间
                String.valueOf(now - 1000),
                //限流窗口的左区间
                String.valueOf(now),
                //id值,保证zset集合里面不重复,不然会覆盖
                UUID.randomUUID().toString()
        );

        if (!isAccess){
            throw new BusinessException(123, "限流了");
        }
    }
}

脚本读取配置类

@Configuration
public class rateConfig {

/*脚本字符串
    private String lua =
    "local key = KEYS[1]"   +
    "local limit = tonumber(ARGV[1])"   +
    "local start = tonumber(ARGV[2])"   +
    "local now = tonumber(ARGV[3])" +
    "local uuid = ARGV[4]"  +
    "local count = tonumber(redis.call('zcount',key, start, now))"  +
    "if count + 1 >limit then"  +
    "  return false;"  +
    "else"  +
    "  redis.call('zadd', key, now, uuid)"    +
    "  redis.call('zremrangebyscore',key, 0, start)"  +
    "  return true;"    +
    "end";
*/
    @Bean
    public RedisScript<Boolean> loadRedisScript(){
        DefaultRedisScript<Boolean> redisScript = new DefaultRedisScript<>();
        //lua脚本路径
        redisScript.setLocation(new ClassPathResource("LuaScript/token_rate_limit.lua"));
//        redisScript.setScriptText(lua);
        //lua脚本返回值
        redisScript.setResultType(java.lang.Boolean.class);
        return redisScript;
    }
}

统一异常处理类

@RestControllerAdvice
public class ExceptionController {

    @ExceptionHandler(BusinessException.class)
    public Object rateLimitExceptionHandle(BusinessException e) {
        //TODO
        return "限流了";
    }
}

需要限流的方法加个注解

@RateLimit(limit = 1, rateName = "testtoken")
    public ActivityInfoDTO getByLocalCache(String activityId) {
        String key = String.join(CacheKey.cacheKeyDelimiter, CacheKey.activityLocalCacheKeyPrefix, activityId);
        //如果一个key不存在,那么会进入指定的函数生成value
        return activityCaffeineCache.get(key);
    }

写个测试类试一下

@RestController
@RequestMapping("/v1/test")
public class TestController {

    @Resource
    ActivityDaoClient activityDaoClient;


    @PostMapping(value = "/test1", name = "限流测试")
    public Object Test(@RequestHeader HttpHeaders headers, @RequestBody LimitTestDTO request) {
        return activityDaoClient.selectActivityInfoById(request.getId());
    }
}

可以试一下哦,亲测可用,我已经上生产了。

如果想用令牌桶算法的话,只需要以下修改

lua脚本如下:

-- 令牌桶限流: 不支持预消费, 初始桶是满的
-- KEYS[1] string 限流的key
-- ARGV[1] int  桶最大容量
-- ARGV[2] int  每次添加令牌数
-- ARGV[3] int  令牌添加间隔(豪秒)
-- ARGV[4] int  当前时间戳(毫秒)
local bucket_capacity = tonumber(ARGV[1])
local add_token = tonumber(ARGV[2])
local add_interval = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
-- 保存上一次更新桶的时间的key
local LAST_TIME_KEY = KEYS[1].."time";
-- 获取当前桶中令牌数
local token_cnt = tonumber(redis.call("get", KEYS[1]))
-- 桶完全恢复需要的最大时长,秒
local reset_time = math.ceil(bucket_capacity / add_token) * add_interval / 1000;
if token_cnt then -- 令牌桶存在
 -- 上一次更新桶的时间
 local last_time = tonumber(redis.call('get', LAST_TIME_KEY))
 -- 恢复倍数
 local multiple = math.floor((now - last_time) / add_interval)
 -- 恢复令牌数
 local recovery_cnt = multiple * add_token
 -- 确保不超过桶容量
 local token_cnt = math.min(bucket_capacity, token_cnt + recovery_cnt) - 1
 if token_cnt < 0 then
  return false;
 end
 -- 重新设置过期时间, 避免key过期
 redis.call('set', KEYS[1], token_cnt, 'EX', reset_time)
 redis.call('set', LAST_TIME_KEY, last_time + multiple * add_interval, 'EX', reset_time)
 return true;
else -- 令牌桶不存在
 token_cnt = bucket_capacity - 1
 -- 设置过期时间避免key一直存在
 redis.call('set', KEYS[1], token_cnt, 'EX', reset_time);
 redis.call('set', LAST_TIME_KEY, now, 'EX', reset_time + 1);
 return true;
end

注解定义如下

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface RateLimit {

    // 令牌桶大小
    int bucketCapacity();
    // 每次添加令牌数
    int addToken();
    // 令牌添加间隔(毫秒)
    int addInterval();

    // 限流资源名称
    String rateName() default "";
}

aop如下

/**
 * @author shunsheng
 * @Title: RatelimitAspect.java
 * @Description
 * @date 2023 02-08 17:24.
 */
@Aspect
@Component
public class RatelimitAspect {

    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @Autowired
    private RedisScript<Boolean> rateLimitScript;


    @Pointcut("@annotation(com.shunsheng.study.anno.RateLimit)")
    public void pointCut(){}

    @Before("pointCut() && @annotation(rateLimit)")
    public void before(RateLimit rateLimit) throws Throwable {
        //注解上的参数信息
        int bucketCapacity = rateLimit.bucketCapacity();
        int addToken = rateLimit.addToken();
        int addInterval = rateLimit.addInterval();
        String name = rateLimit.rateName();
        //当前时间戳
        long now = System.currentTimeMillis();
        //调用lua脚本获取限流结果
        Boolean isAccess = stringRedisTemplate.execute(
                //lua限流脚本
                rateLimitScript,
                //限流资源名称
                Collections.singletonList(name),
                //桶最大容量
                String.valueOf(bucketCapacity),
                //每次添加令牌数
                String.valueOf(addToken),
                //令牌添加间隔(豪秒)
                String.valueOf(addInterval),
                //当前时间戳
                String.valueOf(System.currentTimeMillis())
        );

        if (!isAccess){
            throw new BusinessException(123, "限流了");
        }
    }
}

需要限流的方法改下注解

@RateLimit(bucketCapacity = 100, addToken = 1, addInterval = 500, rateName = "testtoken")
    public ActivityInfoDTO getByLocalCache(String activityId) {
        String key = String.join(CacheKey.cacheKeyDelimiter, CacheKey.activityLocalCacheKeyPrefix, activityId);
        //如果一个key不存在,那么会进入指定的函数生成value
        return activityCaffeineCache.get(key);
    }