一、什么是延时队列
普通队列: 消息一到就消费
延时队列: 消息到了先放着,到指定时间再消费
css
普通队列: [消息] → 立即消费
延时队列: [消息] → 等 30 分钟 → 到期 → 消费
场景举例:
- 下单 30 分钟未支付,自动取消
- 红包 24 小时未领取,自动退回
- 会议开始前 5 分钟,发送提醒
- 7 天后自动确认收货
二、为什么用 ZSet 实现
| 数据类型 | 结构 | 能做延时队列吗 |
|---|---|---|
| List | 按插入排序 | ❌ 不支持按时间排序 |
| Set | 无序 | ❌ 没法指定执行时间 |
| ZSet | 按 score 排序 | ✅ score 存到期时间戳 |
bash
# ZSet 天然适合:
ZADD delay_queue 1718000000 "task_001" # score=到期时间戳
ZADD delay_queue 1718000300 "task_002" # 自动按时间排序
三、核心流程
ini
生产者 Redis ZSet 消费者(定时任务)
────── ────────── ──────────────
XADD delay_q score = 到期时间戳
score=到期时间 member = 任务数据
┌─────────────────┐
│ 1718000000 task1 │ ← 最早到期
│ 1718000300 task2 │
│ 1718000600 task3 │
└─────────────────┘
↓
ZRANGEBYSCORE 0 当前时间
取到 task1(已到期)
↓
执行 task1 → ZREM 删除
四、基础实现(有并发问题)
php
// 生产者:投递延时任务
$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 脚本原子化(解决并发)
lua
-- 原子操作:查出到期任务 + 立刻删除 + 返回
local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, 1)
if #tasks == 0 then
return nil
end
local task = tasks[1]
local removed = redis.call('ZREM', KEYS[1], task)
if removed == 1 then
return task -- 删除成功,返回任务
else
return nil -- 被别的消费者抢了
end
PHP 端调用:
php
$lua = <<<'LUA'
local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, 1)
if #tasks == 0 then return nil end
local task = tasks[1]
if redis.call('ZREM', KEYS[1], task) == 1 then
return task
else
return nil
end
LUA;
// 定时任务循环执行
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
<?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 end
if redis.call('ZREM', KEYS[1], tasks[1]) == 1 then
return tasks[1]
end
return nil
LUA;
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 谁来赋值的
bash
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 原子抢任务 = 可靠的延时队列