一次关单批次,为什么不能整体重跑
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 条一片计算:
| 提交间隔 | 每片检查点写入次数 | 最坏重放条数 | 适用 |
|---|---|---|---|
| 1 | 250 | 1 | 副作用极贵、写入便宜 |
| 10 | 25 | 10 | 常见起点 |
| 50 | 5 | 50 | 批量大、可接受重放 |
| 批次结束 | 1 | 250 | 只适合可整体重跑的任务 |
这些是算术示例值,不是本机测量;真实选择还要看检查点存储的写延迟和去重表的容量。测试里的 7 条重放是实际运行结果,但它只对应这一组参数。
决策表:分片数与检查点间隔
| 观测 | 判据 | 动作 | 退出条件 |
|---|---|---|---|
| 单分片执行时间 | 超过故障恢复预算 | 增加分片数,不增加 worker 数 | 单分片可在预算内重跑 |
| 重放量 | 超过下游可承受的重复 | 缩小提交间隔 | 重放条数 < 下游限流余量 |
| 检查点写入 | 占存储写入的一半以上 | 增大提交间隔,或换批量提交 | 检查点写入 < 总写入 20% |
| 去重表增长 | 保留期内容量超预算 | 缩短保留期并加业务对账 | 保留期 > 最大重放窗口 |
| 僵尸提交 | 出现进度回退 | 检查提交是否带 epoch | 旧 epoch 提交全部被拒 |
常见错误与边界
第一,先提交检查点再执行副作用。这样崩溃会永久跳过记录,表现为“任务显示完成,但订单没关”。正确顺序是先做副作用、后提交进度,用重放换不丢。
第二,去重键带尝试序号。order_id + attempt 每次重跑都是新键,去重表永远不会命中;键必须是稳定的业务操作标识。
第三,检查点不带 epoch。被接管的旧 worker 恢复后提交一个较小的偏移,新 worker 会从更早的位置重跑,重放窗口被放大,甚至覆盖已经完成的分片。
第四,分片数等于 worker 数。机器故障时没有空闲 worker 接手,扩缩容还要重新分片;分片数应留出余量,并保证分片键不变。
第五,去重保留期短于重放窗口。跨天恢复时旧键已被清理,重放会变成真实的重复副作用;保留期要按最慢恢复路径计算。
小结
分片把失败局部化,检查点把重放窗口限制在提交间隔内,幂等把窗口里的重复副作用消掉,三者是一条链,缺任何一环都会表现为“重跑之后库存不对”。本机测试在 250 条的分片上注入崩溃,检查点停在 30,重放 7 条,去重后副作用仍是 1000 次;这个比例会随提交间隔线性变化。检查点提交还要带 epoch,否则僵尸 worker 能把进度改回去。下一节换一个时间维度:任务不是立刻到期,而是要在未来某个时刻触发,延迟队列如何组织,错过触发又该如何补偿。
阅读导航:上一节:调度模型:轮询、租约与领导者选举 · 下一节:延迟任务、时间轮与错过触发的补偿 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。