从固定窗口到滑动窗口:一个消息服务的频控演进

我给公司做过一个统一消息服务(短信/邮件),把各业务线自接通道的模式收口为统一接入、统一管控、异步发送。这个服务里我花心思最多的模块是频控风控——这篇文章记录它的演进。

需求长什么样

规则语义:「一天内,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 条。

三个细节:

  1. member 必须唯一。如果 member 用手机号,ZSet 里永远只有 1 条,频控直接失效。用请求唯一 ID 保证每条记录独立
  2. 三条规则共用一个 key。1 分钟/1 小时/1 天的判断只是 ZCOUNT 的 score 区间不同——加新规则零存储变更,只是换个窗口参数做查询
  3. 整体 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 解决的过期语义,不要用定时任务——少一个组件,少一类故障