《MMO 服务器开发》16.1 资产冻结与托管

资产冻结与托管是拍卖行与玩家交易的地基:上架时物品必须离开卖家背包,出价时金币必须从可用栏搬进冻结栏,成交或流拍再按结果分流。本节用 internal/trade 的 Escrow 从零实现这套托管,硬约束是任何一步失败都不留下半冻结状态,并用 go test 把重复冻结、金币守恒、成交过户与已结算退回逐一钉死。

16.1 资产冻结与托管

拍卖行有一个绕不开的前提:东西上架之后,它就不能再被卖掉第二次。玩家把一把「烈焰长剑」挂上拍卖行,如果他还能同时把这把剑丢给好友、拿去分解、或者干脆再挂一次,那么同一把剑会变成三份收益——这是所有经济系统崩盘的起点。

金币同理。玩家出价一万竞拍,这一万块必须立刻从他的可用余额里消失,否则他可以用同一笔钱同时竞拍十个拍品、成交十个。但钱又不能真的扣掉——竞价可能被顶掉、拍品可能流拍,扣掉再补回去既难对账也容易出错。

解决办法是冻结:物品移出背包、金币从「可用」搬进「冻结」,所有权仍属于原主,但使用权被系统收走,直到成交或回滚。本节从零写一个 internal/trade 包里的 Escrow,把物品托管与金币冻结放进同一个临界区,硬约束只有一条——任何一步失败都不能留下半冻结的状态。全部代码是 L1,go test 可以直接跑。

本章与站内既有内容的分工

站内已有三篇文章从架构层面讲过拍卖行、价格指数与经济风控,它们负责「架构方案与取舍」。本章不复述结论,只把托管与竞价从零写出来并跑出真实行为。分工如下。

既有文章它负责什么本节只做什么
/game-server-trading-and-auction-house-design/交易与拍卖行的整体架构只实现「冻结 + 托管」这一步
/game-server-market-price-index-architecture/价格指数与行情服务只提供成交记录与冻结账本
/game-server-economy-architecture-and-risk-control/经济系统与风控只做单笔托管的事务性

冻结不是扣款:两栏账本

先看数据结构。金币被拆成「可用 Free」与「冻结 Frozen」两栏,冻结不是扣款而是搬运,两栏之和恒定,任何时刻都能用这个不变量审计出「有没有凭空造钱」。物品则用一个 Locked 位标记托管状态——这套口径与第 8 章背包的并发修改完全一致:凡是改变归属的操作(上架、分解、强化)都必须先抢到 Locked 位,由同一把锁串行化。

下面这段是 L1,go test ./internal/trade/ 可以直接跑:

// file: internal/trade/escrow.go
// tier: L1
// verify: go test ./internal/trade/
package trade

import (
	"errors"
	"sync"
)

// 卷二共享的内部 ID 口径(见 BRIEF §3.5):ItemID 是配置表 ID,ItemInstID 是背包内实例 ID。
type (
	PlayerID   uint64
	ItemID     uint32
	ItemInstID uint64
	AuctionID  uint64
)

var (
	ErrItemNotFound  = errors.New("trade: 背包里没有该物品实例")
	ErrItemLocked    = errors.New("trade: 物品已被托管,不能重复冻结")
	ErrEscrowMissing = errors.New("trade: 托管记录不存在或已结算")
	ErrNoGold        = errors.New("trade: 可用金币不足")
	ErrNoFrozen      = errors.New("trade: 冻结金币不足")
)

// ItemStack 是背包里的一格。Locked 与第 8 章背包的并发修改口径一致:
// 任何会改变归属的操作(交易、拍卖、分解)都必须先把 Locked 置位,
// 由同一把锁串行化,禁止出现「已上架又拿去强化」这类双重占用。
type ItemStack struct {
	InstID ItemInstID
	ItemID ItemID
	Count  int
	Locked bool
}

// Bag 是玩家背包的最小模型,只保留托管用得到的那部分。
type Bag struct {
	Items map[ItemInstID]ItemStack
}

// Account 把金币拆成「可用」与「冻结」两栏。冻结不是扣款,
// 而是把金币从可用栏搬到冻结栏,总量守恒,便于审计。
type Account struct {
	Free   int64
	Frozen int64
}

type escrowState uint8

const (
	escrowHeld escrowState = iota
	escrowSettled
	escrowReleased
)

// escrowRecord 是一条托管记录:谁把哪个物品实例、以什么名义交给了系统。
type escrowRecord struct {
	Auction AuctionID
	Owner   PlayerID
	Item    ItemStack
	State   escrowState
}

// Escrow 是资产托管服务:物品托管 + 金币冻结共用一把锁,
// 保证「扣物」与「冻金」这两步要么都成、要么都不成。
type Escrow struct {
	mu       sync.Mutex
	seq      uint64
	items    map[PlayerID]*Bag
	accounts map[PlayerID]*Account
	records  map[AuctionID]*escrowRecord
}

func NewEscrow() *Escrow {
	return &Escrow{
		items:    make(map[PlayerID]*Bag),
		accounts: make(map[PlayerID]*Account),
		records:  make(map[AuctionID]*escrowRecord),
	}
}

// PutBag / PutAccount 只是测试与初始化用的挂载点。
func (e *Escrow) PutBag(p PlayerID, b *Bag)         { e.mu.Lock(); e.items[p] = b; e.mu.Unlock() }
func (e *Escrow) PutAccount(p PlayerID, a *Account) { e.mu.Lock(); e.accounts[p] = a; e.mu.Unlock() }
func (e *Escrow) NextAuctionID() AuctionID {
	e.mu.Lock()
	defer e.mu.Unlock()
	e.seq++
	return AuctionID(e.seq)
}
func (e *Escrow) Account(p PlayerID) (int64, int64, bool) {
	e.mu.Lock()
	defer e.mu.Unlock()
	a, ok := e.accounts[p]
	if !ok {
		return 0, 0, false
	}
	return a.Free, a.Frozen, true
}

func (e *Escrow) bag(p PlayerID) *Bag {
	b, ok := e.items[p]
	if !ok {
		b = &Bag{Items: make(map[ItemInstID]ItemStack)}
		e.items[p] = b
	}
	return b
}

// FreezeItem 把物品从背包搬到托管,是「上架」的第一步。
// 关键点:先校验再改状态,任何一步失败都不留下半冻结的物品。
func (e *Escrow) FreezeItem(seller PlayerID, inst ItemInstID, auc AuctionID) (ItemStack, error) {
	e.mu.Lock()
	defer e.mu.Unlock()
	b := e.bag(seller)
	it, ok := b.Items[inst]
	if !ok {
		return ItemStack{}, ErrItemNotFound
	}
	if it.Locked {
		return ItemStack{}, ErrItemLocked
	}
	// 冻结 = 置位 Locked 并移出背包格;物还在系统手里,记录在 escrowRecord。
	it.Locked = true
	delete(b.Items, inst)
	e.records[auc] = &escrowRecord{Auction: auc, Owner: seller, Item: it, State: escrowHeld}
	return it, nil
}

// FreezeGold 为竞价冻结金币:从可用栏搬到冻结栏,总量守恒。
func (e *Escrow) FreezeGold(p PlayerID, amount int64) error {
	if amount < 0 {
		return ErrNoGold
	}
	e.mu.Lock()
	defer e.mu.Unlock()
	a := e.accounts[p]
	if a == nil {
		return ErrNoGold
	}
	if a.Free < amount {
		return ErrNoGold
	}
	a.Free -= amount
	a.Frozen += amount
	return nil
}

// ReleaseGold 是回滚路径:把冻结金币原路退回可用栏。
func (e *Escrow) ReleaseGold(p PlayerID, amount int64) error {
	if amount < 0 {
		return ErrNoFrozen
	}
	e.mu.Lock()
	defer e.mu.Unlock()
	a := e.accounts[p]
	if a == nil || a.Frozen < amount {
		return ErrNoFrozen
	}
	a.Frozen -= amount
	a.Free += amount
	return nil
}

// ReleaseItem 把托管物品退回卖家背包(流拍、取消、超时都走这里)。
func (e *Escrow) ReleaseItem(auc AuctionID) error {
	e.mu.Lock()
	defer e.mu.Unlock()
	rec, ok := e.records[auc]
	if !ok || rec.State != escrowHeld {
		return ErrEscrowMissing
	}
	it := rec.Item
	it.Locked = false
	e.bag(rec.Owner).Items[it.InstID] = it
	rec.State = escrowReleased
	return nil
}

// DeliverItem 把托管物品交给买家(成交路径),与结算同属一个临界区。
func (e *Escrow) DeliverItem(auc AuctionID, winner PlayerID) (ItemStack, error) {
	e.mu.Lock()
	defer e.mu.Unlock()
	rec, ok := e.records[auc]
	if !ok || rec.State != escrowHeld {
		return ItemStack{}, ErrEscrowMissing
	}
	it := rec.Item
	it.Locked = false
	e.bag(winner).Items[it.InstID] = it
	rec.State = escrowSettled
	return it, nil
}

// Record 只读地回看一条托管记录,供审计与测试断言。
func (e *Escrow) Record(auc AuctionID) (ItemStack, escrowState, bool) {
	e.mu.Lock()
	defer e.mu.Unlock()
	rec, ok := e.records[auc]
	if !ok {
		return ItemStack{}, 0, false
	}
	return rec.Item, rec.State, true
}

注意 FreezeItem 的顺序:先校验、再改状态。物品不存在或已被锁定,直接返回错误,一个字节都没动;只有全部检查通过,才把它从背包 map 里删掉、写进 records。这个「先校验后改」的顺序是后面所有事务性讨论的基础——它让失败路径天然是空操作,不需要额外的补偿逻辑。

托管三态:上架、流拍、成交

一条托管记录只有三种归宿。上架时 FreezeItem 把它置为 escrowHeld;流拍、取消或超时走 ReleaseItem,物品解锁后回到卖家背包,状态转 escrowReleased;成交走 DeliverItem,物品交给买家,状态转 escrowSettled。三个方法都以 rec.State != escrowHeld 作为前置条件,因此已结算的托管不可能再被退回——这一条挡掉了「成交后又流拍、物品发两份」这个经典 bug。

竞价冻结同理:FreezeGold 在余额不足时返回 ErrNoGold 且不改动任何一栏;ReleaseGold 是它的逆操作,把冻结金币原路退回。16.2 会看到,正是这两个原语让「出价失败必须完整回滚」变得廉价。

对应到线上协议,交易域用卷一既定的命令号:

CmdAuctionListReq = 0x0B01   // 上架 / 列拍品
CmdAuctionBidReq  = 0x0B02   // 出价

与第 8 章背包的并发口径对齐

托管不是孤立服务。同一个物品实例可能同时被强化、分解、交易的请求打到,如果每个服务各持一把锁,就会互相看不到对方的中间状态。本卷的统一口径是:背包格上的 Locked 位就是「所有权正在迁移」的唯一标志,任何会改变归属的操作都要在同一把背包锁下先把 Locked 置位,托管也不例外。这样第 8 章的并发修改与本章的托管共享同一套判定,详见 8.2 并发修改与事务 。

本节小结 / 实测结论

本机(Go 1.26.0,darwin/arm64)实跑:

$ go test ./internal/trade/
ok  aegis/internal/trade  0.587s   (15 个 test 全绿)

$ go test -race ./internal/trade/
ok  aegis/internal/trade           (无 data race)

15 个用例里,托管侧覆盖了「冻结后物品不在背包」「重复冻结报错」「金币冻结前后总量守恒」「超额冻结无副作用」「成交后物品只到买家」「已结算不能退回」。以上是同机相对量级,绝对数值不可移植。下一节看竞价:多个玩家同时出价时,如何保证只有一个赢家、失败者分文不损。

阅读导航:上一节:目录 · 下一节:16.2 竞价并发与异常回滚 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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