《后端架构设计·分布式与数据一致性》11.2 任务分片、幂等重跑与进度检查点

把 1000 个未支付订单拆成 4 个分片,用每 10 条提交一次的检查点跑批;在副作用之后、检查点提交之前注入崩溃,实测重放 7 条、去重后副作用仍为 1000 次,并给出带 epoch 的检查点与幂等 Sink 的 Go 实现和崩溃恢复边界。

一次关单批次,为什么不能整体重跑

arch-lab 的夜间任务要处理 1000 个未支付订单:逐个调用关单、释放库存预占、写审计记录。批次跑到第 937 个订单时进程被 OOM kill。值班同学的第一反应是“重新跑一遍”,第二反应是“反正关单接口有幂等键”。第一个反应会让前 936 个订单再走一遍;第二个反应只对了一半:关单接口幂等,释放库存的接口却不是,重复调用会把已经退回的库存再扣一次。

这个场景里有两个独立的问题。第一,失败后从哪儿继续:没有进度记录,只能从头再来,重放量与已完成量成正比。第二,重放本身是否安全:即使从正确的位置继续,边界上的那一条记录也可能已经被执行过,只是进度还没提交。前者靠检查点,后者靠幂等,两者缺一不可。

站内 幂等与可靠性 讲过幂等键的一般做法;本节把它和分片、检查点放进同一条批处理链路,并用一次注入崩溃的测试量出重放窗口。

分片:先让失败局部化

把 1000 个订单按 order_id 做 FNV-1a 哈希,模 4 分成 4 片。本机测试里每片正好 250 个键,这是合成键的分布结果,不是生产保证。分片的第一收益不是并行度,而是失败域:第 3 片崩了,前两片已经提交的进度不受影响,重跑只需要从第 3 片的检查点继续。

分片键的选择要满足两个条件:稳定、可路由。order_id 创建后不变,消息里也带着它,满足条件;status 会变化,不能做分片键,否则订单会在分片之间漂移。分片数通常大于 worker 数,例如 4 个 worker 配 16 个分片,这样单台机器故障时,其他 worker 能接手完整的分片,而不是把一个分片再切开。

候选分片键稳定性可路由主要问题
order_id不变消息自带按用户聚合的查询要扇出
user_id不变订单表可反查大用户热点
status会变可路由分片漂移,禁止
created_at 日期不变可路由单日热点,跨日边界

分片只解决“从哪儿继续”,不解决“重复执行是否安全”。检查点提交的位置决定了重放窗口,而窗口里的副作用必须由幂等层兜住。

检查点:提交的是“已经产生的副作用”

检查点记录每个分片已经处理到的偏移。语义必须是:偏移之前的副作用都已经落地,偏移之后的记录可以安全重放。常见实现是每处理 N 条提交一次,而不是每条都提交:每条提交会把存储写入放大到与记录数同阶,每 N 条提交则把重放窗口限制在 N 条。

崩溃点的选择决定我们看到什么。测试在第 37 次尝试后注入崩溃,提交间隔是 10,因此持久化的检查点是 30,而第 30 到 36 条记录的副作用已经发生。恢复时从 30 开始,这 7 条会被重新执行,去重层必须挡住它们。如果检查点在副作用之前提交,这 7 条就永远不会执行,属于丢数据;如果只在批次结束时提交,重放量就是整个分片。

检查点还需要一个 epoch。worker 可能因为 GC 停顿被误判为死亡,分片被另一个 worker 接管;旧 worker 恢复后如果还能提交检查点,就会把进度回退。提交接口因此要带上分片租约的 epoch,只接受不小于当前 epoch 的写入,和上一节的围栏是同一个思路。

重跑:幂等是检查点的另一半

幂等 Sink 用业务键去重:关单任务用 order_id,支付任务用 payment_id,退款任务用 refund_id。键必须描述“这次副作用是什么”,不能带尝试序号或时间戳;带尝试序号的键每次重跑都不同,等于没有去重。

去重表的保留期要覆盖最大重放窗口。如果一次故障恢复跨越了保留期,旧键被清理,重放就会被当成新请求。保留期至少应大于“最慢分片的执行时间 + 人工介入时间”,而不是拍一个 24 小时。

这里的顺序不能反:先执行副作用,再提交检查点。反过来会在“提交成功、副作用失败”时永久丢失记录;而当前顺序的最坏情况只是重放,重放由幂等层吸收。这是 at-least-once 的典型取舍:用重复换不丢,再用幂等把重复消掉。

可运行实现

下面的包只依赖标准库,包含分片、带 epoch 的检查点、幂等 Sink 和一个可注入崩溃的 Runner。Split 保证同一个键永远落在同一片;Commit 拒绝旧 epoch 和同 epoch 的回退;Run 从已提交偏移开始,崩溃发生在副作用之后、下一次提交之前。

// file: internal/platform/shardjob/shard.go
// tier: L2
// verify: go test ./internal/platform/shardjob/ -count=1
package shardjob

import (
	"errors"
	"fmt"
	"hash/fnv"
	"sync"
)

var (
	// ErrStaleEpoch means a worker from a superseded shard term tried to write.
	ErrStaleEpoch = errors.New("shardjob: stale shard epoch")
	// ErrStaleOffset means a checkpoint moved backwards inside one term.
	ErrStaleOffset = errors.New("shardjob: stale checkpoint offset")
	// ErrInjectedCrash marks the deterministic failure used by tests.
	ErrInjectedCrash = errors.New("shardjob: injected crash")
)

// Split assigns keys to shards by FNV-1a. The same key always lands on the same
// shard, and the result keeps input order inside each shard.
func Split(keys []string, shards int) ([][]string, error) {
	if shards <= 0 {
		return nil, fmt.Errorf("split %d keys: shards must be positive, got %d", len(keys), shards)
	}
	out := make([][]string, shards)
	for _, k := range keys {
		h := fnv.New32a()
		if _, err := h.Write([]byte(k)); err != nil {
			return nil, fmt.Errorf("hash %q: %w", k, err)
		}
		idx := int(h.Sum32() % uint32(shards))
		out[idx] = append(out[idx], k)
	}
	return out, nil
}

// Checkpoint stores the last committed offset per shard. Epoch is the shard
// lease term, so a worker that lost its lease cannot move the offset.
type Checkpoint struct {
	mu      sync.Mutex
	offsets map[string]int
	epochs  map[string]uint64
}

// NewCheckpoint builds an empty checkpoint store.
func NewCheckpoint() *Checkpoint {
	return &Checkpoint{offsets: make(map[string]int), epochs: make(map[string]uint64)}
}

// Load returns the committed offset for shard, zero when nothing is committed.
func (c *Checkpoint) Load(shard string) int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.offsets[shard]
}

// Commit advances shard to offset under epoch. Equal offset is idempotent;
// a lower epoch or a lower offset inside the same epoch is rejected.
func (c *Checkpoint) Commit(shard string, epoch uint64, offset int) error {
	c.mu.Lock()
	defer c.mu.Unlock()
	cur := c.epochs[shard]
	if epoch < cur {
		return fmt.Errorf("commit %s epoch %d: %w", shard, epoch, ErrStaleEpoch)
	}
	if epoch == cur && offset < c.offsets[shard] {
		return fmt.Errorf("commit %s offset %d < %d: %w", shard, offset, c.offsets[shard], ErrStaleOffset)
	}
	c.epochs[shard] = epoch
	c.offsets[shard] = offset
	return nil
}

// Sink is the side-effect boundary. It deduplicates by business key, so a
// replayed batch cannot charge or ship the same order twice.
type Sink struct {
	mu         sync.Mutex
	seen       map[string]bool
	applied    int
	suppressed int
}

// NewSink builds an empty idempotent sink.
func NewSink() *Sink {
	return &Sink{seen: make(map[string]bool)}
}

// Apply performs the effect once per key and reports whether it was applied.
func (s *Sink) Apply(key string) bool {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.seen[key] {
		s.suppressed++
		return false
	}
	s.seen[key] = true
	s.applied++
	return true
}

// Counts returns applied and suppressed effect counts.
func (s *Sink) Counts() (applied, suppressed int) {
	s.mu.Lock()
	defer s.mu.Unlock()
	return s.applied, s.suppressed
}

// Runner executes one shard from its committed offset.
type Runner struct {
	Sink        *Sink
	CP          *Checkpoint
	Epoch       uint64
	CommitEvery int
}

// Result reports one run, including replays after a restart.
type Result struct {
	Attempts   int
	Applied    int
	Suppressed int
	Checkpoint int
}

// Run processes items from the committed offset. crashAfter > 0 stops the run
// after that many attempts, before the next checkpoint, emulating a process
// death between the side effect and the offset commit.
func (r *Runner) Run(shard string, items []string, crashAfter int) (Result, error) {
	start := r.CP.Load(shard)
	res := Result{Checkpoint: start}
	for i := start; i < len(items); i++ {
		res.Attempts++
		if r.Sink.Apply(items[i]) {
			res.Applied++
		} else {
			res.Suppressed++
		}
		if crashAfter > 0 && res.Attempts == crashAfter {
			return res, fmt.Errorf("crash after %d attempts at offset %d: %w", res.Attempts, i, ErrInjectedCrash)
		}
		if r.CommitEvery > 0 && (i+1-start)%r.CommitEvery == 0 {
			if err := r.CP.Commit(shard, r.Epoch, i+1); err != nil {
				return res, err
			}
			res.Checkpoint = i + 1
		}
	}
	if err := r.CP.Commit(shard, r.Epoch, len(items)); err != nil {
		return res, err
	}
	res.Checkpoint = len(items)
	return res, nil
}

Run 的崩溃注入点值得单独说明:它在 Sink.Apply 之后、Commit 之前返回错误,精确对应“副作用已发生、进度未持久化”的窗口。这不是为了制造一个好看的测试,而是批处理里最常见的一类故障:进程在两次外部调用之间死亡。

本机实测:崩溃点、检查点与重放

测试用 1000 个合成订单号、4 个分片、每 10 条提交一次;分片 0 在第 37 次尝试后崩溃,然后从检查点恢复。相关测试输出(节选)如下:

=== RUN   TestRunCrashResume
    shard_test.go:40: shard=0 keys=250
    shard_test.go:40: shard=1 keys=250
    shard_test.go:40: shard=2 keys=250
    shard_test.go:40: shard=3 keys=250
    shard_test.go:126: shard0=250 crash_attempts=37 checkpoint=30 replay=7 applied=1000 suppressed=7
--- PASS: TestRunCrashResume (0.00s)

读数分三层。进度层,崩溃时已尝试 37 条,持久化检查点是 30,恢复从 30 开始,重放 7 条,占分片总量的 2.8%。副作用层,重放的 7 条全部被 Sink 去重,最终 applied=1000,没有重复释放库存。代价层,分片 0 第一段执行 37 次、恢复后执行 220 次,总尝试 257 次,比唯一的 250 条多 7 次。

提交间隔是可以算的,不需要实测。按 250 条一片计算:

提交间隔每片检查点写入次数最坏重放条数适用
12501副作用极贵、写入便宜
102510常见起点
50550批量大、可接受重放
批次结束1250只适合可整体重跑的任务

这些是算术示例值,不是本机测量;真实选择还要看检查点存储的写延迟和去重表的容量。测试里的 7 条重放是实际运行结果,但它只对应这一组参数。

决策表:分片数与检查点间隔

观测判据动作退出条件
单分片执行时间超过故障恢复预算增加分片数,不增加 worker 数单分片可在预算内重跑
重放量超过下游可承受的重复缩小提交间隔重放条数 < 下游限流余量
检查点写入占存储写入的一半以上增大提交间隔,或换批量提交检查点写入 < 总写入 20%
去重表增长保留期内容量超预算缩短保留期并加业务对账保留期 > 最大重放窗口
僵尸提交出现进度回退检查提交是否带 epoch旧 epoch 提交全部被拒

常见错误与边界

第一,先提交检查点再执行副作用。这样崩溃会永久跳过记录,表现为“任务显示完成,但订单没关”。正确顺序是先做副作用、后提交进度,用重放换不丢。

第二,去重键带尝试序号。order_id + attempt 每次重跑都是新键,去重表永远不会命中;键必须是稳定的业务操作标识。

第三,检查点不带 epoch。被接管的旧 worker 恢复后提交一个较小的偏移,新 worker 会从更早的位置重跑,重放窗口被放大,甚至覆盖已经完成的分片。

第四,分片数等于 worker 数。机器故障时没有空闲 worker 接手,扩缩容还要重新分片;分片数应留出余量,并保证分片键不变。

第五,去重保留期短于重放窗口。跨天恢复时旧键已被清理,重放会变成真实的重复副作用;保留期要按最慢恢复路径计算。

小结

分片把失败局部化,检查点把重放窗口限制在提交间隔内,幂等把窗口里的重复副作用消掉,三者是一条链,缺任何一环都会表现为“重跑之后库存不对”。本机测试在 250 条的分片上注入崩溃,检查点停在 30,重放 7 条,去重后副作用仍是 1000 次;这个比例会随提交间隔线性变化。检查点提交还要带 epoch,否则僵尸 worker 能把进度改回去。下一节换一个时间维度:任务不是立刻到期,而是要在未来某个时刻触发,延迟队列如何组织,错过触发又该如何补偿。

阅读导航:上一节:调度模型:轮询、租约与领导者选举 · 下一节:延迟任务、时间轮与错过触发的补偿 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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