从固定窗口到滑动窗口:一个消息服务的频控演进
我给公司做过一个统一消息服务(短信/邮件),把各业务线自接通道的模式收口为统一接入、统一管控、异步发送。这个服务里我花心思最多的模块是频控风控——这篇文章记录它的演进。
需求长什么样
规则语义:「一天内,N 分钟内只能发 M 条,超限禁发 K 分钟」。N/M/K 按业务方配置,一个业务方可以配多条规则、任一命中即禁,短信和邮件独立配置。
多规则的语义是多级防护,比如:
- 1 分钟 3 条——防轰炸
- 1 小时 10 条——防骚扰
- 1 天 50 条——兜底
这套规则挂在一条拦截链上:token 鉴权 → 业务方状态/额度校验 → 频控规则组 → 禁发标记检查 → 黑名单 → ……全部通过才入库为待发送任务。任一节点不通过即拒绝——风控前置,被拒请求不占通道额度、不产生无效任务。拦截链是责任链模式,新规则插节点即可,不动已有逻辑。
第一版:固定窗口计数
最初实现是最直观的:每个(业务方, 手机号)维度一个 Redis key,INCR 计数 + EXPIRE N 分钟,超 M 拒绝。
上线后发现问题:窗口边界跳变。假设规则是 1 分钟 3 条——前一个窗口的最后 10 秒发 3 条,下一个窗口的前 10 秒又发 3 条,20 秒内实际发出 6 条。对防轰炸场景,这不可接受。
固定窗口的问题本质:它的"1 分钟"是自然对齐的格子,不是从用户行为出发的滚动时间。
第二版:ZSet 滑动窗口
改用 Redis ZSet 实现真正的滑动窗口:
- 每个(业务方, 手机号/邮箱)一个 ZSet
- member = 请求唯一 ID,score = 时间戳
判断流程:
ZCOUNT key (now - N分钟) +inf → 数出窗口内的条数,与 M 比较
ZADD key now 请求唯一ID → 记账
ZREMRANGEBYSCORE key -inf (now - 最大窗口) → 裁剪掉窗口外的旧记录
窗口是从"现在"往回滚 N 分钟,边界跳变问题消失——任意时刻看,过去 1 分钟内都不会超过 3 条。
三个细节:
- member 必须唯一。如果 member 用手机号,ZSet 里永远只有 1 条,频控直接失效。用请求唯一 ID 保证每条记录独立
- 三条规则共用一个 key。1 分钟/1 小时/1 天的判断只是
ZCOUNT的 score 区间不同——加新规则零存储变更,只是换个窗口参数做查询 - 整体 TTL 兜底。key 设一个最大窗口级的 TTL,不活跃的号码数据自动清理,不占内存
原子性:整套逻辑写成一个 Lua 脚本
"查禁发 → 裁剪 → 数数 → 记账"如果分成多条 Redis 命令,并发下会双双漏过:两个请求同时 ZCOUNT 都看到 2 条(未超限),各自 ZADD,窗口变 4 条——超限了。
解法是整套逻辑写成一个 Lua 脚本发给 Redis 执行。Redis 单线程执行脚本,期间不会插入其他命令——天然原子,且省了多次网络往返。
-- 简化示意
local banned = redis.call('GET', banned_key)
if banned then return -1 end -- 已禁发
redis.call('ZREMRANGEBYSCORE', zset_key, '-inf', now - max_window)
local count = redis.call('ZCOUNT', zset_key, now - window, '+inf')
if count >= limit then
redis.call('SET', banned_key, '1', 'EX', K) -- 写禁发标记,K 分钟自动解除
return 0
end
redis.call('ZADD', zset_key, now, request_id) -- 记账
return 1
禁发落地:TTL 即解禁
命中规则 → 写一个 Redis key(TTL = K 分钟)→ 后续请求查这个 key,O(1) 秒判拒绝 → 到期自动解除。
这里有个我很喜欢的设计:禁发的解除不靠定时任务。TTL 到期 Redis 自动删 key,下个请求自然放行。零定时清理、零轮询——把"过期"这件事交还给 Redis 的过期机制。
番外:消费侧的锁
频控管"发不发",消费侧管"怎么发"。服务内本地多线程每 1 秒抢一次 Redis 分布式锁(Redisson,leaseTime 5 秒 + watchdog 自动续期),抢到才有资格读任务表执行发送。短信、邮件各一把锁互不阻塞。
单机部署但用分布式锁而不是 JVM 锁的好处:从单机多线程到多机多实例,防重复消费的语义天然成立,代码零改动。watchdog 续期解决"发送耗时长锁被误抢",宕机续期停止锁 5 秒内自动释放——不死锁。
小结
这个模块从固定窗口到滑动窗口的演进,驱动力就是一个边界跳变的真实缺陷;ZSet + Lua 的组合让"多规则、原子性、自动清理"三个需求一次满足。回头看,值得记下的经验是:
- 频控的窗口要从用户行为侧定义,不是从系统时间侧对齐
- Redis Lua 脚本是"多命令原子性"的最短路径,不用引分布式锁就能解决并发漏过
- 能用 TTL 解决的过期语义,不要用定时任务——少一个组件,少一类故障