《游戏服务端架构·核心系统与契约》6.2 交易托管、冻结与回滚

《群岛纪元》的玩家交易曾出现扣了银币却没收到物品、掉线后双方各持一份货的情况。本节把交易拆成锁定、校验、交换、解冻四个状态,说明冻结余额与可用余额的区别、重复提交和并发冲突的处理、跨服托管的边界,并给出可运行的 Go 托管状态机与失败模式兜底。

先扣再给,为什么一定会出事

《群岛纪元》最早的玩家交易是两步:先从 A 扣银币,再给 B 加物品,最后给 A 加物品、从 B 扣银币。四步里任意一步失败,账就歪了。上线第一个月出现过三种事故:A 扣了钱但 B 已下线,物品没发;B 收到物品后 A 掉线,A 的银币没扣;两个请求并发时,双方都收到了对方的物品。

根因不是代码写得不够小心,而是把“交易”拆成了多个可以独立失败的写操作。只要中间存在失败点,就必须有补偿;而补偿本身也会失败。托管(escrow)的思路是把“先扣后给”改成“先锁住,全部校验通过后一次性交换”,把失败点收敛到一个状态机里。

三款产品的交易边界不同:

  • 《群岛纪元》有玩家间直接交易和拍卖行,银币与可交易物品都要走托管。
  • 《泡泡工坊》只有礼物赠送,没有对价交换,可以用单向发货,不需要完整托管。
  • 《裂隙战场》局内经济封闭,对局结束即结算,不开放玩家交易。

托管状态机:四个状态与两次确认

托管把交易生命周期写成显式状态,每个状态只允许有限的动作:

状态进入条件允许动作失败去向
Locked双方报价合法,资产已冻结校验、回滚回滚到 RolledBack
Validated反作弊与物品存在性通过交换、回滚回滚到 RolledBack
Swapped资产已易手释放、反向回滚回滚到 RolledBack
Released交易完成无终态
RolledBack任一步失败或主动取消无终态

关键点是 Locked 和 Validated 之间没有资产移动。这两步只做检查和冻结,所有“可能失败”的外部依赖都放在交换之前。Swapped 之后资产已经易手,回滚不再是简单的解冻,而是一次反向转移;如果对方已经把物品花掉,就只能拒绝回滚并转人工处理。

冻结余额与可用余额

“冻结”不是给数据库记录加一个 trading=true 标记,而是把数量从可用余额移到冻结余额。两者之和才是玩家总资产,任何时刻都必须满足:

总资产 = 可用余额 + 冻结余额
余额用途谁能修改交易失败时
可用余额消费、再交易、丢弃交易引擎、背包服务不变
冻结余额只属于某一笔未完成交易仅该交易的托管方解冻回可用
总资产展示、审计、封号核对不允许直接写不变

把冻结做成标记而不是真实扣减,常见后果是同一件物品被两笔交易同时报价:第一笔检查时可用,第二笔检查时仍然可用,因为标记不参与可用量计算。冻结必须是数量转移,且与交易状态在同一个临界区里完成。

重复提交、并发与跨服

三个边界必须在协议层解决,而不是靠客户端:

  • 重复提交:客户端超时重发、网关重投都会产生同一个交易请求。交易 ID 由服务端生成,客户端重试时带回;托管方遇到已存在的 ID 直接返回原交易,不重复冻结。
  • 并发冲突:同一笔交易可能被两个入口同时推进(玩家点击和超时补偿任务)。每次状态推进都带版本号,版本不匹配就拒绝,调用方重新读取后再决定。
  • 跨服交易:双方可能在不同服。托管方只能有一个,通常放在物品所在服或独立交易服;另一方通过异步消息确认。两边各自扣减会造成双花,必须由托管方串行化。

跨服场景还有一个容易忽略的点:交易过程中玩家转服。转服流程要先检查该玩家是否有未完成交易,有则拒绝转服或强制回滚,否则托管方会指向一个已经不存在的会话。

玩家交易与拍卖行不是一回事

两者都涉及资产转移,但失败语义不同:

维度玩家直接交易拍卖行
撮合双方在线协商挂单后异步成交
托管对象双方报价卖家物品与买家资金
手续费成交时扣成交或流拍时按规则扣
失败处理回滚整笔交易流拍退回,部分成交拆分
一致性要求强,双方同时易手最终一致,允许挂单等待

直接交易可以要求双方在线、一次提交、一次交换;拍卖行必须支持挂单、撤单、流拍和部分成交,状态机更接近订单而不是交易。把拍卖行硬套进直接交易的四个状态,最后会在“部分成交”和“手续费退还”上补出大量特例。

可运行片段:冻结、交换与回滚

下面的 internal/tradeescrow 把冻结、版本校验、交换和回滚写成可测试的状态机。它只管数量,不管物品实例属性;实例绑定、强化等级属于物品服务。

// file: internal/tradeescrow/tradeescrow.go
// tier: L1
// verify: go test ./internal/tradeescrow/
package tradeescrow

import (
	"errors"
	"fmt"
	"sync"
)

var (
	ErrPartyCount      = errors.New("tradeescrow: trade needs exactly two distinct parties")
	ErrEmptyOffer      = errors.New("tradeescrow: each party must offer at least one item")
	ErrBadQuantity     = errors.New("tradeescrow: quantity must be positive")
	ErrBadItem         = errors.New("tradeescrow: item id is empty")
	ErrDuplicateItem   = errors.New("tradeescrow: duplicate item in one offer")
	ErrInsufficient    = errors.New("tradeescrow: insufficient available quantity")
	ErrUnknownTrade    = errors.New("tradeescrow: unknown trade")
	ErrVersionConflict = errors.New("tradeescrow: stale version")
	ErrIllegalState    = errors.New("tradeescrow: illegal state transition")
	ErrRollbackDenied  = errors.New("tradeescrow: receiver already spent swapped items")
)

// State is the escrow lifecycle. Swapped is the point of no easy return: items
// have moved, so Rollback from Swapped needs a reverse transfer.
type State string

const (
	StateLocked     State = "locked"
	StateValidated  State = "validated"
	StateSwapped    State = "swapped"
	StateReleased   State = "released"
	StateRolledBack State = "rolled_back"
)

// Item is a stack of one item id. Per-instance attributes belong to the item
// service; the trade only moves quantities.
type Item struct {
	ID  string
	Qty int64
}

// Offer is one party's side of the trade.
type Offer struct {
	Party string
	Items []Item
}

// Trade is the externally visible state. Version increments on every accepted
// transition and is the compare-and-swap token callers must send back.
type Trade struct {
	ID      string
	State   State
	Version int
	Offers  map[string][]Item
}

// Engine owns available and frozen quantities. The mutex makes validation,
// freezing and state change one critical section.
type Engine struct {
	mu        sync.Mutex
	available map[string]map[string]int64
	frozen    map[string]map[string]int64
	trades    map[string]*Trade
}

// New creates an empty escrow engine.
func New() *Engine {
	return &Engine{
		available: make(map[string]map[string]int64),
		frozen:    make(map[string]map[string]int64),
		trades:    make(map[string]*Trade),
	}
}

// Open validates both offers, moves quantities from available to frozen, and
// creates the trade in StateLocked. A repeated id returns the original trade
// with replay=true and freezes nothing twice.
func (e *Engine) Open(id string, offers []Offer) (*Trade, bool, error) {
	e.mu.Lock()
	defer e.mu.Unlock()
	if existing, ok := e.trades[id]; ok {
		return snapshot(existing), true, nil
	}
	if len(offers) != 2 || offers[0].Party == "" || offers[1].Party == "" ||
		offers[0].Party == offers[1].Party {
		return nil, false, ErrPartyCount
	}
	want := make(map[string]map[string]int64, 2)
	for _, offer := range offers {
		if len(offer.Items) == 0 {
			return nil, false, ErrEmptyOffer
		}
		want[offer.Party] = make(map[string]int64, len(offer.Items))
		for _, item := range offer.Items {
			if item.ID == "" {
				return nil, false, ErrBadItem
			}
			if item.Qty <= 0 {
				return nil, false, ErrBadQuantity
			}
			if _, dup := want[offer.Party][item.ID]; dup {
				return nil, false, fmt.Errorf("%w: %s/%s", ErrDuplicateItem, offer.Party, item.ID)
			}
			want[offer.Party][item.ID] = item.Qty
		}
	}
	for party, items := range want {
		for item, qty := range items {
			if e.available[party][item] < qty {
				return nil, false, fmt.Errorf("%w: %s/%s", ErrInsufficient, party, item)
			}
		}
	}
	for party, items := range want {
		if e.frozen[party] == nil {
			e.frozen[party] = make(map[string]int64)
		}
		for item, qty := range items {
			e.available[party][item] -= qty
			e.frozen[party][item] += qty
		}
	}
	stored := make(map[string][]Item, 2)
	for _, offer := range offers {
		stored[offer.Party] = append([]Item(nil), offer.Items...)
	}
	trade := &Trade{ID: id, State: StateLocked, Version: 1, Offers: stored}
	e.trades[id] = trade
	return snapshot(trade), false, nil
}

// Validate is the anti-cheat and item-existence gate. No inventory moves yet.
func (e *Engine) Validate(id string, expect int) (*Trade, error) {
	return e.transition(id, expect, StateLocked, StateValidated)
}

// Swap moves frozen items to the other party and marks the trade Swapped.
func (e *Engine) Swap(id string, expect int) (*Trade, error) {
	e.mu.Lock()
	defer e.mu.Unlock()
	trade, err := e.lookup(id, expect)
	if err != nil {
		return nil, err
	}
	if trade.State != StateValidated {
		return nil, fmt.Errorf("%w: %s -> swapped", ErrIllegalState, trade.State)
	}
	for party, items := range trade.Offers {
		other := otherParty(trade, party)
		if e.available[other] == nil {
			e.available[other] = make(map[string]int64)
		}
		for _, item := range items {
			e.frozen[party][item.ID] -= item.Qty
			if e.frozen[party][item.ID] == 0 {
				delete(e.frozen[party], item.ID)
			}
			e.available[other][item.ID] += item.Qty
		}
	}
	trade.State = StateSwapped
	trade.Version++
	return snapshot(trade), nil
}

// Release closes a swapped trade. At this point the items are final.
func (e *Engine) Release(id string, expect int) (*Trade, error) {
	return e.transition(id, expect, StateSwapped, StateReleased)
}

// Rollback unfreezes a pre-swap trade, or reverses a swapped one. A reverse
// transfer is refused if the receiver no longer holds enough of an item.
func (e *Engine) Rollback(id string, expect int) (*Trade, error) {
	e.mu.Lock()
	defer e.mu.Unlock()
	trade, err := e.lookup(id, expect)
	if err != nil {
		return nil, err
	}
	switch trade.State {
	case StateLocked, StateValidated:
		for party, items := range trade.Offers {
			for _, item := range items {
				e.frozen[party][item.ID] -= item.Qty
				if e.frozen[party][item.ID] == 0 {
					delete(e.frozen[party], item.ID)
				}
				e.available[party][item.ID] += item.Qty
			}
		}
	case StateSwapped:
		for party, items := range trade.Offers {
			other := otherParty(trade, party)
			for _, item := range items {
				if e.available[other][item.ID] < item.Qty {
					return nil, fmt.Errorf("%w: %s/%s", ErrRollbackDenied, other, item.ID)
				}
			}
		}
		for party, items := range trade.Offers {
			other := otherParty(trade, party)
			for _, item := range items {
				e.available[other][item.ID] -= item.Qty
				e.available[party][item.ID] += item.Qty
			}
		}
	default:
		return nil, fmt.Errorf("%w: %s -> rolled_back", ErrIllegalState, trade.State)
	}
	trade.State = StateRolledBack
	trade.Version++
	return snapshot(trade), nil
}

func (e *Engine) transition(id string, expect int, from, to State) (*Trade, error) {
	e.mu.Lock()
	defer e.mu.Unlock()
	trade, err := e.lookup(id, expect)
	if err != nil {
		return nil, err
	}
	if trade.State != from {
		return nil, fmt.Errorf("%w: %s -> %s", ErrIllegalState, trade.State, to)
	}
	trade.State = to
	trade.Version++
	return snapshot(trade), nil
}

func (e *Engine) lookup(id string, expect int) (*Trade, error) {
	trade, ok := e.trades[id]
	if !ok {
		return nil, fmt.Errorf("%w: %s", ErrUnknownTrade, id)
	}
	if trade.Version != expect {
		return nil, fmt.Errorf("%w: have %d, want %d", ErrVersionConflict, trade.Version, expect)
	}
	return trade, nil
}

func otherParty(trade *Trade, party string) string {
	for candidate := range trade.Offers {
		if candidate != party {
			return candidate
		}
	}
	return ""
}

func snapshot(trade *Trade) *Trade {
	out := &Trade{
		ID:      trade.ID,
		State:   trade.State,
		Version: trade.Version,
		Offers:  make(map[string][]Item, len(trade.Offers)),
	}
	for party, items := range trade.Offers {
		out.Offers[party] = append([]Item(nil), items...)
	}
	return out
}

实现里有几个刻意的选择:

  • Open 先完成全部校验再冻结,任何一侧库存不足都不会留下半冻结状态。
  • 重复 ID 返回原交易并把 replay 置为 true,调用方据此判断是重试而不是新交易。
  • 每次状态推进都要求调用方带版本号,把并发冲突变成可处理的错误,而不是覆盖写。
  • Rollback 在 Swapped 状态下做反向转移,并先检查接收方是否还持有足够数量;不足时拒绝,而不是把余额扣成负数。

配套的测试覆盖正常流程、非法报价、幂等重放、版本冲突和五种回滚时机;运行方式见包注释里的 verify 行。

失败模式与兜底

失败表现兜底
冻结后进程崩溃资产卡在冻结余额启动时扫描未完成交易,按状态回滚或续做
交换只完成一半一方得物、一方失金交换在单事务内完成;跨服用补偿日志
重复提交同一交易冻结两次交易 ID 幂等,返回原交易
版本冲突后到的请求覆盖先到CAS 拒绝,调用方刷新后重试
跨服双扣两边都认为自己是托管方托管方唯一,另一侧只做确认
回滚时对方已消费无法原样退回拒绝回滚,冻结相关账户转人工
手续费漏扣成交后资产对不上手续费与交换同一事务

最危险的是“看起来成功”的失败:交换完成、响应丢失,客户端重试后又被当成新交易。幂等键和版本号要一起用,前者防重复创建,后者防重复推进。

常见错误

第一,用标记代替冻结。可用余额没变,两笔交易都能通过校验。

第二,用玩家 ID 加时间戳做幂等键。同一秒内的两次点击会得到不同键,等于没有幂等。

第三,先改余额再校验。校验失败时已经产生副作用,回滚代码比重写还难。

第四,让拍卖行复用直接交易状态机。部分成交、撤单和手续费退还都不是四状态能表达的。

第五,跨服各自托管。没有唯一托管方,就一定有双花窗口。

小结

交易托管的价值不是“多写了几个状态”,而是把所有可能失败的检查前移到资产移动之前,并让每一次状态推进都可识别、可拒绝、可回滚。冻结必须是真实的余额转移,幂等键防重复创建,版本号防重复推进,跨服必须有唯一托管方。

托管解决“一笔交易怎么安全完成”。下一节往上一层:当大量交易、产出和转移同时发生时,如何从全局发现异常,以及价格指数能做什么、不能做什么。

阅读导航:上一节:6.1 货币、产出与消耗 · 下一节:6.3 经济风控与价格指数 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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