《后端架构设计·分布式与数据一致性》11.3 延迟任务、时间轮与错过触发的补偿

用 1000 个延迟任务对比最小堆与 64 桶时间轮的排序结果和扫描成本,实测 heap 16997 次比较、wheel 4416 次检查;给出按 next、now、间隔与上限计算 misfire 补偿的 Go 实现,覆盖补跑一次、跳过与大规模补跑上限的边界。

一个 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。

计算分三步:

  1. missed = (now-next)/interval + 1,表示从 next 到 now 之间一共有多少个计划点已经过去;
  2. fires = min(missed, maxFires),本次最多立即执行几个;
  3. 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 必须跳过所有错过的计划点,并且补跑有上限。下一节离开单机调度,进入多活拓扑:单元化、双活与就近接入如何划分流量和失败域。

阅读导航:上一节:任务分片、幂等重跑与进度检查点 · 下一节:多活拓扑:单元化、双活与就近接入 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查