《MMO 服务器开发》10.1 跨服务事务与补偿

跨服发奖发到一半崩了怎么办:点券扣了奖励没到、奖励到了流水没记。本节把跨服务事务的三种方案摆开——2PC 为什么不适合游戏服、TCC 的冻结确认撤销、Saga 的编排式与编舞式——并用 aegis 的 saga 包实现一套可跑的 Saga 状态机与补偿动作,断言正向全成功状态一致、中途失败逐个补偿回到一致态、补偿自身失败时重试并打上人工介入标记。

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 对账与数据修复工单 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标