最新下载
热门教程
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
详解Redis延时队列
时间:2026-07-04 10:39:47 编辑:袖梨 来源:一聚教程网
一、什么是延时队列
普通队列: 消息一到就消费

延时队列: 消息到了先放着,到指定时间再消费
普通队列: [消息] → 立即消费
延时队列: [消息] → 等 30 分钟 → 到期 → 消费
场景举例:
- 下单 30 分钟未支付,自动取消
- 红包 24 小时未领取,自动退回
- 会议开始前 5 分钟,发送提醒
- 7 天后自动确认收货
二、为什么用 ZSet 实现
| 数据类型 | 结构 | 能做延时队列吗 |
|---|---|---|
| List | 按插入排序 | ❌ 不支持按时间排序 |
| Set | 无序 | ❌ 没法指定执行时间 |
| ZSet | 按 score 排序 | ✅ score 存到期时间戳 |
# ZSet 天然适合:ZADD delay_queue 1718000000 "task_001" # score=到期时间戳ZADD delay_queue 1718000300 "task_002" # 自动按时间排序
三、核心流程
生产者 Redis ZSet 消费者(定时任务)────── ────────── ──────────────XADD delay_q score = 到期时间戳score=到期时间 member = 任务数据 ┌─────────────────┐ │ 1718000000 task1 │ ← 最早到期 │ 1718000300 task2 │ │ 1718000600 task3 │ └─────────────────┘ ↓ ZRANGEBYSCORE 0 当前时间 取到 task1(已到期) ↓ 执行 task1 → ZREM 删除
四、基础实现(有并发问题)
// 生产者:投递延时任务$redis->zAdd('delay:orders', time() + 1800, json_encode([ 'order_id' => 12345, 'action' => 'auto_cancel',]));// 消费者:每秒轮询到期任务(有 BUG 的版本)$now = time();$tasks = $redis->zRangeByScore('delay:orders', 0, $now, ['limit' => [0, 1]]);if ($tasks) { $task = $tasks[0]; // ⚠️ 这里有 BUG!如果同时多个消费者拿到同一条 $redis->zRem('delay:orders', $task); processTask($task);}问题在哪? ZRANGEBYSCORE 和 ZREM 是分开的,多个消费者可能同时拿到同一条任务!
五、Lua 脚本原子化(解决并发)
-- 原子操作:查出到期任务 + 立刻删除 + 返回local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, 1)if #tasks == 0 then return nilendlocal task = tasks[1]local removed = redis.call('ZREM', KEYS[1], task)if removed == 1 then return task -- 删除成功,返回任务else return nil -- 被别的消费者抢了endPHP 端调用:
$lua = <<<'LUA'local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, 1)if #tasks == 0 then return nil endlocal task = tasks[1]if redis.call('ZREM', KEYS[1], task) == 1 then return taskelse return nilendLUA;// 定时任务循环执行while (true) { $task = $redis->eval($lua, ['delay:orders', time()], 1); // ↑ KEYS 部分 ↑ key数量 if ($task) { $data = json_decode($task, true); echo "处理任务: {$data['order_id']}n"; processTask($data); } else { sleep(1); // 没任务就等一下 }}六、完整实战:30 分钟未支付自动取消
<?php// ====== 生产者(下单时) ======function createOrder($orderId) { $redis = new Redis(); $redis->connect('127.0.0.1', 6380); // 1. 创建订单... // 2. 投递延时任务:30分钟后自动取消 $delayAt = time() + 1800; // 30分钟 $task = json_encode([ 'order_id' => $orderId, 'action' => 'auto_cancel', 'create_at'=> date('Y-m-d H:i:s'), ]); $redis->zAdd('delay:orders', $delayAt, $task); echo "订单 {$orderId} 已创建,30分钟后未支付将自动取消n";}// ====== 消费者(定时脚本) ======$lua = <<<'LUA'local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, 1)if #tasks == 0 then return nil endif redis.call('ZREM', KEYS[1], tasks[1]) == 1 then return tasks[1]endreturn nilLUA;while (true) { $task = $redis->eval($lua, ['delay:orders', time()], 1); if ($task) { $data = json_decode($task, true); // 检查订单是否已支付 $order = getOrder($data['order_id']); if ($order['status'] === 'unpaid') { cancelOrder($data['order_id']); echo "⏰ 订单 {$data['order_id']} 超时未支付,已自动取消n"; } else { echo "✓ 订单 {$data['order_id']} 已支付,跳过n"; } } else { sleep(1); }}七、ZSet 延时队列 vs 其他方案
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| ZSet | score=时间戳,轮询取 | 简单,Redis 自带 | 需要轮询,精度秒级 |
| Redis 过期回调 | key 过期触发通知 | 不用轮询 | 通知不可靠,可能丢失 |
| RabbitMQ 延时插件 | 消息自带 TTL + 死信队列 | 专业可靠 | 需要额外装插件 |
| 数据库轮询 | 定时扫表 | 实现简单 | 大量数据时很慢 |
八、ZSet 的 score 谁来赋值的
ZADD key score member ↑ 你自己指定的ZADD delay_queue 1718000000 "task_001"# ↑ 时间戳就是 score,你自己算的# score 决定了 ZSet 里的排序
排序规则: score 越小越靠前,所以过期时间越早的排最前面。
九、Set vs ZSet 能不能做延时队列
| Set | ZSet | |
|---|---|---|
| 有序吗 | ❌ 无序 | ✅ 按 score 排序 |
| 能查到期的吗 | ❌ | ✅ ZRANGEBYSCORE 0 now |
| 能做延时队列吗 | 不行 | 可以 |
延时队列的核心需求是"按时间排序、查到期任务",只有 ZSet 能做到。
十、面试常问
Q: 延时队列为什么不用 List?
List 只能头进头出,无法按时间排序,不知道哪些消息到期了。
Q: 轮询会不会性能差?
单次 ZRANGEBYSCORE + ZREM 是 O(log N),每秒轮询对 Redis 压力很小。10万条延时任务也没问题。
Q: 很大量级的延时任务怎么办?
- 用多个 ZSet key 分桶(按分钟/小时)
- 每个桶一个消费者线程
- 配合 Redis Cluster 分片
Q: 消息丢了怎么办?
Redis 纯内存的话宕机会丢。重要业务建议:
- AOF 持久化
- 订单状态双写(Redis + DB),定时任务扫表兜底
Q: score 存毫秒级时间戳可以吗?
可以,ZSet 的 score 是 double 浮点数,毫秒时间戳完全放得下。
核心记住:ZSet 的 score 排序 + Lua 原子抢任务 = 可靠的延时队列