《游戏服务端架构·品类、状态与拓扑》6.3 经济系统与随机数的可信边界

从《群岛纪元》一次掉落表热更导致历史抽奖无法复盘讲起,区分服务端权威随机、种子提交-揭示与客户端可验证三种做法;给出掉落表版本固定、HMAC 派生随机数、双分录账本与对账边界的 Go 实现,并用 7 组测试覆盖提交篡改、权重边界、版本回溯与账本非法分录。

一次热更,让历史抽奖无法复盘

《群岛纪元》的掉落表放在配置中心,运营可以直接改权重。某次活动把稀有材料的权重从 1 调到 3,热更生效。两周后玩家投诉“同一场战斗,回放里掉的是材料,背包里却是金币”。排查发现:战斗结算时读的是旧表,发奖时读的是新表,而抽奖记录只存了“掉了什么”,没存“用的是哪一版表、哪个随机数”。

这不是随机数算法的问题。即使换成密码学安全的随机源,只要“当时的输入”没有被完整记录,事后就无法复现,也无法证明服务端没有偏袒。经济系统的可信边界,首先是一条可审计的边界,其次才是“不可预测”。

《裂隙战场》的诉求不同。开箱结果需要让玩家相信没有被操控,所以采用种子提交-揭示:开局前公布承诺,结算后公布种子,玩家可以自行验证。《泡泡工坊》则只需要服务端权威加流水对账,玩家不验证概率,但运营必须能解释每一笔补偿。

三种随机方案与取舍

方案谁能验证防运营作弊实现成本适用
纯服务端随机只有服务端弱,依赖审计低休闲、单机
客户端提供随机种子客户端无,客户端可挑结果低不推荐
提交-揭示 + 双种子服务端与玩家强,承诺不可抵赖中竞技、开箱
链上可验证随机所有人最强高,依赖外部网络特殊场景

选择的关键不是“哪个更安全”,而是“谁需要验证”。如果只有运营需要复盘,服务端权威加完整流水就够了;如果玩家会质疑概率,提交-揭示才值得引入。链上方案在延迟、成本和合规上都有额外负担,不适合作为默认选项。

提交-揭示的顺序决定它是否有效

提交-揭示只有顺序正确才成立:

开局前   服务端生成 serverSeed,公布 commit = SHA256(serverSeed)
         玩家提交 clientSeed(或在对局开始时固定)
结算时   rand = HMAC(serverSeed, clientSeed || round)
         按 rand 在掉落表当前版本中取结果
公布后   服务端公布 serverSeed
         玩家校验 SHA256(serverSeed) == commit,并自行重算 rand

如果服务端先看到 clientSeed 再生成 serverSeed,它就可以反复挑选种子来得到想要的结果。承诺必须在玩家输入之前发出,或者把玩家输入绑定进承诺的验证路径。本节实现假设承诺先于结算,代码只负责验证承诺与派生随机数,时序必须由房间服的状态机保证。取模偏差在总权重远小于 2^64 时可以忽略;权重接近该量级时应改用拒绝采样。

掉落表必须版本化

掉落表有三个容易踩的坑:

第一,原地修改。运营在配置中心把权重改掉,历史记录失去复现依据。正确做法是每次调整生成新版本,旧版本永久保留。

第二,只存结果不存版本。抽奖记录里只有 item=稀有材料,没有 table=boss_drop, version=7,审计时无法判断当时用的是哪张表。

第三,用“最新表”重算历史。对账脚本为了省事直接读当前版本,会把一次正常的旧结果判成异常,或者把一次真实的配置错误洗掉。

因此抽奖记录至少包含:表名、表版本、serverSeed 的承诺、clientSeed、轮次、派生随机数、结果。有了这七项,任何一次抽奖都可以离线重放。

双分录账本:让每一笔产出都有对手方

随机数决定“掉什么”,账本决定“东西怎么进来的”。单边记账只记录“玩家 +100 金币”,无法回答“这 100 金币从哪来”。双分录要求每笔交易至少两条分录,且和为零:

掉落:  player:1  +100     system:faucet  -100
消费:  player:1   -30     system:sink     +30
补偿:  player:1   +50     system:support  -50

system:* 是系统账户,允许为负;玩家账户的负值策略由业务决定,账本本身只保证每笔平衡。这样做的收益是:任何时刻所有账户余额之和为零,凭空多出的货币一定对应某个系统账户的异常,审计范围从“全量流水”缩小到“系统账户”。

对账是账本与外部快照的比较。快照可能来自数据库备份、运营后台或第三方支付。对账只报告差异,不自动修正;自动修正会覆盖掉“哪一边错了”的证据,让下一次对账继续错。

代码:economy 包

下面的实现包含四部分:承诺与验证、HMAC 随机派生、不可变掉落表与版本集合、双分录账本与对账。它不包含发奖流程,也不包含数据库,因为这两部分应该由经济服务和存储层负责。

// file: internal/economy/economy.go
// tier: L1
// verify: go test ./internal/economy/
package economy

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/binary"
	"encoding/hex"
	"errors"
	"fmt"
	"sort"
)

// Commit seals a server seed before the round starts. SHA-256 is enough here
// because the seed is 32 random bytes, not a guessable user value.
func Commit(serverSeed []byte) string {
	sum := sha256.Sum256(serverSeed)
	return hex.EncodeToString(sum[:])
}

func Reveal(serverSeed []byte, commit string) bool { return Commit(serverSeed) == commit }

// Roll derives a uint64 from both seeds and the round number. HMAC keeps the
// server seed from being recoverable from published outcomes.
func Roll(serverSeed, clientSeed []byte, round uint64) uint64 {
	var buf [8]byte
	binary.BigEndian.PutUint64(buf[:], round)
	mac := hmac.New(sha256.New, serverSeed)
	mac.Write(clientSeed)
	mac.Write(buf[:])
	sum := mac.Sum(nil)
	return binary.BigEndian.Uint64(sum[:8])
}

type Entry struct {
	Item   string
	Weight int
}

// DropTable is immutable. A rebalance creates a new version instead of editing
// an existing one, so historical draws can always be re-derived.
type DropTable struct {
	Name    string
	Version int
	entries []Entry
	total   int
}

func NewTable(name string, version int, entries []Entry) (*DropTable, error) {
	if name == "" {
		return nil, errors.New("economy: empty table name")
	}
	if version < 1 {
		return nil, errors.New("economy: version must be >= 1")
	}
	if len(entries) == 0 {
		return nil, errors.New("economy: empty drop table")
	}
	t := &DropTable{Name: name, Version: version, entries: append([]Entry(nil), entries...)}
	seen := make(map[string]bool, len(entries))
	for _, e := range t.entries {
		if e.Item == "" {
			return nil, errors.New("economy: empty item name")
		}
		if e.Weight <= 0 {
			return nil, fmt.Errorf("economy: item %s has non-positive weight", e.Item)
		}
		if seen[e.Item] {
			return nil, fmt.Errorf("economy: duplicate item %s", e.Item)
		}
		seen[e.Item] = true
		t.total += e.Weight
	}
	return t, nil
}

func (t *DropTable) Total() int { return t.total }

// Draw maps an already-derived random value onto the table. Callers must pass a
// value from Roll, never a client-supplied index.
func (t *DropTable) Draw(rand uint64) (string, error) {
	if t.total <= 0 {
		return "", errors.New("economy: table has no weight")
	}
	roll := int(rand % uint64(t.total))
	acc := 0
	for _, e := range t.entries {
		acc += e.Weight
		if roll < acc {
			return e.Item, nil
		}
	}
	return "", errors.New("economy: roll outside table")
}

// TableSet keeps every published version. Get is how an audit re-runs an old
// draw; Latest is how live traffic picks the current table.
type TableSet struct {
	tables map[string][]*DropTable
}

func NewTableSet() *TableSet {
	return &TableSet{tables: make(map[string][]*DropTable)}
}

func (s *TableSet) Add(t *DropTable) error {
	for _, old := range s.tables[t.Name] {
		if old.Version == t.Version {
			return fmt.Errorf("economy: table %s version %d already exists", t.Name, t.Version)
		}
	}
	s.tables[t.Name] = append(s.tables[t.Name], t)
	sort.Slice(s.tables[t.Name], func(i, j int) bool {
		return s.tables[t.Name][i].Version < s.tables[t.Name][j].Version
	})
	return nil
}

func (s *TableSet) Get(name string, version int) (*DropTable, error) {
	for _, t := range s.tables[name] {
		if t.Version == version {
			return t, nil
		}
	}
	return nil, fmt.Errorf("economy: table %s version %d not found", name, version)
}

func (s *TableSet) Latest(name string) (*DropTable, error) {
	versions := s.tables[name]
	if len(versions) == 0 {
		return nil, fmt.Errorf("economy: table %s not found", name)
	}
	return versions[len(versions)-1], nil
}

type Posting struct {
	Account string
	Amount  int64
}

// Journal is one balanced transaction. Positive amounts debit, negative
// amounts credit; the names are bookkeeping, not game semantics.
type Journal struct {
	ID       string
	Round    uint64
	Memo     string
	Postings []Posting
}

var (
	ErrUnbalanced = errors.New("economy: journal does not sum to zero")
	ErrDuplicate  = errors.New("economy: duplicate journal id")
	ErrNoPostings = errors.New("economy: journal has no postings")
)

// Ledger is an append-only double-entry book. Balances are a cache derived from
// journals; Verify rebuilds them to catch accidental direct writes.
type Ledger struct {
	balances map[string]int64
	journals []Journal
	seen     map[string]bool
}

func NewLedger() *Ledger {
	return &Ledger{balances: make(map[string]int64), seen: make(map[string]bool)}
}

func (l *Ledger) Post(j Journal) error {
	if j.ID == "" {
		return errors.New("economy: empty journal id")
	}
	if l.seen[j.ID] {
		return ErrDuplicate
	}
	if len(j.Postings) == 0 {
		return ErrNoPostings
	}
	var sum int64
	for _, p := range j.Postings {
		if p.Account == "" {
			return errors.New("economy: empty account")
		}
		if p.Amount == 0 {
			return errors.New("economy: zero posting")
		}
		sum += p.Amount
	}
	if sum != 0 {
		return ErrUnbalanced
	}
	l.seen[j.ID] = true
	for _, p := range j.Postings {
		l.balances[p.Account] += p.Amount
	}
	copied := j
	copied.Postings = append([]Posting(nil), j.Postings...)
	l.journals = append(l.journals, copied)
	return nil
}

func (l *Ledger) Balance(account string) int64 { return l.balances[account] }

func (l *Ledger) Verify() error {
	rebuilt := make(map[string]int64)
	for _, j := range l.journals {
		var sum int64
		for _, p := range j.Postings {
			rebuilt[p.Account] += p.Amount
			sum += p.Amount
		}
		if sum != 0 {
			return fmt.Errorf("economy: journal %s is unbalanced", j.ID)
		}
	}
	if len(rebuilt) != len(l.balances) {
		return errors.New("economy: account set drift")
	}
	for account, want := range rebuilt {
		if l.balances[account] != want {
			return fmt.Errorf("economy: balance drift on %s", account)
		}
	}
	return nil
}

type Diff struct {
	Account  string
	Ledger   int64
	Snapshot int64
}

// Reconcile compares the ledger with an external snapshot. It reports drift; it
// never "fixes" it, because an automatic fix destroys the evidence needed to
// decide whether the ledger or the snapshot is wrong.
func (l *Ledger) Reconcile(snapshot map[string]int64) []Diff {
	seen := make(map[string]bool, len(l.balances))
	var out []Diff
	for account, balance := range l.balances {
		seen[account] = true
		if snap, ok := snapshot[account]; !ok || snap != balance {
			out = append(out, Diff{Account: account, Ledger: balance, Snapshot: snap})
		}
	}
	for account, snap := range snapshot {
		if !seen[account] {
			out = append(out, Diff{Account: account, Ledger: 0, Snapshot: snap})
		}
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Account < out[j].Account })
	return out
}

DropTable 的字段全部私有,构造后没有修改入口。版本集合 TableSet 按版本号排序,Get 用于审计重放,Latest 用于线上抽取;两者分开,避免“为了查历史顺手用了最新表”。

Ledger.Post 在写入前完成全部校验:ID 非空且唯一、分录非空、账户非空、金额非零、总和为零。任何一条不满足都不写入,也不留下半成品。余额只是缓存,Verify 会从流水重新计算并逐账户比对。

Reconcile 返回按账户名排序的差异列表,包含“账本有、快照没有”和“快照有、账本没有”两种情况,排序让输出稳定、便于比对。

测试覆盖了什么

lab 里的 internal/economy 有 7 个测试函数,其中 4 个是表驱动:

测试覆盖的失败模式
TestCommitReveal正确种子、错误种子、被篡改的承诺
TestTableValidation空表名、零版本、空条目、零权重、重复物品
TestDrawBoundaries权重边界与取模回绕
TestLedgerRejectsInvalidJournals不平、无分录、零金额、重复 ID
TestRollIsDeterministicPerRound同输入同结果,不同轮次不同结果
TestVersionPinning旧版本可重放,最新版本独立演进
TestLedgerVerifyAndReconcile余额重算、差异报告、直接改余额被发现

本机运行结果:

$ go test -count=1 ./internal/economy/
ok  	gsarchlab/internal/economy	0.468s

失败模式与兜底

种子提前泄露。 承诺已公布但种子在结算前泄露,玩家可以预测结果。泄露应按事故处理:作废该轮、换种子、重新承诺,并保留事故记录;不能“假装没发生”继续发奖。

热更原地改表。 版本号没变但内容变了,历史重放全部失效。配置系统必须禁止原地修改已发布版本,或者在加载时计算内容哈希并与版本绑定。

补偿写成了单边分录。 客服直接给玩家加钱,没有对手方账户,账本总和不再为零。应统一走 system:support 账户,让补偿也进入对账范围。

对账自动修正。 脚本发现差异后直接以快照为准覆盖账本,下一次差异继续被覆盖,问题永远查不出来。对账输出必须只读,修正要走人工审批和新的分录。

常见错误与边界

第一,用 math/rand 的全局源做经济随机。它可预测、可并发争用,且种子往往来自时间;经济随机必须使用密码学随机源派生,并记录派生输入。

第二,只让客户端提供种子。客户端可以反复重连直到拿到想要的结果;客户端种子只能作为附加熵,不能成为唯一随机源。

第三,抽奖记录只存物品 ID。没有表版本、种子和轮次,任何“概率不公”的质疑都无法自证。

第四,账本只存玩家侧分录。没有对手方就无法判断货币是产出、转移还是复制,也无法做全局守恒校验。

第五,把“可审计”理解成“不可预测”。对玩家而言不可预测很重要,对运营而言可复现更重要;两者不冲突,但实现顺序应该是先可审计,再谈不可预测。

小结

经济系统的可信边界由三件事组成:随机数的输入可复现、掉落表的版本可追溯、货币的流动可守恒。提交-揭示解决“玩家凭什么相信”,版本化解决“运营凭什么复盘”,双分录解决“系统凭什么自证没有凭空造钱”。

internal/economy 把这三点压缩成可测试的包。它不追求密码学协议的完备性,也不替代账务系统;它给出的是最小可信骨架:任何一次掉落都能重放,任何一笔货币都有对手方,任何一次对账差异都不会被静默抹平。

下一章进入具体品类:MOBA 的房间、tick 与帧同步,看看这些权威与信任约束如何在 5v5 对局里落地。

阅读导航:上一节:6.2 反作弊应该放在架构的哪一层 · 下一节:7.1 MOBA 的房间、tick 与帧同步 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查