分布式限流解决三方接口QPS限制异常

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

问题描述

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

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

解决方案

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

滑动窗口算法:

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

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

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

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

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

漏桶算法:

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

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

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

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

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

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

令牌桶算法:

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

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

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

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

方案落地

思考一下,在分布式场景,我们要计数的话,就需要在同一个服务或者中间件计数。计数用个服务属实没必要,而且涉及高可用集群以及集群间计数信息同步的问题。这样的话就只能考虑中间件了,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);
    }

  • 3
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
### 回答1: 是的,我知道一些常用的分布式限流工具。以下是一些比较流行的限流工具: 1. Redis: 使用 Redis 可以简单地实现限流,特别是滑动窗口限流。 2. Guava RateLimiter: Guava 是 Google 的 Java 工具包,其中 RateLimiter 可以提供高效的令牌桶限流。 3. Bucket4J: Bucket4J 是一个 Java 库,可以提供功能强大的令牌桶和漏桶限流。 4. Netflix Zuul: Netflix Zuul 是一个微服务网关,内置了对限流的支持,可以很方便地在应用程序的边界处实现限流。 这些工具都有不同的优势和特性,请根据您的具体需求和环境选择适合您的工具。 ### 回答2: 有一些好用的分布式限流工具可以帮助我们实现系统的流量控制和限流,以下是其中几个常用的: 1. Alibaba Sentinel(阿里巴巴开源):一个功能强大的分布式系统流量防卫士,支持实时的流量控制、熔断降级、系统保护等功能,提供了丰富的配置和管理方式。 2. Spring Cloud Gateway:Spring Cloud生态系统中的一个网关工具,可以通过使用过滤器和限流机制来实现分布式限流,支持基于QPS、令牌桶等算法进行限流控制。 3. Nacos(阿里巴巴开源):一个用于动态服务发现、配置管理和服务治理的平台,其中包含了限流的功能,可以通过配置限流规则来实现请求的限制。 4. Redis+Lua脚本:通过在Redis中使用Lua脚本来实现限流功能。可以利用Redis的高性能和原子操作特性,结合令牌桶、漏桶等算法来实现流量控制。 5. ZooKeeper:一个分布式协调服务,可以用于实现分布式限流。可以利用ZooKeeper的有序节点特性和Watch机制来控制请求的并发量。 这些工具各有特点,具体选择取决于应用场景和需求。在实际使用时,需要根据系统的规模、性能需求和业务特点等因素,综合考虑选择合适的分布式限流工具。 ### 回答3: 当今的分布式系统越来越复杂和庞大,限流是保证系统稳定性和高可用性的重要策略之一。以下是我所知的几个好用的分布式限流工具: 1. Redis:Redis是一个高性能的内存数据存储系统,通过其提供的分布式缓存和限流功能可以实现简单而高效的限流逻辑。需要利用Redis的计数器或令牌桶等数据结构,将请求和访问进行计数,在达到限流阈值时进行拒绝或延迟处理。 2. Sentinel:Sentinel是阿里巴巴开源的一款分布式流量控制组件,它提供了流量控制、熔断降级、系统负载等功能。通过在每个服务节点上配置规则,可以统一限制请求的数量,避免系统被过多的请求压垮。 3. Nginx:Nginx是一款高性能的开源Web服务器,也可以用作分布式限流工具。通过配置Nginx反向代理服务器的限流策略,可以限制请求的并发数、连接数等,而且能够根据不同的URL或IP设置不同的限流策略。 4. Alibaba Yet Another Distributed Rate Limiter (Sentinel):Sentinel是一个用于流量控制的分布式限流组件,由Alibaba开源。它具有动态规则的特性,可以基于各种参数和维度(如QPS、线程数、CPU负载等)对请求进行限流,以保护系统免受过载。 以上是一些我所知道的分布式限流工具,每个工具都有其独特的特点和适用场景。根据具体的需求和系统架构,选择适合的工具进行分布式限流是非常重要的。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值