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 竞价并发与异常回滚 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。