主题
第二十三章:延迟队列与任务调度
Q71:什么是延迟队列?为什么订单超时、定时提醒这些场景需要它?
答:
延迟队列是指「消息投递后不立即消费,而是等到指定时间之后才被消费者拿到」的队列。普通队列是「先进先出、立即消费」,延迟队列多了一个「延迟时间」维度。
一、普通队列 vs 延迟队列
text
普通队列:入队 ──────► 立即被消费
延迟队列:入队 ── 延迟 N 秒 ──► 到点后才被消费二、为什么需要延迟队列
很多业务逻辑是「某个时间点到了才触发」,而不是「来了就处理」:
| 场景 | 延迟的诉求 | 典型延迟时间 |
|---|---|---|
| 订单超时未支付自动取消 | 下单后 N 分钟没支付,自动取消订单、释放库存 | 15 分钟 / 30 分钟 |
| 交易超时自动确认收货 | 发货后 N 天没确认,自动确认收货 | 7 天 / 15 天 |
| 定时提醒 / 待办 | 到某个时间点提醒用户 | 具体到某时刻 |
| 延迟重试 | 失败后延迟一段时间再重试 | 几秒到几分钟 |
| 定时发布 | 文章/活动到点自动发布/上线 | 具体到某时刻 |
| 消息延迟推送 | 营销消息延迟到某时间发送 | 具体到某时刻 |
这些场景的共同点是:「状态变更」不是立即发生,而是在未来的某个时间点自动发生。延迟队列就是承载这种「延迟触发的任务」的机制。
三、如果不用延迟队列会怎样
以一个最常见的「订单超时自动取消」为例,如果不用延迟队列,通常有两种笨办法:
- 数据库轮询:定时任务每隔一段时间扫一遍「未支付且超过 30 分钟的订单」再取消。缺点:扫描间隔难把握(太短压力大、太长不及时),且无关订单也要反复扫,效率低。
- 用户操作时再判断:用户下次操作时才发现订单超时。缺点:用户不操作就不处理,库存、状态长时间挂着,体验差。
延迟队列的优势是:每个任务都有自己的「到期时间」,到点才被精确处理,不用全表扫描、不用用户触发。
四、延迟队列要达到的目标
一个好的延迟队列应满足:
- 延迟精确:尽量接近目标时间触发(可容忍秒级误差);
- 不丢任务:任务入队后不能因为进程重启/崩溃而丢失;
- 可扩展:能承接大量延迟任务,不会因单点瓶颈卡住;
- 幂等:任务可能重复触发,消费端要能幂等处理。
Q72:用 Redis 的 ZSet 怎么实现延迟队列?具体流程是什么?
答:
Redis 最经典的延迟队列实现方式是 ZSet(有序集合):把「任务的执行时间戳」作为 score,把「任务内容」作为 member,然后定时扫描「score 已到期的任务」弹出处理。
一、核心思想
text
ZSet:delay_queue
member = 任务内容(或任务 id)
score = 任务应该执行的时间戳(Unix 时间戳)
到期判断:score <= 当前时间戳,说明任务到期,可以消费利用 ZSet 的按 score 排序特性,ZRANGEBYSCORE 就能精确取出「所有已到期(score ≤ now)的任务」。
二、完整流程
text
生产任务:
ZADD delay_queue <执行时间戳> <任务内容>
消费任务(定时扫描):
1. ZRANGEBYSCORE delay_queue -inf <now> 取已到期任务
2. 处理这些任务
3. ZREM delay_queue <已处理的任务>,从队列移除三、代码实现
生产任务:
python
import time
import json
def add_delay_task(queue_key: str, task: dict, delay_seconds: int):
execute_at = time.time() + delay_seconds
redis_client.zadd(queue_key, {json.dumps(task): execute_at})消费任务(定时轮询):
python
def poll_delay_task(queue_key: str, limit: int = 100):
now = time.time()
# 1. 取出所有 score <= now 的到期任务
tasks = redis_client.zrangebyscore(queue_key, 0, now, start=0, num=limit)
if not tasks:
return []
# 2. 处理任务(幂等处理,业务逻辑)
results = []
for raw in tasks:
task = json.loads(raw)
# 处理业务 ...
results.append(task)
# 3. 移除已处理任务
redis_client.zrem(queue_key, raw)
return results一个常驻的后台进程/定时任务周期性调用 poll_delay_task,不断把到期任务捞出来处理。
四、必须解决的几个问题
问题 1:多个消费者竞争同一条任务
多个 worker 同时扫描,可能同时拿到同一条到期任务,导致重复处理。解决方式:消费前用 ZREM 原子抢占——谁 ZREM 成功谁处理:
python
def poll_delay_task_atomic(queue_key: str, limit: int = 100):
now = time.time()
tasks = redis_client.zrangebyscore(queue_key, 0, now, start=0, num=limit)
parsed = []
for raw in tasks:
# ZREM 返回 1 表示「我抢到了」,返回 0 表示已被别人抢走
if redis_client.zrem(queue_key, raw):
parsed.append(json.loads(raw))
return parsedZREM 是原子的,多个消费者同时删同一条,只有一个能成功,天然实现「一次任务只被一个消费者处理」。
问题 2:任务处理失败 / 消费者崩溃导致丢失
如果任务 ZREM 后、真正处理前,消费者崩溃了,这个任务就被删了但没处理 → 丢失。解决方式:
- 先处理,成功后再 ZREM:但会引入「重复处理」(处理中崩溃,下次又拿到);
- 引入确认机制:用「延迟队列 + 可靠队列」两层,先把到期任务原子转移到另一个「待确认」队列,处理完再确认(类似 Stream 的 ACK);
- 接受幂等 + 少量重复:业务处理设计成幂等,即使重复也不出错,这是最务实的做法。
问题 3:延迟精度
轮询间隔决定了延迟精度。间隔太短,空轮询浪费;间隔太长,延迟误差大。通常用「固定间隔轮询(如每 1 秒)+ 阻塞等待」平衡。更精确的方案可以参考 Redis 的 BZPOPMIN(阻塞弹出最小 score)。
bash
# 阻塞弹出 score 最小的元素(即最早到期的任务)
redis-cli BZPOPMIN delay_queue 5BZPOPMIN 会阻塞等待,直到有元素弹出或超时,比轮询更实时。但要注意它是「弹出 score 最小」而非「弹出到期的」,仍需要判断弹出的 score 是否已到期。
五、小结
- ZSet 延迟队列 = score 存时间戳 + 定时
ZRANGEBYSCORE捞到期任务; - 多消费者用
ZREM原子抢占保证只处理一次; - 处理失败/崩溃用「幂等 + 少量重复」或「两层队列 + ACK」兜底;
- 精度靠轮询间隔或
BZPOPMIN阻塞等待控制。
Q73:除了用 Redis 自己实现延迟队列,还有哪些方案?如何选型?
答:
延迟队列的实现方案很多,从「Redis 自己造」到「专业 MQ 原生支持」到「专门的延迟任务框架」,各有适用场景。
一、各方案对比
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| Redis ZSet | score 存时间戳 + 轮询 | 复用现有 Redis,简单灵活 | 精度一般、需自己处理并发和可靠性 |
| Redis Stream + 消费组 | 类似 ZSet + 可靠确认 | 有 ACK、消费组 | 延迟能力仍需配合时间戳/ZSet |
| Redis 过期事件(keyspace 通知) | 监听 key 过期事件触发处理 | 简单 | 不可靠:过期事件不保证精准、可能丢失 |
| RabbitMQ 延迟插件 | 延迟交换机 x-delayed-message | 专业、可靠、支持 ACK | 需装插件、引入 RabbitMQ 组件 |
| RocketMQ 延迟消息 | 原生支持 18 个延迟级别 | 原生、可靠、分布式 | 延迟级别固定(如 1s/5s/10s...) |
| Kafka + 定时任务 | Kafka 不支持原生延迟,靠外部定时扫描 | 高吞吐 | 延迟能力弱,需自己实现 |
| 专门任务调度框架 | XXL-Job、ElasticJob、Celery + beat 等 | 功能全、有可视化界面 | 重量级,引入额外框架 |
二、关键方案详解
1. Redis keyspace 过期事件(谨慎使用)
Redis 支持「key 过期时发布通知」,理论上可以监听「key 过期」来做延迟任务:
text
1. SET delay:{order_id} "" EX 1800 # 设置 30 分钟后过期
2. 监听 expired 事件
3. 收到事件 → 取消订单但强烈不建议依赖它做关键业务,原因:
- 过期事件不可靠:Redis 的过期删除是惰性 + 定期随机清理,key 过期了不保证立即触发事件,可能延迟很久;
- 可能丢失:Redis 重启、主从切换等情况下,过期事件可能不触发;
- 事件没持久化:订阅者离线期间的事件直接丢失。
所以 keyspace 通知只能用于**「丢失可容忍」的非关键场景**,不能用于订单取消这类重要业务。
2. RabbitMQ 延迟插件
RabbitMQ 配合 rabbitmq_delayed_message_exchange 插件,通过「延迟交换机」实现真正的延迟消息:
text
生产者 ──► 延迟交换机 ── 延迟 N 秒 ──► 队列 ──► 消费者优点是专业、可靠、有 ACK、延迟精确;缺点是引入 RabbitMQ 组件 + 装插件,运维成本上升。
3. RocketMQ 延迟消息
RocketMQ 原生支持延迟消息,但延迟时间是预设的固定级别(如 1s、5s、10s、30s、1m、2m ... 30m、1h 等),不支持「任意延迟时间」。适合延迟时间能落在这些级别内的场景。
三、选型决策
text
已在用 Redis、任务量适中 ──► Redis ZSet 自己实现(最常用)
要可靠 ACK、延迟精确、能接受引入组件 ──► RabbitMQ 延迟插件
延迟时间在固定级别内、用 Java 生态 ──► RocketMQ 延迟消息
高吞吐日志流、延迟要求不高 ──► Kafka + 外部定时
要可视化、任务丰富、团队有运维能力 ──► XXL-Job 等调度框架
简单、容忍丢失的提醒 ──► Redis keyspace 通知(仅限非关键)四、选型核心考量
- 现有技术栈:优先复用已在使用的基础设施,避免为延迟队列单独引入组件;
- 可靠性与精度:关键业务(订单、资金)要可靠延迟,不能靠 keyspace 通知;
- 任务量:海量延迟任务要考察吞吐和单点瓶颈;
- 延迟灵活性:是否需要任意延迟时间(RocketMQ 固定级别就不适合);
- 幂等设计:无论哪种方案,消费端都要幂等,因为延迟任务可能重复触发。
五、小结
- 兜底方案是 Redis ZSet(score 存时间戳 + 轮询),简单灵活,多数场景够用;
- 专业方案用 RabbitMQ 延迟插件 / RocketMQ 延迟消息,更可靠但更重;
- Redis keyspace 过期事件不可靠,只用于非关键、可容忍丢失的场景;
- 关键原则:复用现有组件优先、消费端幂等、关键业务别用不可靠方案。