一、什么是延时队列 普通队列是指消息一旦到达,立即被消费处理。 延时队列则不同:消息到达后不立即处理,而是暂存起来,等到指定的时间点再进行消费。其机制类似一个定时闹钟。 普通队列: [消息] → 立即消费 延时队列: [消息] → 等待 30 分钟 → 到期 &rarr
普通队列是指消息一旦到达,立即被消费处理。

长期稳定更新的攒劲资源: >>>点此立即查看<<<
延时队列则不同:消息到达后不立即处理,而是暂存起来,等到指定的时间点再进行消费。其机制类似一个定时闹钟。
普通队列: [消息] → 立即消费
延时队列: [消息] → 等待 30 分钟 → 到期 → 消费
这些场景的核心需求是:让任务在未来的某个时刻被触发执行。
Redis 提供了多种数据结构,哪一种最适合实现延时队列?我们对比分析如下:
| 数据类型 | 结构 | 能否实现延时队列 |
|---|---|---|
| List | 按插入顺序排序 | 不支持按时间排序 |
| Set | 无序集合 | 无法指定执行时间 |
| ZSet | 按 score 排序 | 将到期时间戳作为 score |
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 删除
生产者负责将任务加入 ZSet,消费者则像哨兵一样不断检查:是否存在 score 小于等于当前时间的任务,一旦发现即取出执行并删除。
根据上述思路,可以快速写出第一版代码:
// 生产者:投递延时任务
$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 是两条独立的命令,并非原子操作。当部署了多个消费者实例(生产环境常见),多个进程可能同时读取到同一条任务,然后各自删除、各自执行。例如,订单被取消两次,导致业务错误。
Redis 的 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 端调用:
$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 分钟未支付自动取消”的实现:
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);
}
}
这里有一个细节:即使任务到期,消费端仍需二次确认订单的真实状态,避免用户在超时前一秒完成支付。这种“双保险”设计是必要的业务逻辑考量。
Redis ZSet 方案并非唯一,但属于最简单、最轻量级的实现。下表对比了常见方案:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| ZSet | score=时间戳,轮询读取 | 简单,Redis 自带 | 需要轮询,精度秒级 |
| Redis 过期回调 | key 过期触发通知 | 无需轮询 | 通知可能丢失,不可靠 |
| RabbitMQ 延时插件 | 消息自带 TTL + 死信队列 | 专业可靠 | 需要额外安装插件 |
| 数据库轮询 | 定时扫描数据库表 | 实现简单 | 大数据量时性能差 |
总体而言,ZSet 方案在“足够使用且实现简单”这一维度上具有明显优势。
score 由 生产者 在写入时指定。
ZADD key score member
↑
你指定的
ZADD delay_queue 1718000000 "task_001"
# ↑ 时间戳即 score,由你计算
# score 决定 ZSet 中的排序顺序
排序规则: score 越小,元素越靠前。因此过期时间越早的任务排在 ZSet 最前面,优先被消费。
尽管两者名称相似,但能力迥异:
| Set | ZSet | |
|---|---|---|
| 有序吗 | 无序 | 按 score 排序 |
| 能查询到期任务吗 | ZRANGEBYSCORE 0 now |
|
| 能否用于延时队列 | 不能 | 可以 |
延时队列的核心需求是 “按时间排序、查询到期任务”,只有带 score 的 ZSet 能够满足。
问:为什么延时队列不用 List?
List 只能从头部或尾部操作,无法判断内部哪个消息已到期。它更适用于纯粹的 FIFO 队列。
问:轮询方式会不会影响性能?
单次 ZRANGEBYSCORE + ZREM 的时间复杂度为 O(log N),每秒轮询一次对 Redis 压力很小。即使有 10 万条延时任务,也能轻松承载。
问:如何处理海量延时任务?
当数据量增长时,可采用以下优化:
问:消息丢失怎么办?
Redis 是纯内存数据库,宕机可能导致数据丢失。对于重要业务,建议采取双重保障:
问:score 可以存储毫秒级时间戳吗?
可以。ZSet 的 score 是 double 类型浮点数,毫秒时间戳虽然精度上存在微小损失,但存储完全可行。
核心要点:ZSet 的 score 排序 + Lua 原子抢任务 = 轻量可靠的延时队列。
侠游戏发布此文仅为了传递信息,不代表侠游戏网站认同其观点或证实其描述