停写迁移的代价
订单分片从 4 个扩到 5 个,最省事的方案看起来是周末停写 30 分钟:把旧分片的数据复制到新分片,改路由,再恢复写入。这个方案在内部后台也许可行,在订单系统上通常不可行。支付回调不遵守维护窗口,用户下单也不会因为公告就停下;停写期间要么把请求排队,要么直接失败,而排队的请求在恢复后会形成尖峰。
更隐蔽的问题是“复制完再切”的缝隙。假设 10:00 开始复制,10:20 复制完成,10:30 切换路由。10:20 到 10:30 之间写入旧分片的订单不在副本里;如果复制工具只做一次全量,这十分钟的数据就丢了。要补上这个缝隙,要么在复制期间停写,要么让新分片同时接收写入,也就是双写。
错误直觉是“双写只是多写一份,成本等于一次额外写”。真实成本在于失败处理:源分片写成功、目标分片写失败时,请求算成功还是失败?如果算成功,目标分片会缺数据,切换后读不到;如果算失败,源分片已经提交,重试可能重复下单。双写把一个问题拆成两个:写入顺序与补偿,以及切换前如何证明目标分片已经追平。
站内 分布式数据一致性与对账 讨论过差异如何收敛;本节不重复对账方法,只把订单分片迁移的状态机、双写路径和回滚条件写成可测试的代码。
五个阶段与不变量
把再平衡拆成五个阶段,每个阶段都有明确的进入条件和退出条件:
| 阶段 | 进入条件 | 动作 | 退出条件 |
|---|---|---|---|
| 快照 | 迁移工单批准 | 记录源分片键列表与位点 | 键列表稳定,双写已开启 |
| 双写 | 快照完成 | 所有写入同时写源和目标 | 目标写入成功率达标 |
| 回填 | 双写稳定 | 按批复制历史键 | 所有快照键进入 journal |
| 校验 | 回填完成 | 抽样比对值与版本 | 差异为零或有明确豁免 |
| 切换 | 校验通过 | 路由切到目标,停止双写 | 回滚窗口结束 |
状态机要守住四条不变量。第一,从双写开启那一刻起,所有写请求必须经过迁移组件,不能有旁路直接写源分片;绕过双写的写入不会被复制,切换后就丢了。第二,回填使用版本条件写入,目标已有更新版本时不覆盖;这保证“复制旧快照”不会把双写期间的新值改回去。第三,切换要求快照里的每个键都已进入 journal,缺一个都不允许。第四,回滚只允许发生在切换前;切换后再回滚,需要反向迁移,不能假装目标上的写入不存在。
双写不是双倍可靠性
双写的顺序应当是“源分片先成功,目标分片后成功”。源分片是切换前的权威,源写失败必须让请求失败;目标写失败则记录为待补偿,重试直到成功,并阻塞切换。反过来先写目标、后写源,会在源失败时留下一个只有目标知道的订单,切换前读源又读不到,语义更乱。
目标写失败的重试必须是幂等的。订单写入带版本号或幂等键,重复写同一个版本不会产生第二次副作用;如果目标端按“插入”处理,重试就会撞唯一键。迁移组件应把“已写目标”的位点持久化,崩溃恢复后从位点继续,而不是从头再写一遍。
还有一个容易忽略的边界:双写期间读请求仍然读源分片。目标分片的值可能因为回填顺序而短暂落后,如果这时把读流量切过去,用户会看到旧状态。读切换必须等到回填和校验都完成,并且路由版本已经发布;读己之写的会话还要额外处理,这部分留到一致性模型一章。
可重入迁移
可重入的核心是 journal:每复制完一个键,就记下它;重启后从 journal 之外继续。journal 不需要记录值,只需要键和完成标记;值以源分片当前版本为准,写入目标时用 ConditionalPut 比较版本。这样同一个批次重复执行不会产生第二份数据,也不会覆盖更新的值。
下面的实现把快照、双写、回填、切换、回滚放进一个状态机。CopyBatch 每次最多处理 limit 个未完成键,返回本次复制数和剩余数;Resume 用快照键列表和已复制键列表重建 worker,模拟进程崩溃后的恢复。
// file: internal/platform/migration/migration.go
// tier: L2
// verify: go test ./internal/platform/migration/ -count=1
package migration
import (
"errors"
"fmt"
"sort"
"sync"
)
// ErrInvalidState is returned when a step is not allowed in the current state.
var ErrInvalidState = errors.New("invalid migration state")
// ErrIncomplete is returned when cutover runs before every key is copied.
var ErrIncomplete = errors.New("migration incomplete")
// Record is a versioned value. Version is the only conflict resolver, so a
// stale snapshot never overwrites a newer target value.
type Record struct {
Value string
Version int64
}
// Store is the minimal shard interface used by the state machine.
type Store interface {
Get(key string) (Record, bool)
Put(key string, rec Record)
ConditionalPut(key string, rec Record) bool
Keys() []string
}
// MemoryStore is an in-process Store for tests and local drills.
type MemoryStore struct {
mu sync.Mutex
data map[string]Record
}
// NewMemoryStore returns an empty store.
func NewMemoryStore() *MemoryStore { return &MemoryStore{data: make(map[string]Record)} }
// Get returns the stored record.
func (s *MemoryStore) Get(key string) (Record, bool) {
s.mu.Lock()
defer s.mu.Unlock()
rec, ok := s.data[key]
return rec, ok
}
// Put writes unconditionally.
func (s *MemoryStore) Put(key string, rec Record) {
s.mu.Lock()
defer s.mu.Unlock()
s.data[key] = rec
}
// ConditionalPut writes only when rec is newer than the current value.
func (s *MemoryStore) ConditionalPut(key string, rec Record) bool {
s.mu.Lock()
defer s.mu.Unlock()
if old, ok := s.data[key]; ok && old.Version >= rec.Version {
return false
}
s.data[key] = rec
return true
}
// Keys returns all keys in sorted order.
func (s *MemoryStore) Keys() []string {
s.mu.Lock()
defer s.mu.Unlock()
keys := make([]string, 0, len(s.data))
for k := range s.data {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
// State is the migration state exposed to routing and operators.
type State string
const (
StateIdle State = "idle"
StateDualWrite State = "dual-write"
StateBackfill State = "backfill"
StateDone State = "done"
StateRolledBack State = "rolled-back"
)
// Migration copies one shard to another with dual writes. Every step is
// idempotent: replaying a batch or resuming after a crash converges.
type Migration struct {
Source Store
Target Store
state State
keys []string
journal map[string]bool
order []string
}
// NewMigration returns a migration in StateIdle.
func NewMigration(source, target Store) *Migration {
return &Migration{Source: source, Target: target, state: StateIdle, journal: make(map[string]bool)}
}
// State returns the current state.
func (m *Migration) State() State { return m.state }
// Begin snapshots the source key list and turns on dual writes.
func (m *Migration) Begin() error {
if m.state != StateIdle {
return fmt.Errorf("migration: begin in state %s: %w", m.state, ErrInvalidState)
}
m.keys = m.Source.Keys()
m.state = StateDualWrite
return nil
}
// Write sends writes to both shards during migration, target after cutover,
// and source after rollback.
func (m *Migration) Write(key string, rec Record) error {
switch m.state {
case StateDualWrite, StateBackfill:
m.Source.Put(key, rec)
m.Target.Put(key, rec)
case StateDone:
m.Target.Put(key, rec)
case StateRolledBack:
m.Source.Put(key, rec)
default:
return fmt.Errorf("migration: write in state %s: %w", m.state, ErrInvalidState)
}
return nil
}
// Read reads the source until cutover and the target afterwards.
func (m *Migration) Read(key string) (Record, bool) {
if m.state == StateDone {
return m.Target.Get(key)
}
return m.Source.Get(key)
}
// CopyBatch copies at most limit not-yet-journaled keys. Replay is safe: the
// journal skips done keys and ConditionalPut rejects stale versions.
func (m *Migration) CopyBatch(limit int) (copied, remaining int, err error) {
if m.state != StateDualWrite && m.state != StateBackfill {
return 0, m.remaining(), fmt.Errorf("migration: copy in state %s: %w", m.state, ErrInvalidState)
}
if limit <= 0 {
return 0, m.remaining(), fmt.Errorf("migration: batch limit must be positive, got %d", limit)
}
m.state = StateBackfill
for _, key := range m.keys {
if copied >= limit {
break
}
if m.journal[key] {
continue
}
if rec, ok := m.Source.Get(key); ok {
m.Target.ConditionalPut(key, rec)
}
m.journal[key] = true
m.order = append(m.order, key)
copied++
}
return copied, m.remaining(), nil
}
// Cutover requires every snapshot key to be copied, then switches reads to
// the target. It moves no data, so it is fast and retryable.
func (m *Migration) Cutover() error {
if m.state != StateBackfill {
return fmt.Errorf("migration: cutover in state %s: %w", m.state, ErrInvalidState)
}
if left := m.remaining(); left > 0 {
return fmt.Errorf("migration: %d keys remain: %w", left, ErrIncomplete)
}
m.state = StateDone
return nil
}
// Rollback abandons the migration before cutover and keeps the source
// authoritative.
func (m *Migration) Rollback() error {
switch m.state {
case StateDualWrite, StateBackfill:
m.state = StateRolledBack
return nil
default:
return fmt.Errorf("migration: rollback in state %s: %w", m.state, ErrInvalidState)
}
}
// SnapshotKeys returns the key list captured by Begin.
func (m *Migration) SnapshotKeys() []string { return append([]string(nil), m.keys...) }
// CopiedKeys returns journaled keys in copy order.
func (m *Migration) CopiedKeys() []string { return append([]string(nil), m.order...) }
// Remaining reports how many snapshot keys are not journaled.
func (m *Migration) Remaining() int { return m.remaining() }
func (m *Migration) remaining() int {
n := 0
for _, key := range m.keys {
if !m.journal[key] {
n++
}
}
return n
}
// Resume rebuilds a worker from a snapshot key list and a copied-key journal.
func Resume(source, target Store, keys, copied []string) *Migration {
m := NewMigration(source, target)
m.state = StateBackfill
m.keys = append([]string(nil), keys...)
m.order = append([]string(nil), copied...)
for _, key := range copied {
m.journal[key] = true
}
return m
}
代码里有三个关键点。Begin 只做一次键列表快照,之后新增的键通过 Write 双写,不进入 journal;因此切换校验只覆盖快照键,新键的正确性由双写路径保证。CopyBatch 每批结束都返回剩余数,调度器可以据此决定是否继续、暂停或告警。Resume 不重新读源分片的键列表,而是信任传入的快照,这样即使迁移期间源分片新增了键,恢复后的 worker 也不会把新键误当成历史键重复处理。
本机实测:中断续跑与回滚
测试用 100 个订单键,第一批判 30 个,然后模拟进程崩溃:丢弃原 worker,只保留 SnapshotKeys() 和 CopiedKeys(),用 Resume 重建,再以每批 20 个继续。命令与原始输出:
$ go test ./internal/platform/migration/ -v -count=1
=== RUN TestMigrationResumesAfterCrash
migration_test.go:54: resumed: first_copied=30 remaining_after_first=70 resumed_batches=4 resumed_copied=70
--- PASS: TestMigrationResumesAfterCrash (0.00s)
=== RUN TestDualWriteKeepsTargetFresh
migration_test.go:101: dual write survived backfill: value=paid version=2
--- PASS: TestDualWriteKeepsTargetFresh (0.00s)
=== RUN TestStaleCopyDoesNotOverwriteNewerTarget
migration_test.go:115: stale copy rejected: value=newer version=2
--- PASS: TestStaleCopyDoesNotOverwriteNewerTarget (0.00s)
=== RUN TestCutoverRequiresCompleteBackfill
--- PASS: TestCutoverRequiresCompleteBackfill (0.00s)
=== RUN TestRollbackReturnsWritesToSource
migration_test.go:164: rollback state=rolled-back source_value=after-rollback
--- PASS: TestRollbackReturnsWritesToSource (0.00s)
PASS
ok archlab/internal/platform/migration 0.265s
读数:第一次复制 30 个,剩余 70 个;恢复后以每批 20 个执行了 4 批(20+20+20+10),共 70 个,最终 journal 覆盖 100 个键并通过切换。双写测试在回填过程中把某个键从版本 1 更新到 paid/2,切换后读目标仍是版本 2,说明双写没有被稍后的回填覆盖。版本条件写入测试把目标预置为版本 2、用版本 1 复制,返回拒绝,目标保持 newer/2。回填未完成时调用 Cutover 返回 ErrIncomplete,状态仍是 backfill;回滚后写入只落源分片,再次切换返回 ErrInvalidState。
这些数字是单进程内存存储上的行为验证,不是生产迁移吞吐。真实环境还要把 journal 持久化到数据库或对象存储,并在每批之间记录位点;本节验证的是状态转移和不变量,不是磁盘 I/O 或网络重试的耗时。
切换判据
迁移是否继续,不能靠“看起来快好了”。下表给出可以自动化的退出条件;任何一项不满足,都应暂停而不是强行切换。
| 检查项 | 通过条件 | 不通过时的动作 |
|---|---|---|
| journal 剩余 | 快照键剩余为 0 | 继续回填或排查卡住的键 |
| 目标写入延迟 | 在预设窗口内追平源分片 | 降速、扩容目标或暂停双写 |
| 版本冲突 | 陈旧复制被拒绝的次数可解释 | 检查是否有绕过双写的写路径 |
| 抽样校验 | 值与版本差异为 0 | 修复差异后重跑校验,不切换 |
| 回滚演练 | 切换前回滚能恢复源写入 | 补齐回滚脚本再排期 |
其中“版本冲突可解释”最容易被忽略。回填期间出现少量拒绝是正常的,因为目标已经通过双写拿到更新的版本;但如果拒绝次数持续增长,说明回填读到的源版本总是落后,可能是双写顺序错误或源分片有第二个写入者。此时切换只会把问题带到新分片。
常见错误与边界
第一,先切读再切写。读切到目标后,双写还没停,源分片仍然接收写;用户可能读到目标上的旧值,再刷新又看到源上的新值。正确顺序是先完成回填和校验,再切写路由,最后切读,且读写切换都要以路由版本为准。
第二,双写失败被当成成功。目标写失败只记日志不重试,切换后才发现缺数据;或者重试没有幂等键,重复写产生两条订单。双写路径必须把目标失败变成可观测的待补偿项,并阻塞切换。
第三,切换后立刻删源分片。回滚窗口内源分片仍是唯一能证明“切换前状态”的地方;删除后既无法回滚,也无法对账。至少保留一个完整业务周期,等对账和审计都通过再下线。
第四,把回滚理解成“撤销目标上的写入”。回滚只改变路由,已经写到目标的数据不会自动消失;如果回滚后又有写入目标,下次迁移会看到混合版本。回滚必须同时冻结目标写入,并记录回滚位点。
小结
再平衡的核心不是复制速度,而是“任何时刻都有一条权威写入路径”。快照、双写、回填、校验、切换五个阶段用 journal 和版本条件写入保证可重入:中断后能从已复制键继续,旧快照不会覆盖新值,未完成回填不允许切换,回滚后写入回到源分片。本机 100 个键的测试验证了这四条不变量,但没有验证生产网络和持久化 journal 的成本。迁移完成后,客户端还要面对元数据传播延迟:旧路由可能仍把请求发到已经不再拥有该键的分片,这正是下一节要处理的错误预算问题。
阅读导航:上一节:分片键、热点与一致性哈希 · 下一节:请求路由、元数据与扩容期间的错误预算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。