队列模型

那年夏天,我差点被线程睡死了

这是 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 ✅ 可以消费了      │               │
│   └──────────┬───────────────┘               │
│              │                               │
│              ▼                               │
│           消费者                              │
│     (处理到期任务)                           │
└──────────────────────────────────────────────┘

这样一想,思路清晰了!所有订单先进入”等待区”,时间到了才能去”就绪区”被处理。

当前架构
延时队列的最小模型
核心不是立刻消费,而是先等待,到期后再进入可消费状态。
写入
业务事件
延时时间
任务状态
等待
等待区
到期时间
可见性判断
消费
就绪区
消费者
结果确认

延时队列的核心操作

使用示例:


两种消费模型:谁来做主?

第一版上线后,我又遇到了新问题。消费者应该主动来拉任务,还是队列主动推任务?

拉取模型(Pull):自己找活干

我一开始选这个,因为简单:消费者自己控制节奏,忙的时候少拉点,闲的时候多拉点。

优点: 消费者自己控制节奏
缺点: 没任务时也在不断轮询,浪费资源

推送模型(Push):任务送上门

后来订单量涨到 10 万单/天,我发现轮询太慢了。改成推送模式后,实时性提升明显:

优点: 到期即推,延迟更低
缺点: 消费者处理不过来时会积压

实战经验对比

维度拉取模型推送模型
实时性取决于轮询间隔到期即推
消费者压力自己控制可能被压垮
实现复杂度简单中等
适用场景简单场景高实时要求
典型实现Redis ZSetRabbitMQ

我的建议:刚开始用拉取,够用就行。等业务量上来,再考虑推送。别过度设计。


队列的生命周期:一个任务的旅程

在排查线上问题时,我发现如果不知道任务处于什么状态,根本没法定位问题。于是设计了状态机。

一个延时任务从创建到完成,会经历这些状态:

┌──────────┐    到达执行时间    ┌──────────┐    消费者取走    ┌──────────┐
│  等待中   │ ──────────────► │  就绪     │ ──────────────► │  处理中   │
│ (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 分钟内,用户可能已经付款了。延时任务到期后,必须先检查业务状态,再决定是否执行。这就是”幂等性”——多次执行结果是一样的。

我在代码里加了个显眼的注释:

这个小小的检查,避免了无数的投诉。


想一想

  1. 为什么延时队列通常使用最小堆而不是数组来存储? 时间复杂度有什么区别?

  2. 如果有 1000 万个任务同时在等待区,堆的操作会不会变慢? 应该怎么优化?

  3. 拉取模型和推送模型,哪个更适合”订单超时取消”场景? 为什么?

  4. 分布式环境下,多个调度器如何保证一个任务只被调度一次? 你能想到几种方案?


参考答案

思考 1:为什么延时队列通常使用最小堆而不是数组来存储?

参考答案

问题分析:

延时队列的核心需求是:快速找到最早到期的任务。我们需要比较不同数据结构在”插入”和”取出最小元素”这两个操作上的效率。

时间复杂度对比:

操作数组最小堆(优先队列)优化说明
插入任务O(1)O(log n)数组直接追加,堆需要上浮调整
取出最早任务O(n)O(log n)数组需要遍历全部,堆只需取出堆顶
查看最早任务O(n)O(1)数组需要遍历,堆直接看堆顶

代码对比:

性能测试对比:

为什么堆更适合?

  1. 业务特点:延时队列总是需要”最早到期的任务先处理”
  2. 堆的特性:堆顶永远是最小值,O(1) 时间就能获取
  3. 操作平衡:虽然插入稍慢(O(log n)),但取出快太多,整体性能优

最佳实践:

  • Python 用 heapq 模块(基于列表的最小堆实现)
  • Java 用 PriorityQueueDelayQueue
  • Go 用 container/heap
  • C++ 用 std::priority_queue

思考 2:如果有 1000 万个任务同时在等待区,堆的操作会不会变慢?应该怎么优化?

参考答案

问题分析:

堆的时间复杂度是 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 都用这个

关键优化点总结:

  1. 减少堆操作次数:用分桶/时间轮降低 log n 的 n 值
  2. 利用 CPU 缓存:数组比链表缓存友好
  3. 批量处理:一次取出多个任务,分摊开销
  4. 异步持久化:写操作放后台,不阻塞主流程
  5. 懒加载:只在需要时才加载任务数据

思考 3:拉取模型和推送模型,哪个更适合”订单超时取消”场景?为什么?

参考答案

业务需求分析:

先明确”订单超时取消”的核心特点:

特性说明业务影响
延时时间长30 分钟不需要毫秒级实时性
容忍延迟晚几秒取消可接受允许轮询间隔
突发性下单高峰期任务激增消费者不能被压垮
可靠性要求高不能漏取消需要重试机制
幂等性要求已付款订单不能取消消费者要做状态检查

拉取模型(Pull)适合的理由:

1. 消费者可以自我保护

对比推送模型的问题:

2. 容错性更好

推送模式下,如果推送时消费者挂了,需要复杂的重试逻辑。

3. 实现简单

什么时候用推送模型?

推送模型适合这些场景:

场景示例理由
超低延迟要求秒级杀倒计时不能等轮询
任务轻量发送通知处理快,不会积压
实时系统即时通讯需要即时响应

最佳实践:混合模式

最终建议:

对于”订单超时取消”场景,拉取模型是更好的选择

  1. ✅ 30 分钟延时,不需要秒级实时性
  2. ✅ 消费者可以自我保护,避免被高峰压垮
  3. ✅ 实现简单,故障容错性好
  4. ✅ 可以用多消费者并行拉取,提高吞吐量

如果确实需要更低的延迟,可以用批量拉取 + 异步处理的方式优化。

思考 4:分布式环境下,多个调度器如何保证一个任务只被调度一次?你能想到几种方案?

参考答案

问题本质:

这是经典的分布式互斥问题。多个调度器同时看到同一个到期任务,如何保证只有一个能拿到它?

方案 1:Redis 分布式锁

最直接的方案:

优缺点:

  • ✅ 实现简单,Redis 原生支持
  • ⚠️ 每个任务都要加锁,性能开销大
  • ⚠️ 锁超时设置需要权衡(太短可能导致任务重复执行)

方案 2:Redis ZSET + Lua 脚本原子操作

将”查询 + 删除”合并为原子操作:

核心思想:

  • ZRANGEBYSCORE + ZREM 在 Lua 中执行,Redis 保证原子性
  • 多个调度器同时执行,只有一个能 ZREM 成功
  • 无需显式加锁,性能更好

优缺点:

  • ✅ 原子操作,无锁竞争
  • ✅ 性能高,Redis 单线程处理
  • ⚠️ 任务取出后必须处理成功(否则丢失)
  • 💡 需要配合死信队列处理失败任务

方案 3:数据库悲观锁

如果任务存储在数据库:

数据设计要点

  • 核心是在 delay_tasks 里保存业务事实,而不是把规则散落在应用逻辑里。
  • 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
  • 关键字段包括 idpayloadexecute_atscheduler_idupdated_at,它们决定后续查询和管理能力。

SKIP LOCKED 的作用:

  • 多个调度器并发查询
  • 行已被锁定时,自动跳过
  • 每个调度器拿到不同的任务

优缺点:

  • ✅ 数据库原生支持,可靠
  • ✅ 任务有持久化记录
  • ⚠️ 性能受数据库限制
  • ⚠️ 需要数据库支持 SKIP LOCKED(PostgreSQL、MySQL 8.0+)

方案 4:分片调度

将任务按 ID 分片,每个调度器只负责一部分:

优势:

  • ✅ 完全避免竞争,每个调度器独立工作
  • ✅ 性能最优,无需锁
  • ⚠️ 调度器挂掉,分片任务无人处理
  • 💡 需要配合主备切换或分片迁移

方案 5:Lease 机制

借鉴 Kubernetes 的 Lease 机制:

特点:

  • 调度器动态加入/退出
  • 自动重新分片
  • 无需人工配置

方案对比总结:

方案复杂度性能可靠性适用场景
Redis 锁⭐⭐⭐⭐⭐⭐⭐小规模任务
Lua 原子操作⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐推荐:高性能场景
数据库锁⭐⭐⭐⭐⭐⭐⭐⭐⭐任务已存在数据库
分片调度⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐大规模固定集群
Lease 机制⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐动态扩缩容

最佳实践建议:

  1. 中小规模(< 10 万任务/天):Lua 原子操作 + 死信队列
  2. 大规模(> 10 万任务/天):分片调度 + 主备切换
  3. 已有数据库:直接用数据库悲观锁
  4. 云原生环境:考虑用 Kubernetes 的 CronJob 或 Celery + RabbitMQ

关键要点:

  • ✅ 优先选择无锁方案(Lua 原子操作、分片)
  • ✅ 失败任务必须有补偿机制(死信队列、重试)
  • ✅ 监控每个调度器的负载,避免热点
  • ✅ 记录任务处理日志,便于排查问题