首页 / 资讯中心 / 文章详情

Java RedisTemplate+Lua限流实战:固定窗口与滑动窗口

Java RedisTemplate+Lua限流实战:固定窗口与滑动窗口 ★ FEATURED ARTICLE
说到限流很多人第一反应是“加个开关超了就拦截”真到写代码的时候就会发现坑比想象的多同一秒里 200 个请求打进来计数器读到 199然后 200 个人全都通过了。这种事我在几个项目里都见过最后基本都收敛到同一个解法——用 Redis 做集中的计数窗口用 RedisTemplate 在应用层调用脚本保证原子性。这篇就围绕“用 Java 的 RedisTemplate 控制某段时间内最大执行次数”这一件事把方案选型、脚本写法、参数计算、AOP 封装和线上踩过的坑一次性讲透。内容偏实战默认你手上已经有 Spring Boot 工程和可用的 Redis。刚接触限流的新人可以顺着看从算法取舍到代码落地都有已经写过一版但总觉得计数不准、key 不释放、集群报错的可以直接跳到第 4 节和第 6 节那里是我花时间最多的地方。1. 先把需求翻译成技术问题1.1 「某段时间内最多执行 N 次」到底约束了什么这句话听起来简单拆开其实有三个维度的约束。第一是频次单位时间内允许的次数比如一分钟 5 次、一天 100 次。第二是时间窗口的形态这个窗口是固定对齐的每秒、每分钟、每小时还是滚动的从当前时刻往前推 60 秒。第三是作用粒度是按用户限、按 IP 限、按接口限还是按某个业务主键订单号、手机号、任务 ID限。这三件事没说清楚代码写出来一定是错的。我见过一个需求写的是“每个用户一分钟最多发 3 条短信”开发同学做成了全局每分钟 3 条上线后所有用户抢那 3 个名额客服电话直接被打爆。所以动手之前先把这句话翻译成一句能写进代码的约束key 业务前缀 用户标识窗口 60 秒阈值 3策略 超过即拒绝或者超过即排队。还有一个容易被忽略的点超过之后怎么办。是直接抛异常告诉用户“操作太频繁”还是返回剩余等待秒数还是降级成异步处理这个决策会反向影响限流接口的设计——如果前端要展示“还需等待 42 秒”那限流方法就不能只返回一个 boolean得把最早那条记录的时间也带回来。这一点在第 4 节的脚本里会体现。1.2 单机限流为什么在多数场景下不够Guava 的RateLimiter、Spring 的Bucket4j本地实现性能确实好纳秒级还不用网络往返。但它的计数器是存在 JVM 堆里的。假设你部署了 4 个实例配的是“每用户每分钟 10 次”实际效果是每用户每分钟最多能打 40 次因为每个实例各算各的。这个偏差在什么情况下不能忍下面几类场景我基本不会考虑单机方案成本敏感型操作短信、邮件、外呼、第三方付费 API 调用多花一条就是真金白银。风控型操作登录失败次数、验证码校验、优惠券领取误放行等于给攻击者开绿灯。资源消耗型操作报表导出、大文件生成、批量任务触发一台机器扛不住全量流量。幂等型需求同一订单重复提交这个跟限流不完全等价但用同一套 key 思路能一起解决。反过来如果只是想在单实例内部保护一下数据库连接池比如某个方法每秒钟最多被调用 50 次那 Guava 完全够用没必要为它加一次 Redis 网络往返。选型上我的判断标准很朴素计数是否需要跨进程共享需要就上 Redis不需要就别上。1.3 四种经典算法在 Redis 上的落地成本算法核心结构精确度突发流量Redis 实现复杂度适用场景固定窗口计数String INCR有临界问题允许 2 倍突发极低一个脚本 6 行短信频控、日内次数上限滑动窗口ZSET精确平滑中需清理过期成员接口防刷、登录保护令牌桶Hash 时间戳精确允许积攒令牌中高状态字段多需要控制平均速率的场景漏桶List / 队列精确完全平滑高需要队列消费削峰填谷、下游保护选哪个不是看谁更高级是看你的业务能不能接受误差。短信一天 10 条这种需求用滑动窗口是浪费固定窗口按天切 key 完全够用误差最多是跨天那几秒钟多发一条。但如果是“登录密码错误 5 次锁定 15 分钟”这个就必须精确因为误差直接对应安全漏洞得用滑动窗口甚至 ZSET 存失败明细。我的默认选择是日维度、月维度这种长窗口用固定窗口分钟级、秒级的短窗口用滑动窗口。长窗口的临界误差在整体占比里极小而短窗口的临界误差会被放大得很明显——固定窗口做“每秒 10 次”攻击者可以在第 1 秒的最后 10 毫秒打 10 次第 2 秒的前 10 毫秒再打 10 次20 毫秒内 20 次实际速率是标称值的 100 倍。这个漏洞是真能被打穿的。2. 方案选型为什么落在 RedisTemplate2.1 RedisTemplate 和 StringRedisTemplate 该用哪个Spring 的 Redis 客户端有两套常用模板RedisTemplate和StringRedisTemplate。前者是泛型模板序列化器可以自己配后者是前者的特化版本key 和 value 都固定用StringRedisSerializer。做限流我强烈建议用StringRedisTemplate原因就一条Lua 脚本的入参必须是纯字符串。Lua 里的tonumber(ARGV[1])只能解析 ASCII 数字如果传进去的是 JDK 序列化后的字节开头带着\xac\xed\x00\x05那串魔数tonumber直接返回 nil后面的if current limit就会抛attempt to compare number with nil报错信息还特别难懂只告诉你脚本执行失败不告诉你是序列化的问题。如果你因为历史原因必须用默认的RedisTemplate那就显式把三个序列化器都改成 StringBean public RedisTemplateString, String rateLimitRedisTemplate(RedisConnectionFactory factory) { RedisTemplateString, String template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer serializer new StringRedisSerializer(); template.setKeySerializer(serializer); template.setValueSerializer(serializer); template.setHashKeySerializer(serializer); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; }这里有个细节afterPropertiesSet()必须显式调用不然自定义的 Bean 在容器启动时不走初始化流程序列化器可能还没生效就已被注入使用。这个坑不常见但很隐蔽出问题的表现是“我明明配了 String 序列化器写进去的 key 还是乱码”。另外提醒一句setHashValueSerializer很多人会漏。滑动窗口如果用 Hash 存反而错了但如果你后面要做令牌桶Hash 的 value 就是数字漏配了照样炸。2.2 脚本放配置文件还是硬编码三种写法我都用过说一下真实感受。第一种是Bean里直接 new 一个DefaultRedisScript脚本内容用字符串拼接写在 Java 里。优点是集中缺点是代码里塞了一大坨 Lua引号转义看得眼睛疼改一行要重新编译。第二种是放在resources/lua/*.lua里用ResourceScriptSource加载。这是我最推荐的方式脚本可以独立编辑、可以给 DBA 审查、可以单独跑测试。写法如下Configuration public class RateLimitScriptConfig { Bean(fixedWindowScript) public DefaultRedisScriptLong fixedWindowScript() { DefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptSource(new ResourceScriptSource( new ClassPathResource(lua/fixed_window.lua))); script.setResultType(Long.class); return script; } }setResultType(Long.class)这行不能省。不设的话execute()返回的是Object你强转成 Long 的时候Redis 返回的整数在 Lettuce 驱动下可能是Long也可能是byte[]取决于你用哪个execute重载。我第一次写就吃过这个亏本地跑得好好的换了驱动版本直接ClassCastException。第三种是脚本内容来自数据库或配置中心好处是可以热更新阈值坏处是每次改脚本都要感知EVALSHA的缓存失效。不推荐给限流这种核心链路用运维复杂度上去了收益却不明显。提示脚本文件编码统一用 UTF-8且不要在 Lua 里写中文注释后直接部署到 Windows 环境开发再打包到 Linux个别构建工具的编码参数会把中文注释变成乱码导致脚本解析失败。注释用英文最稳。3. 固定窗口计数器最快落地的方案3.1 INCR 和 PEXPIRE 的时序陷阱最朴素的实现是这样Long count stringRedisTemplate.opsForValue().increment(key); if (count 1) { stringRedisTemplate.expire(key, 60, TimeUnit.SECONDS); } if (count limit) { return false; }这段代码有两个致命问题。第一increment和expire是两次网络往返如果第一次执行完、第二次还没执行时应用宕机这个 key 就永久存在计数只增不减用户这辈子都别想再操作了。第二即使不宕机在高并发下多个线程同时把 count 加到 1 的可能性极低但“count 已经是 1 了之前的 expire 还没生效”这个窗口期是真实存在的这期间读到的 TTL 是 -1。正确的做法是把所有操作塞进一个 Lua 脚本让 Redis 单线程保证原子性-- fixed_window.lua -- KEYS[1]: 限流 key -- ARGV[1]: 阈值 -- ARGV[2]: 窗口长度(毫秒) local key KEYS[1] local limit tonumber(ARGV[1]) local window tonumber(ARGV[2]) local current redis.call(INCR, key) if current 1 then redis.call(PEXPIRE, key, window) end -- 兜底万一 key 没有过期时间补一个避免永久累积 if redis.call(PTTL, key) 0 then redis.call(PEXPIRE, key, window) end if current limit then return 0 end return 1PTTL那个兜底判断很多人不写觉得current 1已经覆盖了。但实际运维中会遇到一种情况有人用RENAME或者运维脚本复制了一个没有 TTL 的 key 过来或者 Redis 在某个极端场景下丢了过期时间比如从 RDB 恢复时版本差异。加上这两行成本几乎为零能防止一个永久脏 key 把某个用户彻底卡死。3.2 Java 侧调用与参数传递Service public class FixedWindowLimiter { Resource(name fixedWindowScript) private DefaultRedisScriptLong fixedWindowScript; Resource private StringRedisTemplate stringRedisTemplate; /** * param key 限流维度 key例如 sms:uid:10086 * param limit 窗口内最大次数 * param window 窗口长度毫秒 */ public boolean tryAcquire(String key, int limit, long windowMillis) { Long result stringRedisTemplate.execute( fixedWindowScript, Collections.singletonList(key), String.valueOf(limit), String.valueOf(windowMillis)); return result ! null result 1L; } }调用侧就很干净了boolean allowed limiter.tryAcquire(sms:uid: userId, 3, 60_000); if (!allowed) { throw new BizException(发送过于频繁请稍后再试); }注意execute的第二个参数是ListK keys在 Lua 脚本里对应KEYS数组。这里必须传 key 的集合不能图省事把 key 拼到 ARGV 里。单机 Redis 上你这么做也能跑但一旦上了 ClusterRedis 需要根据 KEYS 来校验所有 key 是否落在同一个 slot拼进 ARGV 的 key 它是看不见的脚本里对它的操作会直接报CROSSSLOT Keys in request dont hash to the same slot。这个错误在测试环境永远复现不了只有生产集群才会暴露。3.3 临界问题以及用 key 切分来规避固定窗口的临街问题前面提过数学上它是这样的窗口 [0s, 60s) 内允许 100 次窗口 [60s, 120s) 内允许 100 次。如果全部请求集中在第 59 秒和第 61 秒那么 2 秒内就打了 200 次瞬时速率是标称速率的 60 倍。规避方式有三种。第一种是接受它用在日级别、月级别的窗口上误差可以忽略。第二种是换成滑动窗口代价是内存和脚本复杂度上升第 4 节讲。第三种是把窗口 key 切细比如按分钟对齐生成 keyString key String.format(sms:uid:%s:%s, userId, LocalDateTime.now().format(DateTimeFormatter.ofPattern(yyyyMMddHHmm)));这样每个 key 只活一分钟TTL 设 90 秒比窗口略长防止边界时刻 key 提前消失过期自动清理。好处是彻底摆脱了「TTL 丢失」的担忧坏处是 key 数量爆炸——一个用户一分钟一个 key10 万活跃用户就是 10 万个 key虽然每个都很小但 Redis 的 key 数量上去以后内存碎片和SCAN开销都会增加。我的取舍是短窗口秒级、分钟级且用户基数不大时用切 key长窗口或者用户量巨大时用单 key 加 TTL。切换点在十万级 key 附近可以按实际内存监控调整。4. 滑动窗口用 ZSET 做得更精确4.1 为什么用 ZSET 存时间戳而不是 List滑动窗口的本质是记录窗口内每一次操作的时间统计数量超了就拒。ZSET 的 score 天然适合存时间戳还能按 score 范围批量删除过期记录ZREMRANGEBYSCORE一条命令搞定。List 要做这件事得从头部一个个LPOP然后再判断脚本里循环性能差一个量级。member 的设计是这里最容易出错的地方。很多人图省事直接ZADD key now now把时间戳同时当 score 和 member。问题在于同一毫秒内如果有多个请求命中同一个 keymember 相同ZADD会覆盖而不是新增计数永远上不去限流形同虚设。正确的做法是给 member 加唯一后缀String member System.currentTimeMillis() : UUID.randomUUID();或者用ThreadLocalRandom.current().nextInt(100000)加长随机数省掉 UUID 的生成开销。在高并发写限流的场景UUID 的生成成本其实不可忽视我一般用now _ random.nextInt(1 20)冲突概率低到可以忽略性能好很多。4.2 完整的滑动窗口脚本顺便返回剩余等待时间-- sliding_window.lua -- KEYS[1]: 限流 key -- ARGV[1]: 当前时间戳(毫秒) -- ARGV[2]: 窗口长度(毫秒) -- ARGV[3]: 阈值 -- ARGV[4]: 本次请求的唯一标识 local key KEYS[1] local now tonumber(ARGV[1]) local window tonumber(ARGV[2]) local limit tonumber(ARGV[3]) local member ARGV[4] -- 1. 清掉窗口外的旧记录左闭右开避免边界重复 redis.call(ZREMRANGEBYSCORE, key, 0, now - window) -- 2. 统计当前窗口内的次数 local count redis.call(ZCARD, key) if count limit then redis.call(ZADD, key, now, member) redis.call(PEXPIRE, key, window 1000) return 0 -- 0 表示放行 end -- 3. 被限流了算出最早那条记录什么时候过期返回等待毫秒数 local oldest redis.call(ZRANGE, key, 0, 0, WITHSCORES) if oldest[2] then local wait window - (now - tonumber(oldest[2])) if wait 1 then wait 1 end return wait -- 正数表示需要等待的毫秒数 end return 1Java 侧public long tryAcquireSliding(String key, int limit, long windowMillis) { Long res stringRedisTemplate.execute( slidingWindowScript, Collections.singletonList(key), String.valueOf(System.currentTimeMillis()), String.valueOf(windowMillis), String.valueOf(limit), System.currentTimeMillis() _ ThreadLocalRandom.current().nextInt(1 20)); return res null ? -1L : res; }约定返回值0放行正数表示还需等待的毫秒数-1表示脚本执行异常。这个约定比 boolean 好用太多前端可以直接拿这个数字去做倒计时按钮。PEXPIRE那里我设的是window 1000而不是window多留 1 秒缓冲。原因是如果刚好卡在窗口边界TTL 到期和记录被清理可能有个先后key 提前消失会导致计数从零开始虽然影响很小但白给一个缓冲不亏。4.3 内存和性能到底吃多少这是必须算清楚的账不算就会被 Redis 内存打脸。ZSET 的每个元素member score在 skiplist 编码下大概占用组成估算大小member 字符串SDS 头 数据约 20 字节32 Bscoredouble8 Bskiplist 层指针平均 1.33 层 × 2 × 822 Bdict 哈希表节点开销约 40 B合计约 100 B / 元素再加上 key 本身、ZSET 的元信息约 100 B单个限流 key 的内存大概是100 100 × N字节N 是窗口内最大记录数。举个例子某接口限制“每用户每分钟 20 次”10 万活跃用户。最坏情况是每个用户都打满 20 次总记录数 200 万内存约200 MB。这个量级对一般 Redis 实例是可以接受的但如果你的阈值是“每分钟 200 次”那就变成 2 GB直接打爆。所以滑动窗口的阈值不能拍脑袋定得反算可用内存 / 100 字节 / 活跃 key 数 单 key 可承受的记录数。如果算下来发现阈值不能设太高那就说明这个需求根本不该用滑动窗口换固定窗口或者令牌桶。还有一点Redis 7.0 之后 ZSET 在小规模时用 listpack 编码元素数 ≤ 128 且 member 长度 ≤ 64内存能省一半以上。如果你的阈值本来就在 100 以内那实际占用比上表小很多。可以执行OBJECT ENCODING your_key确认一下当前编码是listpack还是skiplist。5. 注解 AOP让业务代码只留一行5.1 注解设计要留哪些口子工具类写好了业务代码里满地都是limiter.tryAcquire(...)也不好看。抽成注解更清爽Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RateLimit { /** 限流 key 的 SpEL 表达式可用方法参数如 #userId */ String key(); /** 窗口内最大次数 */ int limit() default 10; /** 窗口长度 */ long window() default 60; /** 窗口单位 */ TimeUnit unit() default TimeUnit.SECONDS; /** 触发限流后的提示语 */ String message() default 操作过于频繁请稍后再试; /** 是否使用滑动窗口false 则走固定窗口 */ boolean sliding() default true; }这里key用 SpEL 而不是简单的#参数名占位符是因为实际业务经常要拼前缀比如sms:uid: #userId。SpEL 直接支持字符串拼接省掉一层中间解析。5.2 切面实现与降级策略Aspect Component public class RateLimitAspect { Resource private FixedWindowLimiter fixedLimiter; Resource private SlidingWindowLimiter slidingLimiter; private final ExpressionParser parser new SpelExpressionParser(); private final ParameterNameDiscoverer discoverer new DefaultParameterNameDiscoverer(); Around(annotation(rateLimit)) public Object around(ProceedingJoinPoint pjp, RateLimit rateLimit) throws Throwable { String key buildKey(pjp, rateLimit.key()); long windowMillis rateLimit.unit().toMillis(rateLimit.window()); boolean allowed; long waitMillis 0; try { if (rateLimit.sliding()) { waitMillis slidingLimiter.tryAcquire(key, rateLimit.limit(), windowMillis); allowed waitMillis 0; } else { allowed fixedLimiter.tryAcquire(key, rateLimit.limit(), windowMillis); } } catch (Exception e) { // Redis 不可用默认放行记录告警 log.warn(rate limit check failed, key{}, fallback to pass, key, e); allowed true; } if (!allowed) { throw new RateLimitException(rateLimit.message(), waitMillis); } return pjp.proceed(); } private String buildKey(ProceedingJoinPoint pjp, String expression) { Method method ((MethodSignature) pjp.getSignature()).getMethod(); EvaluationContext ctx new MethodBasedEvaluationContext( pjp.getTarget(), method, pjp.getArgs(), discoverer); return parser.parseExpression(expression).getValue(ctx, String.class); } }这里有个关键的决策点Redis 挂了是放行还是拒绝。我在catch里选的是放行理由是这个注解主要用于防刷和频控不是资金安全的核心校验。Redis 挂了说明整个系统已经在异常状态这时候把所有请求全拒了等于雪上加霜。但如果你的场景是“防止重复支付”那必须改成拒绝宁可让用户重试也不能重复扣款。提示放行策略一定要配合告警。我见过一次 Redis 主从切换持续了 8 分钟限流全程没生效但因为没人看告警是第二天看监控才发现攻击流量被打进来的。catch块里那行log.warn要接到告警平台不能只写日志。5.3 用起来是什么样RateLimit(key sms:uid: #userId, limit 3, window 60, sliding true) public void sendVerifyCode(Long userId, String mobile) { // 真正的发送逻辑 }一行注解业务方法里干干净净。要改阈值只改注解参数不用动逻辑。如果想把阈值集中到配置中心可以把limit改成String limitExpr()从Environment里读这个改造量很小适合多环境部署的项目。6. 常见问题排查实录6.1 脚本执行报错与返回值 NPE最典型的报错是redis.clients.jedis.exceptions.JedisDataException: ERR Error compiling script一般是 Lua 语法问题比如用了 Lua 5.2 的语法但 Redis 内置的是 5.1。goto语句在 Redis 的 Lua 里就不支持。解决办法很简单脚本写完后用redis-cli --eval script.lua key , arg1 arg2先跑一遍能过再往 Java 里放。第二个高频问题是返回值 NPE。stringRedisTemplate.execute(...)返回Long如果脚本里某条分支没有return返回的就是 nilJava 侧拿到 null。我那个固定窗口脚本最后兜底的return 1就是防这个的。检查方法是在脚本的每条分支上都加return宁可冗余。第三个是NOSCRIPT No matching script. Please use EVAL。Spring Data Redis 的DefaultScriptExecutor内部会先尝试EVALSHA失败后自动回退到EVAL并缓存脚本。正常情况下你碰不到这个报错但如果脚本内容改了但 SHA 没变比如只改了空白字符或者用了自己实现的脚本执行器就可能踩到。规避方式脚本内容有任何改动都重启应用不要指望热更新。6.2 集群环境的 CROSSSLOT 与 hash tag前面提过一次这里展开说。Redis Cluster 有 16384 个 slotkey 通过 CRC16 取模决定落在哪个 slot。如果一次脚本执行涉及多个 key且这些 key 不在同一 slotRedis 会直接拒绝。典型场景是你要同时限“用户维度”和“IP 维度”// 错误写法两个 key 大概率不在同一个 slot stringRedisTemplate.execute(script, Arrays.asList(limit:uid: userId, limit:ip: ip), args);解决办法是加 hash tag用{}包住参与哈希计算的部分让两个 key 用同一个 tagString tag {rate: userId }; Arrays.asList(tag :uid, tag :ip);这样两个 key 的 CRC16 输入都是{rate:10086}必然落到同一个 slot。代价是这些 key 会集中在一个节点上如果用户 ID 分布不均可能形成热点。所以 hash tag 只在你确实需要多 key 原子操作时用单 key 限流不需要。另一个坑是Collections.singletonList(key)传单个 key即便这个 key 里带{}也不影响因为只有一个 key 不存在 slot 一致性问题。这个细节很多人在排查 CROSSSLOT 时会搞错方向。6.3 key 不释放、内存持续上涨排查步骤我一般是这三步。第一步SCAN出可疑 key看 TTLredis-cli --scan --pattern limit:* | head -20 | xargs -I{} redis-cli TTL {}如果返回-1说明没有过期时间这就是内存泄漏的源头。常见原因是脚本里PEXPIRE那条分支没执行到比如ZADD成功了但后面抛异常了脚本里抛异常不会回滚已执行的命令或者被限流的分支直接return了没走到PEXPIRE。所以PEXPIRE一定要放在ZADD之后、判断之前或者干脆每次调用都刷一次。第二步看INFO memory里的used_memory和mem_fragmentation_ratio。如果内存涨但 key 数量不涨可能是大 key 或者碎片率高。滑动窗口的记录数是有限的最大就是阈值单个 key 不会无限大但如果你把 member 设计成了时间戳 业务全量参数那单个元素就能撑到几 KB几十条就是几十 KB。第三步看SLOWLOG。Lua 脚本里如果用了ZRANGE ... WITHSCORES拉了全量数据或者做了KEYS *这种操作会阻塞 Redis 主线程。滑动窗口脚本里只拉了第一条0, 0就是为了避免这个问题。6.4 时间源不一致导致计数错乱滑动窗口的 score 用的是应用传入的时间戳。如果应用部署在 4 台机器上其中一台机器时间快了 30 秒那这台机器写入的记录 score 会偏大ZREMRANGEBYSCORE清不掉它们导致这个 key 的计数被虚高用户被误限流。解决方案有两个。一是所有应用节点必须开 NTP 同步误差控制在毫秒级这是基本功。二是把时间源换成 Redis 自己的TIME命令local timeArr redis.call(TIME) local now tonumber(timeArr[1]) * 1000 math.floor(tonumber(timeArr[2]) / 1000)这样所有节点用的是同一台 Redis 的时钟天然一致。但要注意TIME在 Redis 3.2 之前的版本里如果在写命令之前调用会报错因为脚本被视为只读时会拒绝非确定性命令。现在主流版本都是 5 以上没这个问题。另外用了TIME之后脚本就不能再通过EVALSHA的只读优化走从库影响很小实际项目里不用在意。6.5 问题速查表现象大概率原因处理方式计数一直是 1member 重复导致 ZADD 覆盖member 加随机后缀key 永不过期PEXPIRE 分支未执行每次调用都刷 TTL或加 PTTL 兜底本地正常、集群报 CROSSSLOTkey 拼在 ARGV 里或跨 slotkey 放进 KEYS多 key 加 hash tag报tonumber得到 nilRedisTemplate 序列化器不对换 StringRedisTemplate限流阈值被突破INCR 与 EXPIRE 非原子合并成 Lua 脚本返回值 ClassCastException未设setResultType显式指定 Long.class用户被永久限流脏 key 无 TTL排查并手工删除加兜底逻辑同一秒超发用了固定窗口做秒级限流换滑动窗口7. 压测验证与上线后的观测7.1 怎么验证它真的没超发代码写完不等于对必须压一遍。最土但最有效的方法是自己写个 main 方法50 个线程每个线程循环调 200 次统计成功次数int threads 50, loop 200, limit 1000; AtomicInteger pass new AtomicInteger(); CountDownLatch latch new CountDownLatch(threads); for (int i 0; i threads; i) { new Thread(() - { for (int j 0; j loop; j) { if (limiter.tryAcquire(test:key, limit, 60_000)) { pass.incrementAndGet(); } } latch.countDown(); }).start(); } latch.await(); System.out.println(放行次数 pass.get() 期望 limit);理想结果是「放行次数恰好等于 1000」一点不多一点不少。如果多了说明有并发穿透多半是脚本没走原子路径如果少了说明 key 冲突或者 TTL 设短了。这个测试我每次上线前都会跑一遍跑 3 次取稳定值。用 JMeter 也行但要注意 JMeter 本身的线程模型会引入额外误差测出来的数字不如自己写循环准。真要压到 1 万 QPS 以上还得看 Redis 单节点的处理能力一般单 key 的 Lua 脚本能做到 3~5 万 QPS超过这个量级就得考虑分片或者本地预限流。7.2 上线后盯哪几个指标限流这种「平时看不出来出事才要命」的组件监控必须提前埋好。我一般会加这几个被限流次数按 key 前缀打点突增说明有异常流量或者阈值设得太紧。脚本执行耗时Lua 脚本的耗时应该在 1ms 以内超过 5ms 说明 key 太大或者 Redis 负载高。降级次数catch块里的计数一旦非零就要立刻排查 Redis 连接。Redis 内存增长曲线特别是有没有因为 key 泄漏导致的阶梯式上涨。指标埋点用 Micrometer 就行Counter和Timer各一个成本很低。有次就是靠「被限流次数」这个指标发现某个接口的阈值被误配成了 1导致所有用户只能操作一次五分钟内告警响了三次。7.3 几个我踩过之后才明白的细节第一个限流要在业务校验之后、实际执行之前。如果先限流再查权限越权请求也会消耗配额正常用户的名额被攻击者抢走。正确顺序是参数校验 → 权限校验 → 限流 → 执行业务。第二个失败要不要回滚计数。比如短信限流通过了但实际调用短信网关失败了。我倾向不回滚因为失败的调用也消耗了资源网络请求、上游配额不回滚反而更保守。如果业务强烈要求「失败不计数」那就在 catch 里手动ZREM那条记录但要小心这个补偿操作本身可能失败。第三个别在限流的 key 里放会话 ID 或者随机数那样每个请求都是新 key等于没限。key 的维度必须和「谁在被限制」严格对应用户维度就用 userId设备维度就用 deviceId接口维度就用固定字符串。第四个多级限流叠加时要注意顺序。先限全局再限用户和先限用户再限全局效果完全不同。我的习惯是先细粒度后粗粒度这样被限流的请求不会占用全局配额全局阈值能更准确地反映真实压力。这套方案我在几个不同的业务线上都用过从短信频控到接口防刷改动量都不大核心就是那个 Lua 脚本加一层注解。真要说有什么心得就是别急着写通用框架。先把单个场景的脚本跑对压测压过再抽成注解。我见过太多人一上来就设计一套支持令牌桶、漏桶、滑动日志的限流框架最后连固定窗口的原子性都没写对。
阅读完成 · 觉得有帮助?
咨询建站