队列模型
这是 2018 年的夏天,我们的小创业公司刚上线外卖系统,日订单量终于突破 5000 单。团队只有 3 个后端,大家在出租屋里敲代码,氛围热烈而紧张。
那天晚上 11 点,运营跑过来找到我:
“老板,后台发现好多订单超时没付款,但 30 分钟后没自动取消。库存一直被占用,有用户在投诉!”
我一看代码,心凉了半截。原来是我一周前写的”订单超时取消”功能:
那时候我天真地以为:一个订单一个线程,简单直接。
但现实狠狠地打了我一巴掌。运维小王连夜查了监控,脸色惨白:
“老板,你看这个图……当前活跃线程数 8 万多,服务器内存已经用掉 90% 了。再这么下去,今晚就要 OOM。”
我盯着那个监控图表,突然意识到问题的严重性:
- 😱 5000 日单 峰值时段同时在线的订单可能上万,那上万个线程?
- 😱 一台服务器重启,所有等待中的任务全丢了,订单永远不取消?
- 😱 内存不够用,晚上高峰期直接崩溃?
那一夜我没睡,盯着服务器日志发呆。凌晨 4 点,我突然醒悟:我需要一个更靠谱的模型。
那天凌晨,我重新翻开了大学时的数据结构课本。原来,问题的根源是我对”队列”的理解太浅了。
队列(Queue)是一个”先进先出”(FIFO)的数据结构,就像排队买奶茶:
生产者 队列 消费者
│ │ │
│ ── 放入消息 A ──► [A, B, C] ──► 取出消息 A ──► │
│ ── 放入消息 B ──► [B, C] ──► 取出消息 B ──► │
│ ── 放入消息 C ──► [C] ──► 取出消息 C ──► │
普通队列的特点:放入就立即可取。
但我的需求不同——订单需要等 30 分钟才能取消。这就是延时队列的核心:放入后,需要等一段时间才能取。
我画了一整晚的图,终于想清楚了:延时队列 = 普通队列 + 时间维度。
普通队列: 放入 ──► 立即可取
延时队列: 放入 ──► 等待 N 秒 ──► 可取
核心概念:两个房间
我把延时队列想象成有两个房间的餐厅:
┌──────────────────────────────────────────────┐
│ 延时队列模型 │
│ │
│ 生产者 │
│ │ │
│ │ 放入消息 + 延时时间 │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ 等待区(Wait Room) │ │
│ │ │ │
│ │ 消息A - 还剩 28 分钟 │ │
│ │ 消息B - 还剩 15 分钟 │ │
│ │ 消息C - 还剩 3 分钟 │ │
│ │ 消息D - 到期!→ 出列 │ │
│ └──────────┬───────────────┘ │
│ │ │
│ │ 到期消息 │
│ ▼ │
│ ┌──────────────────────────┐ │
│ │ 就绪区(Ready Zone) │ │
│ │ │ │
│ │ 消息D ✅ 可以消费了 │ │
│ └──────────┬───────────────┘ │
│ │ │
│ ▼ │
│ 消费者 │
│ (处理到期任务) │
└──────────────────────────────────────────────┘
这样一想,思路清晰了!所有订单先进入”等待区”,时间到了才能去”就绪区”被处理。
当前架构
延时队列的最小模型
核心不是立刻消费,而是先等待,到期后再进入可消费状态。
写入
等待
消费
延时队列的核心操作
使用示例:
不管底层是 Redis ZSet、RabbitMQ 还是数据库轮询,延时队列对外暴露的操作都是同一套。理解这一层抽象,就能看懂任何一种具体实现。
操作 1:延时入队(Schedule)
给任务指定一个未来的执行时刻,队列负责让它”等”到那一刻。
# 创建订单时,立刻挂一个 30 分钟后执行的取消任务
task_id = queue.schedule(
payload={"order_id": "ORD20241015001", "action": "cancel"},
delay=timedelta(minutes=30),
)
操作 2:到期出队(Pop Due)
消费者只取走已经到期的任务,没到期的任务留在队列里。
# 只取出执行时间 <= 当前时间的任务
task = queue.pop_due(now=datetime.now())
这一步是延时队列和普通队列的唯一区别,也是它最核心的能力。
操作 3:取消任务(Cancel)
用户支付了订单,那条”30 分钟后取消”的任务就不该再执行了。
# 按任务 ID 撤销,队列内部把它标记为已取消
queue.cancel(task_id)
这一操作在业务上极其重要:订单超时取消和支付是两个互斥的动作,谁先到谁生效。没有取消能力,用户付了钱订单还是被取消,是直接的资损事故。
操作 4:查询与对账(Inspect)
出错时要能回答”这条任务现在什么状态”——这是排查问题的基础。
queue.status(task_id) # → waiting / ready / processing / completed / cancelled
把四个操作放在一起看,延时队列的契约就清楚了:
| 操作 | 输入 | 输出 | 谁调用 |
|---|---|---|---|
| Schedule | 负载 + 延时时长 | 任务 ID | 业务方 |
| Pop Due | 当前时刻 | 到期任务或空 | 消费者 |
| Cancel | 任务 ID | 是否成功撤销 | 业务方 |
| Inspect | 任务 ID | 任务状态 | 运维 / 对账 |
难点不在接口,而在”等”这个过程怎么实现。 接下来两章会分别讲最朴素的数据库轮询方案和最工程化的消息队列方案——它们的接口完全一样,差别只在”等待”这个动作被放在了哪里。
第一版上线后,我又遇到了新问题。消费者应该主动来拉任务,还是队列主动推任务?
拉取模型(Pull):自己找活干
我一开始选这个,因为简单:消费者自己控制节奏,忙的时候少拉点,闲的时候多拉点。
优点: 消费者自己控制节奏
缺点: 没任务时也在不断轮询,浪费资源
推送模型(Push):任务送上门
后来订单量涨到 10 万单/天,我发现轮询太慢了。改成推送模式后,实时性提升明显:
优点: 到期即推,延迟更低
缺点: 消费者处理不过来时会积压
实战经验对比
| 维度 | 拉取模型 | 推送模型 |
|---|---|---|
| 实时性 | 取决于轮询间隔 | 到期即推 |
| 消费者压力 | 自己控制 | 可能被压垮 |
| 实现复杂度 | 简单 | 中等 |
| 适用场景 | 简单场景 | 高实时要求 |
| 典型实现 | Redis ZSet | RabbitMQ |
我的建议:刚开始用拉取,够用就行。等业务量上来,再考虑推送。别过度设计。
在排查线上问题时,我发现如果不知道任务处于什么状态,根本没法定位问题。于是设计了状态机。
一个延时任务从创建到完成,会经历这些状态:
┌──────────┐ 到达执行时间 ┌──────────┐ 消费者取走 ┌──────────┐
│ 等待中 │ ──────────────► │ 就绪 │ ──────────────► │ 处理中 │
│ (Waiting) │ │ (Ready) │ │(Processing)│
└──────────┘ └──────────┘ └─────┬────┘
│
┌───────────┼───────────┐
│ │ │
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 完成 │ │ 失败 │ │ 重试 │
│(Completed)│ │ (Failed) │ │ (Retry) │
└──────────┘ └──────────┘ └─────┬────┘
│
▼
重新进入等待
有了状态机,系统稳定多了。但 3 个月后,日订单量突破 10 万单,新的问题来了。
单机延时队列的问题暴露无遗:
单机延时队列的问题:
1. 内存有限 ── 任务太多放不下
2. 单点故障 ── 机器挂了全丢了
3. 性能瓶颈 ── 单机处理能力有限
解决方案:
1. 存储 ── 把任务放到外部存储(数据库 / Redis / MQ)
2. 高可用 ── 多副本 + 持久化
3. 分布式 ── 多消费者并行处理
那段时间,我和小王几乎天天通宵。我们决定把存储层抽离出来,改用 Redis 集群。
分布式延时队列模型
┌───────────┐ ┌───────────┐ ┌───────────┐
│ 生产者 A │ │ 生产者 B │ │ 生产者 C │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
└────────────┬────┴─────────────────┘
│
▼
┌─────────────────┐
│ 集中式存储层 │
│ (DB / Redis / │
│ MQ 集群) │
└────────┬────────┘
│
┌───────────┼───────────┐
│ │ │
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ 调度器 A │ │ 调度器 B │ │ 调度器 C │ ← 互斥调度
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
▼ ▼ ▼
┌───────────┐ ┌───────────┐ ┌───────────┐
│ 消费者 A │ │ 消费者 B │ │ 消费者 C │
└───────────┘ └───────────┘ └───────────┘
这样改造后,单机故障不再致命。即使一台服务器挂了,其他服务器继续工作,任务数据也都在 Redis 里持久化保存。
这一步的代价:架构复杂度大幅上升,分布式一致性、锁竞争、网络分区……问题接踵而至。但为了支撑业务增长,这些代价是值得的。
为了帮新人理解整个流程,我画了一张完整的数据流图。以”订单超时取消”为例:
1. 用户下单
POST /api/order
│
▼
2. 创建订单记录(数据库)
order.status = "pending_payment"
│
▼
3. 投递延时任务
delay_queue.push({
task_id: "cancel_order_12345",
payload: { order_id: 12345 },
delay: 30 * 60 // 30 分钟
})
│
▼
4. 任务在等待区等待 30 分钟
... 时间流逝 ...
│
▼
5. 任务到期,进入就绪区
│
▼
6. 调度器分配给消费者
│
▼
7. 消费者执行取消逻辑
- 查询订单状态(还是 pending_payment 吗?)
- 如果是:取消订单 + 释放库存
- 如果不是(已付款):跳过
│
▼
8. 标记任务完成
task.status = "completed"
这里有个踩坑点!
注意第 7 步的幂等检查!
我们早期就踩过这个坑。有一次,用户在第 29 分钟付款了,但延时任务到期后还是把订单取消了,用户气炸了。
教训:在 30 分钟内,用户可能已经付款了。延时任务到期后,必须先检查业务状态,再决定是否执行。这就是”幂等性”——多次执行结果是一样的。
我在代码里加了个显眼的注释:
这个小小的检查,避免了无数的投诉。
-
为什么延时队列通常使用最小堆而不是数组来存储? 时间复杂度有什么区别?
-
如果有 1000 万个任务同时在等待区,堆的操作会不会变慢? 应该怎么优化?
-
拉取模型和推送模型,哪个更适合”订单超时取消”场景? 为什么?
-
分布式环境下,多个调度器如何保证一个任务只被调度一次? 你能想到几种方案?
思考 1:为什么延时队列通常使用最小堆而不是数组来存储?
解题思路:
延时队列的访问模式很特殊:写入是随意的,但读取永远只问一件事——最早到期的那个是谁。所以选型只需比较两个操作的复杂度,插入和取最小值。数组插入快但取最小值要扫全表,最小堆恰好把这个短板压到 log n。
详细步骤:
问题分析:
延时队列的核心需求是:快速找到最早到期的任务。我们需要比较不同数据结构在”插入”和”取出最小元素”这两个操作上的效率。
时间复杂度对比:
| 操作 | 数组 | 最小堆(优先队列) | 优化说明 |
|---|---|---|---|
| 插入任务 | O(1) | O(log n) | 数组直接追加,堆需要上浮调整 |
| 取出最早任务 | O(n) | O(log n) | 数组需要遍历全部,堆只需取出堆顶 |
| 查看最早任务 | O(n) | O(1) | 数组需要遍历,堆直接看堆顶 |
代码对比:
性能测试对比:
为什么堆更适合?
- 业务特点:延时队列总是需要”最早到期的任务先处理”
- 堆的特性:堆顶永远是最小值,O(1) 时间就能获取
- 操作平衡:虽然插入稍慢(O(log n)),但取出快太多,整体性能优
最佳实践:
- Python 用
heapq模块(基于列表的最小堆实现) - Java 用
PriorityQueue或DelayQueue - Go 用
container/heap包 - C++ 用
std::priority_queue
最终答案:
延时队列应使用最小堆。数组插入是 O(1),但取出最早到期任务和查看最早任务都需要 O(n) 的全表遍历;最小堆插入 O(log n)、取出堆顶 O(log n)、查看堆顶 O(1),整体更优。工程上 Python 用 heapq,Java 用 PriorityQueue 或 DelayQueue,Go 用 container/heap,C++ 用 std::priority_queue。
思考 2:如果有 1000 万个任务同时在等待区,堆的操作会不会变慢?应该怎么优化?
解题思路:
先把 O(log n) 换算成具体感受:log₂(1000 万) 约等于 23,也就是每次插入删除要做 23 次比较交换,这个量级本身并不吓人。然后要分辨真正的瓶颈在哪——CPU 计算其实是最轻的,缓存未命中和重启恢复才是痛点。最后按任务规模分档给选型。
详细步骤:
问题分析:
堆的时间复杂度是 O(log n),当 n=1000 万时,log₂(10,000,000) ≈ 23。这意味着每次插入或删除需要约 23 次比较和交换操作。
理论计算:
性能瓶颈分析:
| 瓶颈类型 | 具体表现 | 影响程度 |
|---|---|---|
| CPU 计算 | O(log n) 的堆调整 | ⚠️ 中等(23 次操作可接受) |
| 内存占用 | 1000 万任务 ≈ 1GB | ⚠️ 中等(现代服务器内存足够) |
| 缓存未命中 | 堆的随机访问导致 CPU 缓存失效 | 🔴 严重(真正瓶颈) |
| 持久化成本 | 重启恢复需要加载 1GB 数据 | 🔴 严重(启动慢) |
优化方案:
方案 1:分层堆(Tiered Heap)
将任务按到期时间分成多个桶:
优势:
- ✅ 插入 O(1)(直接追加到桶)
- ✅ 取出 O(1) 平均(只扫描当前桶)
- ✅ 内存友好(可以清理过期桶)
方案 2:时间轮(Timing Wheel)
适合大量短延时的场景:
时间轮的复杂度:
- 插入:O(1)
- 取出:O(1) 平均(每次 tick 处理一个槽位)
- 空间:O(wheel_size),与任务数无关!
方案 3:外部存储 + 缓存
将 1000 万任务存储到 Redis,内存只保留热点任务:
方案选择建议:
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 任务数 < 100 万 | 单机最小堆 | 简单高效,无需优化 |
| 100 万 - 1000 万 | 分层堆 | 平衡性能和复杂度 |
| > 1000 万,短延时 | 时间轮 | O(1) 操作,内存可控 |
| > 1000 万,长延时 | Redis ZSet + 缓存 | 持久化 + 分布式支持 |
| 极致性能要求 | 时间轮 + 多级轮 | Kafka、Netty 都用这个 |
关键优化点总结:
- 减少堆操作次数:用分桶/时间轮降低 log n 的 n 值
- 利用 CPU 缓存:数组比链表缓存友好
- 批量处理:一次取出多个任务,分摊开销
- 异步持久化:写操作放后台,不阻塞主流程
- 懒加载:只在需要时才加载任务数据
最终答案:
堆在 1000 万任务时每次操作约 23 次比较交换,CPU 计算本身可以接受,真正的瓶颈是堆的随机访问导致缓存未命中,以及进程重启要加载约 1GB 数据的持久化成本。选型上,任务数低于 100 万用单机最小堆,100 万到 1000 万用分层堆,超过 1000 万且以短延时为主用时间轮,超过 1000 万且以长延时为主用 Redis ZSet 加缓存,极致性能要求则用时间轮加多级轮。
思考 3:拉取模型和推送模型,哪个更适合”订单超时取消”场景?为什么?
解题思路:
先把”订单超时取消”的业务特性摊开:延时 30 分钟、可以容忍晚几秒、下单高峰有突发、不能漏取消、必须幂等。这几条一旦列出来,“要不要秒级实时性”就有答案了——不需要;剩下的问题就是谁来承担突发流量。
详细步骤:
业务需求分析:
先明确”订单超时取消”的核心特点:
| 特性 | 说明 | 业务影响 |
|---|---|---|
| 延时时间长 | 30 分钟 | 不需要毫秒级实时性 |
| 容忍延迟 | 晚几秒取消可接受 | 允许轮询间隔 |
| 突发性 | 下单高峰期任务激增 | 消费者不能被压垮 |
| 可靠性要求高 | 不能漏取消 | 需要重试机制 |
| 幂等性要求 | 已付款订单不能取消 | 消费者要做状态检查 |
拉取模型(Pull)适合的理由:
1. 消费者可以自我保护
对比推送模型的问题:
2. 容错性更好
推送模式下,如果推送时消费者挂了,需要复杂的重试逻辑。
3. 实现简单
什么时候用推送模型?
推送模型适合这些场景:
| 场景 | 示例 | 理由 |
|---|---|---|
| 超低延迟要求 | 秒级杀倒计时 | 不能等轮询 |
| 任务轻量 | 发送通知 | 处理快,不会积压 |
| 实时系统 | 即时通讯 | 需要即时响应 |
最佳实践:混合模式
最终建议:
对于”订单超时取消”场景,拉取模型是更好的选择:
- ✅ 30 分钟延时,不需要秒级实时性
- ✅ 消费者可以自我保护,避免被高峰压垮
- ✅ 实现简单,故障容错性好
- ✅ 可以用多消费者并行拉取,提高吞吐量
如果确实需要更低的延迟,可以用批量拉取 + 异步处理的方式优化。
最终答案:
“订单超时取消”场景应选拉取模型。30 分钟的延时不需要秒级实时性,消费者可以自己控制拉取速率、不会被下单高峰压垮,实现简单且容错性好,还可以用多消费者并行拉取提高吞吐;若确实需要更低延迟,可用批量拉取加异步处理来优化。推送模型更适合超低延迟要求(如秒杀倒计时)、任务轻量(如发送通知)或实时系统(如即时通讯)这三类场景。
思考 4:分布式环境下,多个调度器如何保证一个任务只被调度一次?你能想到几种方案?
解题思路:
这题本质是分布式互斥:多个调度器同时看到同一个到期任务,要保证只有一个能拿走。判断一个方案好不好,就看它有没有把”查询再删除”这件事做成一次原子操作——做成原子操作就不需要锁,做不成就得靠锁,而锁是性能和无锁竞争的根源。
详细步骤:
问题本质:
这是经典的分布式互斥问题。多个调度器同时看到同一个到期任务,如何保证只有一个能拿到它?
方案 1:Redis 分布式锁
最直接的方案:
优缺点:
- ✅ 实现简单,Redis 原生支持
- ⚠️ 每个任务都要加锁,性能开销大
- ⚠️ 锁超时设置需要权衡(太短可能导致任务重复执行)
方案 2:Redis ZSET + Lua 脚本原子操作
将”查询 + 删除”合并为原子操作:
核心思想:
ZRANGEBYSCORE+ZREM在 Lua 中执行,Redis 保证原子性- 多个调度器同时执行,只有一个能 ZREM 成功
- 无需显式加锁,性能更好
优缺点:
- ✅ 原子操作,无锁竞争
- ✅ 性能高,Redis 单线程处理
- ⚠️ 任务取出后必须处理成功(否则丢失)
- 💡 需要配合死信队列处理失败任务
方案 3:数据库悲观锁
如果任务存储在数据库:
数据设计要点
- 核心是在
delay_tasks里保存业务事实,而不是把规则散落在应用逻辑里。- 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
- 关键字段包括
id、payload、execute_at、scheduler_id、updated_at,它们决定后续查询和管理能力。
SKIP LOCKED 的作用:
- 多个调度器并发查询
- 行已被锁定时,自动跳过
- 每个调度器拿到不同的任务
优缺点:
- ✅ 数据库原生支持,可靠
- ✅ 任务有持久化记录
- ⚠️ 性能受数据库限制
- ⚠️ 需要数据库支持
SKIP LOCKED(PostgreSQL、MySQL 8.0+)
方案 4:分片调度
将任务按 ID 分片,每个调度器只负责一部分:
优势:
- ✅ 完全避免竞争,每个调度器独立工作
- ✅ 性能最优,无需锁
- ⚠️ 调度器挂掉,分片任务无人处理
- 💡 需要配合主备切换或分片迁移
方案 5:Lease 机制
借鉴 Kubernetes 的 Lease 机制:
特点:
- 调度器动态加入/退出
- 自动重新分片
- 无需人工配置
方案对比总结:
| 方案 | 复杂度 | 性能 | 可靠性 | 适用场景 |
|---|---|---|---|---|
| Redis 锁 | ⭐⭐ | ⭐⭐ | ⭐⭐⭐ | 小规模任务 |
| Lua 原子操作 | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | 推荐:高性能场景 |
| 数据库锁 | ⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | 任务已存在数据库 |
| 分片调度 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | 大规模固定集群 |
| Lease 机制 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 动态扩缩容 |
最佳实践建议:
- 中小规模(< 10 万任务/天):Lua 原子操作 + 死信队列
- 大规模(> 10 万任务/天):分片调度 + 主备切换
- 已有数据库:直接用数据库悲观锁
- 云原生环境:考虑用 Kubernetes 的 CronJob 或 Celery + RabbitMQ
关键要点:
- ✅ 优先选择无锁方案(Lua 原子操作、分片)
- ✅ 失败任务必须有补偿机制(死信队列、重试)
- ✅ 监控每个调度器的负载,避免热点
- ✅ 记录任务处理日志,便于排查问题
最终答案:
优先选择无锁的原子方案。中小规模(每天 10 万任务以内)用 Redis ZSet 配 Lua 脚本原子删除并配合死信队列;大规模(每天 10 万任务以上)用分片调度加主备切换;任务本来就在数据库里则直接用悲观锁配合 SKIP LOCKED;云原生环境可考虑 Kubernetes 的 CronJob 或 Celery 加 RabbitMQ。无论选哪种,失败任务都必须有补偿机制(死信队列、重试),并监控各调度器的负载避免热点。
发现内容有问题?
告诉我们哪里需要改进。
