一个 30 分钟未支付关单,为什么提前触发
arch-lab 的订单在创建 30 分钟后如果仍未支付,就要关单并释放库存。最直接的实现是把 (order_id, due_at) 写进一张表,由一个扫描器每秒查一次 due_at <= now()。订单量上来之后,扫描器每秒扫几十万行,延迟从秒级涨到分钟级;团队换成内存最小堆,单机很顺,但进程重启后堆里的任务全丢了,重启后的十分钟里没有任何关单发生。
这两个故障看起来一个是性能问题、一个是持久化问题,实际都指向同一组决策:延迟任务用什么结构组织、到期时间以什么精度触发、错过触发之后补几次。前两个决定正常路径的成本,最后一个决定故障后的行为。补偿策略选错,系统会在恢复瞬间把所有积压任务一次性打向下游,制造第二次故障。
站内 调度器设计 讨论过控制面与数据面的分层;本节只处理延迟任务这一个数据面组件,并把 misfire 的边界写成可执行的公式。
最小堆与时间轮:两种成本曲线
最小堆按 due_at 排序,插入和弹出都是 O(log n),支持任意精度和任意远的到期时间。它的缺点是每个任务都要参与比较,任务越密集,比较次数越多;好处是没有空转,没有到期任务时堆不做任何事。
时间轮把时间切成固定大小的桶,任务按 due_at % bucket_count 放入桶中,每 tick 推进一个桶并扫描。插入是 O(1),代价是每个任务在等待期间会被重复扫描:延迟 200 tick、轮子 16 个桶,一个任务会被检查 13 次才到期。桶越多,单次扫描越短,但内存和空桶推进的成本越高;桶越少,长延迟任务被重复检查的次数越多。
| 维度 | 最小堆 | 时间轮 |
|---|---|---|
| 插入 | O(log n) | O(1) |
| 触发 | O(log n) / 个 | O(桶内任务) / tick |
| 时间精度 | 任意 | 一个 tick |
| 长延迟 | 无额外成本 | 每转一圈重扫一次 |
| 空转 | 无 | 每 tick 推进,空桶也推进 |
| 适用 | 稀疏、远到期、精度敏感 | 密集、近到期、大量超时 |
真实的超时场景往往是混合的:连接超时、订单关单集中在分钟级,对账任务在小时级。常见做法是分层时间轮,把近期任务放细粒度轮、远期任务放粗粒度轮,到期后逐层下沉;单层轮加绝对到期时间也能工作,只是长延迟任务的重复扫描次数会上升。
时间轮的两个实现边界
第一,到期时间必须是绝对的。桶只决定“什么时候被看到”,任务是否触发由 due <= now 判断。如果只按相对延迟入桶,进程暂停后恢复,所有任务的相对时间都会整体后移,表现为集体延后。
第二,当前桶已经扫过,不能把“立即到期”的任务放回当前桶。实现里把 due <= now 钳到 now+1,代价是最多晚一个 tick 触发,收益是不会漏掉整整一圈。这个边界在测试里用 200 tick 的长延迟验证:16 个桶的轮子在第 200 tick 精确触发,期间扫描 13 次。
持久化:内存结构只是缓存
时间轮和堆都在进程内存里,重启即丢。持久化延迟任务的顺序应该是:先把 (task_id, due_at, state, version) 写入存储,再放进内存结构;触发前先用租约或条件更新把任务 claim 成“执行中”,执行完标记完成。这样重启后可以从存储重建 due_at <= now + horizon 的任务,horizon 是内存结构能覆盖的时间窗。
| 存储 | 优点 | 代价 | 适用 |
|---|---|---|---|
| 关系库行 + 轮询 | 事务、可查询 | 扫描压力、索引膨胀 | 量中等、要审计 |
| KV/Sorted Set | 按分数范围取 | 需要额外的 claim 语义 | 量大、延迟敏感 |
| 消息队列延迟消息 | 削峰、天然分布 | 精度受 broker 限制 | 允许秒级误差 |
| 内存 + 定期快照 | 最快 | 崩溃丢窗口内任务 | 可重算的任务 |
无论选哪种,触发都必须至少一次:claim 成功后进程崩溃,任务会在租约过期后被另一个 worker 重新取到;要消除重复副作用,仍然要在业务侧做幂等,和上一节的分片重跑是同一套收口方式。
misfire:错过之后补几次
调度器暂停、进程重启、下游限流都会让任务错过计划时间。恢复时不能简单地把“现在”当成计划时间,否则一个每 10 分钟运行的任务在暂停 2 小时后会立刻连跑 12 次。补偿函数需要四个输入:计划的下一次时间 next、当前时间 now、间隔 interval、补跑上限 maxFires。
计算分三步:
missed = (now-next)/interval + 1,表示从next到now之间一共有多少个计划点已经过去;fires = min(missed, maxFires),本次最多立即执行几个;next = next + missed*interval,跳到第一个严格晚于now的计划点。
第三步最容易写错。如果写成 next + fires*interval,当 fires < missed 时,新的 next 仍然在过去,任务会立刻再次到期,形成补跑循环。跳过的数量是 missed - fires,这些点不再补,只记录跳过数用于告警。
| 场景 | 建议策略 | 上限 | 理由 |
|---|---|---|---|
| 未支付关单 | 补跑,上限小 | 3 | 漏跑会让库存长期占用 |
| 支付对账 | 只跑一次,按窗口重算 | 1 | 重复对账会重复入账 |
| 用户通知 | 跳到下一个计划点 | 0 | 补发会造成通知风暴 |
| 计费出账 | 不自动补,转人工核对 | 0 | 金额错误不可自动修复 |
可运行实现
下面的包只依赖标准库:Heap 是最小堆基线,Wheel 是单层时间轮,CatchUp 实现上面的三步补偿。两个队列都用 (Due, Seq) 排序,保证同一到期的任务按插入顺序触发,测试可以逐条比较。
// file: internal/platform/delayq/delayq.go
// tier: L2
// verify: go test ./internal/platform/delayq/ -count=1
package delayq
// Task is one delayed job. Due is an absolute tick; Seq breaks ties so both
// queue implementations fire same-deadline tasks in insertion order.
type Task struct {
ID string
Due int64
Seq uint64
}
// Heap is a binary min-heap ordered by Due then Seq. It is the baseline for
// arbitrary deadlines; push and pop are O(log n).
type Heap struct {
items []Task
seq uint64
comparisons int
}
// Push schedules id at due.
func (h *Heap) Push(id string, due int64) {
h.seq++
h.items = append(h.items, Task{ID: id, Due: due, Seq: h.seq})
h.up(len(h.items) - 1)
}
func (h *Heap) less(i, j int) bool {
h.comparisons++
if h.items[i].Due != h.items[j].Due {
return h.items[i].Due < h.items[j].Due
}
return h.items[i].Seq < h.items[j].Seq
}
func (h *Heap) up(i int) {
for i > 0 {
parent := (i - 1) / 2
if !h.less(i, parent) {
return
}
h.items[i], h.items[parent] = h.items[parent], h.items[i]
i = parent
}
}
func (h *Heap) down(i int) {
for {
left := 2*i + 1
if left >= len(h.items) {
return
}
small := left
if right := left + 1; right < len(h.items) && h.less(right, left) {
small = right
}
if !h.less(small, i) {
return
}
h.items[i], h.items[small] = h.items[small], h.items[i]
i = small
}
}
// PopDue removes every task with Due <= now and returns them in order.
func (h *Heap) PopDue(now int64) []Task {
var out []Task
for len(h.items) > 0 && h.items[0].Due <= now {
out = append(out, h.items[0])
last := len(h.items) - 1
h.items[0] = h.items[last]
h.items = h.items[:last]
if len(h.items) > 0 {
h.down(0)
}
}
return out
}
// Len reports the number of queued tasks.
func (h *Heap) Len() int { return len(h.items) }
// Comparisons reports how many ordering comparisons Push and PopDue performed.
func (h *Heap) Comparisons() int { return h.comparisons }
// Wheel is a hashed timing wheel with one-tick resolution. Buckets hold tasks
// by absolute due tick; a bucket is re-scanned once per revolution, so a task
// due far in the future is inspected several times before it fires.
type Wheel struct {
now int64
size int
buckets [][]Task
seq uint64
scanned int
}
// NewWheel builds a wheel with size buckets.
func NewWheel(size int) *Wheel {
if size < 1 {
size = 1
}
return &Wheel{size: size, buckets: make([][]Task, size)}
}
// Schedule inserts id at due. Deadlines in the past are clamped to the next
// tick, because the current bucket has already been scanned.
func (w *Wheel) Schedule(id string, due int64) {
if due <= w.now {
due = w.now + 1
}
w.seq++
slot := int(due % int64(w.size))
w.buckets[slot] = append(w.buckets[slot], Task{ID: id, Due: due, Seq: w.seq})
}
// Advance moves the wheel one tick and returns the tasks that fired.
func (w *Wheel) Advance() []Task {
w.now++
slot := int(w.now % int64(w.size))
bucket := w.buckets[slot]
var fired, keep []Task
for _, t := range bucket {
w.scanned++
if t.Due <= w.now {
fired = append(fired, t)
continue
}
keep = append(keep, t)
}
w.buckets[slot] = keep
return fired
}
// Now reports the current tick.
func (w *Wheel) Now() int64 { return w.now }
// Scanned reports how many bucket entries Advance inspected.
func (w *Wheel) Scanned() int { return w.scanned }
// Misfire is the outcome of one catch-up decision.
type Misfire struct {
Fires int
Skipped int
Next int64
}
// CatchUp decides what an interval task should do when the scheduler wakes at
// now and the next planned run was at next. At most maxFires runs are executed
// immediately; the remaining missed runs are skipped, and the schedule moves
// to the first slot strictly after now.
func CatchUp(next, now, interval int64, maxFires int) Misfire {
if interval <= 0 || now < next {
return Misfire{Next: next}
}
if maxFires < 1 {
maxFires = 1
}
missed := (now-next)/interval + 1
fires := missed
if fires > int64(maxFires) {
fires = int64(maxFires)
}
return Misfire{
Fires: int(fires),
Skipped: int(missed - fires),
Next: next + missed*interval,
}
}
Wheel.Advance 每次只推进一个 tick,桶里的任务按绝对 Due 判断是否触发;未到期的任务留在桶里,等下一圈再检查。这就是“长延迟任务被重复扫描”的来源,也是它和堆的核心成本差异。
本机实测:排序一致性与扫描次数
测试用 1000 个任务,到期时间按 1 + (i*37) % 500 分布在 1 到 500 tick,时间轮 64 个桶;两个结构各跑一遍,逐条比较触发顺序。相关测试输出(节选)如下:
=== RUN TestHeapAndWheelAgree
delayq_test.go:46: tasks=1000 heap_comparisons=16997 wheel_scanned=4416 ratio=0.26
--- PASS: TestHeapAndWheelAgree (0.00s)
=== RUN TestWheelHandlesDelayBeyondOneRevolution
delayq_test.go:64: due=200 size=16 scanned=13 revolutions
--- PASS: TestWheelHandlesDelayBeyondOneRevolution (0.00s)
=== RUN TestCatchUp
delayq_test.go:89: exactly due fires=1 skipped=0 next=110
delayq_test.go:89: one late fires=1 skipped=0 next=110
delayq_test.go:89: eleven missed capped at three fires=3 skipped=8 next=210
delayq_test.go:89: cap zero means once fires=1 skipped=10 next=210
delayq_test.go:89: large gap capped fires=100 skipped=99901 next=1000010
--- PASS: TestCatchUp (0.00s)
读数要分清“比较次数”和“耗时”。1000 个任务下,堆做了 16997 次排序比较,轮子检查了 4416 个桶内条目,后者是前者的 0.26 倍;这不代表时间轮快 4 倍,因为一次比较和一次桶扫描的内存访问模式不同,而且轮子每 tick 都要推进,即使桶是空的。这个比例只说明:在“任务密集、到期时间集中在 500 tick 内”的分布下,轮子的检查次数更少。
长延迟测试更能说明边界:200 tick 的延迟、16 个桶,任务被检查 13 次,约等于 200/16 + 1。如果把桶数降到 4,检查次数会上升到约 51 次;如果把桶数升到 256,检查次数降到 1 次,但空桶推进和内存占用上升。桶数是在“重扫次数”和“空转成本”之间取值,没有免费的一侧。
misfire 的读数全部来自表驱动测试。计划点 100、间隔 10、当前 200 时,一共错过 11 个点,上限 3 表示立即补 3 次、跳过 8 次,下一次计划点跳到 210;上限为 0 时按 1 次处理,跳过 10 次。上限为 100、错过 100001 个点时,补 100 次、跳过 99901 次,下一计划点是 1000010。最后一个数字说明:补偿上限必须存在,否则“恢复”本身就是一次流量攻击。
常见错误与边界
第一,用相对延迟入桶。进程暂停后恢复,所有任务的到期时间整体后移;桶位置必须由绝对 due_at 计算,是否触发也由绝对时间判断。
第二,把立即到期任务放回当前桶。当前桶已经扫描过,任务要等一整圈才触发;实现应钳到下一个 tick,接受最多一个 tick 的延迟。
第三,next 只推进 fires*interval。当补跑次数被上限截断时,next 仍在过去,会立即再次触发,形成补跑循环;必须按 missed*interval 跳过所有已错过的点。
第四,补偿不设上限。暂停两小时后的对账任务如果补跑 12 次,会把下游打满;上限、跳过计数和告警要一起设计,而不是只写一个 for 循环。
第五,认为内存队列可靠。进程重启会丢掉窗口内的所有任务;持久化、claim、幂等三件事缺一不可,内存结构只是加速层。
小结
最小堆适合稀疏、远到期、精度敏感的任务,时间轮适合密集、近到期的超时;单层时间轮用绝对到期时间也能覆盖长延迟,代价是每个任务每转一圈被重扫一次。本机 1000 个任务的测试里,堆比较 16997 次、64 桶轮子检查 4416 次,长延迟 200 tick 的任务被检查 13 次;这些是特定分布下的读数,不是通用性能结论。misfire 的关键不是“补几次”,而是 next 必须跳过所有错过的计划点,并且补跑有上限。下一节离开单机调度,进入多活拓扑:单元化、双活与就近接入如何划分流量和失败域。
阅读导航:上一节:任务分片、幂等重跑与进度检查点 · 下一节:多活拓扑:单元化、双活与就近接入 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。