一次多发,查不出源头
《群岛纪元》的活动配置把“每日首胜”奖励从 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 交易托管、冻结与回滚 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。