Redis 限流和延时消息:我在消息推送项目里的理解
这篇记录我在消息推送项目里用 Redis 做滑动窗口限流和 ZSet 延时消息时,对数据结构、原子性、持久化和重复消费的理解。
消息推送系统里,Redis 用得比较多。我一开始只知道 Redis 快,适合缓存,但做项目时发现,它也很适合做一些高频调度类的功能,比如限流、延时消息、分布式锁。
这篇主要写两个点:滑动窗口限流和秒级延时消息。它们看起来都能用 ZSet 做,但真正放进项目里,还是有不少细节。
为什么推送系统需要限流
推送系统不是自己把消息发出去就结束,很多时候要调用第三方厂商。比如短信厂商会有 QPS 限制,邮件服务也可能限制发送频率。如果我们不做限流,短时间内把请求全打过去,轻则被限流,重则触发风控。
限流要考虑几个维度:
- 按渠道限流,比如短信、邮件、飞书。
- 按厂商限流,比如阿里云短信、腾讯云短信。
- 按业务方限流,避免某个业务把系统打满。
- 按用户限流,避免重复轰炸同一个接收人。
这时候 Redis 很适合做实时计数,因为请求频率高,如果每次都查 MySQL,压力会比较大。
固定窗口的问题
最简单的限流是固定窗口。比如一分钟最多 100 次,用 Redis INCR 加过期时间就能做。
但固定窗口有边界问题。假设用户在 12:00:59 发了 100 次,又在 12:01:00 发了 100 次,从窗口角度看都没超,但实际 1 秒内打了 200 次。
所以我更倾向于滑动窗口。虽然实现稍微复杂一点,但效果更平滑。
用 ZSet 做滑动窗口
ZSet 的 score 可以存时间戳,member 可以存请求 ID。每次请求进来:
- 删除窗口外的数据。
- 统计当前窗口内请求数量。
- 如果没超过限制,就写入当前请求。
- 设置过期时间,避免 key 一直存在。
伪代码大概是:
long now = System.currentTimeMillis();
long windowStart = now - windowSizeMillis;
redis.zremrangeByScore(key, 0, windowStart);
long count = redis.zcard(key);
if (count < limit) {
redis.zadd(key, now, requestId);
redis.expire(key, expireSeconds);
return true;
}
return false;
但这段代码在高并发下有问题,因为删除、统计、写入不是原子操作。多个请求可能同时看到 count < limit,最后都写进去,限流就不准了。
所以更合理的是用 Lua 脚本,把这几步放在 Redis 里一次执行。
限流配置不能写死
限流值最好不要写死在代码里。不同厂商、不同通道、不同业务方的额度都可能不同。
我会把配置存在 MySQL,Redis 做缓存:
MySQL:保存权威配置
Redis:缓存热点配置
本地缓存:短时间减少 Redis 查询
配置变更时,可以删除 Redis 缓存,或者发一个配置变更事件。这样系统既能动态调整,又不会每条消息都查数据库。
ZSet 做延时消息
延时消息也可以用 ZSet。score 存执行时间,member 存消息 ID。
比如一条消息 30 秒后发送:
zadd delay:message 1770000000000 msg-10001
扫描器每秒查:
zrangebyscore delay:message 0 now limit 0 100
查到到期消息后,把它们删除并投递到 Kafka 或本地执行队列。
这个方案实现起来比较直观,秒级精度也够用。但它不适合作为唯一存储。
为什么还要 MySQL
Redis 很快,但我不想把关键消息体只放在 Redis 里。我的理解是:
- MySQL 保存消息详情和状态。
- Redis ZSet 保存延时索引。
- Kafka 承接到期后的异步发送。
也就是说 Redis 只负责“什么时候该处理”,MySQL 负责“消息是什么、现在什么状态”。
这样即使 Redis 数据丢了,也可以通过 MySQL 里未执行的延时消息重建 ZSet。虽然会麻烦一点,但可靠性更好。
集群扫描会有重复问题
如果系统部署多个实例,每个实例都有扫描器,就可能多个实例同时扫到同一条消息。
有两种处理方式:
- Lua 脚本:原子地查询并删除到期消息。
- 分布式锁:同一时刻只允许一个实例扫描某个 bucket。
Lua 性能更好,但脚本会复杂。分布式锁更容易理解,但要注意锁过期和看门狗机制。
不过无论哪种方式,消费端还是要做幂等。因为即使扫描不重复,后面的 Kafka 消费也可能重复。
延时消息的分桶
如果所有延时消息都放在一个 ZSet,数据量很大时扫描压力会变高。可以按时间分桶,比如:
delay:message:2026050518
delay:message:2026050519
或者按业务、渠道分桶。这样扫描范围更小,也方便监控每个桶的积压情况。
当然分桶会增加复杂度。学生项目里不一定要实现得很完整,但设计时可以说明,数据量上来后会考虑这个优化。
我觉得 Redis 这块能体现什么
Redis 不是只会 set/get。这个项目里我觉得比较能聊的是:
- ZSet 为什么适合滑动窗口。
- Lua 为什么能解决原子性问题。
- Redis 和 MySQL 怎么分工。
- 延时消息怎么避免重复扫描。
- 分布式锁为什么要考虑锁过期。
- 限流配置怎么动态加载。
这些点比单纯说“用 Redis 做缓存”要具体很多。
小结
Redis 在消息推送系统里承担的是高频、轻量、实时的调度能力。滑动窗口限流保护下游厂商,ZSet 延时消息支持定时发送和失败重试。
但我不会把 Redis 当成万能存储。真正可靠的设计还是要让 Redis、MySQL、Kafka 和业务幂等一起配合。Redis 负责快,MySQL 负责准,Kafka 负责异步流转,幂等负责兜底。