《MMO 服务器开发》16.3 价格索引与经济风控

价格索引是经济风控的眼睛:它把散落的成交记录压成中位价与成交量加权均价,再用偏离度和对手方频次给每笔拟成交打分。本节用 internal/trade 的 PriceIndex 从零实现滑动窗口中位价、VWAP、对敲计数与三级风控打分,并用测试验证单笔异常价不污染中位价、滑出窗口的对敲计数会衰减、自买自卖与严重偏离会被拦下。

16.3 价格索引与经济风控

前两节盯着的是一笔交易:物品怎么冻、金币怎么冻、出价怎么回滚。这一节把镜头拉远,盯整服的成交流水。

流水本身没有意义。一万条「某玩家以 1200 金币买了某材料」堆在一起,既回答不了「这个材料现在大概值多少钱」,也发现不了「有人在用两个号互相倒货」。要回答这些,得把流水压成指数:一个物品在一个时间窗口里的代表价,以及围绕这个代表价的偏离程度。有了指数,风控才有参照系——偏离指数三倍以上的成交,几乎一定是刷钱、洗钱或价格操纵。

这件事的难点有两层。第一层是选什么当代表价:均价、中位价、成交量加权均价各有取舍,选错一个就会让单笔异常把整条曲线带歪。第二层是窗口怎么滑:只增不减的统计量迟早会失真,比如「同一对买卖双方成交了 100 次」如果永远不清零,一个正常的长期交易伙伴也会被误判成对敲。本节用 internal/trade 的 PriceIndex 从零实现这两层。全部代码是 L1,go test 可以直接跑。

为什么中位价比均价更稳

先做取舍。假设某材料最近 9 笔成交都是 1000 金币,第 10 笔有人用 999999 金币刷了一单——这笔钱可能来自两个小号之间的转账,也可能只是想给排行榜注水。

用均价,这 10 笔的平均值是 (9×1000 + 999999)/10 ≈ 100900,整条价格曲线瞬间被抬高两个数量级,所有后续正常成交都会「偏离」这个假基准。用中位价,10 个数的中位数仍是 1000,那笔异常被完全吸收。这就是为什么本节的代表价以中位数为准。

但中位数也有盲点:它对「量」不敏感。一笔 9 个的成交和一笔 1 个的成交在中位数里权重相同。所以再补一个 VWAP(成交量加权均价):大额成交对它的影响更大,专门用来回答「这笔大单本身是否异常」。中位数做日常参考,VWAP 做单笔大额的风控基准,两者各司其职。

滑动窗口:中位价、VWAP 与对敲计数

实现上,每个物品维护最近 window 笔成交,新记录进来时把最旧的一笔挤出去。关键细节是:被挤出去的那笔,必须同时从「同一对买卖双方」的计数里扣掉。否则对敲计数只增不减,一个长期合作的交易伙伴也会被误报成对敲团伙。下面这段是 L1,go test ./internal/trade/ 可以直接跑:

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

import (
	"sort"
	"sync"
)

// TradeRecord 是一笔已成交记录:拍卖成交与玩家面对面交易都会落成它。
type TradeRecord struct {
	ItemID ItemID
	Price  int64
	Count  int64
	Buyer  PlayerID
	Seller PlayerID
	At     int64
}

// PriceIndex 按物品维护最近 window 笔成交,产出中位价与成交量加权均价(VWAP)。
// 用中位数而非均价做主参考,是因为单笔异常高价对中位数几乎没有影响。
type PriceIndex struct {
	mu            sync.Mutex
	window        int
	pairThreshold int
	byItem        map[ItemID][]TradeRecord
	pairs         map[pairKey]int // 窗口内同一对买卖双方的成交次数
}

type pairKey struct {
	Buyer, Seller PlayerID
}

func NewPriceIndex(window int) *PriceIndex {
	if window <= 0 {
		window = 200
	}
	return &PriceIndex{
		window:        window,
		pairThreshold: 5,
		byItem:        make(map[ItemID][]TradeRecord),
		pairs:         make(map[pairKey]int),
	}
}

// Add 追加一笔成交,并滑出窗口最旧的一笔;被滑出的那笔同时从对敲计数里扣掉,
// 否则「高频对敲」会因计数只增不减而永远误报。
func (p *PriceIndex) Add(r TradeRecord) {
	p.mu.Lock()
	defer p.mu.Unlock()
	rs := append(p.byItem[r.ItemID], r)
	if len(rs) > p.window {
		drop := rs[0]
		rs = rs[1:]
		if k := (pairKey{drop.Buyer, drop.Seller}); p.pairs[k] > 0 {
			p.pairs[k]--
		}
	}
	p.byItem[r.ItemID] = rs
	p.pairs[pairKey{r.Buyer, r.Seller}]++
}

// Median 返回窗口内的中位价;窗口为空时 ok=false,调用方须自行决定兜底价。
func (p *PriceIndex) Median(item ItemID) (int64, bool) {
	p.mu.Lock()
	defer p.mu.Unlock()
	rs := p.byItem[item]
	if len(rs) == 0 {
		return 0, false
	}
	prices := make([]int64, len(rs))
	for i, r := range rs {
		prices[i] = r.Price
	}
	sort.Slice(prices, func(i, j int) bool { return prices[i] < prices[j] })
	return prices[len(prices)/2], true
}

// VWAP 是成交量加权均价:大额成交对它的影响更大,适合做「大单是否异常」的基准。
func (p *PriceIndex) VWAP(item ItemID) (int64, bool) {
	p.mu.Lock()
	defer p.mu.Unlock()
	return p.vwapLocked(item)
}

func (p *PriceIndex) vwapLocked(item ItemID) (int64, bool) {
	rs := p.byItem[item]
	var sumPV, sumV int64
	for _, r := range rs {
		sumPV += r.Price * r.Count
		sumV += r.Count
	}
	if sumV == 0 {
		return 0, false
	}
	return sumPV / sumV, true
}

type RiskLevel uint8

const (
	RiskNone RiskLevel = iota
	RiskWatch
	RiskBlock
)

// 偏离阈值的单位是百分比:超过 watchPct 进观察,超过 blockPct 直接拦截。
const (
	watchPct = 150
	blockPct = 300
)

// Assess 对一笔拟成交记录打分。它是经济风控的第一道闸:自买自卖与严重偏离
// 直接拦;同一对手方高频对敲与轻度偏离先记观察,交人工复核。
func (p *PriceIndex) Assess(r TradeRecord) (RiskLevel, string) {
	if r.Buyer == r.Seller {
		return RiskBlock, "自买自卖:买卖双方为同一角色"
	}
	p.mu.Lock()
	defer p.mu.Unlock()
	if p.pairs[pairKey{r.Buyer, r.Seller}] >= p.pairThreshold {
		return RiskWatch, "同一对手方高频对敲"
	}
	ref, ok := p.vwapLocked(r.ItemID)
	if !ok {
		return RiskNone, ""
	}
	dev := r.Price - ref
	if dev < 0 {
		dev = -dev
	}
	switch {
	case dev*100 > ref*blockPct:
		return RiskBlock, "价格严重偏离指数(>300%)"
	case dev*100 > ref*watchPct:
		return RiskWatch, "价格偏离指数(>150%)"
	}
	return RiskNone, ""
}

Add 里那句 p.pairs[k]-- 是整段代码里最容易被漏掉的一行。漏掉它,测试里「刷 5 笔同对手 → 记观察」会永远成立,哪怕这 5 笔早就滑出了窗口——风控从此只会误报、不会收敛。Median 与 VWAP 都在同一把锁下读,保证并发写入时不会读到半更新的窗口。

风控打分:自买自卖、对敲与偏离

Assess 是三级打分,从最确定的规则开始:

  • 自买自卖(Buyer == Seller)直接 RiskBlock。同一角色左手倒右手,除了刷成就与洗钱没有别的解释,不需要看指数。
  • 高频对敲(同一对手方在窗口内成交 ≥ pairThreshold 次)记 RiskWatch。单次看都是正常成交,累积起来才是信号,所以交给人工复核而不是自动拦。
  • 价格偏离:以 VWAP 为基准,偏离超过 150% 记观察,超过 300% 直接拦。用 VWAP 而非中位数,是因为偏离通常发生在「大单」上,而 VWAP 对大单更敏感。

下面是 internal/trade/index_test.go 里的两个用例,同属一个已跑通的 L1 包:

// snippet: internal/trade/index_test.go(摘录)
func TestMedianResistsSingleOutlier(t *testing.T) {
	p := NewPriceIndex(100)
	for i := 0; i < 9; i++ {
		p.Add(TradeRecord{ItemID: 3001, Price: 1000, Count: 1, Buyer: PlayerID(i + 1), Seller: 900})
	}
	p.Add(TradeRecord{ItemID: 3001, Price: 999_999, Count: 1, Buyer: 77, Seller: 900})
	med, ok := p.Median(3001)
	if !ok || med != 1000 {
		t.Fatalf("中位价 = %d ok=%v,期望 1000(不受 999999 影响)", med, ok)
	}
}

func TestAssessBlocksSelfTradeAndExtremeDeviation(t *testing.T) {
	p := NewPriceIndex(100)
	for i := 0; i < 20; i++ {
		p.Add(TradeRecord{ItemID: 3001, Price: 1000, Count: 1, Buyer: PlayerID(i + 1), Seller: 900})
	}
	if lvl, reason := p.Assess(TradeRecord{ItemID: 3001, Price: 1000, Buyer: 5, Seller: 5}); lvl != RiskBlock {
		t.Fatalf("自买自卖 = %d (%s),期望 RiskBlock", lvl, reason)
	}
	if lvl, _ := p.Assess(TradeRecord{ItemID: 3001, Price: 3000, Count: 1, Buyer: 55, Seller: 900}); lvl != RiskWatch {
		t.Fatalf("偏离 200%% = %d,期望 RiskWatch", lvl)
	}
	if lvl, _ := p.Assess(TradeRecord{ItemID: 3001, Price: 9999, Count: 1, Buyer: 55, Seller: 900}); lvl != RiskBlock {
		t.Fatalf("偏离 900%% = %d,期望 RiskBlock", lvl)
	}
}

第一个用例证明单笔 999999 的异常成交不会污染中位价;第二个用例把三级打分从 RiskNone(正常 1000)到 RiskWatch(3000,偏离 200%)再到 RiskBlock(9999,偏离约 900%)全走一遍。两个阈值不是拍脑袋:150% 允许正常的行情波动与稀有度溢价,300% 之上基本只剩刷钱一种解释。

从单笔到全服:金币水槽与人工复核

把视角再抬高一格。Assess 只处理单笔,整服经济还要看两个总量:

  • 金币水槽:16.2 的 5% 成交税、修理费、传送费都在回收金币,而打怪、任务在投放金币。两者的净差就是通胀率。指数本身不解决通胀,但它是通胀的报警器——当所有材料的中位价在一周内齐涨,通常是金币投放超了。
  • 人工复核队列:RiskWatch 不是自动拒绝,而是入队。真正的洗钱往往用大量「看着正常」的小额成交把黑钱洗白,单笔都过不了 RiskBlock 的线,但累积的对敲计数会把它们标出来。风控的产出不是「抓了谁」,而是「给运营一份按风险分排序的复核清单」。

这套东西的边界要说清楚:PriceIndex 是内存实现,重启即失。生产上它会接一个持久化的成交流水,把窗口预热回来;但判定逻辑与这里逐字相同。本节只做单服内的价格索引,跨服行情与全服经济大屏不在本书范围。

本节小结 / 实测结论

本机(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 个用例覆盖了「中位价抗单笔异常」「VWAP 按量加权」「窗口滑动后对敲计数衰减」「自买自卖与三级偏离打分」「并发读写无竞争」。以上是同机相对量级,绝对数值不可移植。到这里第 16 章收尾:从一笔托管,到并发竞价,再到全服价格与风控,交易域的三块地基都跑通了。下一章转向匹配与房间,看另一类「把玩家凑到一起」的玩法系统。

阅读导航:上一节:16.2 竞价并发与异常回滚 · 下一节:17.1 匹配算法与公平性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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