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

常见坑点

  1. 轮询间隔:太频繁 = 浪费 CPU。太慢 = 执行延迟。从 1 秒轮询开始,按需调整。
  2. 大载荷:不要在 ZSET 成员中存储完整任务数据。存任务 ID,载荷放在单独的 Hash 中:
ZADD delayed:queue 1739600100 "job:1001"
HSET job:1001 type "email" to "user@example.com" template "welcome"
  1. 无持久化保证:如果 Redis 在没有 AOF/RDB 的情况下重启,队列中的任务会丢失。启用 AOF(appendonly yes)保证持久性。
  2. 单消费者瓶颈:高吞吐场景下,使用多个消费者配合基于 Lua 的原子弹出模式。
  3. 时区问题:始终使用 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 是怎么抓取分数最低(最早截止)的任务并原子性移除的。这就是你的延迟队列消费者在运行。