如何避免不当限流策略引发的线上故障:关键策略和解决方案

文章探讨了使用Redisson加锁实现限流的问题,发现会导致性能瓶颈。作者提出了使用incr和lua实现滑动窗口限流的方法,并通过压测对比了它们的性能。Redisson锁不适用于高并发场景,而incr适合对最大请求数要求不严格的场景,lua则提供原子性但牺牲了可读性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

限流实现方式

/**
     * 频率控制
     *
     * @param rateLimiter
     * @return
     */
    public boolean checkRateLimit(String rateLimiter)  {
        String lockKey = OPEN_AI_RATE_LIMITER_KEY + rateLimiter + ":lock";
        RLock lock = redissonClient.getLock(lockKey);
        try {
            lock.lock(3, TimeUnit.SECONDS);
            Long perMinuteNum = 12000L;

            String rateKey = OPEN_AI_RATE_LIMITER_KEY + rateLimiter + "_" + DateUtil.format(new Date(), "yyyyMMddHHmm");
            Long num = redisManager.incrLong(rateKey, 1, DateUtil.ONE_MINUTE_SECONDS + 1);
            if (Objects.nonNull(num) && num > perMinuteNum) {
                log.info("checkRateLimit-超限 rateKey : {} perMinuteNum : {} num : {}", rateKey, perMinuteNum, num);
                return false;
            }
            log.info("checkRateLimit-正常 rateKey : {} perMinuteNum : {} num : {}", rateKey, perMinuteNum, num);
            return true;
        } catch (Exception e) {
            log.error("checkRateLimit-失败 rateLimiter : {}", JSON.toJSONString(rateLimiter), e);
        } finally {
            if (lock.isLocked() && lock.isHeldByCurrentThread()) {
                lock.forceUnlock();
            }
        }

        return false;
    }

上线后大量慢请求报警

在这里插入图片描述

排查原因

  • 通过阿里云监控面板可以看到 /v2/get-data3 接口请求非常慢,达到了分钟级(正常接口响应时间在 200ms 以内)
    在这里插入图片描述
  • 通过方法栈执行时间可以看到接口主要慢在 Redisson 加锁,导致大量请求堵塞
    在这里插入图片描述

解决方案

回滚代码

压测

  • 10 个线程,每个线程调 20 次,时间间隔 0,限流方法就达到 30ms
    在这里插入图片描述
  • 60 个线程,每个线程调 5 次,时间间隔 0,限流方法达到 155ms
    在这里插入图片描述
  • 通过方法栈执行时间和压测,可以确定 Redisson 加全局锁的方式堵塞时间会随着瞬时流量增加而增加

解决方案

incr 实现限流

import com.alibaba.fastjson.JSONObject;
import lombok.NonNull;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.connection.RedisZSetCommands;
import org.springframework.data.redis.connection.ReturnType;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.data.redis.core.query.SortQuery;
import org.springframework.data.redis.core.types.Expiration;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;

import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

/**
* 实现滑动窗口限流 - 通过 incr
*
* @param key                 key
* @param maxRequests         窗口时间内允许的最大请求数
* @param timeWindowInSeconds 窗口时长(s)
* @return
*/
public boolean checkRateLimitByIncr(@NonNull String key, @NonNull int maxRequests, @NonNull int timeWindowInSeconds) {
   if (maxRequests <= 0) {
       throw new RuntimeException("最大请求数必须大于 0");
   }

   if (timeWindowInSeconds > ONE_DAY_SECONDS) {
       throw new RuntimeException("窗口时长最大为 1 天");
   }

   long stTime = System.currentTimeMillis();
   long time = stTime / 1000;
   long suffix = time / timeWindowInSeconds;

   String keySplic = String.format(acquire_token_incr_prefix, key, suffix);
   Long num = incrLong(keySplic, 1L, timeWindowInSeconds + 1);

   boolean res = false;
   if (Objects.nonNull(num) && num <= maxRequests) {
       res = true;
       log.debug("checkRateLimitByIncr-通过 key : {} keySplic : {} timeWindowInSeconds : {} maxRequests : {} num : {} consumeTime : {}"
               , key, keySplic, timeWindowInSeconds, maxRequests, num, System.currentTimeMillis() - stTime);
   } else {
       log.warn("checkRateLimitByIncr-限流 key : {} keySplic : {} timeWindowInSeconds : {} maxRequests : {} num : {} consumeTime : {}"
               , key, keySplic, timeWindowInSeconds, maxRequests, num, System.currentTimeMillis() - stTime);
   }

   return res;
}

/**
* value为数字类型,value增加
*
* @param key   键
* @param delta 要增加数量(大于0)
* @param time  过期时间(秒)
* @return 计算后的结果
*/
public Long incrLong(String key, long delta, long time) {

   Long increment = null;
   try {
       increment = redisTemplate.opsForValue().increment(key, delta);
   } catch (Exception e) {
       log.error("failed key : {} delta : {} time : {}", key, delta, time);
       return null;
   }
   return increment;
}
  • 注意:该方式并不是原子的,可能导致最大请求数超过设置的最大请求数

lua 实现限流

/**
     * 实现滑动窗口限流 - 通过 lua
     *
     * @param key                 key
     * @param maxRequests         窗口时间内允许的最大请求数
     * @param timeWindowInSeconds 窗口时长(s)
     * @return
     */
    public Boolean checkRateLimitByLua(@NonNull String key, @NonNull int maxRequests, @NonNull int timeWindowInSeconds) {
        if (maxRequests <= 0) {
            throw new RuntimeException("最大请求数必须大于 0");
        }

        if (timeWindowInSeconds > ONE_DAY_SECONDS) {
            throw new RuntimeException("窗口时长最大为 1 天");
        }

        long stTime = System.currentTimeMillis();
        String keySplic = String.format(acquire_token_lua_prefix, key);

        /**
         local key = KEYS[1]:获取 Lua 脚本执行时传入的键名,通过 KEYS[1] 获取,这个键名通常用于标识某个操作或资源。

         local count = tonumber(ARGV[1]):获取 Lua 脚本执行时传入的最大执行次数,通过 ARGV[1] 获取,将其转换为 Lua 中的数字类型。

         local time = tonumber(ARGV[2]):获取 Lua 脚本执行时传入的时间窗口时长,通过 ARGV[2] 获取,同样转换为 Lua 中的数字类型。

         local current = redis.call('get', key);:通过 Redis 命令 GET 获取键 key 对应的值,这个值被用作计数器记录当前执行次数。

         if current and tonumber(current) > count then ...:如果当前值存在且大于指定的最大执行次数,说明已经超过了限制,直接返回当前值。这里用到了 Lua 的条件判断和 tonumber 函数。

         current = redis.call('incr', key):如果未超过限制,则使用 INCR 命令增加计数器的值。如果之前不存在键 key,则会创建并将其值初始化为1。

         if tonumber(current) == 1 then redis.call('expire', key, time) end:如果当前值是第一次执行,使用 EXPIRE 命令设置键 key 的过期时间为指定的时间窗口时长。这样就确保计数器在一个时间窗口内有效。

         return tonumber(current);:返回当前的执行次数,不论是新创建的计数器还是已存在的计数器经过增加操作后的值。
         */
        String luaScript = "local key = KEYS[1]\n" +
                "local count = tonumber(ARGV[1])\n" +
                "local time = tonumber(ARGV[2])\n" +
                "local current = redis.call('get', key);\n" +
                "if current and tonumber(current) > count then\n" +
                "    return tonumber(current);\n" +
                "end\n" +
                "current = redis.call('incr', key)\n" +
                "if tonumber(current) == 1 then\n" +
                "    redis.call('expire', key, time)\n" +
                "end\n" +
                "return tonumber(current);";

        // 执行Lua脚本
        Long num = redisTemplate.execute((RedisCallback<Long>) connection ->
                connection.eval(luaScript.getBytes(), ReturnType.INTEGER, 1,
                        keySplic.getBytes(), String.valueOf(maxRequests).getBytes(), String.valueOf(timeWindowInSeconds).getBytes()));

        boolean res = false;
        if (Objects.nonNull(num) && num <= maxRequests) {
            res = true;
            log.debug("checkRateLimitByLua-通过 key : {} timeWindowInSeconds : {} maxRequests : {} num : {} consumeTime : {}"
                    , key, timeWindowInSeconds, maxRequests, num, System.currentTimeMillis() - stTime);
        } else {
            log.warn("checkRateLimitByLua-限流 key : {} timeWindowInSeconds : {} maxRequests : {} num : {} consumeTime : {}"
                    , key, timeWindowInSeconds, maxRequests, num, System.currentTimeMillis() - stTime);
        }

        return res;
    }
  • lua 脚本是原子的,但是维护和可读性较差

分别压测

  • 10 个线程,每个线程调 20 次,时间间隔 0,耗时 1~2 ms
    在这里插入图片描述
  • 10 个线程,每个线程调 20 次,时间间隔 0,耗时 1~3 ms
    在这里插入图片描述
  • 60 个线程,每个线程调 5 次,时间间隔 0,incr 和 lua 仍然基本稳定在 1~3 毫秒

总结

  • 不能使用 Redisson 锁的方式保证原子性,Redisson 在瞬时流量较大时会到导致大量锁堵塞
  • 对最大请求数要求不是那么严格的情况下可以使用 incr 的方式限流
  • 对最大请求数要求严格情况可使用 lua 脚本的方式限流
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值