10.1 跨服务事务与补偿
一场跨服战场打完,结算要动好几个地方:把跨服点券从报名时冻结的账户里扣掉、把奖励发进玩家的背包、把这条结算流水写进跨服中心的账本。这三步分属三个服务、可能落在三台机器上,中间没有一张能罩住它们的大表。
于是最经典的故障出现了:扣款成功、发奖时进程崩了,玩家点券没了、奖励也没到;或者奖励到了、流水没写,账本上这笔单子凭空消失。单机数据库里一个 BEGIN ... COMMIT 能解决的事,一旦跨了服务边界就变成分布式事务问题。
这一节不讲「怎么让分布式事务变成 ACID」——那在游戏服的延迟预算里做不到。这一节讲的是:承认没有全局事务,用补偿把系统拉回一致态,并把这条路径写成能跑的代码。所有代码在 internal/saga/ 包,只用标准库。
三种方案与取舍
跨服务一致性有三条主流路线,各自适合的边界很不一样。
2PC(两阶段提交):协调者先让所有参与方 prepare(锁住资源、保证能提交),全部就绪后再统一 commit。它给出原子性,代价是锁:从 prepare 到 commit 之间,参与方持有的行锁一直不放。跨服结算里这一步可能横跨几十毫秒到数秒,期间玩家改背包、走交易都会被卡住。更麻烦的是协调者在 prepare 之后崩溃——参与方只能一直锁着等它回来,这就是「阻塞式」协议的由来。游戏服的高并发写入受不了这种持锁,所以 2PC 基本被排除。
TCC(Try-Confirm-Cancel):把资源锁从数据库层提到业务层。Try 冻结资源(点券划到冻结账户,可用余额先减、冻结余额先加),Confirm 确认扣减,Cancel 解冻回滚。它不持有数据库锁,延迟可控,但要求每个参与方都实现三个接口,而且业务上得能「冻结」——点券能冻结,一封已经发出去的邮件却很难冻结。
Saga:把一个大事务拆成一串本地事务,每个本地事务配一个补偿动作。正向按序执行,一旦某步失败,就倒着把已完成的步骤补偿掉。Saga 有两种组织方式:**编排式(orchestration)**由一个中心编排器按状态机依次调用各服务;**编舞式(choreography)**没有中心,各服务监听事件、各自决定下一步。编排式的状态机集中在一处,容易审计和排障,是本节的默认选择。
| 方案 | 一致性 | 持锁 | 实现成本 | 适合场景 |
|---|---|---|---|---|
| 2PC | 强(原子) | 全程持有 | 低(协议内建) | 同库多表、低并发 |
| TCC | 强(业务级) | 冻结期持有 | 高(每参与方三接口) | 少数强一致资产操作 |
| Saga 编排 | 最终一致 | 不持有 | 中(每步一个补偿) | 跨服结算这类长链路 |
本节与站内既有文章的分工如下——本站 practice 专栏偏架构选型与可观测性,本卷这一节给的是能 go test 的最小可运行实现:
| 站内既有文章 | 主题 | 分工 |
|---|---|---|
/game-server-cross-service-transaction-architecture/ | 跨服务事务架构 | 讲方案选型与落地骨架;本文给可跑的 Saga 状态机与补偿 |
/game-server-cross-service-observability-architecture/ | 跨服务可观测性 | 讲链路追踪与指标口径;本文只关心补偿结果怎么留痕 |
Saga 放弃了隔离性(ACID 里的 I),中间状态对外可见。所以设计上有两条纪律:补偿动作必须幂等(它会被重试),补偿必须能处理「正向只做了一半」的情形。这两条贯穿下面所有代码。
Saga 编排器:正向执行与反向补偿
编排器的职责很小:按顺序跑正向步骤,第一个失败就掉头,把已经跑完的步骤从后往前补偿掉。倒序很重要——正向是「扣款 → 发奖 → 记流水」,补偿就必须是「撤流水 → 撤奖 → 退款」,否则撤到一半的状态会自相矛盾。
下面这段是 L1,go test 可以直接跑,定义了状态机、步骤与编排器:
// file: internal/saga/saga.go
// tier: L1
// verify: go test ./internal/saga/
package saga
// Status is the terminal state of one saga instance.
type Status int
const (
StatusCommitted Status = iota // every forward step succeeded
StatusRolledBack // a step failed; all completed steps compensated
StatusManual // some compensation kept failing; needs a human
)
// Step is one forward action and the action that undoes it. Compensate must be
// idempotent: the orchestrator may call it more than once when retrying.
type Step struct {
Name string
Do func() error
Compensate func() error
}
// Orchestrator drives an ordered list of steps and rolls back on failure.
type Orchestrator struct {
// MaxCompRetry is the number of extra attempts after the first failed
// compensation. MaxCompRetry=1 means at most two tries per step.
MaxCompRetry int
}
// Result records what happened so callers (and on-call humans) can audit it.
type Result struct {
Status Status
FailedStep string // the forward step that failed, "" if none
Compensated []string // steps compensated, newest-first
ManualSteps []string // steps whose compensation never succeeded
}
// Run executes steps in order; on the first failure it compensates the already
// completed steps in reverse order so the world returns to its initial state.
func (o *Orchestrator) Run(steps []Step) Result {
done := make([]int, 0, len(steps))
for i, s := range steps {
if err := s.Do(); err != nil {
return o.compensate(steps, done, s.Name)
}
done = append(done, i)
}
return Result{Status: StatusCommitted}
}
// compensate undoes done (indices into steps) newest-first. A step still failing
// after MaxCompRetry retries is flagged for a human, but the loop keeps going:
// one stuck step must not strand the others.
func (o *Orchestrator) compensate(steps []Step, done []int, failed string) Result {
r := Result{Status: StatusRolledBack, FailedStep: failed}
for j := len(done) - 1; j >= 0; j-- {
st := steps[done[j]]
ok := false
for attempt := 0; attempt <= o.MaxCompRetry; attempt++ {
if err := st.Compensate(); err == nil {
ok = true
break
}
}
if ok {
r.Compensated = append(r.Compensated, st.Name)
} else {
r.ManualSteps = append(r.ManualSteps, st.Name)
}
}
if len(r.ManualSteps) > 0 {
r.Status = StatusManual
}
return r
}
Result 的三个字段各有用途:FailedStep 是压垮链路的那一步,Compensated 是已成功回滚的步骤(顺序就是实际执行顺序),ManualSteps 是要人收尾的。这里有个刻意设计——补偿循环不因一个步骤卡住就整体停下。如果「撤流水」一直失败,而「退款」「撤奖」是好的,就该先把能撤的撤掉、把卡住的那一个标出来,而不是让整个回滚队列堵在第一个失败点上。
三个服务与补偿动作
真实系统里三个步骤分属三个服务,这里用一个 World 把它们的存储拼在一起,方便断言「最终状态一致」。CrossSignup 把三个服务拼成一条 Saga:扣点券、发奖励、记流水,每步都带一个反操作。
// file: internal/saga/services.go
// tier: L1
// verify: go test ./internal/saga/
package saga
import "fmt"
// Account is the asset view a cross-server saga mutates.
type Account struct {
Points int64 // 跨服点券
Items map[uint32]int64 // ItemID -> count
}
// World stands in for the three services a cross-server signup touches: the
// point wallet, the bag, and the settlement ledger. Fail / FailComp inject
// forward / compensation failures and exist only to drive tests.
type World struct {
Accounts map[uint64]*Account
Ledger []string // settled order ids, append-only
Fail map[string]bool
FailComp map[string]bool
}
// NewWorld returns an empty world with injectable failure maps.
func NewWorld() *World {
return &World{Accounts: map[uint64]*Account{}, Fail: map[string]bool{}, FailComp: map[string]bool{}}
}
func (w *World) account(pid uint64) *Account {
a := w.Accounts[pid]
if a == nil {
a = &Account{Items: map[uint32]int64{}}
w.Accounts[pid] = a
}
return a
}
// PointsService is the point-wallet service (may live on another shard).
type PointsService struct{ w *World }
// Deduct removes n points; it fails if the player cannot afford it.
func (p PointsService) Deduct(pid uint64, n int64) error {
a := p.w.account(pid)
if a.Points < n {
return fmt.Errorf("points: player %d has %d, need %d", pid, a.Points, n)
}
a.Points -= n
return nil
}
// Refund returns n points; idempotent because the saga may retry it.
func (p PointsService) Refund(pid uint64, n int64) error { p.w.account(pid).Points += n; return nil }
// RewardService grants items into the bag.
type RewardService struct{ w *World }
func (r RewardService) Grant(pid uint64, itemID uint32, n int64) error {
r.w.account(pid).Items[itemID] += n
return nil
}
// Revoke removes n of itemID; the inverse of Grant.
func (r RewardService) Revoke(pid uint64, itemID uint32, n int64) error {
a := r.w.account(pid)
a.Items[itemID] -= n
if a.Items[itemID] <= 0 {
delete(a.Items, itemID)
}
return nil
}
// LedgerService records the cross-server settlement line.
type LedgerService struct{ w *World }
func (l LedgerService) Append(orderID string) error {
l.w.Ledger = append(l.w.Ledger, orderID)
return nil
}
// RemoveLast drops the top line, undoing the matching Append.
func (l LedgerService) RemoveLast(orderID string) error {
n := len(l.w.Ledger)
if n == 0 || l.w.Ledger[n-1] != orderID {
return fmt.Errorf("ledger: top is not %q", orderID)
}
l.w.Ledger = l.w.Ledger[:n-1]
return nil
}
// CrossSignup builds the three-step saga: charge points, grant the reward, then
// record the settlement line. Each step carries its inverse.
func CrossSignup(w *World, pid uint64, cost int64, itemID uint32, reward int64, orderID string) []Step {
points, rewardSvc, ledger := PointsService{w}, RewardService{w}, LedgerService{w}
wrap := func(name string, fn func() error) func() error {
return func() error {
if w.Fail[name] {
return fmt.Errorf("injected failure at %s", name)
}
return fn()
}
}
wrapComp := func(name string, fn func() error) func() error {
return func() error {
if w.FailComp[name] {
return fmt.Errorf("injected compensation failure at %s", name)
}
return fn()
}
}
return []Step{
{Name: "charge", Do: wrap("charge", func() error { return points.Deduct(pid, cost) }),
Compensate: wrapComp("charge", func() error { return points.Refund(pid, cost) })},
{Name: "grant", Do: wrap("grant", func() error { return rewardSvc.Grant(pid, itemID, reward) }),
Compensate: wrapComp("grant", func() error { return rewardSvc.Revoke(pid, itemID, reward) })},
{Name: "settle", Do: wrap("settle", func() error { return ledger.Append(orderID) }),
Compensate: wrapComp("settle", func() error { return ledger.RemoveLast(orderID) })},
}
}
三个补偿动作各有讲究。Refund 只是把点数加回去,天然幂等;Revoke 减到 0 时顺手删掉条目,避免背包里留一个数量为 0 的空槽;RemoveLast 要求栈顶恰好是这笔单子——它不是在「删一笔流水」,而是在「撤销刚刚那次 append」,所以必须严格对应,否则说明日志已被别人动过。共同点是:重复执行的结果和执行一次一样,这正是 Saga 重试的前提。Fail / FailComp 两个开关只服务于测试,生产里对应「下游真的返回了错误」和「补偿时下游还是不可用」。
中途失败、重试与人工介入
三种结局都要能测出来,下面这段是 L1,go test 可以直接跑,把「全成功」「中途失败」「补偿自身失败」各跑一遍:
// file: internal/saga/saga_test.go
// tier: L1
// verify: go test ./internal/saga/ -run TestSaga -v
package saga
import "testing"
func TestSaga(t *testing.T) {
o := &Orchestrator{MaxCompRetry: 1}
// (1) all steps succeed: committed, assets move exactly once.
w := NewWorld()
w.Accounts[1] = &Account{Points: 100, Items: map[uint32]int64{}}
if res := o.Run(CrossSignup(w, 1, 30, 7001, 5, "order-A")); res.Status != StatusCommitted {
t.Fatalf("committed: status=%v", res.Status)
}
if a := w.Accounts[1]; a.Points != 70 || a.Items[7001] != 5 || len(w.Ledger) != 1 {
t.Fatalf("committed: %+v ledger=%v", a, w.Ledger)
}
// (2) last step fails: roll back newest-first, world back to the start.
w2 := NewWorld()
w2.Accounts[2] = &Account{Points: 100, Items: map[uint32]int64{}}
w2.Fail["settle"] = true
res := o.Run(CrossSignup(w2, 2, 30, 7001, 5, "order-B"))
if res.Status != StatusRolledBack || res.FailedStep != "settle" {
t.Fatalf("rollback: %+v", res)
}
if len(res.Compensated) != 2 || res.Compensated[0] != "grant" || res.Compensated[1] != "charge" {
t.Fatalf("rollback order=%v want [grant charge]", res.Compensated)
}
if a := w2.Accounts[2]; a.Points != 100 || a.Items[7001] != 0 || len(w2.Ledger) != 0 {
t.Fatalf("rollback not clean: %+v ledger=%v", a, w2.Ledger)
}
// (3) compensation itself fails: retried, then flagged for a human while
// the other steps still roll back.
w3 := NewWorld()
w3.Accounts[3] = &Account{Points: 100, Items: map[uint32]int64{}}
w3.Fail["settle"] = true
w3.FailComp["grant"] = true
res3 := o.Run(CrossSignup(w3, 3, 30, 7001, 5, "order-C"))
if res3.Status != StatusManual || len(res3.ManualSteps) != 1 || res3.ManualSteps[0] != "grant" {
t.Fatalf("manual: %+v", res3)
}
if len(res3.Compensated) != 1 || res3.Compensated[0] != "charge" || w3.Accounts[3].Points != 100 {
t.Fatalf("manual: refund should still happen: %+v", res3)
}
}
三种结局的含义逐一对照:
- 全成功:
StatusCommitted,点券 100→70、背包多 5 个 7001、流水一条。三个服务各自只做了本地事务,但编排器按序推进,最终状态一致。 - 中途失败:
settle崩了,grant和charge已完成,编排器倒序补偿——先grant撤奖、再charge退款。最关键的一条断言是点券回到 100、背包清空、流水为空:资产回到起点,好像这笔单子从没发生过。 - 补偿自身失败:
settle失败触发回滚,但撤grant也一直失败。重试MaxCompRetry次仍不行,编排器把它记进ManualSteps、状态置StatusManual,同时继续把charge退掉。结果是「退款成功、撤奖待人工」。
StatusManual 不是异常,而是一种必须被处理的正常结局。生产上它应该触发告警、生成一张工单(正是下一节 10.2 要讲的修复工单),并把整份 Result 落盘,让人能查到「哪笔单子、卡在哪一步、已经撤了哪些、还差哪些」。补偿失败不可怕,可怕的是失败了却没人知道——那才是真正的账目黑洞。
本节小结 / 实测结论
- 跨服务没有全局事务:2PC 全程持锁不适合高并发游戏服,TCC 要每个参与方实现三接口,Saga 用「正向 + 补偿」换最终一致。
- Saga 编排式把状态机集中在一处,便于审计排障;编舞式无单点但链路难追踪,本卷默认编排式。
- 补偿必须倒序执行、且必须幂等;补偿要能处理「正向只做了一半」的情形。
- 补偿失败时先重试(
MaxCompRetry),仍失败就置StatusManual并记ManualSteps,但不阻塞其余步骤回滚。 Result是留痕的核心:失败步、已补偿步、待人工步,一个都不能少。
阅读导航:上一节:9.3 分片与再均衡 · 下一节:10.2 对账与数据修复工单 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。