《游戏服务端架构·品类、状态与拓扑》8.3 异步结算、离线收益与补算

《群岛纪元》玩家离线三天回来,资源产出、维护消耗、仓库上限和运营补偿要在一次登录里算清;客户端时间不可信,时区与夏令时又会移动每日边界。本节把登录补算、定时结算与读时结算放进同一套幂等账本,给出包含封顶、补算窗口与补偿领取的 Go 实现和测试。

玩家离线三天,回来该拿多少

《群岛纪元》国庆活动结束后的第一个工作日,客服收到一串相似工单:

  • 玩家 A 离线 3 天,铁矿只增加了 1 天的量;
  • 玩家 B 离线 3 天,铁矿增加了 7 天的量,还触发了仓库上限;
  • 玩家 C 的补偿邮件显示“已领取”,但他从没点过;
  • 玩家 D 把手机时间调到一周后,反复登录,试图多领离线收益。

四个工单对应四个不同的问题:每日封顶、补算窗口、补偿幂等、客户端时间不可信。它们看起来都是“离线收益”,实际上分属结算触发、时间口径、状态上限和运营补偿四条路径。把它们塞进一个 now - lastSeen 的公式里,任何一条路径出错都会变成经济漏洞。

第 4.3 节 已经给出通用的时间模型:按本地自然日切段、每日封顶、时钟回拨返回零。本节不重复那个模型,而是往上走一层,回答 SLG 的运营边界:产出、消耗、仓库上限、补算窗口和运营补偿,如何在一次结算里算清,并且只算一次。

三个结算触发点

离线收益不是只有“登录时补一次”。SLG 至少有三个触发点:

触发点时机延迟要求幂等键主要风险
读时结算玩家登录、打开经济界面必须同步返回结算窗口 + 配置版本登录洪峰放大计算量
定时结算每小时/每天批量跑可延迟批次 ID + 玩家 ID批次重跑导致重复发放
事件驱动建筑完成、部队到达事件时刻附近事件 ID事件丢失或重复消费

《群岛纪元》的选择是:玩家经济走读时结算,排行榜与赛季奖励走定时结算,跨玩家事件只读取已裁决结果。 读时结算保证玩家一登录就看到正确库存;定时结算处理“没有玩家在线也要推进”的全局状态;事件驱动只用于建筑完成这类单玩家事件,不用于战斗结果。

读时结算的代价是登录洪峰。一个玩家离线 30 天,按天切段要循环 30 次;十万玩家同时回归,就是三百万次循环。所以外层必须先做最大窗口截断,再进入按天循环。截断不是优化,是正确性的一部分:没有窗口上限,补算成本随离线时长线性增长,而且经济会无限膨胀。

时间口径:只信服务器

离线结算的第一原则是:客户端时间永远不是输入。 客户端可以改系统时间、改时区、重放请求;服务端只能相信自己的时钟和持久化的 lastSettled。

具体做法:

  • lastSettled 由服务端在每次成功结算后写入,和库存、账本在同一个事务里;
  • 结算时使用服务器当前时间 now,而不是客户端上传的时间戳;
  • 每日重置按赛区配置的时区计算,时区变更要记录 economy_timezone_version;
  • 时钟回拨直接拒绝并告警,不发放、不更新 lastSettled;
  • 重复请求(now == lastSettled)返回空结算,由幂等账本去重。

时区与夏令时是最容易被低估的部分。下面三种口径会产生不同结果:

口径 A:UTC 自然日              -> 全球统一重置,玩家本地体验不一致
口径 B:赛区时区自然日          -> 本地 00:00 重置,符合玩家预期
口径 C:滚动 24 小时            -> 与“每日”语义不符,跨时区活动难对齐

SLG 通常选 B:每个赛区一个时区,每日重置在本地 00:00。夏令时切换时,本地日长度可能是 23 或 25 小时;按本地日历切段会自动处理,按固定 24 小时切段会在切换日错位。第 4.3 节的 AccrueInZone 已经按本地日历切段,本节直接复用它,不再写第二套时间模型。

产出、消耗与仓库上限

离线结算不是“产出多少给多少”。SLG 的库存同时受三件事影响:

因素方向结算方式边界
资源产出增加每小时产率 × 时长,按日封顶每日上限、最大窗口
维护消耗减少每小时消耗 × 时长库存不能为负
仓库上限截断结算后取 min(库存, 上限)超出部分记为溢出
运营补偿增加按补偿单领取过期、重复领取

一个简化但可解释的模型是:

库存' = clamp(库存 + 产出 - 消耗, 0, 仓库上限)
溢出   = max(库存 + 产出 - 消耗 - 仓库上限, 0)

这个模型假设产出和消耗在整个窗口内连续发生,库存不足时只把库存截到 0,不额外模拟“维护费付不起导致停产”。对大多数 SLG 的资源田和主城维护,这个近似足够;如果维护消耗占比很高,应该缩短结算窗口,而不是把模型写得更复杂。架构上要明确这条边界,避免读者以为公式能覆盖所有经济规则。

仓库上限与每日上限是两道不同的闸门:

  • 每日上限限制“一天最多产出多少”,防止离线越久收益越高;
  • 仓库上限限制“最多存多少”,防止资源无限堆积;
  • 最大窗口限制“最多补多久”,防止离线一年补一年。

三者缺一不可。只做每日上限,玩家可以每天登录一次把资源花掉再离线;只做仓库上限,离线一个月的收益仍然会顶满仓库;只做最大窗口,回归玩家会一次性拿到过多资源。

代码:Settle 与幂等账本

下面的 settle 包把产出、消耗、仓位、窗口截断和幂等键写成一个纯函数,并把“结算结果只应用一次”和“补偿只领取一次”放进 Ledger:

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

import (
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"fmt"
	"time"

	"gsarchlab/internal/offline"
)

var (
	ErrInvalidConfig = errors.New("settle: invalid config")
	ErrClockRollback = errors.New("settle: clock moved backwards")
	ErrInvalidGrant  = errors.New("settle: invalid compensation")
	ErrExpired       = errors.New("settle: compensation expired")
)

// Config is the SLG economy contract. DailyCap is the per-local-day production
// cap from the generic time model; StorageCap is the warehouse limit; Upkeep is
// paid continuously out of stock.
type Config struct {
	ProductionPerHour float64
	UpkeepPerHour     float64
	StorageCap        float64
	DailyCap          float64
	MaxWindow         time.Duration
	Location          *time.Location
	Version           int
}

type Result struct {
	PlayerID  string
	From      time.Time
	To        time.Time
	Gross     float64
	Upkeep    float64
	Stored    float64
	Overflow  float64
	Days      int
	Capped    bool
	Truncated bool
	Key       string
}

// Settle replays one settlement window. The caller must pass server-recorded
// lastSettled and a server time; client clocks are never an input. A retry must
// replay the same (from, to) pair, which is what makes the key stable.
func Settle(playerID string, lastSettled, now time.Time, stock float64, cfg Config) (Result, error) {
	if playerID == "" || cfg.ProductionPerHour < 0 || cfg.UpkeepPerHour < 0 ||
		cfg.DailyCap <= 0 || cfg.StorageCap <= 0 || cfg.MaxWindow < 0 {
		return Result{}, ErrInvalidConfig
	}
	if now.Before(lastSettled) {
		return Result{}, ErrClockRollback
	}
	loc := cfg.Location
	if loc == nil {
		loc = time.UTC
	}
	from := lastSettled
	truncated := false
	if cfg.MaxWindow > 0 && now.Sub(from) > cfg.MaxWindow {
		from = now.Add(-cfg.MaxWindow)
		truncated = true
	}

	acc := offline.AccrueInZone(from, now, cfg.ProductionPerHour, cfg.DailyCap, loc)
	upkeep := cfg.UpkeepPerHour * now.Sub(from).Hours()
	stored := stock + acc.Amount - upkeep
	overflow := 0.0
	if stored < 0 {
		stored = 0
	}
	if stored > cfg.StorageCap {
		overflow = stored - cfg.StorageCap
		stored = cfg.StorageCap
	}

	return Result{
		PlayerID:  playerID,
		From:      from,
		To:        now,
		Gross:     acc.Amount,
		Upkeep:    upkeep,
		Stored:    stored,
		Overflow:  overflow,
		Days:      acc.Days,
		Capped:    acc.Capped,
		Truncated: truncated,
		Key:       key(playerID, from, now, cfg, loc),
	}, nil
}

// key covers the window, the economy contract and the timezone. It deliberately
// does not include stock: after a successful write the client may retry with
// the old lastSettled, and the stock has already changed.
func key(playerID string, from, to time.Time, cfg Config, loc *time.Location) string {
	raw := fmt.Sprintf("%s|%d|%d|%.4f|%.4f|%.4f|%.4f|%d|%s",
		playerID, from.UnixNano(), to.UnixNano(),
		cfg.ProductionPerHour, cfg.UpkeepPerHour,
		cfg.StorageCap, cfg.DailyCap, cfg.Version, loc.String())
	sum := sha256.Sum256([]byte(raw))
	return hex.EncodeToString(sum[:])
}

// Ledger is the idempotency boundary for one player. It is intentionally not
// concurrency-safe: a player's settlement is serialized by the owning shard.
type Ledger struct {
	applied map[string]Result
	claimed map[string]time.Time
}

func NewLedger() *Ledger {
	return &Ledger{applied: make(map[string]Result), claimed: make(map[string]time.Time)}
}

// Apply returns the first result for a key and applied=false on a replay.
func (l *Ledger) Apply(r Result) (Result, bool) {
	if l.applied == nil {
		l.applied = make(map[string]Result)
	}
	if prev, ok := l.applied[r.Key]; ok {
		return prev, false
	}
	l.applied[r.Key] = r
	return r, true
}

// Compensation is an operator-issued grant such as a maintenance apology. It
// is separate from natural production so the two can be reconciled.
type Compensation struct {
	GrantID   string
	PlayerID  string
	Amount    float64
	ExpiresAt time.Time
}

// Claim grants a compensation once. An expired grant is an error, not a silent
// zero, because the operator needs to see rejected claims.
func (l *Ledger) Claim(c Compensation, now time.Time) (bool, error) {
	if c.GrantID == "" || c.PlayerID == "" || c.Amount <= 0 {
		return false, ErrInvalidGrant
	}
	if !c.ExpiresAt.IsZero() && now.After(c.ExpiresAt) {
		return false, ErrExpired
	}
	if l.claimed == nil {
		l.claimed = make(map[string]time.Time)
	}
	if _, ok := l.claimed[c.GrantID]; ok {
		return false, nil
	}
	l.claimed[c.GrantID] = now
	return true, nil
}

几个设计点:

  • Settle 只接收服务器时间。lastSettled 和 now 都由调用方从服务端状态读取,客户端没有任何时间参数可以注入。
  • 最大窗口在进入按天循环之前截断。from = now - MaxWindow,并标记 Truncated;玩家能看到“离线收益最多计算 N 天”,而不是沉默地少给。
  • 产出复用 offline.AccrueInZone,按本地自然日封顶;消耗按整个窗口连续计算;最后统一做库存下限与仓库上限截断。
  • Key 包含玩家、结算窗口、经济配置和时区,但不包含库存。原因很实际:如果第一次结算已经写了库存,客户端没收到响应而重试,库存已经变了;把库存放进 key 会让重试变成一次新的结算,直接重复发放。
  • Ledger.Apply 以 key 为唯一索引,重复 key 返回第一次的结果和 applied=false。调用方应该把返回的旧结果直接回给客户端,而不是重新计算。
  • Ledger.Claim 处理运营补偿:GrantID 唯一、Amount > 0、过期返回错误而不是静默零。补偿与自然产出分开记账,才能在对账时解释差额来源。
  • Ledger 不是并发安全的。同一个玩家的结算必须由所属分片串行化;跨分片并发写同一个玩家,是比公式错误更严重的架构问题。

幂等键为什么必须绑定“同一窗口”

重试有两种常见形态:

形态 A:客户端重试登录,服务端重新取 now
形态 B:定时任务重跑同一批次,使用批次内固定的 now

形态 A 的 now 每次都变,key 每次都不同,幂等账本无法识别。正确做法是让客户端重试时带上第一次的结算票据,或者由服务端把 lastSettled 作为窗口起点、把“本次结算的服务器时间”持久化后再返回。形态 B 更简单:批次开始时固定 now,重跑时复用同一个批次时间。

无论哪种形态,幂等键绑定的是“结算窗口 + 经济配置 + 时区”,不是“请求”。同一窗口重复请求只发一次;不同窗口是两次合法结算。把这两件事混在一起,就会出现“玩家重连拿到两份”或者“跨天收益被吞掉”。

补偿与自然产出分账

运营补偿(维护道歉、活动补发、客服赔付)和自然产出必须分开记账:

来源幂等键过期对账口径
自然产出结算窗口 key无按窗口汇总
运营补偿GrantID有按补偿单核对
战斗掉落battle_id无按战斗记录
商店购买order_id无按支付订单

每日对账时,库存变化应该等于四类来源之和减去消耗。差额超过阈值就告警,并冻结异常账号的补偿领取,先人工核对。只有“当前库存”而没有来源账本,永远无法解释差额来自哪里。

本机验证

$ cd .local/checks/gsarch-lab
$ gofmt -l internal/settle
$ go test -count=1 ./internal/settle/
ok  	gsarchlab/internal/settle	0.649s

5 个测试全部通过,覆盖产出/消耗/仓库上限、时钟回拨与窗口截断、UTC 与 UTC+8 的日边界差异、账本幂等、补偿过期与重复领取。时间为本机一次运行结果,后续运行可能显示 (cached)。

失败模式与兜底

失败表现兜底
重复发放玩家重连后资源翻倍幂等 key + 唯一索引 + 返回首次结果
响应丢失客户端没收到结算结果重试同一窗口,账本返回旧结果
时钟回拨离线时长为负或收益异常返回错误、告警、不更新 lastSettled
时区变更跨天收益口径突变记录时区版本,变更时做一次迁移
夏令时切换某天多算或少算一小时按本地日历切段,不按固定 24 小时
窗口未截断离线一年补一年,经济膨胀MaxWindow 截断并在响应中说明
补偿过期玩家点了旧邮件却领不到明确提示过期,记录拒绝原因
客户端改时间反复领取离线收益服务端时间 + lastSettled 持久化
结算与写库不同事务发了资源但 lastSettled 没前进同一事务或条件写 + 版本号
并发结算同一玩家两个请求同时通过按玩家串行化,或数据库唯一键兜底

常见错误

第一,用客户端时间算离线时长。改系统时间、改时区、重放请求都能绕过;服务端时间没有商量余地。

第二,把库存放进幂等键。第一次结算后库存已经变化,重试会生成新 key,重复发放。

第三,每日上限、仓库上限、最大窗口只做其中一个。三者解决三个不同的问题,缺一个就会留下套利路径。

第四,用滚动 24 小时代替自然日。它和运营配置的每日重置不一致,跨时区活动会错位。

第五,补偿和自然产出写同一本账但不标来源。出现差额时无法判断是产出、补偿、战斗还是支付。

第六,结算成功但 lastSettled 更新失败。发钱和推进时间必须在同一事务;最终一致性存储要用条件写和版本号。

第七,把战斗、交易、集结放进离线补算。它们必须在发生时刻裁决,补算只能读取已落定结果,不能重新模拟。

小结

SLG 的离线结算不是“时间乘产率”,而是产出、消耗、仓位、窗口和补偿五条路径的合成。工程上要守住三条线:只用服务器时间;每日上限、仓库上限、最大窗口同时存在;结算与补偿都以幂等键为准,重复请求返回第一次结果。

本节的可运行产物是 internal/settle:Settle 负责窗口截断、产出复用、消耗与仓位截断,Ledger 负责结算去重和补偿领取。下一节进入休闲小游戏:无状态大厅加有状态房间,讨论当品类从“长线 SLG”切到“快速上线的小游戏”时,前面这些重装备哪些可以拆掉。

阅读导航:上一节:8.2 分区分服、跨服与合服 · 下一节:9.1 无状态大厅加有状态房间 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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