Redis ZSet 实现延迟队列完全可行,但必须自行实现轮询、原子性与并发安全逻辑,否则会导致消息丢失、重复或漏执行;ZSet 因 score 天然支持时间戳排序且可用 ZRANGEBYSCORE 高效查询到期消息,而 List 无排序能力、Stream 无原生延迟语义,故更适合作为延迟队列基座。

Redis ZSet 实现延迟队列完全可行,但必须自己补足轮询、原子性、并发安全三块逻辑,否则消息会丢、重复或漏执行。
为什么选 ZSet 而不是 List 或 Stream?
ZSet 的 score 天然支持时间戳排序,能用 zrangebyscore 快速捞出“已到期”的消息;List 没有内置排序能力,得靠客户端维护优先级;Stream 虽支持消费组和 pending 状态,但原生不带延迟语义,仍需搭配 XADD + EXPIRE 或外部定时器,反而更重。
常见错误是直接把消息塞进 List 然后靠 BLPOP 等——这根本不是延迟队列,只是阻塞队列。
-
zset的score必须是 Unix 时间戳(秒级或毫秒级),不是相对延迟秒数(如30),否则无法跨进程/重启对齐时间基准 - 如果用毫秒级时间戳,所有客户端(Java/Python/Go)必须统一用
System.currentTimeMillis()或time.time() * 1000,不能混用秒和毫秒 - Redis 本身不校验
score合法性,填错成负数或远古时间戳,会导致消息永远卡在队列里
如何避免轮询时漏消息或重复消费?
核心在于 zrangebyscore + zremrangebyscore 的窗口设计。简单用 now-1 到 now 这一秒区间,看似合理,但存在两个致命问题:
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
- 同一秒内大量消息到期,
zrangebyscore返回结果无序(ZSet 对相同score的 member 按字典序排),可能打乱业务期望的处理顺序 - 两次轮询间隔若略大于 1 秒(比如 GC 导致
Thread.sleep(1000)实际停了 1050ms),中间那 50ms 的消息就会被跳过 - 更稳妥的做法是用滑动窗口:每次查
-inf到当前时间,处理完立刻用zremrangebyscore删除,再休眠固定间隔(如 500ms)——但要注意并发消费者时,得用 Lua 脚本保证“查+删”原子性
示例 Lua 脚本(防止多实例同时捞到同一批消息):
local data = redis.call('zrangebyscore', KEYS[1], '-inf', ARGV[1])
if #data > 0 then
redis.call('zremrangebyscore', KEYS[1], '-inf', ARGV[1])
end
return data为什么不能只靠 EXPIRE + KEYSAPCE 事件?
有人想用 set key value ex 30 + 开启 notify-keyspace-events Ex 监听过期事件来触发回调,这条路走不通:
- Redis 的过期事件是异步的,不保证准时;极端情况下延迟几秒甚至更久
- 事件通知只发一次,若消费者宕机没收到,消息就彻底丢失
- Key 过期后自动删除,你连“查一下还有没有这个任务”都做不到,无法做幂等或重试
- 它本质是“键失效通知”,不是“延迟任务调度”,语义错位
所以,ZSet 方案虽然要自己写轮询,但可控、可追溯、可补偿——这才是生产环境能托付的关键。
真正容易被忽略的点是:ZSet 延迟队列从来不是“开箱即用”的功能,它只是一个数据结构基座。你必须亲手实现超时判定逻辑、失败重入机制、消息去重 ID 维护、以及消费者崩溃后的消息回滚策略——这些都不在 Redis 里,而在你的代码里。

















