《后端架构设计·分布式与数据一致性》13.1 订单系统的分布式改造:从单库到分片

订单库从 4 个分片扩到 5 个时,按用户取模会让八成键搬家。本节用可运行的 Go 分片实验比较取模与 rendezvous 路由,给出按用户查询与跨分片查询的边界、全局 ID 的时钟回拨保护、双写迁移的 journal 与切换门,以及只允许在切换前执行的回滚条件。

扩容当天,八成订单换了分片

订单库原来按用户取模分成 4 个分片,每片一个主库。业务量涨上来后要扩到 5 个分片,最常见的做法是改一行路由代码:shard = hash(userID) % 4 改成 % 5。上线后监控显示缓存命中率骤降、从库延迟拉高,客服按订单号查单大量超时。代码没有 bug,问题是取模扩容把大约八成的键重新映射了,而这些键背后的订单、索引和缓存都要跟着搬家。

第二个错误直觉是“按用户分片之后,所有订单查询都只打一个分片”。只有带用户维度的查询成立;运营按状态导单、客服按订单号查单、风控按商品扫单,都会变成跨分片操作。分片键解决的是写入归属,不解决所有读取形态。

这一节把订单系统从单库改到分片的几个硬问题落成可运行代码:路由、跨分片查询、全局 ID、双写迁移和回滚门。再平衡的状态机在再平衡:迁移、双写与客户端路由 已经拆过,这里不重复阶段定义,只把订单扩容的比例、查询边界和切换判据量化。

路由:先分清哪些查询必须单分片

路由算法决定迁移成本。取模实现最简单,但分片数一变,绝大多数键的目标分片都变;rendezvous(最高随机权重)哈希让每个键独立地在所有分片里选分数最高的那个,增加一个分片时只有被新分片抢走的键需要搬。下面的实验用 10000 个用户比较 4 到 5 个分片的迁移比例。

跨分片查询要先分类,再决定是否允许:

查询形态路由方式代价约束
按 userID 查该用户订单单分片一次索引扫描用户维度必须带在请求里
按 orderID 查单全局索引或 ID 内嵌分片一次点查订单号必须能定位分片
按状态/时间导出全部分片 scatter-gatherN 次查询加归并深分页要游标,不能用 offset
按商品聚合全部分片或异步数仓N 次查询加聚合在线链路只做小范围

订单号如果只做全局唯一、不携带分片信息,就必须维护一张 orderID 到 shard 的索引表,否则按单号查单只能扫全部分片。全局 ID 一节会把这两种选择写清楚。

全局 ID:唯一性之外还要能拒绝回拨

订单号、支付单号、库存预占单号都要全局唯一。Snowflake 形状的 ID 把时间戳、机器号和序列号拼在一起,单机每毫秒最多 4096 个。它最容易被忽略的边界不是唯一性,而是时钟回拨:NTP 把本机时间往回拨一秒,生成器如果继续发号,就可能发出比已落库订单更小的 ID,按 ID 排序的分页和增量同步都会错乱。

本节的生成器在时钟回拨时直接返回 ErrClockRegression,让调用方决定是等待、切备用节点还是拒绝写入。号段模式可以在数据库里分配一段 ID、绕过时钟依赖,代价是多一次数据库往返和号段耗尽的处理;两种方案都可用,但不能既想要无状态又想要严格的单调递增。

迁移:双写、回填、校验、切换四道门

迁移按四个阶段推进,每个阶段都有可检查的退出条件:

阶段动作退出条件失败时
双写源分片成功后再写目标分片目标写失败数为零记录 pending,禁止切换
回填按 journal 复制未复制的历史订单源分片每个 ID 都在 journal续跑,不从头再来
校验逐键比较源与目标的版本、状态mismatch 为零且真的跑过校验修数据后重跑
切换改路由,停止双写前三道门全部通过切换前可回滚

回滚条件必须写死:切换前,路由还没指向目标,回滚只是丢弃目标分片并恢复源写入;切换后,目标已经接受过写请求,回滚意味着反向迁移,不能假装目标上的订单不存在。代码里的 Rollback 在 PhaseCutover 之后返回 ErrCutoverDone,就是为了避免把“改回配置”误当成回滚。

代码:路由、ID 与迁移状态机

下面的文件是一个自包含的 sharding 包,包含 rendezvous 路由、版本条件写入、单分片与跨分片查询、Snowflake 形状的 ID 生成器,以及带 journal、pending 和 shadow read 门的迁移状态机。同一 module 的测试用表驱动方式覆盖路由搬迁、跨分片查询、ID 唯一性与回拨、迁移切换门。

// file: internal/platform/sharding/sharding.go
// tier: L2
// verify: go test ./internal/platform/sharding/ -count=1
package sharding

import (
	"errors"
	"fmt"
	"sort"
	"strconv"
	"sync"
)

var (
	ErrClockRegression = errors.New("clock moved backwards")
	ErrTargetWrite     = errors.New("target shard write failed")
	ErrPendingWrites   = errors.New("pending dual-write repairs")
	ErrMismatch        = errors.New("shadow read mismatch")
	ErrCutoverDone     = errors.New("cutover already completed")
	ErrInvalidPhase    = errors.New("invalid migration phase")
)

// Router maps a user to a shard with rendezvous hashing. Adding one shard
// only moves the keys the new shard wins, instead of almost every key.
type Router struct{ Shards int }

// ShardOf returns the shard index for userID. The splitmix finalizer is part
// of the contract: FNV alone is biased when only the node suffix changes.
func (r Router) ShardOf(userID string) int {
	if r.Shards <= 1 {
		return 0
	}
	best, score := 0, mix(fnv1a(userID+"#0"))
	for i := 1; i < r.Shards; i++ {
		if s := mix(fnv1a(userID + "#" + strconv.Itoa(i))); s > score {
			best, score = i, s
		}
	}
	return best
}

func mix(x uint64) uint64 {
	x += 0x9e3779b97f4a7c15
	x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9
	x = (x ^ (x >> 27)) * 0x94d049bb133111eb
	return x ^ (x >> 31)
}

func fnv1a(s string) uint64 {
	const offset, prime = 14695981039346656037, 1099511628211
	var h uint64 = offset
	for i := 0; i < len(s); i++ {
		h ^= uint64(s[i])
		h *= prime
	}
	return h
}

// Order is the aggregate stored in a user shard.
type Order struct {
	ID, UserID, Status string
	Amount             int64
	Version            int64
}

// Shard is an in-process, versioned order store.
type Shard struct {
	mu   sync.Mutex
	data map[string]Order
}

func NewShard() *Shard { return &Shard{data: make(map[string]Order)} }

// Put writes only when the incoming version is not older.
func (s *Shard) Put(o Order) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if old, ok := s.data[o.ID]; ok && old.Version > o.Version {
		return
	}
	s.data[o.ID] = o
}

func (s *Shard) All() []Order {
	s.mu.Lock()
	defer s.mu.Unlock()
	out := make([]Order, 0, len(s.data))
	for _, o := range s.data {
		out = append(out, o)
	}
	return out
}

// Cluster routes user writes and scatters cross-user reads.
type Cluster struct {
	router Router
	shards []*Shard
}

func NewCluster(r Router) *Cluster {
	if r.Shards < 1 {
		panic("sharding: router needs at least one shard")
	}
	c := &Cluster{router: r, shards: make([]*Shard, r.Shards)}
	for i := range c.shards {
		c.shards[i] = NewShard()
	}
	return c
}

func (c *Cluster) Put(o Order) { c.shards[c.router.ShardOf(o.UserID)].Put(o) }

func (c *Cluster) All() []Order {
	var out []Order
	for _, s := range c.shards {
		out = append(out, s.All()...)
	}
	return out
}

// ByStatus reads every shard and merges the partial results. This is the
// query shape that a user shard key does not make cheap.
func (c *Cluster) ByStatus(status string) []Order {
	var out []Order
	for _, o := range c.All() {
		if o.Status == status {
			out = append(out, o)
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
	return out
}

// IDGen is Snowflake-shaped: 41 bits of milliseconds, 10 bits of node and
// 12 bits of sequence. The clock is injected so tests never sleep.
type IDGen struct {
	node, last, seq int64
	now             func() int64
}

const epochMillis int64 = 1700000000000

func NewIDGen(node int64, now func() int64) *IDGen {
	return &IDGen{node: node & 1023, now: now}
}

func (g *IDGen) Next() (int64, error) {
	ms := g.now()
	if ms < g.last {
		return 0, fmt.Errorf("%w: last=%d now=%d", ErrClockRegression, g.last, ms)
	}
	if ms == g.last {
		g.seq++
		if g.seq > 4095 {
			return 0, errors.New("idgen: sequence exhausted in one millisecond")
		}
	} else {
		g.last, g.seq = ms, 0
	}
	return ((ms - epochMillis) << 22) | (g.node << 12) | g.seq, nil
}

type Phase string

const (
	PhaseDualWrite  Phase = "dual_write"
	PhaseBackfill   Phase = "backfill"
	PhaseVerify     Phase = "verify"
	PhaseCutover    Phase = "cutover"
	PhaseRolledBack Phase = "rolled_back"
)

// Migration dual-writes new orders, backfills history and gates cutover on
// an empty repair list and zero shadow-read mismatches.
type Migration struct {
	from, to         Router
	source, target   *Cluster
	phase            Phase
	journal, pending map[string]bool
	mismatches       int
	verified         bool
	failTarget       bool
}

func NewMigration(from, to Router, source *Cluster) *Migration {
	return &Migration{from: from, to: to, source: source, target: NewCluster(to),
		phase: PhaseDualWrite, journal: map[string]bool{}, pending: map[string]bool{}}
}

func (m *Migration) Phase() Phase { return m.phase }

func (m *Migration) StartBackfill() { m.phase = PhaseBackfill }

// Write sends the order to the source and, during dual-write, to the target.
// A target failure is recorded for retry and blocks cutover.
func (m *Migration) Write(o Order) error {
	if m.phase == PhaseCutover || m.phase == PhaseRolledBack {
		return fmt.Errorf("write in phase %s: %w", m.phase, ErrInvalidPhase)
	}
	m.source.Put(o)
	if m.failTarget {
		m.pending[o.ID] = true
		return fmt.Errorf("order %s: %w", o.ID, ErrTargetWrite)
	}
	m.target.Put(o)
	delete(m.pending, o.ID)
	m.journal[o.ID] = true
	return nil
}

// Backfill copies at most limit uncopied source orders and is re-entrant.
func (m *Migration) Backfill(limit int) int {
	copied := 0
	for _, o := range m.source.All() {
		if copied >= limit {
			break
		}
		if m.journal[o.ID] {
			continue
		}
		m.target.Put(o)
		m.journal[o.ID] = true
		copied++
	}
	return copied
}

func (m *Migration) Verify() int {
	target := make(map[string]Order)
	for _, o := range m.target.All() {
		target[o.ID] = o
	}
	m.mismatches, m.verified = 0, true
	for _, src := range m.source.All() {
		dst, ok := target[src.ID]
		if !ok || !m.journal[src.ID] || dst.Version != src.Version || dst.Status != src.Status {
			m.mismatches++
		}
	}
	return m.mismatches
}

// CanCutover enforces all orders copied, no pending repair and no mismatch.
func (m *Migration) CanCutover() error {
	if len(m.pending) > 0 {
		return fmt.Errorf("%d orders: %w", len(m.pending), ErrPendingWrites)
	}
	for _, o := range m.source.All() {
		if !m.journal[o.ID] {
			return fmt.Errorf("order %s not copied: %w", o.ID, ErrPendingWrites)
		}
	}
	if !m.verified {
		return fmt.Errorf("shadow read not run: %w", ErrMismatch)
	}
	if m.mismatches > 0 {
		return fmt.Errorf("%d mismatches: %w", m.mismatches, ErrMismatch)
	}
	return nil
}

func (m *Migration) Cutover() error {
	if err := m.CanCutover(); err != nil {
		return err
	}
	m.phase = PhaseCutover
	return nil
}

// Rollback is allowed only before cutover; afterwards a reverse migration is
// required because the target already accepted writes.
func (m *Migration) Rollback() error {
	if m.phase == PhaseCutover {
		return ErrCutoverDone
	}
	m.phase = PhaseRolledBack
	return nil
}

实测:迁移比例与切换门

命令:go test ./internal/platform/sharding/ -count=1 -v。环境为 macOS darwin/arm64、Go 1.26.0,测试串行执行。完整输出保存在 .local/reports/be-arch/be-arch-dist-13-1-order-sharding-migration-run.txt。

=== RUN   TestRendezvousMovesFewerKeys
    sharding_test.go:25: 4->5 moved: rendezvous=0.205 modulo=0.799
--- PASS: TestRendezvousMovesFewerKeys (0.01s)
=== RUN   TestClusterQueries
--- PASS: TestClusterQueries (0.00s)
=== RUN   TestIDGenUniqueAndClockGuard
--- PASS: TestIDGenUniqueAndClockGuard (0.00s)
=== RUN   TestMigrationCutoverGate
--- PASS: TestMigrationCutoverGate (0.00s)
PASS
ok  	archlab/internal/platform/sharding	0.281s

4 到 5 个分片时,rendezvous 只移动 20.5% 的键,取模移动 79.9%。这不是说 rendezvous 一定更好:它的路由计算要遍历全部分片,分片数很多时 CPU 成本更高;取模可以用一次除法,但扩容要接受大规模搬迁。选择取决于分片数量、扩容频率和能否承受迁移窗口。

迁移测试固定了三条判据:双写目标失败时 CanCutover 返回 ErrPendingWrites;回填 200 个订单后 Verify 返回 0;切换后 Rollback 返回 ErrCutoverDone,而切换前的实例可以正常回滚。测试还生成了 10000 个 ID 并断言无重复,随后把时钟回拨 1ms,断言生成器返回 ErrClockRegression 而不是继续发号。

常见错误与边界

  • 用取模做分片路由,却按“改配置”估算扩容成本。4 到 5 个分片的实测搬迁比例是 79.9%,不是 20%。
  • 以为按用户分片后所有查询都变单分片。按状态、按商品、按时间范围的查询仍然是 scatter-gather,深分页必须改成游标。
  • 只测试 ID 唯一性,不测试时钟回拨。唯一性在回拨时通常还成立,错的是顺序和增量同步位点。
  • 把双写当成双倍可靠性。目标写失败必须记录并阻塞切换;切换后再回滚需要反向迁移,不能只改路由。
  • 本节是单进程 L2 实验,没有真实数据库、网络分区和跨节点时钟;分片数、键分布和失败注入都是确定值,不能当作生产容量数据。

小结

订单分片的难点不在“把数据分开放”,而在三个可验收的边界:路由算法决定扩容要搬多少键,查询分类决定哪些请求会变成 scatter-gather,迁移门决定什么时候可以切换、什么时候只能反向迁移。本节的实测给出 4 到 5 个分片时 rendezvous 搬迁 20.5%、取模搬迁 79.9%,并用测试固定了切换前的 pending 门、校验门和切换后的回滚拒绝。

路由和迁移解决的是“订单写到哪里”,下一节要解决更难的问题:订单分片与库存分片不在同一个本地事务里,下单链路怎么在重复、超时和崩溃下仍然守住库存不超卖、支付不重复扣款。

阅读导航:上一节:容灾切换:RTO/RPO、演练与回切 · 下一节:一次跨库下单的一致性验证 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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