关键决策

关键决策点

延时队列的设计说到底是四个问题的答案:存在哪、准到什么程度、怎么保证不丢、怎么横向扩。这四个问题彼此牵制——精度高了成本就上去,存储换了可靠性就得重新论证。下面按这个顺序,把每个决策的推导过程摊开讲:问题是什么、有哪些候选、各自的代价是什么、我们最后为什么选它、以及如果重新做一次会不会改。


决策 1:存储方案选择

问题: 使用 Redis 还是数据库?

决策树:

任务量 < 10 万/天?
├─ 是 → 数据库轮询(简单)
└─ 否 → 
    ├─ 精度要求秒级?
    │   ├─ 是 → Redis ZSet
    │   └─ 否 → 数据库轮询
    └─ 成本敏感?
        ├─ 是 → 分层方案
        └─ 否 → Redis ZSet

推荐:

  • 小规模:数据库轮询
  • 中等规模:Redis ZSet
  • 大规模:分层方案

决策分析:

把四个候选放在同一张表里对比,才能看清各自的边界在哪里。

维度数据库轮询Redis ZSet分层方案(Redis + 数据库)MQ 内置延时
查询性能100-500ms,取决于索引5-20ms短任务同 Redis,长任务同数据库触发由 broker 决定
写入性能10-50ms1-5ms按档位落到不同存储最高
持久化✅ 天然持久⚠️ 依赖 AOF / RDB✅ 长任务在数据库里✅ 磁盘日志
内存成本低高,全量任务常驻内存中,长任务已下放低
取消任务✅ 一条 UPDATE✅ ZREM O(log n)✅ 看任务在哪一层❌ 消息无法删除
可查询 / 可审计✅ 任意条件⚠️ 只能按 score 和 member 查✅ 长任务可查❌ 难查
实现复杂度低中中高高,要自建调度
适用规模每天十万级以内每天十万到千万级每天千万级以上已有 MQ 基础设施时

对比《Redis ZSet 基础》里那张性能对比表可以看到,Redis 相对数据库的提升主要在查询和写入延迟上,代价是内存和持久化。而”取消任务”这一列往往被低估——订单付款后要撤销取消任务,这个能力在 MQ 上是缺失的,在数据库和 Redis 上都是一行命令的事。

我们的选择:

我们最终选择的是分层方案,具体形态是:

短延时(30 分钟以内)   → Redis ZSet,秒级触发
中延时(30 分钟 ~ 7 天) → Redis ZSet,低频扫描
长延时(7 天以上)      → MySQL delay_tasks 表,分钟级扫描

选择理由有三条:

  1. 短延时决定用户体验,必须快。 订单超时取消的体感就在这几秒里,这部分流量必须走内存。
  2. 长延时的成本不能按短延时算。 自动确认收货这类任务每天几十万条,但在内存里躺七天是纯浪费,下放到数据库后内存占用降了一个数量级。
  3. 取消能力要保留。 长延时任务落在数据库里,UPDATE ... SET status = 'CANCELLED' 就能撤销,不依赖任何中间件的能力。

分层的具体切法和队列隔离见《多队列》。

如果重来,会改变吗?

会,但只改一件事:把”长延时下放数据库”改成”长延时下放数据库 + 一张统一的任务索引表”。

我们最初的设计是长延时任务只存在 delay_tasks 表里,各自业务一张表。半年后遇到的问题是:没人能回答”这个订单一共有几个延时任务、现在什么状态”。任务分散在十几张表里,排查一个用户的问题要挨个查。

重来一次我们会在架构初期就加一张全局的 delay_task_index 表,记录 task_id、业务类型、目标状态、实际触发时间、最终结果。任务详情仍在各业务表里,但索引表提供全局视图。这张表的写入量等于任务创建量,成本可控,换来的是可观测性和对账能力。

其余部分不变。数据库轮询到 Redis ZSet 的演进路径是对的,中间不需要跳到 MQ——除非业务本身已经是 MQ 驱动、团队已有成熟的 MQ 运维能力。


决策 2:精度要求

问题: 需要多高的精度?

精度分级:

┌─────────────────────────────────────────┐
│            精度要求分级                 │
├─────────────────────────────────────────┤
│  高精度(毫秒级)                        │
│  ├─ 成本:高                            │
│  ├─ 适用:秒杀倒计时                    │
│  └─ 方案:时间轮                        │
│                                         │
│  中精度(秒级)                          │
│  ├─ 成本:中                            │
│  ├─ 适用:订单超时                      │
│  └─ 方案:Redis ZSet                    │
│                                         │
│  低精度(分钟级)                        │
│  ├─ 成本:低                            │
│  ├─ 适用:定时任务                      │
│  └─ 方案:数据库轮询                    │
└─────────────────────────────────────────┘

决策分析:

精度不是一个孤立指标,它由三个因素共同决定:调度周期、扫描成本、以及业务能否容忍误差。

精度档位触发误差实现成本典型场景对应方案
毫秒级几毫秒到几十毫秒高,需要常驻内存调度器秒杀倒计时、连接超时时间轮,见《时间轮原理》
秒级1 秒以内中,一次范围查询加一次批量取订单超时取消、优惠券过期Redis ZSet,秒级扫描
10 秒级10 秒以内中低消息提醒、状态回写Redis ZSet,低频扫描
分钟级1 分钟以内低自动确认收货、自动评价数据库轮询
小时级1 小时以内很低周期对账、数据归档数据库轮询 + 定时任务

这里有个容易被忽略的判断标准:误差的可接受度取决于业务后果,而不是取决于技术难度。

订单超时取消晚 30 秒,没有任何用户会投诉——但订单被错误取消,用户会投诉,而且可能引发赔付。这两件事的容错度完全不同。真正需要毫秒级精度的场景,往往不是”要准时触发”,而是”要准时取消”,而后者用”精确到分钟 + 执行时复核业务状态”就能满足。

业务要求真正需要的其实是
30 分钟内取消未付款订单定时检查 + 执行前复核订单状态
秒杀 3 秒后开抢精确的绝对时间点,不能用轮询
7 天后自动确认收货每天扫一次即可

我们的选择:

我们选了按档位分级,而不是全局统一到一个精度:

任务类型精度触发方式
订单超时取消秒级Redis ZSet 每秒扫描
优惠券过期秒级Redis ZSet 每秒扫描
支付回调补偿10 秒级Redis ZSet 每 10 秒扫描
自动确认收货分钟级数据库每分钟扫描
报表汇总小时级定时任务

选择分级而不是统一的理由是成本。同样是秒级扫描,扫 1000 条和扫 100 万条的成本完全不同;而长延时任务本来就对精度不敏感,让它们跟着短任务一起用秒级扫描,纯属浪费。

这里踩过一个坑:我们最初把 score 设计成秒级时间戳,结果同一秒内到达的任务 score 完全相同,取的时候顺序就是随机的,导致”先创建的后执行”。修复方式是把 score 改成毫秒级并附加序列号,细节见《Score 设计》。

如果重来,会改变吗?

会改一件事:精度档位应该在代码里配置,而不是写死在各个扫描任务里。

我们最初把”每秒扫一次短队列”直接写在定时任务的注解上。后来需求变了——某个业务队列量很小,不需要秒级,改代码要重新发版。后来把它改成了配置项:

delay-queue:
  queues:
    - name: order-short
      key: dq:order:short
      interval-ms: 1000
      batch-size: 500
    - name: order-medium
      key: dq:order:medium
      interval-ms: 60000
      batch-size: 200

这样调整节奏不用发版,也顺带解决了”新业务接入要改代码”的问题。

精度档位本身不会改。秒级对订单超时取消是合适的,再往精细推(比如 100 毫秒)带来的成本换不来业务价值。


决策 3:可靠性保证

问题: 如何保证可靠性?

可靠性方案:

方案任务不丢任务不重任务不漏实现复杂度适用规模
纯内存队列❌⚠️❌低仅原型
数据库轮询✅✅ 靠状态机✅ 靠扫描兜底低小规模
Redis ZSet + AOF⚠️ 最多丢 1 秒✅ 靠 Lua 原子取⚠️ 靠补偿扫描中中等规模
Redis + 数据库双写✅✅ 靠幂等键✅ 靠数据库兜底高中大规模
MQ + 死信队列✅⚠️ 靠幂等✅ 靠 DLQ 重投高已有 MQ 时

各方案的取舍:

纯内存队列在《可靠性》里那个愚人节事故里已经演示过后果了——服务重启,任务全丢,用户收到已支付订单被取消的通知。方案本身没有讨论价值。

数据库轮询的可靠性来自持久化和状态机:任务状态存在表里,重启不影响;调度器用带条件的 UPDATE 抢占任务,天然互斥;扫描本身是幂等的,漏掉的下一轮还会看到。代价是每次扫描都有固定开销,规模大了扛不住。

Redis ZSet + AOF是性能最优的性价比选择。开启 AOF 后数据最多丢失 1 秒,配合 Lua 脚本原子取走任务,保证不会重复投递。缺点是”最多丢 1 秒”这个窗口对于支付回调补偿这类任务是真实风险。

Redis + 数据库双写牺牲性能换可靠性:任务先写数据库(事实来源),再写 Redis(调度索引),Redis 挂了可以从数据库重建。代价是每次投递多一次数据库写入,吞吐会明显下降。

MQ + 死信队列是已有 MQ 基础设施时的自然选择,可靠性由 broker 保证,但”消息无法撤销”这个缺陷依然存在。

我们的选择:

我们选的是 Redis ZSet + AOF + 数据库兜底 的组合,三层防线:

第一层:Redis ZSet 负责高性能触发
        - 开启 AOF everysec,崩溃最多丢 1 秒
        - Lua 脚本原子完成「取走 + 移入 processing 集合」

第二层:数据库任务表负责事实与兜底
        - 所有任务都有持久化记录,含 status、execute_at、retry_count
        - 补偿任务每 10 分钟扫一次「已过期但仍是 pending」的任务,重新投递

第三层:消费端幂等
        - 消费前用业务状态复核(订单是否已支付)
        - 用幂等键防止重复执行

为什么不是双写?因为补偿任务已经起到了双写的作用,但成本低得多。双写是在每次投递时多一次数据库写;补偿任务是每 10 分钟批量扫一次,通常是空的。

为什么消费端幂等不可省?因为Redis 和数据库之间没有事务,“任务丢了”和”任务重复”是同一类问题的两面:补偿任务把一个已经执行过的任务重新投递了一次,就是重复执行。幂等键 + 业务状态复核是最后一道防线。

《可靠性》里把这件事总结成三个维度——不丢失、不重复、不遗漏——我们对应的手段分别是:AOF 和持久化记录、幂等键和状态机、补偿扫描和心跳检测。

如果重来,会改变吗?

会改一件事,而且是重要的一件:把”补偿任务”提到第一优先级,而不是当成兜底。

我们上线时把补偿任务设成了低优先级(凌晨跑),理由是”正常情况下不需要它”。结果上线第三周就出事了:一次 Redis 主节点故障切换,丢了 3 秒内的任务,而补偿任务要等到凌晨才跑。中间这十几个小时里,用户的订单状态一直是错的。

重来一次我们会:

  1. 补偿任务常驻运行,频率从 10 分钟降到 1 分钟,成本很低但恢复窗口大幅缩短。
  2. 补偿任务和主调度器共用同一套代码路径,只是扫描范围不同。现在两套代码跑久了逻辑会漂移,这本身就是 bug 来源。
  3. 给”待处理任务数超过历史同期”配告警,而不是等补偿任务去发现。

还有一个更根本的教训:可靠性的设计不应该假设主链路不会失败。 我们最初的设计里,Redis 是唯一的调度入口,它一旦不可用,整条链路就停了。正确的心态是”主链路一定会失败,兜底必须随时待命”。


决策 4:扩展性设计

问题: 如何支持水平扩展?

扩展方案:

┌─────────────────────────────────────────┐
│            扩展性方案                   │
├─────────────────────────────────────────┤
│  存储扩展                              │
│  ├─ Redis Cluster                     │
│  ├─ 数据库分片                        │
│  └─ 消息队列分区                      │
│                                         │
│  计算扩展                              │
│  ├─ 增加消费者节点                    │
│  ├─ 任务分片调度                      │
│  └─ 负载均衡                          │
│                                         │
│  网络扩展                              │
│  ├─ 多机房部署                        │
│  ├─ 就近访问                          │
│  └─ 降级策略                          │
└─────────────────────────────────────────┘

决策分析:

延时队列的扩展和普通服务不同:普通的无状态服务,加机器就能加吞吐;延时队列的调度器加机器,可能只是让更多节点抢同一批任务。

扩展维度手段效果风险
消费侧增加消费者实例线性提升执行吞吐需要幂等,否则重复执行
调度侧按队列分片,每个调度器负责固定几条队列消除抢锁竞争队列分布不均会出现局部热点
调度侧动态分片,按负载重新分配负载自动均衡分配表出错会重复调度或无人调度
存储侧Redis Cluster / 数据库分片突破单实例容量跨分片的事务和查询变复杂
存储侧多队列分层每个集合变小队列过多导致运维爆炸,见《多队列》
跨机房就近消费 + 全局兜底降低网络延迟时钟偏差、跨机房数据一致性

其中最关键的是理解”抢锁竞争”这件事。

多个调度器同时扫同一条 ZSet 时,谁先执行 Lua 脚本谁拿到任务,其余的拿到空集。这意味着加调度器节点并不会提升扫描吞吐——瓶颈在 Redis 单线程和脚本执行上。真正的扩展方式是按队列分片:每个调度器负责固定的若干条队列,队列之间互不干扰。

调度器分片(推荐):

  调度器 A ──► dq:order:short     dq:order:medium
  调度器 B ──► dq:coupon:short    dq:coupon:medium
  调度器 C ──► dq:mall:short      dq:mall:medium

  每个队列只被一个调度器负责 → 无抢锁竞争
  调度器挂了 → 该队列暂时无人调度 → 靠租约转移

代价是”调度器挂了,队列就停”这个风险。所以分片必须配合租约和故障转移:调度器定期续租,租约过期的队列由其他调度器接管。这套机制在《任务调度》和《故障转移》里展开。

我们的选择:

我们选了队列维度静态分片 + 租约转移 + 消费侧自由扩容:

层次方案说明
存储Redis Cluster(按队列 key 分片)队列数固定后,key 分布可以预先规划,热点可控
调度队列静态分片 + 租约正常状态无竞争,故障时租约过期自动转移
消费消费组自由扩容执行侧瓶颈时直接加实例,因为有幂等键兜底
跨机房单机房部署早期规模不需要,跨机房带来时钟和一致性问题

为什么跨机房放最后? 因为延时队列对时钟偏差很敏感:多机房的机器时间不一致,可能出现”任务在写入时已经过期”或者”永远差几秒”。这类问题极难排查,收益却有限——延时队列本来就不追求毫秒级响应。等单机房成为瓶颈时再拆,收益远大于成本。

为什么消费侧反而是自由扩容? 因为消费侧有幂等键兜底,多一个消费者最多是重复执行,不会造成数据错误。而调度侧没有这个保护,多一个调度器可能抢走任务。所以扩展优先级是:先扩消费,后扩调度。

如果重来,会改变吗?

会改一件我们当时觉得”以后再说”的事:把队列到调度器的映射从静态配置改成注册中心管理。

我们最初在配置文件里写死”调度器 A 负责哪些队列”,扩调度器要改配置、发版。这个做法在 3 个调度器时完全没问题,到 20 个调度器时就成了噩梦:配置文件改错、漏改一台机器,队列就静默地没人消费了,而且往往要等告警才能发现。

重来一次会用注册中心维护”队列归属”这张表,调度器启动时注册自己负责的队列,租约过期自动释放给其他节点。配套的监控是:每条队列都必须有一个活跃的调度器,这条监控比任何告警都重要。

另一个会改的是跨机房的优先级。我们把跨机房排在最后,后来发现有一次机房级网络抖动,还是影响了单机房内的任务调度,因为所有调度器都要访问同一个 Redis 集群。如果重来一次,跨机房的隔离应该在架构初期就规划好,而不是等到出事才补。


决策框架

这四个决策不是并列的,它们有严格的先后顺序:

决策顺序:

  1. 精度要求(决策 2)        ← 最上游,由业务场景决定
         │
         │  精度决定扫描频率,扫描频率决定成本
         ▼
  2. 存储方案(决策 1)        ← 由精度和任务量倒推
         │
         │  存储决定可用的可靠性手段
         ▼
  3. 可靠性保证(决策 3)      ← 在选定存储上做加强
         │
         │  可靠性要求决定调度器必须多可靠
         ▼
  4. 扩展性设计(决策 4)      ← 最后决定怎么扩

先定精度,是因为它成本最低、影响最广。 一旦定了”秒级”,扫描频率、存储选型、消息 score 设计全都跟着定下来了。反过来先选存储再谈精度,很容易出现”用了 Redis 就想做毫秒级”这种本末倒置。

再定存储,是因为存储决定了可靠性的可选集合。 选了 Redis,就必然要考虑 AOF 和补偿扫描;选了数据库,事务本身就是可靠性的来源。存储定了之后,可靠性方案是在这个集合里挑,而不是自由发挥。

最后定扩展性,因为扩展性是对前面三个的响应。 存储是单实例还是集群,决定了调度器要不要做分片;可靠性做到什么程度,决定了要不要租约和故障转移。

做决策时反复问的三个问题:

  1. 这个选择的最坏情况是什么? 选了 Redis,最坏是丢 1 秒任务;选了数据库,最坏是扫描延迟导致触发变晚。有了最坏情况的预期,就不会在半夜被意料之外的事情吓醒。
  2. 这个选择的复杂度会不会持续增长? 每引入一个新组件(Redis、MQ、注册中心),运维成本都是持续叠加的,而不是一次性的。能用现有组件解决的问题,不要引入新组件。
  3. 如果这个决策错了,最快的修复路径是什么? 精度选高了可以降,存储从 Redis 换回数据库也可以,但代价都是一次数据迁移。可逆的决策可以果断,不可逆的决策要多想三秒。

CAP 权衡

延时队列的 CAP 权衡:

一致性(Consistency):
├─ 任务不能重复执行
├─ 任务状态一致
└─ 推荐:最终一致性

可用性(Availability):
├─ 服务始终可用
├─ 任务不丢失
└─ 推荐:高可用

分区容错(Partition Tolerance):
├─ 网络分区时继续工作
├─ 故障自动恢复
└─ 推荐:支持分区容错

选择:AP 或 CP
├─ AP:可用性和分区容错(推荐)
└─ CP:一致性和分区容错

先说清楚一件事:这里的”一致性”不是指所有副本的数据完全一致,而是指”一个任务不能被重复执行”。这是延时队列的业务约束,不是分布式存储的一致性模型。搞清楚这一点,CAP 的选择才不会选错。

为什么选 AP?

在网络分区发生时,CP 要求系统拒绝服务以保证一致性,AP 要求系统继续服务并 eventual 一致。对延时队列来说:

分区发生时CP 的选择AP 的选择哪个对
调度器和 Redis 之间断连拒绝调度,任务堆积继续调度本地缓存的任务AP 更好:堆积等于延迟执行,拒服务等于任务全部停摆
两个调度器之间互相看不见停掉一个,避免重复执行两个都执行,靠幂等去重AP 更好:重复执行可以靠幂等键消除,停服不能
存储主节点切换等待选主完成短暂不可用后继续取决于是否有备节点

关键在于:重复执行是可以事后消除的,服务中断不行。 幂等键 + 业务状态复核让”重复”从一个故障降级成一次无效调用;而”停摆”意味着所有延时任务集体停止,业务直接受损。

所以我们的选择是 AP,具体落地为:

CAP 要素我们的选择实现方式
一致性最终一致幂等键去重 + 业务状态复核;状态更新用乐观锁
可用性高可用Redis 哨兵/集群 + 多调度器实例;单点故障自动转移
分区容错支持分区期间允许重复,靠幂等兜底;不主动拒绝服务
一致性检查定期对账补偿任务扫描状态异常的任务

“最终一致”要怎么落地?

延时队列的状态机天然就是最终一致的:任务从 pending 变成 processing 再变成 completed,每一步都可能因为崩溃而中断,下一轮扫描会把它捡起来。

最终一致的三种中间态及自愈方式:

  pending ──► processing ──► completed
     ▲            │
     │            ├──► failed(重试次数用尽 → 进死信)
     │            │
     └── 租约超时 ◄┘   调度器崩溃时任务悬在 processing,
                      租约过期后由补偿任务改回 pending 重投

分区容错的边界。 AP 不是”什么都能忍”。我们明确划了三条线:

  1. 任务不丢——存储层保证,Redis 用 AOF + 哨兵,数据库靠磁盘。
  2. 执行不重复——消费端保证,靠幂等键和业务状态复核。
  3. 进度可查——任何时刻都能回答”这个任务现在什么状态”,靠持久化的状态表。

这三条之外的部分,我们接受最终一致带来的短暂不一致。

最后一句提醒:CAP 讨论的是”故障时怎么选”,不是”平时怎么设计”。 正常情况下,我们依然要保证任务被准时执行、只执行一次、状态实时可查。CAP 只是告诉我们,当这三者与”不停服”发生冲突时该牺牲哪一个——在延时队列这个场景里,答案是牺牲”绝不重复”,因为它最容易被工程手段补回来。

发现内容有问题?

告诉我们哪里需要改进。