迁移窗口里的一次重试
《群岛纪元》赛季中做公会数据迁移,一个玩家在商城购买加速道具。请求到达源分片,写入成功,但响应在网络上超时。客户端按规则重试,这时路由表已经切到目标分片,重试请求落到目标。目标没有这条购买记录,于是又扣了一次款。
事故复盘时,团队发现三个独立问题叠在一起:路由切换没有等待旧请求窗口结束;购买请求没有稳定的幂等键;目标分片在双写阶段漏掉了这一笔。任何一个问题单独存在,都不会扣两次款,但它们同时出现时,补偿逻辑也救不回来。
再均衡期间的读写规则,本质上是回答三个问题:当前谁说了算、读到旧值怎么办、重试落到别处怎么办。本节按“源可读可写 → 双写 → 目标接管”三态展开,并用一个 Go 状态机把允许的操作写成代码。
三态路由:每个阶段只有一个权威写者
| 阶段 | 读 | 写 | 允许 | 禁止 |
|---|---|---|---|---|
| 源可读可写 | 源 | 源 | 全量业务 | 目标直接读写 |
| 双写 | 源 | 源 + 目标镜像 | 全量业务、迁移复制 | 从目标读作为权威 |
| 目标接管 | 目标 | 目标 | 全量业务、源只读回滚 | 源继续接受新写 |
source --advance--> dual --advance--> target
source <--rollback-- dual <--rollback-- target
三条硬规则:
第一,双写阶段仍然从源读。目标此时是镜像,可能落后;把目标当读源会把“迁移延迟”变成“玩家看到旧数据”。读修复可以在后台把源的新值推到目标,但不能反过来让目标覆盖源。
第二,没有从源直接跳到目标的捷径。跳过双写意味着目标没有追平窗口,切换瞬间会丢写。状态机只提供 Advance 和 Rollback,不提供 CutoverNow。
第三,回滚只能退一格。目标接管后回滚到双写,不能直接回源。目标可能已经接受了源没见过的写,直接切回源会丢这些写;正确顺序是回到双写、对账、再决定。
读到旧值:版本号与读修复
迁移期间读到旧值的概率不高,但一旦发生,必须能判断“谁更新”。版本号由权威写者在写路径上分配,单调递增,不能用墙上时钟:
- 墙上时钟会回拨,也会在多节点间漂移;两个节点可能给出相同时间戳。
- 数据库自增序列只在单库可靠,跨分片不保证顺序。
- 业务版本号(如
revision)由源分片在事务内递增,目标镜像原样保存,才有可比性。
Resolve 的规则是:版本高者胜;版本相同且值相同,视为已一致;版本相同但值不同,返回冲突,不猜。最后一条最重要:平局冲突通常意味着“两个合法写都发生了”,用节点 ID 或时间戳强行选一个,会把数据错误掩盖成“正常”。
读修复发生在读到旧值之后:把胜者写回落后的一侧,并记录修复次数。修复必须是幂等的,否则同一行被反复修复会放大写流量。修复也要限速,迁移期间目标分片已经在接双写,读修复不能把它打满。
重试落到别处:幂等账本
幂等键要在请求进入网关时生成,并贯穿所有重试。常见错误是在每个服务里重新生成 UUID:客户端重试一次,网关生成新 ID,下游看到的就是两笔不同请求。
幂等账本按 request_id 记录已处理的请求和结果。它的语义有四种:
| 情况 | 结果 | 处理 |
|---|---|---|
| 首次见到该请求 | 应用 | 写入并记录结果 |
| 同一请求再次到达 | 重复 | 返回原结果,不再写 |
| 新请求但版本更旧 | 过期 | 拒绝覆盖,返回当前值 |
| 新请求且版本相同、值不同 | 冲突 | 报错,交业务或人工裁决 |
账本必须被源和目标共享,或者至少能被两边查询。只放在源分片,重试落到目标时查不到;只放在目标分片,双写阶段的源写又查不到。常见做法是把账本放在独立的幂等服务,按 request_id 分片,读写都带短 TTL 缓存。
账本不可用时要有明确策略:货币、库存、购买这类操作失败关闭,宁可让玩家重试,不可重复扣款;纯展示类操作可以失败打开,因为重复写不会造成资产损失。这个选择要写进接口契约,不能由每个服务自行决定。
把三态与幂等写成代码
// file: internal/rebalance/machine.go
// tier: L1
// verify: go test ./internal/rebalance/
package rebalance
import (
"errors"
"fmt"
)
// Phase is the routing contract during a migration. Every phase names exactly
// one authoritative writer; "dual" is the only phase with two writes, and even
// there only the source is read as the truth.
type Phase string
const (
PhaseSource Phase = "source"
PhaseDual Phase = "dual"
PhaseTarget Phase = "target"
)
var (
ErrEmptyTarget = errors.New("rebalance: source and target must be non-empty")
ErrSameTarget = errors.New("rebalance: source and target are the same")
ErrTransition = errors.New("rebalance: transition not allowed")
ErrVersion = errors.New("rebalance: version must not be negative")
ErrConflict = errors.New("rebalance: equal versions with different values")
ErrRequestID = errors.New("rebalance: empty request id")
)
// Machine moves source -> dual -> target and can roll back one step at a time.
// It owns no data; it only decides which operations the router may allow.
type Machine struct {
source string
target string
phase Phase
}
func NewMachine(source, target string) (*Machine, error) {
if source == "" || target == "" {
return nil, ErrEmptyTarget
}
if source == target {
return nil, ErrSameTarget
}
return &Machine{source: source, target: target, phase: PhaseSource}, nil
}
func (m *Machine) Phase() Phase { return m.phase }
func (m *Machine) Source() string { return m.source }
func (m *Machine) Target() string { return m.target }
// Advance moves one step forward. Cutover is deliberate: there is no method
// that jumps from source to target and skips the dual-write window.
func (m *Machine) Advance() error {
switch m.phase {
case PhaseSource:
m.phase = PhaseDual
case PhaseDual:
m.phase = PhaseTarget
default:
return fmt.Errorf("%w: %s -> forward", ErrTransition, m.phase)
}
return nil
}
// Rollback moves one step back. A failed cutover returns to dual write, not to
// source-only: the target may already hold writes that the source has not seen.
func (m *Machine) Rollback() error {
switch m.phase {
case PhaseDual:
m.phase = PhaseSource
case PhaseTarget:
m.phase = PhaseDual
default:
return fmt.Errorf("%w: %s -> backward", ErrTransition, m.phase)
}
return nil
}
// Route is the read/write plan for one logical key. Mirror is empty unless the
// machine is in dual-write phase and the caller is performing a write.
type Route struct {
Read string
Write string
Mirror string
}
func (m *Machine) Route(write bool) Route {
switch m.phase {
case PhaseDual:
if write {
return Route{Read: m.source, Write: m.source, Mirror: m.target}
}
return Route{Read: m.source}
case PhaseTarget:
if write {
return Route{Read: m.target, Write: m.target}
}
return Route{Read: m.target}
default:
if write {
return Route{Read: m.source, Write: m.source}
}
return Route{Read: m.source}
}
}
// Record is one versioned value. Versions are logical, assigned by the owning
// service, not by wall-clock time: two replicas must not invent their own.
type Record struct {
Version int64
Value string
}
// Decision says which side wins and whether the loser needs read repair.
type Decision struct {
Winner Record
Repair bool
}
// Resolve applies last-writer-wins by version. Equal versions with different
// values are a conflict, not a tie to break silently; that is the point where
// a retry may have produced two legitimate writes.
func Resolve(source, target Record) (Decision, error) {
if source.Version < 0 || target.Version < 0 {
return Decision{}, ErrVersion
}
switch {
case source.Version > target.Version:
return Decision{Winner: source, Repair: true}, nil
case target.Version > source.Version:
return Decision{Winner: target, Repair: true}, nil
case source.Value != target.Value:
return Decision{}, fmt.Errorf("%w: version %d", ErrConflict, source.Version)
default:
return Decision{Winner: source}, nil
}
}
// ApplyResult distinguishes a fresh write, a retry and an older write. The
// caller must not retry a duplicate into a different shard without this bit.
type ApplyResult struct {
Applied bool
Duplicate bool
Stale bool
Current Record
}
// Ledger is the idempotency table. It must be shared by every node that can
// accept the same request, or a retry reaching the target will be applied
// twice no matter how careful the router is.
type Ledger struct {
byRequest map[string]Record
current Record
}
func NewLedger() *Ledger {
return &Ledger{byRequest: make(map[string]Record)}
}
func (l *Ledger) Current() Record { return l.current }
func (l *Ledger) Apply(requestID string, rec Record) (ApplyResult, error) {
if requestID == "" {
return ApplyResult{}, ErrRequestID
}
if rec.Version < 0 {
return ApplyResult{}, ErrVersion
}
if prior, ok := l.byRequest[requestID]; ok {
return ApplyResult{Duplicate: true, Current: prior}, nil
}
switch {
case rec.Version < l.current.Version:
l.byRequest[requestID] = l.current
return ApplyResult{Stale: true, Current: l.current}, nil
case rec.Version == l.current.Version && rec.Value != l.current.Value:
return ApplyResult{}, fmt.Errorf("%w: version %d", ErrConflict, rec.Version)
}
l.byRequest[requestID] = rec
l.current = rec
return ApplyResult{Applied: true, Current: rec}, nil
}
这段代码把几个容易含糊的规则变成了可测试的约束:
Advance只能前进一步,Rollback只能后退一步;越界返回ErrTransition,不会静默停在原地。Route(true)在双写阶段返回Write=source、Mirror=target,读仍然指向源;调用方不能把Mirror当读源。Resolve对同版本不同值返回ErrConflict,而不是按节点名排序选一个。Ledger.Apply区分Applied、Duplicate、Stale,重试不会再次应用,旧版本不会覆盖新版本。
代码没有解决“双写失败怎么办”,因为那不是状态机能单独决定的:镜像写失败要进入重试队列并记录滞后,滞后超过阈值就阻止切换。状态机只负责“允许什么”,不负责“数据是否已经追平”。
双写失败与切换门禁
双写阶段最常见的失败是“源写成功、目标镜像失败”。这时不能把整个请求判失败,因为源已经提交;正确做法是把镜像任务写入本地 outbox,由后台重试,并记录目标滞后。
切换门禁至少包含四项:
- 滞后为零:目标没有待重试的镜像写,或滞后在可接受窗口内。
- 校验一致:抽样比对键集合、版本号和关键字段校验和。
- 容量足够:目标能承接全量读写,不只是当前镜像写。
- 错误率正常:源和目标在最近一个窗口内没有异常升高。
四项都满足才允许 Advance 到目标接管。任何一项不满足,停在双写阶段继续追平;不要因为“窗口快结束了”而强行切换。
切换后源分片进入只读回滚窗口。窗口长度按业务决定,示例设计目标是保留 24 小时;期间源不接受新写,但可以读,用于对账和应急回滚。窗口结束、备份完成、校验通过后才允许回收源数据。
常见错误与边界
第一,双写阶段从目标读。目标可能落后,玩家会看到迁移前的旧值;双写的“双”只指写,不指读。
第二,版本号用时间戳。时钟回拨和多节点漂移会产生平局,平局又会被错误地自动裁决,冲突被藏起来。
第三,重试时重新生成幂等键。幂等的前提是同一个业务请求在多次重试中共享同一个键;键变了,账本再完整也认不出来。
第四,同版本冲突用节点 ID 排序解决。这不是业务规则,只是实现巧合;正确做法是报冲突,让业务定义优先级。
第五,切换瞬间删除源。目标可能还有未追平的写,源也没有完成只读回滚窗口;删除后既无法对账,也无法回滚。
第六,忘记双写的容量放大。目标按最终数据量规划,双写阶段实际写入是两倍,先过载再谈迁移。
小结
再均衡期间的读写可以压缩成三句话:每个阶段只有一个权威写者;版本高者胜,平局不猜;重试靠共享幂等账本,不靠调用方自觉。
Machine 把三态前进、后退和路由写成显式约束,Resolve 把读修复的胜负规则固定下来,Ledger 把重复、过期和冲突分开处理。它们不能替代迁移执行器,但能把“迁移时允许什么”从口头约定变成编译期和测试期都能检查的代码。
阅读导航:上一节:4.2 一致性哈希与迁移 · 下一节:5.1 本地缓存、分布式缓存与多级缓存 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。