《游戏服务端编程实践》9.3.2 产出与消耗平衡

经济系统是否健康,不能只看充值流水。本篇区分产出、消耗、存量与通胀四个指标,按日和周聚合流水,讨论工作室与脚本如何扭曲数据,并给出可审计的调控手段。Go 实现用 Ledger 记录带分类和时间的产出消耗,支持时间窗口汇总与重复导入幂等,最后总结常见错误。

一、四个指标不能混着看

讨论经济平衡时,最常被混用的四个词是产出、消耗、存量和通胀。它们描述的是不同东西:

  • 产出是单位时间内新进入玩家钱包的货币总量。
  • 消耗是单位时间内被系统回收、离开玩家钱包的货币总量。
  • 存量是某一时刻玩家钱包里还剩多少货币。
  • 通胀是存量随时间的变化率,不是产出本身。

只看充值流水,等于只看付费货币的一个入口。免费货币的掉落、任务奖励、活动赠送、交易税返还,都可能远超充值。一个健康的日报至少要同时给出四个指标,并且按货币种类拆开。

二、产出来源与消耗池

产出和消耗都要有明确的“池子”。池子不是财务概念,而是代码里可枚举的分类。分类不固定,后续的聚合、告警和调控都会变成手工对账。

方向分类示例说明
产出任务、掉落、活动、补偿、交易税返还每条产出都要能追到具体玩法
消耗修理、商店、手续费、养成、过期回收每条消耗都要能追到具体系统
内部转移玩家交易、邮件领取不改变总存量,单独统计,避免虚增产出
外部流入充值、退款冲正与游戏内产出分开,避免混入通胀口径

内部转移最容易被误记成产出。玩家之间交易 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,才能在下钻、复盘和重复导入时保持一致。调控必须基于窗口数据并留下审计,而不是临时暗改掉落。

常见错误与边界

  • 只统计充值,把免费货币的产出当作不存在。
  • 把存量当成流量,用“玩家总金币”解释今日通胀。
  • 玩家交易同时记产出和消耗,报表总量虚增。
  • 聚合窗口右端闭合,边界事件被相邻两天重复计算。
  • 流水没有业务分类,发现问题时无法定位到具体玩法。
  • 重复导入不去重,补数据一次就把总量翻倍。
  • 调控只改掉落概率,没有记录操作人、时间和影响范围。
  • 用浮点数计算通胀率,报表小数位与流水对不上。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「game」更多文章

  1. 《游戏服务端编程实践》附录 D:参考资料与社区索引
  2. 《游戏服务端编程实践》附录 C:示例项目目录结构
  3. 《游戏服务端编程实践》附录 B:性能调优 Checklist