《游戏服务端架构·核心系统与契约》6.1 货币、产出与消耗

《群岛纪元》一次活动配置把奖励放大十倍,团队却只能看到余额异常,查不出是哪条产出路径、发给了谁、是否已流入玩家交易。本节结合三款产品的货币分层,比较单表余额、双分录账本与事件溯源,说明来源标签、对账频率和差异处置,并给出可运行的 Go 双分录账本与失败模式。

一次多发,查不出源头

《群岛纪元》的活动配置把“每日首胜”奖励从 500 银币误填成 5000,持续了六个小时。运营发现全服银币总量上涨,但账面上只有每个玩家的余额;没人能回答三个问题:多出来的钱是哪条产出路径发的、发给了谁、有没有已经流入玩家交易。团队只好按时间窗口回放日志,再和业务表逐条比对,花了半天才定位到活动配置。

这不是数值问题,而是账本结构问题。只存余额的模型能回答“现在有多少”,回答不了“从哪来、到哪去”。对一款要长期运营、要处理退款和工作室申诉的游戏,后者才是常态需求。

北辰工作室三款产品的货币并不相同:

产品货币获取是否可交易
《裂隙战场》点券、赛季积分付费、对局点券不可交易,积分赛季清零
《群岛纪元》银币、金币、联盟贡献生产、任务、付费、联盟活动银币和金币可在玩家间交易
《泡泡工坊》金币、体力关卡、广告、付费不可交易

它们需要的不是同一套数值,而是同一套可审计的记账边界。

货币分层:共用账本还是分开

最常见的错误是把所有货币塞进一张 wallet 表,用 currency 字段区分。字段本身没错,错在把“同一张表”当成“同一本账”。付费货币和游戏币的审计要求、退款路径、法律属性完全不同,混在一起后,一次游戏币通胀会污染付费流水的对账。

维度付费货币游戏币活动代币
来源支付渠道回调任务、生产、交易活动发放
能否交易通常禁止允许或限制通常禁止
审计要求与订单一一对应按来源聚合按活动批次
失败影响退款、客诉、合规通胀、工作室活动公平性
建议账本独立账本独立账本独立账本或同库不同 currency

结论是:一个 currency 一本账,而不是一个玩家一本账。账本按货币分片,玩家账户只是账本里的 account。这样每笔交易的自然平衡条件“同币种内借贷相等”才有意义;跨币种交换必须显式经过汇率或定价服务,落到两笔独立交易,而不是一笔混币交易。

只记余额为什么不可审计

余额表的写法通常是这样:

UPDATE wallet SET amount = amount + 100 WHERE player_id = ? AND currency = 'silver';

它有三个结构性缺陷。第一,没有对手方:这 100 银币是从任务系统、活动配置还是 GM 工具来的,语句本身不知道。第二,没有业务主键:同一条奖励消息重投两次,余额就加两次,事后无法区分“确实发了两次”还是“重复消费”。第三,并发下容易丢失更新:两个请求同时读改写,后写的覆盖先写的,除非每条语句都带原子条件和版本号。

审计需要的不是更多日志,而是每笔变动都能被重放。这正是双分录账本要解决的问题。

双分录与来源标签

双分录的规则只有一条:一笔交易内的所有 posting 之和为零。创建货币时,玩家账户记正数,系统铸币账户 @mint 记负数;销毁时,玩家记负数,系统回收账户 @sink 记正数;玩家之间交易时,两边一正一负,系统账户不参与。

在此基础上加两个字段:

  • Source:业务来源标签,例如 quest、shop、trade、event、refund、gm。它只统计玩家账户的净流量,所以任务产出读正数、商店消耗读负数,不会被系统账户的冲销抹平。
  • ID:交易幂等键。消息队列重投、客户端重试、运维手工补发,都靠它去重。

这样“多发”就能被回答:按 source=event 聚合,能立刻看到某活动的净产出;按交易 ID 能找到具体玩家和批次;按时间窗口能重放整段历史。

方案审计能力实现成本查询成本适用
单表余额只看当前值最低最低原型、无交易的单机
双分录账本可重放、可归因中余额需聚合或缓存长期运营的网游
事件溯源完整历史与因果高需要投影强合规、复杂经济

事件溯源不是双分录的替代品,而是它的上游。多数团队只需要双分录加不可变 journal,不必把整个玩家状态都事件化。

选定方案与边界

《群岛纪元》最终采用“每货币一本双分录账本 + 来源标签 + 交易幂等键”。边界写得很清楚:

  • 账本只管加减和平衡,不管价格、汇率、过期和上限;这些是上层规则,最终仍要落成一笔合法交易。
  • 玩家账户不允许负余额;系统账户允许,因为它们本来就是冲销账户。
  • 余额是缓存,journal 是事实来源。进程重启后从 journal 重建,或从快照加增量恢复。
  • 账本按货币和账户分片,单笔交易只落在一个分片内;跨币种交换拆成两笔,由上层补偿。

这个边界让账本可以独立测试,也避免它变成第二个业务数据库。

可运行片段:账本、来源与对账

下面的 internal/currencyledger 实现双分录记账、来源聚合、余额校验和 journal 重放。它刻意不处理汇率、过期和交易托管,这些留给后面的章节。

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

import (
	"errors"
	"fmt"
	"reflect"
	"sort"
	"strings"
)

var (
	ErrNoPostings  = errors.New("currencyledger: transaction has no postings")
	ErrUnbalanced  = errors.New("currencyledger: postings do not sum to zero")
	ErrMissingID   = errors.New("currencyledger: transaction id is empty")
	ErrDuplicateTx = errors.New("currencyledger: duplicate transaction id")
	ErrBadDelta    = errors.New("currencyledger: posting delta must be non-zero")
	ErrBadSource   = errors.New("currencyledger: posting source tag is empty")
	ErrCurrency    = errors.New("currencyledger: currency does not match ledger")
	ErrOverdraft   = errors.New("currencyledger: player account would go negative")
	ErrMismatch    = errors.New("currencyledger: journal and balances disagree")
)

// Source is the business reason attached to every posting. Reports group by
// this tag, so a missing or invented tag makes an inflation bug unattributable.
type Source string

const (
	SourceQuest  Source = "quest"
	SourceShop   Source = "shop"
	SourceTrade  Source = "trade"
	SourceEvent  Source = "event"
	SourceRefund Source = "refund"
	SourceGM     Source = "gm"
)

// System accounts hold the counter-entry of creation and destruction. They are
// allowed to go negative; player accounts are not.
const (
	AccountMint Account = "@mint"
	AccountSink Account = "@sink"
)

// Account identifies a wallet or a system counter-account.
type Account string

// Posting is one leg of a transaction. Delta is positive for a credit and
// negative for a debit. Note is for human audit only; nothing joins on it.
type Posting struct {
	Account Account
	Delta   int64
	Source  Source
	Note    string
}

// Transaction is an atomic set of postings in one currency. Postings must sum
// to zero, which is what makes the ledger double-entry rather than a balance
// table with extra columns.
type Transaction struct {
	ID       string
	Currency string
	Postings []Posting
}

// SourceTotal is the net movement attributed to one source tag.
type SourceTotal struct {
	Source Source
	Net    int64
}

// Ledger is an in-memory double-entry book for exactly one currency. One
// currency per ledger keeps sum-zero meaningful without an exchange table.
type Ledger struct {
	currency string
	seen     map[string]struct{}
	balances map[Account]int64
	sources  map[Source]int64
	journal  []Transaction
}

// New creates an empty ledger. The currency is fixed at construction so a
// misrouted transaction fails before it touches any balance.
func New(currency string) *Ledger {
	return &Ledger{
		currency: currency,
		seen:     make(map[string]struct{}),
		balances: make(map[Account]int64),
		sources:  make(map[Source]int64),
		journal:  make([]Transaction, 0, 64),
	}
}

// Post validates and applies one transaction. Validation is complete before
// any map is mutated, so a rejected transaction leaves no partial effect.
func (l *Ledger) Post(tx Transaction) error {
	if err := l.validate(tx); err != nil {
		return err
	}
	next := make(map[Account]int64, len(l.balances)+len(tx.Postings))
	for a, b := range l.balances {
		next[a] = b
	}
	for _, p := range tx.Postings {
		next[p.Account] += p.Delta
	}
	for a, b := range next {
		if !isSystem(a) && b < 0 {
			return fmt.Errorf("%w: %s", ErrOverdraft, a)
		}
	}
	for _, p := range tx.Postings {
		l.balances[p.Account] = next[p.Account]
		if !isSystem(p.Account) {
			l.sources[p.Source] += p.Delta
		}
	}
	l.seen[tx.ID] = struct{}{}
	l.journal = append(l.journal, cloneTx(tx))
	return nil
}

func (l *Ledger) validate(tx Transaction) error {
	if tx.ID == "" {
		return ErrMissingID
	}
	if _, ok := l.seen[tx.ID]; ok {
		return fmt.Errorf("%w: %s", ErrDuplicateTx, tx.ID)
	}
	if tx.Currency != l.currency {
		return fmt.Errorf("%w: %q", ErrCurrency, tx.Currency)
	}
	if len(tx.Postings) == 0 {
		return ErrNoPostings
	}
	var sum int64
	for _, p := range tx.Postings {
		if p.Delta == 0 {
			return fmt.Errorf("%w: %s", ErrBadDelta, p.Account)
		}
		if p.Source == "" {
			return fmt.Errorf("%w: %s", ErrBadSource, p.Account)
		}
		sum += p.Delta
	}
	if sum != 0 {
		return fmt.Errorf("%w: %d", ErrUnbalanced, sum)
	}
	return nil
}

// Balance returns the current balance of an account. Unknown accounts are 0.
func (l *Ledger) Balance(a Account) int64 { return l.balances[a] }

// SourceNet returns the net movement of player accounts tagged with a source.
// Mint and sink legs are excluded so a reward source reads positive and a
// consumption source reads negative instead of always cancelling to zero.
func (l *Ledger) SourceNet(s Source) int64 { return l.sources[s] }

// Minted is total currency created through the mint account.
func (l *Ledger) Minted() int64 { return -l.balances[AccountMint] }

// Burned is total currency destroyed through the sink account.
func (l *Ledger) Burned() int64 { return l.balances[AccountSink] }

// Circulation is the currency still held by player accounts.
func (l *Ledger) Circulation() int64 { return l.Minted() - l.Burned() }

// SourceTotals returns net movement per source in stable order for reports.
func (l *Ledger) SourceTotals() []SourceTotal {
	out := make([]SourceTotal, 0, len(l.sources))
	for s, net := range l.sources {
		out = append(out, SourceTotal{Source: s, Net: net})
	}
	sort.Slice(out, func(i, j int) bool { return out[i].Source < out[j].Source })
	return out
}

// Reconcile replays the journal and compares the result with the cached
// balances. It is the check run after a crash or an import, not on every post.
func (l *Ledger) Reconcile() error {
	balances := make(map[Account]int64, len(l.balances))
	sources := make(map[Source]int64, len(l.sources))
	var total int64
	for _, tx := range l.journal {
		var sum int64
		for _, p := range tx.Postings {
			sum += p.Delta
			total += p.Delta
			balances[p.Account] += p.Delta
			if !isSystem(p.Account) {
				sources[p.Source] += p.Delta
			}
		}
		if sum != 0 {
			return fmt.Errorf("%w: %s", ErrUnbalanced, tx.ID)
		}
	}
	if total != 0 {
		return fmt.Errorf("%w: total %d", ErrUnbalanced, total)
	}
	if !reflect.DeepEqual(balances, l.balances) || !reflect.DeepEqual(sources, l.sources) {
		return ErrMismatch
	}
	return nil
}

// Journal returns a deep copy. Callers may archive it, but cannot mutate the
// ledger through the returned slice.
func (l *Ledger) Journal() []Transaction {
	out := make([]Transaction, len(l.journal))
	for i, tx := range l.journal {
		out[i] = cloneTx(tx)
	}
	return out
}

func cloneTx(tx Transaction) Transaction {
	cp := tx
	cp.Postings = append([]Posting(nil), tx.Postings...)
	return cp
}

func isSystem(a Account) bool { return strings.HasPrefix(string(a), "@") }

几个取舍值得说明:

  • 校验先于写入。任何一条 posting 不合法,整笔交易不落地,不会出现“扣了钱但没发货”的半截状态。
  • 透支检查按账户聚合后再判断。如果按 posting 顺序逐条检查,同一账户先扣后加会被误判为透支。
  • SourceNet 只统计玩家账户。把系统账户也算进去,每个来源都会因为借贷对冲而恒等于零,报表就失去意义。
  • Reconcile 重放 journal 并与缓存余额比较。它是崩溃恢复和批量导入后的检查,不是每笔交易的常规路径。
  • Journal 返回深拷贝,调用方可以归档,但不能绕过 Post 改账。

对账频率与差异处理

频率检查内容发现的问题处置
每笔交易posting 和为零、非负余额、交易 ID 唯一代码 bug、重复消费直接拒绝
每小时来源净流量与业务计数比对漏记、重复发奖标记差异,暂停相关活动
日终全量重放 journal,比对快照哈希存储损坏、并发写丢失从 journal 重建
版本发布后新旧逻辑抽样双跑迁移遗漏回滚版本或补记

差异处理要分级,不能一律“补钱”。少发可以补,多发要先冻结再人工核查;如果差异来自重复交易,补记只会放大问题。对账报告必须保留原始交易 ID,否则申诉时仍然说不清。

失败模式与兜底

失败表现兜底
消息重复投递同一奖励加两次交易 ID 幂等,重复直接返回成功
并发丢失更新两个请求互相覆盖单写者或账户级锁,禁止读改写
负余额玩家花超聚合后检查,拒绝整笔交易
跨币种混记一笔交易里银币和金币互相抵消每货币独立账本,交换拆两笔
来源标签缺失报表对不上业务空来源直接拒绝,不做默认值
journal 损坏重启后余额漂移快照加增量恢复,定期校验哈希
系统账户被当玩家铸币账户被扣成负数后无人发现系统账户命名保留前缀,权限隔离

最容易被忽略的是“重试语义”。客户端超时重发、网关重投、运维手工补发,都会产生重复请求;如果账本只靠调用方保证不重,迟早会多出一笔。

常见错误

第一,把余额表当账本。余额可以丢、可以重建,但没有 journal 就无法解释历史。

第二,用浮点数记货币。0.1 + 0.2 的误差在批量结算后会变成对不上的分;一律用最小单位的整数。

第三,允许来源标签为空。空标签会被归入“其他”,而“其他”永远查不出问题。

第四,只在应用层保证平衡。任何绕过 Post 的直接写库都会破坏不变量;账本要走唯一入口。

第五,把对账当成月底任务。通胀和复制在几小时内就能扩散,日终发现已经晚了。

小结

货币系统的核心不是数值表,而是可审计的记账边界:一种货币一本账、双分录保证平衡、来源标签支持归因、交易 ID 保证幂等、journal 支持重放。把这些做成独立组件,后面的交易托管和经济风控才有可靠的事实来源。

账本解决“钱怎么记”。下一节处理“钱怎么换手”:玩家交易时,物品和货币如何先冻结、再交换、失败时如何回滚。

阅读导航:上一节:5.3 战斗日志、回放与审计 · 下一节:6.2 交易托管、冻结与回滚 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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