《后端架构设计·分布式与数据一致性》5.2 再平衡:迁移、双写与客户端路由

从订单迁移时“先停写再搬数据”的错误直觉出发,把再平衡拆成快照、双写、回填、校验、切换五个阶段,给出可重入的 Go 迁移状态机与版本条件写入,并用 100 个订单键实测迁移中断后的续跑、双写不丢更新、回填未完成时拒绝切换,以及回滚后写回源分片的完整路径,附原始测试输出与阶段判据。

停写迁移的代价

订单分片从 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 的成本。迁移完成后,客户端还要面对元数据传播延迟:旧路由可能仍把请求发到已经不再拥有该键的分片,这正是下一节要处理的错误预算问题。

阅读导航:上一节:分片键、热点与一致性哈希 · 下一节:请求路由、元数据与扩容期间的错误预算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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