《游戏服务端架构·规模化、全球化与成本》4.3 再均衡期间的读写

《群岛纪元》在赛季中迁移公会数据时,玩家一次购买超时重试,第一次写入源分片、第二次落到目标分片,差点扣两次款。本节拆解源可读可写、双写、目标接管三态路由,给出读修复、版本冲突与幂等账本的规则,并用可运行的 Go 状态机验证,最后附切换门禁与回滚窗口检查项。

迁移窗口里的一次重试

《群岛纪元》赛季中做公会数据迁移,一个玩家在商城购买加速道具。请求到达源分片,写入成功,但响应在网络上超时。客户端按规则重试,这时路由表已经切到目标分片,重试请求落到目标。目标没有这条购买记录,于是又扣了一次款。

事故复盘时,团队发现三个独立问题叠在一起:路由切换没有等待旧请求窗口结束;购买请求没有稳定的幂等键;目标分片在双写阶段漏掉了这一笔。任何一个问题单独存在,都不会扣两次款,但它们同时出现时,补偿逻辑也救不回来。

再均衡期间的读写规则,本质上是回答三个问题:当前谁说了算、读到旧值怎么办、重试落到别处怎么办。本节按“源可读可写 → 双写 → 目标接管”三态展开,并用一个 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,由后台重试,并记录目标滞后。

切换门禁至少包含四项:

  1. 滞后为零:目标没有待重试的镜像写,或滞后在可接受窗口内。
  2. 校验一致:抽样比对键集合、版本号和关键字段校验和。
  3. 容量足够:目标能承接全量读写,不只是当前镜像写。
  4. 错误率正常:源和目标在最近一个窗口内没有异常升高。

四项都满足才允许 Advance 到目标接管。任何一项不满足,停在双写阶段继续追平;不要因为“窗口快结束了”而强行切换。

切换后源分片进入只读回滚窗口。窗口长度按业务决定,示例设计目标是保留 24 小时;期间源不接受新写,但可以读,用于对账和应急回滚。窗口结束、备份完成、校验通过后才允许回收源数据。

常见错误与边界

第一,双写阶段从目标读。目标可能落后,玩家会看到迁移前的旧值;双写的“双”只指写,不指读。

第二,版本号用时间戳。时钟回拨和多节点漂移会产生平局,平局又会被错误地自动裁决,冲突被藏起来。

第三,重试时重新生成幂等键。幂等的前提是同一个业务请求在多次重试中共享同一个键;键变了,账本再完整也认不出来。

第四,同版本冲突用节点 ID 排序解决。这不是业务规则,只是实现巧合;正确做法是报冲突,让业务定义优先级。

第五,切换瞬间删除源。目标可能还有未追平的写,源也没有完成只读回滚窗口;删除后既无法对账,也无法回滚。

第六,忘记双写的容量放大。目标按最终数据量规划,双写阶段实际写入是两倍,先过载再谈迁移。

小结

再均衡期间的读写可以压缩成三句话:每个阶段只有一个权威写者;版本高者胜,平局不猜;重试靠共享幂等账本,不靠调用方自觉。

Machine 把三态前进、后退和路由写成显式约束,Resolve 把读修复的胜负规则固定下来,Ledger 把重复、过期和冲突分开处理。它们不能替代迁移执行器,但能把“迁移时允许什么”从口头约定变成编译期和测试期都能检查的代码。

阅读导航:上一节:4.2 一致性哈希与迁移 · 下一节:5.1 本地缓存、分布式缓存与多级缓存 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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