4. 延迟任务与定时任务:基于 Sorted Set 的延迟队列实现、时间轮算法与 Redis 结合

在异步任务编排中,延迟任务与定时任务是两类核心场景。延迟任务要求任务在指定延迟时间后执行一次(如订单超时取消),定时任务则要求任务按固定周期或 Cron 表达式重复执行(如每日数据汇总)。Redis 的 Sorted Set 与时间轮算法分别提供了不同的实现路径,本章将深入剖析两者的原理、实现与性能调优。

4.1 基于 Sorted Set 的延迟队列实现

Redis 的 Sorted Set(有序集合)天然支持按分数排序,将任务到期时间戳作为分数,即可实现延迟队列。其核心思路是:生产者将任务 ID 与到期时间戳写入 Sorted Set,消费者轮询获取分数小于当前时间戳的任务并执行。

4.1.1 核心数据结构与命令

命令 作用 时间复杂度
ZADD key score member 添加任务(member)及其到期时间戳(score) O(log N)
ZRANGEBYSCORE key min max 获取分数在 [min, max] 范围内的任务 O(log N + M)
ZREM key member 移除已执行或取消的任务 O(log N)
ZCOUNT key min max 统计分数范围内的任务数量 O(log N)

4.1.2 生产者与消费者流程

  1. 生产者:计算任务到期时间戳 now + delay,执行 ZADD delay_queue timestamp task_id。
  2. 消费者:循环执行 ZRANGEBYSCORE delay_queue -inf now LIMIT 0 batch_size 获取到期任务。
  3. 原子性检查:使用 Lua 脚本或 Redis 事务确保任务获取与删除的原子性,防止重复消费。
  4. 执行与确认:消费者执行任务逻辑后,调用 ZREM 移除任务。
性能要点:消费者轮询间隔不宜过短(建议 100ms~1s),避免空转消耗 CPU;使用 BLPOP 风格阻塞等待可降低轮询开销,但需配合辅助 List 实现。

4.1.3 基于 Sorted Set 的延迟队列架构图

基于 Sorted Set 的延迟队列架构 生产者 ZADD score=timestamp Sorted Set: delay_queue score: 1710000000 → task:A score: 1710000100 → task:B score: 1710000200 → task:C score: 1710000300 → task:D score: 1710000400 → task:E score: 1710000500 → task:F 按时间戳升序排列 消费者 ZRANGEBYSCORE 轮询到期任务 执行任务 ZREM 移除

4.1.4 优缺点分析

维度 说明
优点 实现简单,原生支持持久化与主从复制;任务按时间排序,范围查询高效;支持批量获取与删除。
缺点 轮询模式存在空转开销;大量过期任务堆积时,ZRANGEBYSCORE 可能阻塞;缺乏任务取消与重试机制。
适用场景 延迟量级在秒级到天级,任务量在百万级以内,对实时性要求不苛刻的场景。

4.2 时间轮算法与 Redis 结合

时间轮(Timing Wheel)是一种高效的定时器数据结构,通过将时间划分为多个槽位,每个槽位对应一个时间间隔,任务根据到期时间放入对应槽位。结合 Redis 的 List 或 Stream 作为槽位容器,可以实现高吞吐、低延迟的延迟任务调度。

4.2.1 时间轮核心原理

  • 槽位(Slot):时间轮被划分为 N 个槽位,每个槽位代表一个时间单位(如 1 秒)。
  • 指针(Pointer):每隔一个时间单位,指针向前移动一个槽位,指向当前时间对应的槽。
  • 任务存储:任务根据到期时间计算应放入的槽位索引,存入该槽位的任务列表。
  • 多级时间轮:当时间跨度较大时,使用多级时间轮(如秒级、分钟级、小时级)减少内存占用。

4.2.2 Redis 时间轮实现方案

将时间轮的每个槽位映射为 Redis 的一个 List 或 Stream Key,槽位索引作为 Key 后缀。Redis 时间轮架构如下:

Redis 时间轮延迟队列架构 槽位 0 槽位 1 槽位 2 槽位 3 槽位 4 槽位 5 槽位 6 槽位 7 指针 时间轮 每个槽位 = Redis List 任务生产者 任务消费者 多级时间轮:秒→分→时

4.2.3 时间轮与 Sorted Set 对比

对比维度 Sorted Set 延迟队列 Redis 时间轮
时间复杂度 添加 O(log N),查询 O(log N + M) 添加 O(1),查询 O(1)
内存占用 随任务数线性增长,无额外槽位开销 固定槽位数组 + 任务列表,槽位数固定
精度 毫秒级(取决于时间戳精度) 受槽位时间单位限制(如 1 秒)
任务取消 需额外维护索引,成本较高 需遍历槽位列表,成本可控
持久化 原生支持 RDB/AOF 依赖 List/Stream 持久化
适用规模 百万级以内 千万级及以上

4.3 性能调优与故障排查

4.3.1 延迟队列性能调优要点

  • 批量操作:使用 ZRANGEBYSCORE ... LIMIT 0 100 批量拉取任务,减少网络往返。
  • Lua 脚本原子化:将查询与删除合并为一个 Lua 脚本,避免竞态条件。
  • 消费者并发控制:多个消费者实例共享同一个 Sorted Set 时,使用 ZREM 的返回值判断是否成功获取任务,实现“至少一次”语义。
  • 时间轮槽位数选择:槽位数建议为 2 的幂(如 64、128、256),便于位运算快速计算槽位索引。
  • 多级时间轮降级:当任务延迟超过一级时间轮范围时,升级到更高级别时间轮,减少槽位数量。

4.3.2 常见故障与排查

故障现象 可能原因 排查方法
任务延迟执行 消费者轮询间隔过长;Redis 主从延迟导致时间戳不一致 检查轮询间隔配置;使用 TIME 命令校准 Redis 服务器时间
任务重复执行 消费者未原子化获取与删除;网络超时导致重试 启用 Lua 脚本;在任务执行前加分布式锁(如 Redlock)
内存持续增长 过期任务未及时清理;时间轮槽位列表无限增长 监控 ZCOUNT 或 LLEN;设置任务最大存活 TTL
消费者空转 CPU 高 轮询频率过高;无任务时仍频繁查询 使用 BLPOP 或 XREADGROUP BLOCK 阻塞等待
注意:时间轮方案中,如果 Redis 实例重启,槽位中的任务可能丢失。建议结合 Redis Stream 的消费者组机制实现持久化与消息确认,或使用 AOF 持久化策略。

4.4 选型建议

  • 任务量 < 100 万,延迟精度要求高:优先选择 Sorted Set 方案,实现简单,维护成本低。
  • 任务量 > 1000 万,吞吐量要求高:采用 Redis 时间轮方案,O(1) 的添加与查询性能优势明显。
  • 需要任务取消、重试、优先级:在 Sorted Set 基础上增加辅助 Hash 存储任务元数据,或使用时间轮 + 优先级队列组合。
  • 需要持久化与故障恢复:Redis Stream 结合消费者组是更可靠的选择,但复杂度较高。
实践建议:在项目初期使用 Sorted Set 快速验证延迟队列功能;当业务规模增长后,再迁移到时间轮或 Stream 方案。迁移时注意双写兼容期,确保数据不丢失。