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 生产者与消费者流程
- 生产者:计算任务到期时间戳
now + delay,执行ZADD delay_queue timestamp task_id。 - 消费者:循环执行
ZRANGEBYSCORE delay_queue -inf now LIMIT 0 batch_size获取到期任务。 - 原子性检查:使用 Lua 脚本或 Redis 事务确保任务获取与删除的原子性,防止重复消费。
- 执行与确认:消费者执行任务逻辑后,调用
ZREM移除任务。
性能要点:消费者轮询间隔不宜过短(建议 100ms~1s),避免空转消耗 CPU;使用
BLPOP 风格阻塞等待可降低轮询开销,但需配合辅助 List 实现。
4.1.3 基于 Sorted Set 的延迟队列架构图
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 时间轮架构如下:
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 方案。迁移时注意双写兼容期,确保数据不丢失。