16.2 竞价并发与异常回滚
16.1 把物品冻进了托管,也把金币拆成了可用与冻结两栏。接下来是最难的一步:让多个玩家同时抢同一个拍品。
一个热门拍品开拍的头几秒,可能有几十个人几乎同时点下「出价」。服务端看到的是几十个并发请求,而它必须保证的结果只有一句话:任意时刻,这个拍品只有一个最高价、一个有效出价人。如果两条请求同时读到「当前价 1000」,各自判定「我出 1100 合法」,然后都写入,那么拍品的价格会停在 1100,却有两个人以为自己是最高价——结算时要么重复发货,要么两个人都不服。
比并发更麻烦的是回滚。出价会失败:余额不够、价格已被别人抬高、拍卖刚好截止。失败时,不能留下任何痕迹——不能多冻一块金币,也不能把已经冻着的上一位出价人放跑。本节用 internal/trade 的 Auctioneer 把出价做成一个要么全成、要么全不成的操作,并用并发测试把它钉死。全部代码是 L1,go test -race 可以直接跑。
出价必须同时守住三条不变量
把要求写清楚,实现才有靶子。一次出价必须同时满足:
- 唯一最高价:任意时刻,
Auction上只有一个Winner与一个Price,且新价必须 ≥ 当前价 + 最小加价。 - 失败者无损:被顶掉的上一位出价人,其冻结金币必须原路退回可用栏,且只退一次。
- 失败无副作用:任何一步失败(冻金不足、价格不够、已截止),拍卖状态与所有人的余额都必须和调用前逐字节相同。
这三条里,第 3 条最容易被忽略。很多人写出的实现是「先退旧的、再冻新的」——看起来对称,但只要新的冻金失败,上一位已经被放跑了,拍品就落在了一个「无人冻结」的空窗里。这就是下面要讲的顺序问题。
先冻新、再退旧:顺序就是正确性
先看完整实现。Auctioneer 用一把互斥锁把出价串行化,锁序固定为 Auctioneer.mu → Escrow.mu,没有任何反向路径,所以嵌套加锁不会死锁。下面这段是 L1,go test -race ./internal/trade/ 可以直接跑:
// file: internal/trade/auction.go
// tier: L1
// verify: go test -race ./internal/trade/
package trade
import (
"errors"
"sync"
)
var (
ErrAuctionGone = errors.New("trade: 拍卖不存在")
ErrAuctionClosed = errors.New("trade: 拍卖已结束")
ErrSellerSelfBid = errors.New("trade: 卖家不能给自己出价")
ErrBidTooLow = errors.New("trade: 出价低于当前价 + 最小加价")
)
// taxBps 是成交税,单位万分之一(500 = 5%)。它是《晨曦大陆》最稳定的一条金币回收。
const taxBps int64 = 500
type auctionState uint8
const (
auctionOpen auctionState = iota
auctionSold
auctionExpired
)
// Auction 是一条拍卖。Price==0 表示尚无人出价;Buyout==0 表示没有一口价。
type Auction struct {
ID AuctionID
Seller PlayerID
ItemInst ItemInstID
ItemID ItemID
StartPrice int64
MinIncrement int64
Buyout int64
Price int64
Winner PlayerID
EndAt int64
State auctionState
}
// BidResult 描述一次成功出价:被顶掉的上一位及其退款额,便于广播与审计。
type BidResult struct {
Auction AuctionID
Bidder PlayerID
Price int64
Refunded PlayerID
Refund int64
Sold bool
}
// Auctioneer 串行化所有出价。锁序固定为 Auctioneer.mu → Escrow.mu,
// 没有任何反向路径,因此嵌套加锁不会死锁。
type Auctioneer struct {
mu sync.Mutex
escrow *Escrow
auctions map[AuctionID]*Auction
}
func NewAuctioneer(e *Escrow) *Auctioneer {
return &Auctioneer{escrow: e, auctions: make(map[AuctionID]*Auction)}
}
// List 上架:先把物品冻进托管,再登记拍卖。
func (a *Auctioneer) List(au Auction) (AuctionID, error) {
a.mu.Lock()
defer a.mu.Unlock()
if au.ID == 0 {
au.ID = a.escrow.NextAuctionID()
}
if _, err := a.escrow.FreezeItem(au.Seller, au.ItemInst, au.ID); err != nil {
return 0, err
}
au.State = auctionOpen
a.auctions[au.ID] = &au
return au.ID, nil
}
// PlaceBid 是本章的核心:一次出价要么完全生效,要么完全无副作用。
// 顺序被刻意固定为「先冻新、再退旧」——若先退旧,一旦新出价冻金失败,
// 就会留下「上一位已解冻、本此也没冻上」的空窗,等于白嫖一次最高价。
func (a *Auctioneer) PlaceBid(auc AuctionID, bidder PlayerID, amount, now int64) (BidResult, error) {
a.mu.Lock()
defer a.mu.Unlock()
au, ok := a.auctions[auc]
if !ok {
return BidResult{}, ErrAuctionGone
}
if au.State != auctionOpen || now >= au.EndAt {
return BidResult{}, ErrAuctionClosed
}
if bidder == au.Seller {
return BidResult{}, ErrSellerSelfBid
}
need := au.StartPrice
if au.Price > 0 {
need = au.Price + au.MinIncrement
}
if amount < need {
return BidResult{}, ErrBidTooLow
}
// 第一步:冻结新出价。失败即返回,拍卖状态一个字节都没动。
if err := a.escrow.FreezeGold(bidder, amount); err != nil {
return BidResult{}, err
}
res := BidResult{Auction: auc, Bidder: bidder, Price: amount}
// 第二步:退回上一位。到这一步新出价已确定被冻住,退款不可能失败。
if au.Winner != 0 {
if err := a.escrow.ReleaseGold(au.Winner, au.Price); err != nil {
// 旧冻结额与当前价不符 = 状态已损坏,回滚本次冻结后报错。
_ = a.escrow.ReleaseGold(bidder, amount)
return BidResult{}, err
}
res.Refunded, res.Refund = au.Winner, au.Price
}
au.Winner, au.Price = bidder, amount
if au.Buyout > 0 && amount >= au.Buyout {
a.settleLocked(au)
res.Sold = true
}
return res, nil
}
// settleLocked 结算:扣掉赢家冻结金币、抽税、把物交给赢家。调用方须持有 a.mu。
func (a *Auctioneer) settleLocked(au *Auction) {
if au.Winner == 0 {
au.State = auctionExpired
_ = a.escrow.ReleaseItem(au.ID)
return
}
_ = a.escrow.SpendFrozen(au.Winner, au.Price)
tax := au.Price * taxBps / 10000
a.escrow.Credit(au.Seller, au.Price-tax)
_, _ = a.escrow.DeliverItem(au.ID, au.Winner)
au.State = auctionSold
}
// SettleDue 由定时器驱动:到点结算,有人出价则成交,无人出价则流拍退物。
func (a *Auctioneer) SettleDue(now int64) int {
a.mu.Lock()
defer a.mu.Unlock()
n := 0
for _, au := range a.auctions {
if au.State == auctionOpen && now >= au.EndAt {
a.settleLocked(au)
n++
}
}
return n
}
func (a *Auctioneer) Snapshot(auc AuctionID) (Auction, bool) {
a.mu.Lock()
defer a.mu.Unlock()
au, ok := a.auctions[auc]
if !ok {
return Auction{}, false
}
return *au, true
}
// SpendFrozen 把冻结金币真正花掉(结算用),金币从系统中消失。
func (e *Escrow) SpendFrozen(p PlayerID, amount int64) error {
e.mu.Lock()
defer e.mu.Unlock()
a := e.accounts[p]
if a == nil || a.Frozen < amount {
return ErrNoFrozen
}
a.Frozen -= amount
return nil
}
// Credit 给账户加可用金币(卖家收款用)。
func (e *Escrow) Credit(p PlayerID, amount int64) {
e.mu.Lock()
defer e.mu.Unlock()
if a := e.accounts[p]; a != nil {
a.Free += amount
}
}
PlaceBid 里所有校验都在冻金之前完成,所以「价格不够」「已截止」「卖家自出价」这三类失败在动到任何余额之前就返回了。真正的两步——冻新、退旧——被刻意排成这个顺序:先把新出价冻住,再退回旧的。如果反过来先退旧的,一旦新出价冻金失败,上一位就已经被放跑,拍品落进「无人冻结」的空窗。顺序不是风格,是正确性。
第二步的退款理论上不会失败:上一位的冻结额恰好等于 au.Price,而 au.Price 只在一次成功的出价里被写入。代码仍然保留了兜底分支——若退款真的失败,说明账本已损坏,此时把本次冻结回滚掉再报错,宁可让这次出价失败,也不让系统带着一个错误的不变量继续跑。
并发验收:60 个 goroutine 抢同一个拍品
并发逻辑不能靠肉眼看。测试让 60 个出价人各起一个 goroutine,出价金额按最小加价递增,因此「出价最大的那个」无论何时抢到锁,最终都会是唯一赢家。下面是 internal/trade/auction_test.go 里的核心用例,属于同一个已跑通的 L1 包:
// snippet: internal/trade/auction_test.go(摘录)
func TestConcurrentBidsSingleWinnerAndConserved(t *testing.T) {
const n = 60
bidders := make([]PlayerID, n)
for i := range bidders {
bidders[i] = PlayerID(100 + i)
}
e, a, id := setup(t, bidders, 10_000_000)
before := systemGold(t, e, append(bidders, 9001))
var wg sync.WaitGroup
for i := 0; i < n; i++ {
wg.Add(1)
go func(k int) {
defer wg.Done()
// 出价随 k 递增且步长 == MinIncrement,保证「最大 k」永远能顶掉前面的。
amount := int64(1000 + k*100)
_, _ = a.PlaceBid(id, PlayerID(100+k), amount, 0)
}(i)
}
wg.Wait()
au, _ := a.Snapshot(id)
wantWinner := PlayerID(100 + n - 1)
wantPrice := int64(1000 + (n-1)*100)
if au.Winner != wantWinner || au.Price != wantPrice {
t.Fatalf("赢家/成交价 = %d/%d,期望 %d/%d", au.Winner, au.Price, wantWinner, wantPrice)
}
if _, frozen, _ := e.Account(wantWinner); frozen != wantPrice {
t.Fatalf("赢家冻结 = %d,期望 %d", frozen, wantPrice)
}
for i := 0; i < n-1; i++ {
if _, frozen, _ := e.Account(PlayerID(100 + i)); frozen != 0 {
t.Fatalf("被顶掉的出价人 %d 仍有 %d 冻结金币", 100+i, frozen)
}
}
if after := systemGold(t, e, append(bidders, 9001)); after != before {
t.Fatalf("出价过程金币总量 %d -> %d,未守恒", before, after)
}
}
这个用例一次验收了三条不变量:赢家是金额最大的那个、其余 59 人冻结额全部归零、出价前后系统金币总量不变。跑 go test -race 时它是最容易暴露数据竞争的地方——如果忘了加锁,-race 会在几十次并发写 au.Price 时直接报 data race。
回滚路径也有独立用例:TestFailedBidRollsBackToPreviousWinner 让一个只有 5000 金币的玩家去出价 900 万,断言冻金失败后,上一位出价人仍是赢家、冻结额原封不动、失败者冻结额为 0。这就是「失败无副作用」的机器验收。
结算:扣冻结、抽税、过户
拍卖到点后由 SettleDue 统一结算。有人出价,settleLocked 做三件事:把赢家的冻结金币真正扣掉(SpendFrozen,金币从系统消失)、按 5% 抽税后把余额打给卖家(Credit)、把托管物品过户给赢家(DeliverItem)。无人出价则流拍,ReleaseItem 把物退回卖家。抽税是最稳定的一条金币回收——它和玩家交易的差价一起,构成经济系统里最主要的「金币水槽」。
一口价(Buyout)走的是同一条 settleLocked:出价达到一口价时立即成交,BidResult.Sold 置真。这样「竞价成交」与「一口价成交」共用一套结算与抽税代码,不会出现两条路径税率不一致的经典问题。
本节小结 / 实测结论
本机(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)
竞价的 5 个用例覆盖了「并发出价唯一赢家 + 金币守恒」「失败出价完整回滚」「最小加价与截止时刻」「一口价成交与抽税」「流拍退物」。以上是同机相对量级,绝对数值不可移植。下一节把视野从单笔交易拉远到整服:如何从这些成交记录里算出价格指数,并识别自买自卖、对敲与异常定价。
阅读导航:上一节:16.1 资产冻结与托管 · 下一节:16.3 价格索引与经济风控 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。