Redis 延时队列详解

一、什么是延时队列

普通队列: 消息一到就消费

延时队列: 消息到了先放着,到指定时间再消费

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

问题在哪? ZRANGEBYSCOREZREM 是分开的,多个消费者可能同时拿到同一条任务!


五、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: 很大量级的延时任务怎么办?

  1. 用多个 ZSet key 分桶(按分钟/小时)
  2. 每个桶一个消费者线程
  3. 配合 Redis Cluster 分片

Q: 消息丢了怎么办?

Redis 纯内存的话宕机会丢。重要业务建议:

  • AOF 持久化
  • 订单状态双写(Redis + DB),定时任务扫表兜底

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

可以,ZSet 的 score 是 double 浮点数,毫秒时间戳完全放得下。


核心记住:ZSet 的 score 排序 + Lua 原子抢任务 = 可靠的延时队列

相关推荐
烤代码的吐司君4 小时前
Redis 数据结构 ZSet, BIT, HyperLogLog,Geo 空间数据
redis·后端
leeyi2 天前
Checkpoint 机制:Agent 怎么在断电后接着跑
redis·aigc·agent
云技纵横3 天前
一个 @Async 让循环依赖暴雷:Spring 代理的暗坑
redis
犯困蛋挞yy4 天前
用Claude快速解决Redis代码报错反复无解的问题
redis
用户31693538118310 天前
Java连接Redis
redis
小小工匠12 天前
Redis - 事务机制:能实现 ACID 属性吗
数据结构·redis·性能优化·并发·持久化
taocarts_bidfans12 天前
反向海淘跨境缓存架构优化:taocarts Redis分层缓存实战技术
redis·缓存·架构·反向海淘·taocarts
炘爚12 天前
Linux——Redis
数据库·redis·缓存