一、四个指标不能混着看
讨论经济平衡时,最常被混用的四个词是产出、消耗、存量和通胀。它们描述的是不同东西:
- 产出是单位时间内新进入玩家钱包的货币总量。
- 消耗是单位时间内被系统回收、离开玩家钱包的货币总量。
- 存量是某一时刻玩家钱包里还剩多少货币。
- 通胀是存量随时间的变化率,不是产出本身。
只看充值流水,等于只看付费货币的一个入口。免费货币的掉落、任务奖励、活动赠送、交易税返还,都可能远超充值。一个健康的日报至少要同时给出四个指标,并且按货币种类拆开。
二、产出来源与消耗池
产出和消耗都要有明确的“池子”。池子不是财务概念,而是代码里可枚举的分类。分类不固定,后续的聚合、告警和调控都会变成手工对账。
| 方向 | 分类示例 | 说明 |
|---|---|---|
| 产出 | 任务、掉落、活动、补偿、交易税返还 | 每条产出都要能追到具体玩法 |
| 消耗 | 修理、商店、手续费、养成、过期回收 | 每条消耗都要能追到具体系统 |
| 内部转移 | 玩家交易、邮件领取 | 不改变总存量,单独统计,避免虚增产出 |
| 外部流入 | 充值、退款冲正 | 与游戏内产出分开,避免混入通胀口径 |
内部转移最容易被误记成产出。玩家之间交易 1000 金币,总存量没变,但如果卖方记产出、买方不记消耗,报表上的产出就会凭空多出一倍。正确做法是给内部转移单独一个方向,或者在聚合时直接排除。
三、按日和周聚合,而不是看单点
单点数据无法区分“今天活动开服”与“经济失控”。按日聚合看趋势,按周聚合看周期,两者都要保留。聚合窗口统一用左闭右开区间 [from, to),避免跨窗口边界的事件被重复计算。
示例口径可以是这样:日报统计自然日 00:00 到次日 00:00,周报统计周一 00:00 到下周周一 00:00,时区固定为服务器时区。所有窗口都从同一份流水表计算,不允许日报和周报各自维护一套 SQL。
聚合结果要能下钻到分类。只给一个“今日总产出 1200 万”没有行动价值;告诉运营“其中掉落 700 万、任务 300 万、活动 200 万”,才能判断该调哪个池子。
四、工作室与脚本会扭曲指标
工作室的行为会同时改变产出和消耗:脚本号大量刷掉落,把产出推高;它们几乎不参与养成和修理,消耗端却保持不变。结果是总产出上升、真实玩家的消耗占比下降,通胀看起来像是“全体玩家都在囤币”。
识别这类扭曲,不能只看总量,要看分布:
- 单账号日均产出是否显著高于同等级玩家的分位数。
- 产出是否集中在少数地图、少数时间段。
- 交易行里同一种材料是否由少数账号持续挂单。
- 新增账号的产出曲线是否在极短时间内达到老玩家水平。
这些信号要和流水分类一起看。只看总量,会把工作室的产出算进“活动效果”;只看充值,又完全看不到免费货币的洪水。
五、失败场景:只盯充值,产出已经失控
某项目上线新副本,掉落表把一种可交易材料的概率调高了一个数量级。运营周报只跟踪充值和高价值道具成交价,没有看免费货币的产出分类。两周后,材料价格跌到原来的十分之一,玩家开始用材料直接换金币,金币存量在第三周上涨了示例值 40%。
复盘发现三个问题:产出流水没有按副本和掉落表分类,无法定位是哪个池子;消耗端没有记录交易手续费,实际回收量被低估;调控只能靠热更改掉落,改完没有审计记录,谁在什么时候改的说不清楚。
修复方案不是简单下调概率,而是先补分类、补手续费流水、补调控审计,再根据一周的窗口数据决定幅度。没有度量基础的调控,只是把一次事故换成另一次事故。
六、Go 实现:只追加的 Ledger
Ledger 只接收带业务 ID、方向、分类、货币、金额和时间戳的流水。重复导入同一个 ID 且内容一致时返回 ErrDuplicateEntry,内容不一致时返回 ErrEntryConflict,两种情况都不改变已有数据。
// file: ch09/economy/economy.go
// tier: L1
// verify: go test ./ch09/economy/
package economy
import (
"math"
"time"
)
// FlowKind 区分产出与消耗。存量不是流水,不能混进 Ledger。
type FlowKind int
const (
FlowProduction FlowKind = iota
FlowConsumption
)
// FlowEntry 是一条带业务分类和时间戳的流水。ID 用于导入幂等。
type FlowEntry struct {
ID string
Kind FlowKind
Category string
Currency Currency
Amount int64
At time.Time
}
// Ledger 只追加流水,按时间窗口和分类聚合。
type Ledger struct {
entries map[string]FlowEntry
}
func NewLedger() *Ledger {
return &Ledger{entries: make(map[string]FlowEntry)}
}
// Record 写入一条流水。重复 ID 且内容相同返回 ErrDuplicateEntry,
// 内容不同返回 ErrEntryConflict;两种情况都不改变已有数据。
func (l *Ledger) Record(e FlowEntry) error {
if e.ID == "" || e.Category == "" || e.Amount <= 0 || e.At.IsZero() {
return ErrInvalidEntry
}
if e.Kind != FlowProduction && e.Kind != FlowConsumption {
return ErrInvalidEntry
}
if old, ok := l.entries[e.ID]; ok {
if old.Kind == e.Kind && old.Category == e.Category &&
old.Currency == e.Currency && old.Amount == e.Amount && old.At.Equal(e.At) {
return ErrDuplicateEntry
}
return ErrEntryConflict
}
l.entries[e.ID] = e
return nil
}
func (l *Ledger) Len() int {
return len(l.entries)
}
// Summary 统计 [from, to) 内指定类型的金额,按业务分类聚合。
func (l *Ledger) Summary(kind FlowKind, from, to time.Time) (map[string]int64, error) {
if from.IsZero() || to.IsZero() || !from.Before(to) {
return nil, ErrInvalidWindow
}
out := make(map[string]int64)
for _, e := range l.entries {
if e.Kind != kind || e.At.Before(from) || !e.At.Before(to) {
continue
}
sum := out[e.Category]
if sum > math.MaxInt64-e.Amount {
return nil, ErrAmountOverflow
}
out[e.Category] = sum + e.Amount
}
return out, nil
}
func (l *Ledger) Totals(from, to time.Time) (produced, consumed int64, err error) {
prod, err := l.Summary(FlowProduction, from, to)
if err != nil {
return 0, 0, err
}
cons, err := l.Summary(FlowConsumption, from, to)
if err != nil {
return 0, 0, err
}
for _, v := range prod {
produced += v
}
for _, v := range cons {
consumed += v
}
return produced, consumed, nil
}
// NetDelta 返回窗口内的净产出。正数表示存量增加,负数表示回收。
func (l *Ledger) NetDelta(from, to time.Time) (int64, error) {
produced, consumed, err := l.Totals(from, to)
if err != nil {
return 0, err
}
return produced - consumed, nil
}
// InflationBPS 用基点表示期初到期末的存量变化率,避免浮点。
func InflationBPS(opening, closing int64) (int64, error) {
if opening <= 0 || closing < 0 {
return 0, ErrInvalidEntry
}
delta := closing - opening
if delta > 0 && delta > math.MaxInt64/10000 {
return 0, ErrAmountOverflow
}
if delta < 0 && delta < math.MinInt64/10000 {
return 0, ErrAmountOverflow
}
return delta * 10000 / opening, nil
}
Summary 用左闭右开窗口聚合指定方向的金额,Totals 和 NetDelta 在此基础上计算净差。InflationBPS 用基点表示存量变化率,避免浮点数进入报表口径。
七、测试:分类、窗口、重复导入
测试用固定时间构造四条流水,分别验证分类汇总、窗口边界和空窗口报错。重复导入测试同时检查 Len 和总额,确保重复记录没有被二次计入。
// file: ch09/economy/economy_test.go
// tier: L1
// verify: go test ./ch09/economy/
package economy
import (
"errors"
"testing"
"time"
)
var base = time.Date(2026, 11, 4, 0, 0, 0, 0, time.UTC)
func entry(id string, kind FlowKind, category string, amount int64, at time.Time) FlowEntry {
return FlowEntry{ID: id, Kind: kind, Category: category, Currency: CurrencyFree, Amount: amount, At: at}
}
func TestSummaryByCategoryAndWindow(t *testing.T) {
l := NewLedger()
rows := []FlowEntry{
entry("q1", FlowProduction, "quest", 100, base),
entry("d1", FlowProduction, "drop", 50, base.Add(time.Hour)),
entry("s1", FlowConsumption, "repair", 30, base.Add(2*time.Hour)),
entry("q2", FlowProduction, "quest", 20, base.Add(3*time.Hour)),
}
for _, e := range rows {
if err := l.Record(e); err != nil {
t.Fatal(err)
}
}
got, err := l.Summary(FlowProduction, base, base.Add(2*time.Hour))
if err != nil {
t.Fatal(err)
}
if got["quest"] != 100 || got["drop"] != 50 || len(got) != 2 {
t.Fatalf("summary = %v", got)
}
got, err = l.Summary(FlowProduction, base.Add(time.Hour), base.Add(4*time.Hour))
if err != nil || got["drop"] != 50 || got["quest"] != 20 {
t.Fatalf("second window = %v, err = %v", got, err)
}
if _, err := l.Summary(FlowConsumption, base, base); !errors.Is(err, ErrInvalidWindow) {
t.Fatalf("empty window err = %v", err)
}
}
func TestDuplicateImportAndInvalidEntries(t *testing.T) {
l := NewLedger()
e := entry("q1", FlowProduction, "quest", 100, base)
if err := l.Record(e); err != nil {
t.Fatal(err)
}
if err := l.Record(e); !errors.Is(err, ErrDuplicateEntry) {
t.Fatalf("duplicate err = %v", err)
}
conflict := e
conflict.Amount = 999
if err := l.Record(conflict); !errors.Is(err, ErrEntryConflict) {
t.Fatalf("conflict err = %v", err)
}
produced, consumed, err := l.Totals(base, base.Add(time.Hour))
if err != nil || produced != 100 || consumed != 0 {
t.Fatalf("totals = %d/%d, err = %v", produced, consumed, err)
}
bad := []FlowEntry{
entry("", FlowProduction, "quest", 1, base),
entry("x", FlowProduction, "", 1, base),
entry("x", FlowProduction, "quest", 0, base),
entry("x", FlowKind(9), "quest", 1, base),
entry("x", FlowProduction, "quest", 1, time.Time{}),
}
for i, e := range bad {
if err := l.Record(e); !errors.Is(err, ErrInvalidEntry) {
t.Fatalf("bad case %d err = %v", i, err)
}
}
if l.Len() != 1 {
t.Fatalf("ledger len = %d", l.Len())
}
}
func TestTotalsNetDeltaAndInflation(t *testing.T) {
l := NewLedger()
for _, e := range []FlowEntry{
entry("p1", FlowProduction, "quest", 100, base),
entry("p2", FlowProduction, "drop", 200, base),
entry("c1", FlowConsumption, "repair", 100, base),
} {
if err := l.Record(e); err != nil {
t.Fatal(err)
}
}
produced, consumed, err := l.Totals(base, base.Add(time.Hour))
if err != nil || produced != 300 || consumed != 100 {
t.Fatalf("totals = %d/%d, err = %v", produced, consumed, err)
}
net, err := l.NetDelta(base, base.Add(time.Hour))
if err != nil || net != 200 {
t.Fatalf("net = %d, err = %v", net, err)
}
if bps, err := InflationBPS(1000, 1200); err != nil || bps != 2000 {
t.Fatalf("inflation = %d, err = %v", bps, err)
}
if bps, err := InflationBPS(1000, 800); err != nil || bps != -2000 {
t.Fatalf("deflation = %d, err = %v", bps, err)
}
if _, err := InflationBPS(0, 100); !errors.Is(err, ErrInvalidEntry) {
t.Fatalf("zero opening err = %v", err)
}
}
TestTotalsNetDeltaAndInflation 还覆盖了负通胀和期初为零的非法输入。报表系统最容易漏掉的就是“存量下降”这种情况,测试必须显式覆盖。
八、运行测试
cd .local/checks/gs-practice-lab && gofmt -l ch09/economy && go vet ./ch09/economy/ && go test ./ch09/economy/ -count=1
ok gspractice/ch09/economy 0.xxx s
示例耗时因机器而异。本文的测试与 /game-server-in-action-931/ 共用同一个 economy 包,运行整包测试会同时覆盖货币与流水。
小结
经济平衡要先有度量,再有调控。产出、消耗、存量、通胀四个指标分开统计,内部转移不能算产出,聚合窗口统一左闭右开。流水按业务分类并保留幂等 ID,才能在下钻、复盘和重复导入时保持一致。调控必须基于窗口数据并留下审计,而不是临时暗改掉落。
常见错误与边界
- 只统计充值,把免费货币的产出当作不存在。
- 把存量当成流量,用“玩家总金币”解释今日通胀。
- 玩家交易同时记产出和消耗,报表总量虚增。
- 聚合窗口右端闭合,边界事件被相邻两天重复计算。
- 流水没有业务分类,发现问题时无法定位到具体玩法。
- 重复导入不去重,补数据一次就把总量翻倍。
- 调控只改掉落概率,没有记录操作人、时间和影响范围。
- 用浮点数计算通胀率,报表小数位与流水对不上。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。