扩容当天,八成订单换了分片
订单库原来按用户取模分成 4 个分片,每片一个主库。业务量涨上来后要扩到 5 个分片,最常见的做法是改一行路由代码:shard = hash(userID) % 4 改成 % 5。上线后监控显示缓存命中率骤降、从库延迟拉高,客服按订单号查单大量超时。代码没有 bug,问题是取模扩容把大约八成的键重新映射了,而这些键背后的订单、索引和缓存都要跟着搬家。
第二个错误直觉是“按用户分片之后,所有订单查询都只打一个分片”。只有带用户维度的查询成立;运营按状态导单、客服按订单号查单、风控按商品扫单,都会变成跨分片操作。分片键解决的是写入归属,不解决所有读取形态。
这一节把订单系统从单库改到分片的几个硬问题落成可运行代码:路由、跨分片查询、全局 ID、双写迁移和回滚门。再平衡的状态机在再平衡:迁移、双写与客户端路由 已经拆过,这里不重复阶段定义,只把订单扩容的比例、查询边界和切换判据量化。
路由:先分清哪些查询必须单分片
路由算法决定迁移成本。取模实现最简单,但分片数一变,绝大多数键的目标分片都变;rendezvous(最高随机权重)哈希让每个键独立地在所有分片里选分数最高的那个,增加一个分片时只有被新分片抢走的键需要搬。下面的实验用 10000 个用户比较 4 到 5 个分片的迁移比例。
跨分片查询要先分类,再决定是否允许:
| 查询形态 | 路由方式 | 代价 | 约束 |
|---|---|---|---|
| 按 userID 查该用户订单 | 单分片 | 一次索引扫描 | 用户维度必须带在请求里 |
| 按 orderID 查单 | 全局索引或 ID 内嵌分片 | 一次点查 | 订单号必须能定位分片 |
| 按状态/时间导出 | 全部分片 scatter-gather | N 次查询加归并 | 深分页要游标,不能用 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、演练与回切 · 下一节:一次跨库下单的一致性验证 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。