3. 延迟队列核心原理:基于 ZSET 的延迟队列模型

延迟队列的核心在于将任务的执行时间与当前系统时间解耦,让任务在“未来某个时刻”才被消费者取出并处理。Redis 的 ZSET(有序集合) 天然支持按分数排序,这一特性使其成为实现延迟队列的理想底层结构。

3.1 基于 ZSET 的延迟队列模型

ZSET 延迟队列的基本模型包含三个核心角色:生产者、ZSET 存储层、消费者。生产者将任务写入 ZSET,消费者通过轮询或定时任务扫描 ZSET 中分数(时间戳)小于等于当前时间的元素,取出并执行。

核心流程:生产者写入任务时,将“期望执行时间戳”作为 score;消费者使用 ZRANGEBYSCORE 命令获取已到期的任务,并使用 ZREM 移除,确保任务不被重复消费。
ZSET 延迟队列模型架构图 生产者 Redis ZSET key: delay_queue member: 任务ID score: 执行时间戳 消费者 ZADD ZRANGEBYSCORE 模型要点: 1. 生产者通过 ZADD 写入任务,score = 计划执行时间戳(毫秒级)。 2. 消费者通过 ZRANGEBYSCORE queue 0 now 获取到期任务,并用 ZREM 删除。

3.2 时间轮与 ZSET 对比

时间轮(Timing Wheel)是另一种经典的延迟任务调度算法,常见于 Netty、Kafka 等高性能中间件。下表从多个维度对比两种方案:

对比维度 ZSET 延迟队列 时间轮(Timing Wheel)
底层存储 Redis 有序集合(内存 + 持久化) 环形数组 + 链表(纯内存)
精度 毫秒级(取决于时间戳精度) 受 tick 间隔限制(通常毫秒级)
容量 受 Redis 内存限制,可扩展 受槽位数量限制,大容量需多层时间轮
持久化 支持(RDB/AOF) 不支持(进程重启后任务丢失)
分布式支持 天然支持(Redis 集群) 需额外协调(通常单机)
任务取消/更新 方便(ZREM / ZADD 更新 score) 较复杂(需遍历链表查找)
适用场景 分布式系统、需要持久化、任务量中等 单机高吞吐、低延迟、任务量极大
选型建议:在报表削峰场景中,任务通常需要跨服务、跨实例协调,且要求任务不丢失。ZSET 延迟队列凭借 Redis 的持久化和集群能力,更适合作为分布式延迟队列的选型。时间轮更适合单机、超低延迟的定时任务场景。

3.3 Score 作为时间戳的设计思路

ZSET 的 score 是一个 64 位浮点数,可以精确表示毫秒级时间戳。设计时通常采用以下方案:

  • 时间戳单位:统一使用毫秒级时间戳(13 位数字),避免秒级精度导致任务提前或延迟执行。
  • score 计算公式:score = 当前系统时间戳(ms) + 延迟时间(ms)。例如:当前时间 1700000000000ms,延迟 5 分钟,则 score = 1700000300000。
  • 消费者轮询策略:使用 ZRANGEBYSCORE queue 0 current_timestamp_ms 获取所有已到期任务,配合 ZREM 原子移除。
  • 避免分数冲突:如果同一毫秒内写入多个任务,ZSET 会按 member(任务 ID)排序。建议在 member 中拼接随机数或序列号,确保唯一性。
// 生产者:写入延迟任务
long delayMs = 5 * 60 * 1000; // 5分钟
long executeAt = System.currentTimeMillis() + delayMs;
String taskId = UUID.randomUUID().toString();
jedis.zadd("delay_queue", executeAt, taskId);

// 消费者:轮询到期任务
Set<String> readyTasks = jedis.zrangeByScore("delay_queue", 0, System.currentTimeMillis());
for (String taskId : readyTasks) {
    // 尝试移除任务,防止并发消费
    long removed = jedis.zrem("delay_queue", taskId);
    if (removed == 1) {
        // 成功获取任务,执行处理逻辑
        processTask(taskId);
    }
}
注意:ZRANGEBYSCORE 和 ZREM 不是原子操作。在高并发场景下,多个消费者可能同时获取到同一个任务。建议使用 Lua 脚本将“获取并移除”封装为原子操作,或使用 ZPOPMIN(Redis 5.0+)替代。
Score 时间戳设计流程图 1. 计算执行时间 now + delay 2. ZADD 写入 score = 时间戳 3. 轮询到期任务 ZRANGEBYSCORE 4. 执行 + ZREM Score 设计关键点: • 时间戳精度:使用毫秒级时间戳,避免秒级精度导致任务提前或延迟。 • 分数唯一性:同一毫秒内多个任务,ZSET 按 member 字典序排列。建议 member 包含随机数。 • 原子性保证:使用 Lua 脚本或 ZPOPMIN 确保“获取 + 删除”原子执行。 • 时钟回拨处理:如果系统时间回拨,已写入的任务可能延迟执行。建议监控时钟同步。 • 过期清理:定期清理已执行但未被删除的残留任务,避免 ZSET 无限膨胀。

3.4 延迟队列的可靠性保障

基于 ZSET 的延迟队列虽然简单,但在生产环境中需要额外关注以下可靠性问题:

  • 任务丢失:消费者在 ZREM 之后、业务处理完成之前崩溃,任务会丢失。解决方案:引入“处理中”队列或任务状态表。
  • 重复消费:多个消费者同时轮询到同一任务。解决方案:使用 Lua 脚本原子操作,或利用 Redis 分布式锁。
  • 队列积压:消费者处理速度跟不上任务到期速度。解决方案:增加消费者实例,或使用 Redis 集群分片。
  • 时间精度:ZSET 的 score 是浮点数,高并发下可能出现精度误差。解决方案:使用毫秒级时间戳,避免微秒级精度。
最佳实践:在报表削峰场景中,建议将 ZSET 延迟队列与数据库任务表结合使用。ZSET 负责“定时触发”,数据库负责“任务持久化与状态管理”,两者互补,既保证实时性,又保证可靠性。