1. 从"限次"这个需求说起:为什么统计次数比想象中难
做过接口防护的人大概都碰过这样的场景:某个短信发送接口被刷,某个优惠券领取接口被脚本循环调用,某个查询接口在短时间内被同一个人反复拉取。产品经理的需求文档上写着一行字——"同一用户5分钟内最多触发3次",看上去轻飘飘的一句话,落到代码里就是一套完整的计数、过期、并发控制逻辑。标题里提到的"RedisTemplate来控制某段时间内执行最大次数",说的正是把这句话落地的方式:借助Redis的键过期特性配合原子递增,在指定时间窗口内给某个动作设一个次数上限。
这篇内容面向的是已经能在项目里跑起Spring Boot、手里有RedisTemplate注入对象、但对"怎么把限次做得又稳又不出脏数据"还缺少完整方案的同学。全文围绕一个核心问题展开:如何在Java服务里实现一个可复用的"窗口内最大执行次数"控制器,并且保证它在并发下不失效、在多实例部署下不打架、在异常情况下不留下永远清不掉的计数键。中间会涉及Redis的INCR与EXPIRE配合、Lua脚本的原子性、时间窗口的两种经典模型(固定窗口与滑动窗口)、以及实际接入时那些文档不会告诉你的小坑。
先说清楚一件事:限次的本质是"计数 + 超时"。计数解决"做了几次",超时解决"从什么时候开始算"。这两件事单拎出来都很简单,但组合在一起就会产生经典的非原子问题——先INCR再加EXPIRE,中间如果进程挂掉,这个计数键就永久留存了,用户可能被永久锁死,也可能因为键被别的逻辑清掉而白限一次。这也是为什么几乎所有认真做限次的方案,最后都会走到Lua脚本或者Redis 7之后的命令扩展上。理解这一点,比背下某个现成的工具类更重要。
本文不依赖任何特定的第三方限流框架,全部围绕RedisTemplate自带能力展开,代码可以直接拿去改。会给出三个层次的方案:入门级的两次命令调用、生产可用的Lua原子脚本、以及需要更精细控制时的滑动窗口版本。每一层都会说清楚它的边界在哪里、什么场景下不能用,这样你选型的时候心里有底。
2. 固定窗口模型:把"某段时间"翻译成Redis的键结构
2.1 键的设计决定了这套方案能走多远
任何基于Redis的限次方案,第一步都不是写代码,而是想清楚键长什么样。键设计的核心是三段信息:业务标识、粒度标识、时间片标识。业务标识区分不同接口或不同动作,粒度标识区分限的是用户、IP还是全局,时间片标识区分当前落在哪个时间窗口里。举个具体的:limit:sms:uid:10086:202506121030这样的键,含义是"用户10086在2025年6月12日10点30分这个分钟窗口内的短信发送次数"。
把时间片直接编码进键名,好处是天然实现了"窗口切换":时间一到,键名变了,计数自动从零开始,不需要依赖Redis的过期机制来清零。这种做法的键数量会随用户量和时间增长而膨胀,但好在每个键都可以设一个略大于窗口长度的TTL,比如窗口是1分钟就设90秒过期,让Redis自己回收。这种设计在固定窗口限次里非常常见,也是最容易理解和排查的——你登录Redis客户端,看到某个键的值是3,立刻就能知道这个用户在这个分钟里已经用了3次。
另一种做法是不把时间片放进键名,而是靠EXPIRE来界定窗口:第一次访问时创建键并设置过期时间,之后每次INCR都在同一个键上累加,直到键过期消失。这种做法的键数量稳定(每个用户每个业务一个键),但窗口的起点是"第一次访问时间"而不是"整点",属于滚动窗口的变体。两种方式没有绝对优劣,取决于你要的是"每个自然分钟最多3次"还是"任意连续60秒内最多3次",这两者在业务语义上差别很大,务必在和产品对齐时问清楚。
| 键结构方式 | 窗口语义 | 键数量 | 排查难度 | 适用场景 |
|---|---|---|---|---|
| 时间片入键名 | 自然时间窗口(如每整分钟) | 随用户量×时间片增长 | 低,键名自解释 | 日报表、按整点重置的配额 |
| 依赖EXPIRE界定 | 从首次访问起算的滚动窗口 | 稳定,每用户每业务一个 | 中,需看TTL判断进度 | 短信、验证码、防刷 |
| 时间片入键名+滑动 | 精确滑动窗口 | 较高,需要多键或ZSet | 高 | 对精度要求苛刻的计费场景 |
我在实际项目里的默认选择是:如果是防刷类需求,用"依赖EXPIRE"的滚动窗口;如果是配额类需求(比如每天最多查100次),用"时间片入键名"的固定窗口。原因很直接,防刷关注的是"短时间内的密集行为",滚动窗口更能压住突发;配额关注的是"账面上用了多少次",自然窗口才好对账。
2.2 为什么不能只用INCR然后设置过期
最直觉的写法是这样:
public boolean tryAcquire(String key, int maxCount, long windowSeconds) { Long count = redisTemplate.opsForValue().increment(key); if (count != null && count == 1L) { redisTemplate.expire(key, windowSeconds, TimeUnit.SECONDS); } return count != null && count <= maxCount; }这段代码能跑,在压测里也大概率能过,但它藏着一个失败模式:increment成功之后、expire执行之前,如果这台应用实例被重启、被K8s驱逐、或者网络抖动导致第二条命令没发出去,那么这个键就变成了一个没有过期时间的永久键。后果是计数只增不减,用户在那个键对应的业务上会被永久限制。这种问题在测试环境几乎不可能复现,只有线上真的发生了一次实例重启才会暴露,而那时候往往是半夜。
更隐蔽的一层是并发下的窗口重置问题。假设你想做"每分钟最多5次",用时间片入键名的方式,两个请求同时到达且恰好跨过分钟边界,一个落在旧键、一个落在新键,这没问题。但如果你用的是"count == 1 才设置过期"的逻辑,在键刚过期消失、多个请求同时进来的瞬间,可能出现多个请求都读到count为1、都去设置过期的情形——这本身不致命(过期时间相同),但如果业务逻辑里有"首次请求做额外初始化"的动作,就会重复执行。所以稳妥的做法永远是把"判断+计数+设置过期"打包成一个原子操作。
提示:很多团队会先写上面这段代码上线救急,然后计划"下个迭代换成Lua"。我的建议是别留这个技术债,因为它的故障模式是低频高损,等真出事的时候,排查成本远高于一开始就写对的成本。原子脚本的写法并不复杂,下面就直接给出来。
2.3 用Lua把判断和过期合成一步
Redis执行Lua脚本是单线程且原子的,脚本内部可以自由地读、比、写,外部请求无法插队。把限次逻辑写进脚本,就彻底消除了"计数成功但过期没设上"的窗口期。脚本大概长这样:
-- KEYS[1] 计数键 -- ARGV[1] 窗口秒数 -- ARGV[2] 允许的最大次数 local current = redis.call('INCR', KEYS[1]) if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end if current > tonumber(ARGV[2]) then return 0 end return 1脚本的逻辑很短,但每一行都有讲究。INCR放在最前面,保证无论是否超限,计数都会如实累加——这点很重要,如果你的语义是"超限的请求也要计入次数",那就该这么做;如果你的语义是"超限请求不消耗配额",那就要先判断再决定是否递增,脚本结构会不同。这个细节经常被忽略,导致压测时发现"明明限了5次,怎么实际放过了7次",其实就是超限请求把计数顶上去了,后续正常请求反而被误杀。
current == 1这个判断保证了只有键刚被创建时才设置过期时间,后续的递增不会反复刷新TTL。这一点极其关键:如果你每次INCR都重新EXPIRE,那么这个键的过期时间会被无限推后,只要请求不断,键就永不过期,最终变成一个只增不减的计数器,窗口永远不重置。我见过不止一个项目栽在这里,表现是"限流一开始生效,几分钟后彻底失效,怎么调都不对",最后发现是TTL被反复续期。
Java端调用这个脚本,用DefaultRedisScript即可:
private static final DefaultRedisScript<Long> LIMIT_SCRIPT = new DefaultRedisScript<>( "local c = redis.call('INCR', KEYS[1]) " + "if c == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end " + "if c > tonumber(ARGV[2]) then return 0 end " + "return 1", Long.class); public boolean tryAcquire(String key, int maxCount, long windowSeconds) { Long result = redisTemplate.execute( LIMIT_SCRIPT, Collections.singletonList(key), String.valueOf(windowSeconds), String.valueOf(maxCount)); return result != null && result == 1L; }注意DefaultRedisScript在Spring Data Redis里应当作为单例复用,不要每次调用都new一个——脚本对象内部有SHA1缓存,复用才能走EVALSHA而不是每次传全文,否则在高频调用下网络开销会明显上升。这一点在QPS上千的场景里差别非常明显,属于那种"知道了就能省一大截"的细节。
3. 把限次封装成能落地的组件
3.1 返回值不该只是true或false
真实业务里,光知道"能不能执行"往往不够。用户被限之后,前端需要展示"还需等待多少秒",运营需要知道"这个用户今天被限了几次",排查问题需要看到"当前计数是多少、窗口什么时候结束"。所以一个能用的限次组件,返回结构至少应该包含:是否允许、当前已用次数、剩余可用次数、窗口剩余时间。
public class LimitResult { private final boolean allowed; private final long currentCount; private final long remaining; private final long ttlSeconds; // 构造方法、getter 省略 }对应地把Lua脚本改一下,把TTL也带回来:
local current = redis.call('INCR', KEYS[1]) if current == 1 then redis.call('EXPIRE', KEYS[1], ARGV[1]) end local ttl = redis.call('TTL', KEYS[1]) local maxCount = tonumber(ARGV[2]) if current > maxCount then return {0, current, 0, ttl} end return {1, current, maxCount - current, ttl}Lua返回数组时,Spring Data Redis会把元素转换到你声明的返回类型,用List.class接收即可。这里有个小陷阱:Lua里的数字返回给Java会变成Long,但如果数组里混了字符串或nil,转换可能出问题。稳妥做法是全部返回整数,不要返回nil,缺值用-1占位。我在第一次写这个脚本时就因为ttl可能为-1(键不存在)而踩过转换异常,后来统一约定"所有返回值都是整数",世界就清静了。
3.2 键前缀要按业务隔离,别用裸用户ID
键的设计里有一个容易被忽略但影响很大的点:不同业务必须用不同的前缀。如果短信限次和登录限次都用limit:uid:10086这样的键,两个业务会互相污染计数,用户可能因为发了短信而登录被限。这个错误听起来很蠢,但在快速迭代的项目里真的很常见,尤其是多个开发各自写限次逻辑、没有统一封装的时候。
我的做法是定义一个枚举或常量类,把业务类型固定下来:
public enum LimitScene { SMS_SEND("limit:sms:", 3, 300), LOGIN_ATTEMPT("limit:login:", 5, 600), COUPON_CLAIM("limit:coupon:", 2, 60); private final String prefix; private final int maxCount; private final long windowSeconds; // 构造、getter 省略 }这样做的好处是限次策略集中管理,产品改需求时改一处就够,不用满项目搜魔法数字。同时键名统一带上场景前缀,Redis里SCAN limit:sms:*就能把某个业务的键全部捞出来,排查时非常方便。
另外一个建议是给键加一个环境或应用标识,比如app1:limit:sms:...。如果多个应用共用一个Redis实例(中小团队很常见),不加前缀的话键空间会非常乱,甚至可能撞上。加上应用前缀之后,至少能保证"我的键我做主"。
3.3 降级策略必须提前想好
Redis是会挂的。不管是网络分区、实例重启还是内存打满,限次组件在Redis不可用时必须有明确的行为。这里只有两种选择:放行(fail-open)或拒绝(fail-close)。
放行意味着Redis一挂,所有防护消失,恶意流量直接打穿到下游。拒绝意味着Redis一挂,所有正常用户也都被拦住,业务直接不可用。这两个都不是好结果,所以选择取决于业务性质:防刷类接口一般选放行,宁可短时间裸奔也不能让正常用户全挂;涉及资金、库存、核心资源的接口选拒绝,宁可暂时不可用也不能超卖。
public LimitResult tryAcquire(String key, int maxCount, long windowSeconds) { try { // Lua脚本调用 } catch (Exception e) { log.warn("限次组件Redis异常,降级放行 key={}", key, e); return LimitResult.failOpen(); } }这段异常处理一定要写,而且日志里要带上键名和异常堆栈,否则线上出问题时会怀疑人生。我吃过一次亏:限次组件静默吞了异常,Redis连接池耗尽导致所有请求都被放行,监控上看QPS正常、错误率正常,但下游数据库压力暴涨,查了两个小时才定位到是限次组件失效。后来我强制要求所有降级路径都打WARN日志并埋点计数,这样至少能在监控上看到"降级次数"这条曲线突然抬头。
注意:catch里不要catch Throwable,也不要吞掉InterruptedException这类需要保留中断状态的异常。限次组件的降级只应该针对Redis访问异常,不该把代码bug也一起吞掉,否则会掩盖真正的空指针或序列化问题。
4. 滑动窗口:固定窗口的边界问题怎么补
4.1 固定窗口的临界点漏洞
固定窗口有一个众所周知的缺陷:在窗口交界处可能放过接近两倍的请求。假设限的是"每分钟最多5次",用户在10:00:59的最后半秒发了5次,在10:01:00的第一秒又发了5次,这10次请求在任何一个自然分钟里都没超过5次,但在任意一个连续的60秒里实际发生了10次。对于防刷场景,这种突发足以造成伤害。
解决方式有两种主流思路。第一种是滑动窗口日志,用ZSet记录每次请求的时间戳,查询时把窗口外的记录删掉,统计剩余数量。第二种是滑动窗口计数,把窗口切成若干小格,用多个计数器近似。前者精确但内存开销随请求量线性增长,后者省内存但有精度损失。选择哪一种,取决于你对"精确"的容忍度和请求量级。
4.2 ZSet实现滑动窗口的具体写法
滑动窗口日志的Lua脚本大概是这样:
-- KEYS[1] 限次键(ZSet) -- ARGV[1] 当前时间戳(毫秒) -- ARGV[2] 窗口长度(毫秒) -- ARGV[3] 最大次数 -- ARGV[4] 本次请求的唯一标识(防止同一毫秒重复) local now = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local maxCount = tonumber(ARGV[3]) local member = ARGV[4] redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - window) local count = redis.call('ZCARD', KEYS[1]) if count >= maxCount then return 0 end redis.call('ZADD', KEYS[1], now, member) redis.call('PEXPIRE', KEYS[1], window) return 1脚本做了四件事:清掉窗口外的旧记录、统计窗口内数量、未超限则写入本次请求、刷新键过期时间。整个过程原子执行,不存在并发缝隙。Java端调用时传System.currentTimeMillis()作为当前时间,这一点要特别注意——时间必须以应用端为准还是以Redis端为准,是个需要决策的点。用应用时间简单,但多实例时钟不同步时会有偏差;用Redis的TIME命令可以保证全局一致,但脚本里要多一次调用。中小规模的集群,应用时间通常够用,NTP同步一般能把偏差控制在毫秒级。
写ZSet方案时有几个实操要点值得记下来。第一,member必须唯一,否则同一毫秒的两次请求会因为score相同、member相同而被ZSet当成同一个元素,实际只计一次。常用做法是拼上UUID或请求ID,我在脚本外层用UUID.randomUUID().toString()拼接时间戳,冲突概率可以忽略。第二,ZREMRANGEBYSCORE的范围要写对,0到now - window是删除窗口外的,写成now + window就把有效记录也删了,这个笔误在审查时很难一眼看出。第三,ZSet的成员数量上限要心里有数,如果一个窗口允许几千次请求,那每个键存几千个member,内存和CPU都会开始吃紧,这种量级就该考虑改用分格计数了。
4.3 两种模型的性能与精度实测对比
我在一个中等规模的接口上做过简单对比,场景是限"每用户每60秒最多20次",压测单实例QPS约800,Redis为单节点。结果大致如下:
| 方案 | 单次调用平均耗时 | Redis内存占用 | 精度 |
|---|---|---|---|
| 固定窗口+Lua INCR | 约0.4ms | 每用户约100字节 | 有临界点突发放大 |
| 滑动窗口ZSet | 约0.9ms | 每用户约1.5KB(20个成员) | 精确 |
| 滑动窗口分格计数 | 约0.5ms | 每用户约200字节 | 近似,误差在1格内 |
差异很明显。固定窗口的耗时和内存都最低,但精度有硬伤;ZSet精确但代价高出一个量级;分格计数是折中方案,把60秒切成6个10秒的格子,用Hash存每格的计数,统计时把当前格和之前5格加起来。分格方案的误差来自格子边界,最多可能多放过一格内的请求量,如果格子选得足够小(比如窗口的1/10),这个误差在实际业务里通常可以接受。
选择上我的经验是:防刷且请求量不大的场景用ZSet,代码直观、排查容易;高频接口且能容忍小幅误差的用分格计数;配额类需求老老实实用固定窗口,因为它对账最清晰。千万不要为了"技术先进"而在一个日志上报接口上堆ZSet,那纯属给自己找麻烦。
5. 上线之后才会遇到的那些问题
5.1 多实例并发下的"看起来生效其实没生效"
限次逻辑在一个实例上跑通,不代表多实例下正确。这里最常见的误区是把"应用内计数"和"Redis计数"混着用。比如有人为了减少Redis压力,在本地用AtomicInteger先挡一层,超过阈值再查Redis。这个设计本身没问题,但如果本地阈值和Redis阈值配得不一致,或者本地计数没有按窗口重置,就会出现"某个实例明明超过Redis限制还在放行"的现象,因为本地那层的判断逻辑写错了。
还有一种情况是用了RedisTemplate的increment但没有在脚本里做,靠两次命令拼接。前面说过这个方案有原子性问题,在多实例高并发下被触发的概率会显著上升。压测时用一个实例跑没问题,上生产五个实例一跑,问题就冒出来了。
排查这类问题的方法:把所有限次键的计数变化和允许/拒绝结果都打日志,包括实例标识。如果同一个键在同一时刻被两个实例都判定为"未超限",那基本可以确定是原子性出了问题。我一般在灰度阶段就会开这个日志,观察一两天再关。
5.2 序列化器选错导致的诡异异常
Spring Boot项目里常见的坑:RedisTemplate的默认序列化器是JDK序列化,键会带上一堆不可读的二进制前缀。用SCAN查键时看到一堆乱码,排查非常痛苦。而且JDK序列化对String键也不算友好,虽然功能上没大问题,但可读性极差。
@Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer = new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); template.afterPropertiesSet(); return template; }不过限次场景有个更省事的选择:直接用StringRedisTemplate。因为限次用到的值全是数字,键全是字符串,根本不需要JSON序列化。用StringRedisTemplate既避免了序列化开销,也保证了键名的可读性,Redis客户端里一眼就能看懂。我在限次组件里一律用StringRedisTemplate,把业务缓存的RedisTemplate和限次用的分开,职责清晰。
这里还有个连带问题:Lua脚本的返回类型。如果用RedisTemplate<String, Object>且值序列化器是JSON,脚本返回的数字可能在反序列化时出问题。用StringRedisTemplate就没这个烦恼,返回Long就是Long。
5.3 窗口时长和TTL的取值要留余量
TTL设得和窗口一样长,理论上是对的,但实践中建议多留一点余量。原因有两个:一是Redis的过期删除是惰性加定期采样,键过期的那一刻并不会立刻从内存消失,多给10%到20%的余量能让计数判断更稳定;二是如果业务里存在"读取TTL展示给用户"的逻辑,窗口恰好结束时TTL返回-2(键已删除)会让前端展示异常。
我在配置里统一用这个规则:TTL = 窗口长度 × 1.2,向上取整到秒。比如窗口是300秒,TTL设360秒。这个余量对内存的影响微乎其微,但对稳定性有帮助。
另外,窗口长度本身别设得太短。见过有需求写"每10秒最多1次",这种窗口在分布式环境下,多实例的时钟抖动、网络延迟都可能让判定产生抖动。窗口小于1分钟的场景,建议要么放宽窗口,要么接受一定的不精确。
5.4 关于"限次"和"限流"的边界不要搞混
这两个词经常被混用,但解决的问题不同。限次(rate limit count)关注的是"一段时间内的总次数",比如一天最多发100条短信;限流(rate limiting)关注的是"速率",比如每秒最多10个请求。前者用本文的计数方案就够,后者需要令牌桶或漏桶算法,RedisTemplate虽然也能实现(用Hash存令牌数和上次填充时间),但逻辑复杂度高不少。
很多需求文档写的是"限制频率",实际想要的是"限制次数",这时候要追问清楚。我曾经按速率限流的思路实现了一个令牌桶,上线后被产品投诉"为什么用户连续点10次只有前3次成功,等一会儿又能点",因为令牌桶是按速率匀速放行的,而产品期望的是"攒够额度就能一次性用完"。返工成本不小,所以在动手之前先把语义对齐,比写代码重要得多。
6. 一套可以直接抄走的完整实现
6.1 组件骨架与依赖边界
把前面的东西拼起来,得到一个只依赖Spring Data Redis的限次组件。它对外暴露两个方法:一个是固定窗口的tryAcquire,一个是滑动窗口的tryAcquireSliding。内部共用一个StringRedisTemplate,脚本对象静态持有。
@Component public class RateLimitSupport { private final StringRedisTemplate redis; private final String appPrefix; public RateLimitSupport(StringRedisTemplate redis, @Value("${app.name:default}") String appName) { this.redis = redis; this.appPrefix = appName + ":limit:"; } public LimitResult tryAcquire(LimitScene scene, String identity) { String key = appPrefix + scene.getPrefix() + identity; List<Long> raw = redis.execute(FIXED_WINDOW_SCRIPT, Collections.singletonList(key), String.valueOf(scene.getWindowSeconds()), String.valueOf(scene.getMaxCount())); // 解析 raw 并构造 LimitResult } }组件里不做任何业务判断,只负责计数和返回结果。业务层拿到LimitResult之后,自己决定是抛业务异常、返回提示文案还是静默丢弃。这种分层很重要——如果把"超限抛什么异常"写死在组件里,复用时会很别扭,不同接口想要的错误码和提示语往往不一样。
6.2 在业务方法上的接入方式
最省事的接入是AOP。定义一个注解,标注在需要限次的方法上,切面里读出注解参数、拼出identity、调用组件。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface RateLimit { LimitScene scene(); String identityKey() default ""; }切面里用SpEL解析identity,常见取值是方法参数里的用户ID,或者从上下文里取当前登录用户。这里要注意一点:如果identity解析失败(比如用户未登录、参数为null),必须有一个明确的行为,我的选择是直接放行并打日志,绝不因为限次组件自身的解析问题拦住正常请求。
另外,AOP切面要控制好执行顺序,别和事务切面、日志切面互相干扰。限次切面应该尽量靠前,在业务逻辑真正执行之前就完成判断,避免"先开了事务、查了库,最后才被限次拦住"这种浪费。
6.3 监控指标与日常巡检
组件上线之后,至少要观测三类指标:允许数、拒绝数、降级数。这三个数按场景分别打点,能回答大部分线上问题。比如拒绝数突然飙升,可能是有人在刷,也可能是阈值配错了;降级数不为零,说明Redis有抖动,得去看看连接池和网络。
巡检方面,我习惯定期在Redis里看一眼限次键的分布:SCAN 0 MATCH app1:limit:sms:* COUNT 100,抽几个键看看TTL是否正常、计数是否合理。如果发现有TTL为-1的键(永不过期),那就是有代码路径绕过了Lua脚本,必须排查。这种巡检花不了几分钟,但能提前发现不少隐患。
提示:给限次键加一个统一的过期兜底也可以。比如用Redis的
CONFIG SET调整过期策略不现实,但可以在组件里加一个低频的对账任务,定期扫描本应用前缀下TTL为-1的键并补设过期时间。这属于兜底手段,不是正解,但在多团队共用一个Redis的环境里,它能挡住一些别人的代码造出来的脏键。
6.4 测试怎么写才靠谱
限次组件的测试有两层。单元测试用嵌入式Redis或者mock掉RedisTemplate,验证脚本逻辑和参数拼装;集成测试用真实Redis,重点测并发和过期。并发测试的写法是起N个线程同时调同一个键,统计允许的次数是否恰好等于阈值——这个断言能一次性抓出原子性问题。过期测试是调完之后等窗口过去,再调一次,确认又能通过。
有个细节:测试里的窗口时间不要设太长,用1到2秒即可,否则测试跑起来很慢。但也别设成几十毫秒,Redis的过期精度和测试机器的调度抖动会让断言不稳定。我一般用2秒窗口,测完sleep 2.5秒,稳定性不错。
写到这里,这套方案的核心就齐了。固定窗口用Lua保证原子,滑动窗口用ZSet保证精度,组件化让它可复用,降级和监控让它在线上不至于失控。至于最终选哪一种,还是得回到业务语义上:先问清楚"某段时间"到底是自然窗口还是滚动窗口,再决定用哪套实现。这个问题的答案,比任何代码都更能决定这套限次做出来是不是真的有用。