延时队列怎么实现
常见的做法是把「到期时间戳」当分数存进有序集合:入队就是 ZADD,扫描到期任务就是按分数做范围查询,领取任务则必须保证「判断与删除」是原子的。相比轮询所有键,有序集合天然按分数有序,定位即将到期的元素代价是 O(log N),实现简单且稳定。
基本信息
| 结构 | ZSet,成员为任务标识,分数为到期时间戳 |
|---|---|
| 入队 | ZADD,重复成员名会覆盖,任务标识必须唯一 |
| 扫描 | ZRANGEBYSCORE -inf now LIMIT 0 N WITHSCORES |
| 领取 | Lua 判断并 ZREM,或用 ZPOPMIN |
| 时间粒度 | 建议毫秒时间戳,在 2^53 以内整数精确 |
典型使用场景
- 订单超时未支付自动关闭
- 定时短信、推送与站内提醒
- 失败任务延迟重试,例如 30 秒后再次投递
- 活动定时开始、定时结束与状态切换
- 分布式定时任务的到期扫描与批量触发
入队、扫描与原子领取
入队、扫描与原子领取
ZADD delay:sms 1735689600000 msg:1 # 到期时间戳作为分数
ZRANGEBYSCORE delay:sms -inf $(date +%s%3N) LIMIT 0 20 WITHSCORES
# 原子领取:先判断到期再删除,避免多消费者重复处理
EVAL "local v=redis.call('ZRANGEBYSCORE',KEYS[1],'-inf',ARGV[1],'LIMIT',0,1) if #v==0 then return nil end redis.call('ZREM',KEYS[1],v[1]) return v[1]" 1 delay:sms 1735689600000
ZPOPMIN delay:sms 1 # 简单场景:弹出当前分数最小的成员要素与做法
| 要素 | 做法 | 注意事项 |
|---|---|---|
| 分数 | 到期时间戳(毫秒) | 用秒级时间戳在高并发下会堆积 |
| 入队 | ZADD 成员标识 | 成员名重复会覆盖,需保证唯一 |
| 扫描 | ZRANGEBYSCORE -inf now | 配合 LIMIT 控制单批处理量 |
| 领取 | ZPOPMIN 或 Lua 判断加 ZREM | 两条命令分开会重复消费 |
| 失败重试 | 重新 ZADD 到未来时间点 | 要限制重试次数,否则死循环 |
消费者按秒级间隔轮询即可满足多数场景;想进一步降低延迟可以用阻塞式弹出命令配合定时调度,但要自己处理「还没到期」的情况。
「先查再删」两条独立命令在多个消费者并发时会重复消费同一个任务,必须用 Lua 或 ZPOPMIN 把判断与删除合成一步。
队列积压要能看见:按分数区间统计各时间段的待办数量,就能判断是消费者不足、任务执行超时,还是生产者写入异常增多。