Redis 延迟队列:ZSET + 时间戳模式详解
需要 30 分钟后发邮件?5 秒后重试失败的 Webhook?30 分钟未支付自动取消订单?延迟队列就是答案,而 Redis ZSET 让它出奇地优雅。
问题场景
标准队列(基于 LIST)立即处理任务。但很多真实场景需要延迟执行:
- 注册 24 小时后发送提醒邮件。
- 30 分钟未支付自动取消订单。
- 失败的 API 调用指数退避重试。
- 在指定时间发送通知。
核心模式:ZSET + 时间戳
使用有序集合,其中:
- 分数(score)是任务应该执行的 Unix 时间戳。
- 成员(member)是任务载荷(或任务 ID)。
调度任务
ZADD delayed:queue 1739600100 '{"type":"email","to":"user@example.com","template":"welcome"}'
ZADD delayed:queue 1739600200 '{"type":"cancel_order","order_id":5001}'
ZADD delayed:queue 1739600050 '{"type":"webhook_retry","url":"https://api.example.com","attempt":2}'
查看待处理任务
ZRANGEBYSCORE delayed:queue -inf +inf WITHSCORES
ZCARD delayed:queue
获取到期任务
轮询分数(时间戳)小于等于当前时间的任务:
ZRANGEBYSCORE delayed:queue 0 1739600150 WITHSCORES
返回所有计划在时间戳 1739600150 之前执行的任务。
原子消费任务
关键挑战:多个 Worker 轮询同一个队列。没有原子消费,两个 Worker 可能抓到同一个任务。
模式:ZRANGEBYSCORE + ZREM
ZRANGEBYSCORE delayed:queue 0 1739600150 LIMIT 0 1
ZREM delayed:queue '{"type":"webhook_retry","url":"https://api.example.com","attempt":2}'
但这不是原子的。在 ZRANGEBYSCORE 和 ZREM 之间,另一个 Worker 可能抓到同一个任务。
更好的方案:Lua 脚本(原子弹出)
生产环境中,使用 Lua 脚本原子性地获取并移除:
# 伪代码:
local jobs = redis.call("ZRANGEBYSCORE", key, 0, now, "LIMIT", 0, 1)
if #jobs > 0 then
redis.call("ZREM", key, jobs[1])
return jobs[1]
end
return nil
替代方案:ZPOPMIN(Redis 5.0+)
ZPOPMIN 原子性地移除并返回分数最低的成员:
ZADD delayed:queue 1739600050 "job:1" 1739600100 "job:2" 1739600200 "job:3"
ZPOPMIN delayed:queue
ZPOPMIN delayed:queue
ZRANGE delayed:queue 0 -1 WITHSCORES
但 ZPOPMIN 不检查任务是否到期——它只弹出最低分数。你的 Worker 必须在处理前验证时间戳。
带退避的重试
任务失败时,用更晚的时间戳重新调度:
ZADD delayed:queue 1739600050 '{"type":"webhook","attempt":1}'
ZREM delayed:queue '{"type":"webhook","attempt":1}'
ZADD delayed:queue 1739600110 '{"type":"webhook","attempt":2}'
ZREM delayed:queue '{"type":"webhook","attempt":2}'
ZADD delayed:queue 1739600230 '{"type":"webhook","attempt":3}'
退避策略:第 1 次 T+0,第 2 次 T+60,第 3 次 T+180。
死信队列
达到最大重试次数后,将任务移到死信队列供人工检查:
ZADD delayed:dlq 1739600230 '{"type":"webhook","attempt":3,"error":"timeout"}'
ZRANGE delayed:dlq 0 -1 WITHSCORES
任务去重
用确定性的成员值防止重复任务:
ZADD delayed:queue 1739600100 "cancel_order:5001"
ZADD delayed:queue 1739600200 "cancel_order:5001"
ZSCORE delayed:queue "cancel_order:5001"
第二个 ZADD 更新分数(重新调度)而不是创建重复。这对"防抖"很有用——例如用户操作后重置取消计时器。
取消已调度的任务
直接移除成员:
ZADD delayed:queue 1739600100 "cancel_order:5001"
ZREM delayed:queue "cancel_order:5001"
ZCARD delayed:queue
如果用户在 30 分钟超时前支付了,移除取消任务。
监控
队列深度
ZCARD delayed:queue
逾期任务(应该已经执行了)
ZRANGEBYSCORE delayed:queue 0 1739599000 WITHSCORES
ZCOUNT delayed:queue 0 1739599000
如果返回结果,说明你的 Worker 处理不过来了。
即将执行的任务
ZRANGEBYSCORE delayed:queue 1739600000 +inf WITHSCORES LIMIT 0 10
常见坑点
- 轮询间隔:太频繁 = 浪费 CPU。太慢 = 执行延迟。从 1 秒轮询开始,按需调整。
- 大载荷:不要在 ZSET 成员中存储完整任务数据。存任务 ID,载荷放在单独的 Hash 中:
ZADD delayed:queue 1739600100 "job:1001"
HSET job:1001 type "email" to "user@example.com" template "welcome"
- 无持久化保证:如果 Redis 在没有 AOF/RDB 的情况下重启,队列中的任务会丢失。启用 AOF(
appendonly yes)保证持久性。 - 单消费者瓶颈:高吞吐场景下,使用多个消费者配合基于 Lua 的原子弹出模式。
- 时区问题:始终使用 UTC Unix 时间戳。永远不要用本地时间字符串作为分数。
在线编辑器试一试
前往 Redis 在线编辑器 构建延迟队列:
ZADD delayed:queue 100 "job:email:welcome" 200 "job:cancel:order:5001" 50 "job:webhook:retry"
ZRANGEBYSCORE delayed:queue 0 150 WITHSCORES
ZPOPMIN delayed:queue
ZRANGE delayed:queue 0 -1 WITHSCORES
ZADD delayed:queue 300 "job:webhook:retry:2"
ZCARD delayed:queue
观察 ZPOPMIN 是怎么抓取分数最低(最早截止)的任务并原子性移除的。这就是你的延迟队列消费者在运行。