首页 > 数据库 >Redis延时队列实现详解

Redis延时队列实现详解

来源:互联网 2026-07-04 08:52:14

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

一、什么是延时队列

普通队列是指消息一旦到达,立即被消费处理。

Redis延时队列实现详解

长期稳定更新的攒劲资源: >>>点此立即查看<<<

延时队列则不同:消息到达后不立即处理,而是暂存起来,等到指定的时间点再进行消费。其机制类似一个定时闹钟。

普通队列: [消息] → 立即消费

延时队列: [消息] → 等待 30 分钟 → 到期 → 消费

现实中的典型场景

  • 下单 30 分钟未支付,系统自动取消订单。
  • 红包发出 24 小时未被领取,自动退回。
  • 会议开始前 5 分钟,推送提醒通知。
  • 7 天后系统自动确认收货。

这些场景的核心需求是:让任务在未来的某个时刻被触发执行。

二、为什么选择 ZSet

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);
}

代码看起来正常,但存在严重问题。ZRANGEBYSCOREZREM 是两条独立的命令,并非原子操作。当部署了多个消费者实例(生产环境常见),多个进程可能同时读取到同一条任务,然后各自删除、各自执行。例如,订单被取消两次,导致业务错误。

五、Lua 脚本:原子化解决并发问题

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 分钟未支付自动取消

理论讲解后,我们通过一个完整的实例来演示“下单 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);
    }
}

这里有一个细节:即使任务到期,消费端仍需二次确认订单的真实状态,避免用户在超时前一秒完成支付。这种“双保险”设计是必要的业务逻辑考量。

七、ZSet 延时队列与其他方案对比

Redis ZSet 方案并非唯一,但属于最简单、最轻量级的实现。下表对比了常见方案:

方案 原理 优点 缺点
ZSet score=时间戳,轮询读取 简单,Redis 自带 需要轮询,精度秒级
Redis 过期回调 key 过期触发通知 无需轮询 通知可能丢失,不可靠
RabbitMQ 延时插件 消息自带 TTL + 死信队列 专业可靠 需要额外安装插件
数据库轮询 定时扫描数据库表 实现简单 大数据量时性能差

总体而言,ZSet 方案在“足够使用且实现简单”这一维度上具有明显优势。

八、ZSet 的 score 由谁赋值

score 由 生产者 在写入时指定。

ZADD key score member          
          ↑     
你指定的
ZADD delay_queue 1718000000 "task_001"
#                 ↑    时间戳即 score,由你计算
#                 score 决定 ZSet 中的排序顺序

排序规则: score 越小,元素越靠前。因此过期时间越早的任务排在 ZSet 最前面,优先被消费。

九、Set 与 ZSet 对比:谁更适合延时队列?

尽管两者名称相似,但能力迥异:

Set ZSet
有序吗 无序 按 score 排序
能查询到期任务吗 ZRANGEBYSCORE 0 now
能否用于延时队列 不能 可以

延时队列的核心需求是 “按时间排序、查询到期任务”,只有带 score 的 ZSet 能够满足。

十、常见面试问题

问:为什么延时队列不用 List?

List 只能从头部或尾部操作,无法判断内部哪个消息已到期。它更适用于纯粹的 FIFO 队列。

问:轮询方式会不会影响性能?

单次 ZRANGEBYSCORE + ZREM 的时间复杂度为 O(log N),每秒轮询一次对 Redis 压力很小。即使有 10 万条延时任务,也能轻松承载。

问:如何处理海量延时任务?

当数据量增长时,可采用以下优化:

  1. 使用多个 ZSet key 分桶(例如按分钟或小时分桶)
  2. 每个桶分配独立的消费者线程
  3. 配合 Redis Cluster 进行分片,进一步提升吞吐量

问:消息丢失怎么办?

Redis 是纯内存数据库,宕机可能导致数据丢失。对于重要业务,建议采取双重保障:

  • 开启 AOF 持久化
  • 业务上实行双写:若 Redis 丢失,定时脚本从数据库扫描补偿

问:score 可以存储毫秒级时间戳吗?

可以。ZSet 的 score 是 double 类型浮点数,毫秒时间戳虽然精度上存在微小损失,但存储完全可行。

核心要点:ZSet 的 score 排序 + Lua 原子抢任务 = 轻量可靠的延时队列。

侠游戏发布此文仅为了传递信息,不代表侠游戏网站认同其观点或证实其描述

热游推荐

更多
湘ICP备14008430号-1 湘公网安备 43070302000280号
All Rights Reserved
本站为非盈利网站,不接受任何广告。本站所有软件,都由网友
上传,如有侵犯你的版权,请发邮件给xiayx666@163.com
抵制不良色情、反动、暴力游戏。注意自我保护,谨防受骗上当。
适度游戏益脑,沉迷游戏伤身。合理安排时间,享受健康生活。