关键决策
延时队列的设计说到底是四个问题的答案:存在哪、准到什么程度、怎么保证不丢、怎么横向扩。这四个问题彼此牵制——精度高了成本就上去,存储换了可靠性就得重新论证。下面按这个顺序,把每个决策的推导过程摊开讲:问题是什么、有哪些候选、各自的代价是什么、我们最后为什么选它、以及如果重新做一次会不会改。
决策 1:存储方案选择
问题: 使用 Redis 还是数据库?
决策树:
任务量 < 10 万/天?
├─ 是 → 数据库轮询(简单)
└─ 否 →
├─ 精度要求秒级?
│ ├─ 是 → Redis ZSet
│ └─ 否 → 数据库轮询
└─ 成本敏感?
├─ 是 → 分层方案
└─ 否 → Redis ZSet
推荐:
- 小规模:数据库轮询
- 中等规模:Redis ZSet
- 大规模:分层方案
决策分析:
把四个候选放在同一张表里对比,才能看清各自的边界在哪里。
| 维度 | 数据库轮询 | Redis ZSet | 分层方案(Redis + 数据库) | MQ 内置延时 |
|---|---|---|---|---|
| 查询性能 | 100-500ms,取决于索引 | 5-20ms | 短任务同 Redis,长任务同数据库 | 触发由 broker 决定 |
| 写入性能 | 10-50ms | 1-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 表,分钟级扫描
选择理由有三条:
- 短延时决定用户体验,必须快。 订单超时取消的体感就在这几秒里,这部分流量必须走内存。
- 长延时的成本不能按短延时算。 自动确认收货这类任务每天几十万条,但在内存里躺七天是纯浪费,下放到数据库后内存占用降了一个数量级。
- 取消能力要保留。 长延时任务落在数据库里,
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 秒内的任务,而补偿任务要等到凌晨才跑。中间这十几个小时里,用户的订单状态一直是错的。
重来一次我们会:
- 补偿任务常驻运行,频率从 10 分钟降到 1 分钟,成本很低但恢复窗口大幅缩短。
- 补偿任务和主调度器共用同一套代码路径,只是扫描范围不同。现在两套代码跑久了逻辑会漂移,这本身就是 bug 来源。
- 给”待处理任务数超过历史同期”配告警,而不是等补偿任务去发现。
还有一个更根本的教训:可靠性的设计不应该假设主链路不会失败。 我们最初的设计里,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 和补偿扫描;选了数据库,事务本身就是可靠性的来源。存储定了之后,可靠性方案是在这个集合里挑,而不是自由发挥。
最后定扩展性,因为扩展性是对前面三个的响应。 存储是单实例还是集群,决定了调度器要不要做分片;可靠性做到什么程度,决定了要不要租约和故障转移。
做决策时反复问的三个问题:
- 这个选择的最坏情况是什么? 选了 Redis,最坏是丢 1 秒任务;选了数据库,最坏是扫描延迟导致触发变晚。有了最坏情况的预期,就不会在半夜被意料之外的事情吓醒。
- 这个选择的复杂度会不会持续增长? 每引入一个新组件(Redis、MQ、注册中心),运维成本都是持续叠加的,而不是一次性的。能用现有组件解决的问题,不要引入新组件。
- 如果这个决策错了,最快的修复路径是什么? 精度选高了可以降,存储从 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 不是”什么都能忍”。我们明确划了三条线:
- 任务不丢——存储层保证,Redis 用 AOF + 哨兵,数据库靠磁盘。
- 执行不重复——消费端保证,靠幂等键和业务状态复核。
- 进度可查——任何时刻都能回答”这个任务现在什么状态”,靠持久化的状态表。
这三条之外的部分,我们接受最终一致带来的短暂不一致。
最后一句提醒:CAP 讨论的是”故障时怎么选”,不是”平时怎么设计”。 正常情况下,我们依然要保证任务被准时执行、只执行一次、状态实时可查。CAP 只是告诉我们,当这三者与”不停服”发生冲突时该牺牲哪一个——在延时队列这个场景里,答案是牺牲”绝不重复”,因为它最容易被工程手段补回来。
发现内容有问题?
告诉我们哪里需要改进。
