先扣再给,为什么一定会出事
《群岛纪元》最早的玩家交易是两步:先从 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 经济风控与价格指数 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。