00:00 的结算任务,为什么会跑两遍
arch-lab 每天 00:00 触发一次“未支付订单关单并释放库存”的批处理。单机版本是一个 cron 加一个循环。扩到三个副本后,运维图省事,在三台机器上装了同一个 cron。00:00 一到,三个进程同时扫到同一批订单,同一张库存预占单被释放了三次。库存服务用幂等键挡住了前两次,但释放接口本身不是幂等的,第三次把已经退回的库存又扣了一次。
错误直觉是“多部署几个副本会更可靠”。副本增加的是可用性,不是归属。只要没有一层机制回答“这一轮到底归谁跑”,副本越多,同一行被重复处理的机会越多。调度的第一个问题不是 cron 表达式,而是归属:同一时刻,谁有权扫描、谁有权提交。
站内 分布式任务调度 讨论过调度平台的整体分层;本节只盯住归属这一层,把轮询、租约和领导者选举放在同一组可复现的读数下比较。
三种归属方式,三笔不同的账
| 方式 | 谁决定归属 | 重复触发 | 空转 | 故障恢复 | 适用前提 |
|---|---|---|---|---|---|
| 全量轮询 | 每个 worker 自己读 due 列表 | 高:同一行被多人读到 | 低:人人都在扫 | 立即 | 任务幂等且量小 |
| 抢占轮询 | 存储的原子 claim | 中:执行超过可见期 | 中:抢不到也扫 | 一个可见期 | 大多数批处理 |
| 租约/选举 | 协调存储的 term | 低:单执行者 | 低:只有 leader 扫 | 一个 TTL | 有状态、不能并行 |
三者不是互斥关系。领导者选举本身就是“对一把租约的抢占”,只是抢到之后只有一个人扫描;claim 则是行级的短租约,粒度更细。把它们当成三个并列产品去选型,往往忽略了一个事实:真正决定重复量的是“读与写之间有没有一个带期限的归属”。
轮询:便宜,但重复写在窗口里
最朴素的实现是每个 worker 每轮读取所有到期任务,执行后把状态改为完成。问题出在读和写之间:三个 worker 在同一 tick 都读到“订单 A 未处理”,然后都执行,最后一个写完成状态。完成状态只保留一次,副作用却发生了三次。
模拟里 20 个任务、3 个 worker,每个 worker 每 tick 取一个任务,任务执行 3 tick。全量轮询的结果是 60 次副作用、40 次重复、0 次空转:每个任务刚好被执行 3 次。它没有空转,因为总有人能读到未完成任务;它把成本全部转移到了下游的重复写。
这不是“加个分布式锁”就能顺手解决的问题。锁的持有时间必须覆盖整个执行过程,而执行时间通常不可预测;锁过期与任务完成的竞争,和下一节的检查点问题是同一类。
租约:把归属写进存储
租约把归属变成一个带过期时间的存储行:(key, owner, epoch, expires_at)。获取是一次 compare-and-set:只有当现有租约不存在、已过期,或持有者就是自己时,才写入新 owner,并把 epoch 加一。续约必须同时匹配 owner、epoch,且当前时间早于过期时间。
这里有两条不变量:
- 任意时刻,同一个 key 最多只有一个未过期租约;
epoch随每次成功获取单调递增,永不复用。
第一条保证正常情况下只有一个执行者。第二条是给“不正常情况”准备的:旧 leader 可能因为 GC 停顿、网络隔离或进程挂起,在租约过期后仍然认为自己有效。它恢复后做的第一件事可能就是提交订单。此时本地判断“我还是不是 leader”已经晚了,必须由资源侧用 epoch 拒绝它。
epoch 是围栏,不是装饰
测试里,节点 A 在 tick 0 获得 epoch 1、TTL 5;随后被暂停 6 tick。租约在 tick 5 过期,节点 B 在 tick 6 获得 epoch 2。A 恢复后拿 epoch 1 写订单,资源侧读取当前纪元为 2,返回 ErrFenced,最终只有 B 的写入生效。
这个结论的边界要说清楚:围栏生效的前提是资源侧每次写入都检查当前 epoch,而不是相信调用方传来的“我是 leader”。如果资源侧只接受一个布尔值 isLeader,暂停的 A 仍然能写。围栏不是选举算法的附属品,它必须落在被保护的数据上。
可运行的租约与选举组件
下面的包只依赖标准库。LeaseStore 用互斥锁模拟 CAS,Elector 把“续约或竞选”压缩成一次 Tick,Sink 在写入时读取权威 epoch。包内另有 sim.go,用离散 tick 驱动三种归属方式;本节后面引用的是它的测试输出。
// file: internal/platform/scheduler/lease.go
// tier: L2
// verify: go test ./internal/platform/scheduler/ -count=1
package scheduler
import (
"errors"
"fmt"
"sync"
)
var (
// ErrLeaseHeld means another owner still holds an unexpired lease.
ErrLeaseHeld = errors.New("scheduler: lease held")
// ErrFenced means the caller acted with a superseded epoch.
ErrFenced = errors.New("scheduler: stale epoch")
)
// ManualClock is an injected tick clock, so tests never sleep.
type ManualClock struct {
mu sync.Mutex
now int64
}
// Now returns the current tick.
func (c *ManualClock) Now() int64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.now
}
// Advance moves the clock forward by d ticks.
func (c *ManualClock) Advance(d int64) {
c.mu.Lock()
c.now += d
c.mu.Unlock()
}
// Lease is the right to dispatch for one term. Epoch is a fencing token that
// increases on every acquisition and never decreases.
type Lease struct {
Key string
Owner string
Epoch uint64
ExpiresAt int64
}
// LeaseStore grants at most one unexpired lease per key. It stands in for a
// row updated by compare-and-set: (owner, epoch, expires_at).
type LeaseStore struct {
mu sync.Mutex
now func() int64
terms map[string]Lease
}
// NewLeaseStore builds a store on top of now.
func NewLeaseStore(now func() int64) *LeaseStore {
return &LeaseStore{now: now, terms: make(map[string]Lease)}
}
// Acquire takes the lease for owner. It fails while another owner's lease is
// unexpired; an expired lease is free and the new term gets a higher epoch.
func (s *LeaseStore) Acquire(key, owner string, ttl int64) (Lease, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := s.now()
cur, ok := s.terms[key]
if ok && cur.Owner != owner && now < cur.ExpiresAt {
return Lease{}, fmt.Errorf("acquire %s for %s: %w", key, owner, ErrLeaseHeld)
}
var epoch uint64 = 1
if ok {
epoch = cur.Epoch + 1
}
l := Lease{Key: key, Owner: owner, Epoch: epoch, ExpiresAt: now + ttl}
s.terms[key] = l
return l, nil
}
// Renew extends a lease only for its current owner and epoch, and only before
// expiry. A paused owner that comes back after takeover cannot renew.
func (s *LeaseStore) Renew(l Lease, ttl int64) (Lease, error) {
s.mu.Lock()
defer s.mu.Unlock()
cur, ok := s.terms[l.Key]
if !ok || cur.Owner != l.Owner || cur.Epoch != l.Epoch || s.now() >= cur.ExpiresAt {
return Lease{}, fmt.Errorf("renew %s epoch %d: %w", l.Key, l.Epoch, ErrFenced)
}
l.ExpiresAt = s.now() + ttl
s.terms[l.Key] = l
return l, nil
}
// Current returns the epoch most recently granted for key, or zero.
func (s *LeaseStore) Current(key string) uint64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.terms[key].Epoch
}
// Sink writes orders only when the writer carries the current epoch. It reads
// the authoritative term instead of trusting the caller.
type Sink struct {
mu sync.Mutex
current func() uint64
applied int
}
// NewSink wires a sink to the store that grants dispatch terms.
func NewSink(store *LeaseStore) *Sink {
return &Sink{current: func() uint64 { return store.Current("orders:dispatch") }}
}
// Write applies orderID only if l carries the current epoch.
func (s *Sink) Write(l Lease, orderID string) error {
s.mu.Lock()
defer s.mu.Unlock()
if l.Epoch < s.current() {
return fmt.Errorf("write %s epoch %d: %w", orderID, l.Epoch, ErrFenced)
}
s.applied++
return nil
}
// Len reports how many writes were applied.
func (s *Sink) Len() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.applied
}
// Elector is a non-blocking campaign loop driven by Tick. A crashed process
// simply stops calling Tick, so its lease expires.
type Elector struct {
store *LeaseStore
owner string
ttl int64
lease Lease
lead bool
}
// NewElector creates an elector for owner with the given lease TTL.
func NewElector(store *LeaseStore, owner string, ttl int64) *Elector {
return &Elector{store: store, owner: owner, ttl: ttl}
}
// Tick renews leadership or campaigns for it.
func (e *Elector) Tick() (Lease, bool, error) {
if e.lead {
l, err := e.store.Renew(e.lease, e.ttl)
if err == nil {
e.lease = l
return l, true, nil
}
e.lead = false
}
l, err := e.store.Acquire("orders:dispatch", e.owner, e.ttl)
if err != nil {
return Lease{}, false, err
}
e.lease, e.lead = l, true
return l, true, nil
}
实现上有三个刻意的选择。第一,Acquire 在租约过期后允许任何 owner 获取,并把 epoch 加一;如果旧 owner 回来重新获取,同样会得到更高的 epoch,旧写入自然被围栏挡住。第二,Renew 把 owner、epoch、过期时间放在同一个条件里检查,避免“先查再改”的窗口。第三,Sink 不缓存 epoch,每次写入都问存储,代价是一次读,换来的是暂停进程无法绕过围栏。
领导者选举:少扫描,多一段空窗
选举模式只让 leader 扫描任务,其他 worker 不轮询。模拟中 20 个任务全部由 leader 完成,重复 0 次,空转 1 次;代价出现在故障时:leader 在 tick 5 崩溃,最后一次续约发生在 tick 4,TTL 为 8,租约到 tick 12 才过期,新 leader 在 tick 12 才产生。从崩溃到恢复调度是 7 tick。
这 7 tick 里没有任何任务被派发。对账、结算这类任务可以接受;分钟级的告警聚合就可能超出预算。缩短 TTL 能缩短空窗,但续约请求随之增加,而且对时钟漂移和网络抖动更敏感。TTL 不是越短越好,它是在“故障恢复时间”和“误判切换频率”之间取值。
| 观测 | 判据 | 动作 | 退出条件 |
|---|---|---|---|
| 重复副作用 | 同一业务键被执行超过 1 次 | 先加幂等键,再查 claim 可见期 | 连续一个批次重复为 0 |
| 执行时长 | p99 超过 claim 可见期 | 提高可见期或加执行心跳 | 可见期 > p99 的 1.5 倍 |
| 故障恢复 | 空窗超过业务容忍 | 缩短 TTL,或改为分片多 leader | 空窗 < 预算且误切换可接受 |
| 空转 | idle 扫描占 CPU 比例高 | 改用 leader 或事件驱动 | 空转请求降到可忽略 |
| 跨机房 | 租约存储只在一个机房 | 停止部署双主,先做多数派 | 租约存储能承受单机房故障 |
本机实测:三种归属方式的账
测试用离散 tick 驱动,20 个任务、3 个 worker、执行 3 tick、claim 可见期 2 tick;0 号任务执行 18 tick,模拟一个超大对账批次;leader 模式 TTL 8、在 tick 5 崩溃。相关测试输出(节选)如下:
=== RUN TestSimulateModes
lease_test.go:103: scan effects=60 duplicates=40 empty_polls=0 failover=0
lease_test.go:103: claim effects=23 duplicates=3 empty_polls=12 failover=0
lease_test.go:103: leader effects=20 duplicates=0 empty_polls=1 failover=7
--- PASS: TestSimulateModes (0.00s)
读数分三层。重复层,全量轮询把每个任务执行了三次,重复 40 次;claim 模式只重复了 3 次,全部来自 0 号长任务:它的 claim 在 2 tick 后过期,其他空闲 worker 重新抢到并执行,等原 worker 完成时副作用已经落地。leader 模式重复为 0。
空转层,全量轮询是 0,因为它总能把同一批任务再执行一遍,这不是优点;claim 模式是 12,空闲 worker 会反复扫描到被长任务占住、随后又过期的 claim;过期后它们重新抢到同一任务,这正是那 3 次重复的来源。leader 模式只有 1,因为只有一个执行者。空转低不等于效率高,leader 用一段故障空窗换来了低空转。
恢复层,leader 的空窗是 7 tick,比 TTL 少 1,因为崩溃发生在最后一次续约之后。这个数字只对本机的离散模拟成立,不代表生产环境的秒数;真实系统里还要加上选主存储的读写延迟和客户端超时。
常见错误与边界
第一,用本地状态判断自己是否还是 leader。进程暂停、时钟跳变、网络分区都会让本地判断过期,唯一可靠的做法是资源侧校验 epoch,并拒绝旧 term 的写入。
第二,把 claim 可见期设成平均执行时间。只要有一个任务超过可见期,就会被第二个 worker 重跑;可见期要覆盖慢任务的 p99,而不是平均值。上面的 0 号任务就是反例。
第三,认为 TTL 越短故障恢复越快。缩短 TTL 同时增加续约压力,并把一次网络抖动放大成一次误切换;切换期间新旧 leader 可能都认为自己有效,重复窗口反而变长。
第四,把领导者选举当成 exactly-once。选举只保证同一时刻一个执行者,不保证执行者崩溃前没有产生副作用;重复仍然要靠幂等键和下一节的检查点来收口。
小结
调度的核心不是 cron 表达式,而是“这一轮归谁”的归属机制。全量轮询最便宜也最容易重复;claim 把重复压缩到可见期与执行时间的竞争里;leader 把重复降到零,但用 TTL 换取一段故障空窗。无论选哪种,epoch 围栏都必须落在资源侧,本地判断不能替代它。本机的模拟读数只能说明三种机制的成本方向,生产参数要用真实任务的执行分布重算。下一节处理另一个维度:当一个任务本身大到必须拆分时,分片、检查点和幂等重跑如何配合。
阅读导航:上一节:锁失效后的正确性:从互斥到幂等 · 下一节:任务分片、幂等重跑与进度检查点 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。