《游戏服务端架构·规模化、全球化与成本》7.2 资产归属与跨服结算

《裂隙战场》跨服赛的结算单重复回传,玩家领到两份奖励;《群岛纪元》的活动积分带回时被上限截断,客服说不清差额去了哪里。本节划清主服与跨服的资产边界,给出只读、冻结、带回三种归属模式,以及幂等回传、补偿队列与对账差异的处理规则,并说明补偿队列为何必须按结算单去重。

跨服打完了,奖励算谁的

《裂隙战场》S3 跨服赛第一天,结算服务把同一场比赛的结果回传了两次。第一次网络超时,跨服侧按 at-least-once 重试;主服侧没有按结算单去重,直接加了两遍奖励。玩家在社区晒出两份赛季积分,运营只好先冻结排行榜,再逐条回滚。

同一个月,《群岛纪元》的跨服活动也出了差异。活动积分允许带回主服,上限是 2000 点;结算单写了 2600 点,主服截断到 2000,但没有留下任何说明。玩家问“剩下的 600 点去哪了”,客服只能回答“系统上限”。技术上没有丢数据,体验上却像吞了一笔资产。

两个事故的共同点:跨服与主服之间没有一份写清楚的资产契约。这一节讨论谁拥有资产、结果怎么回传、带回受什么限制、差异怎么查。

主服唯一权威,跨服只持临时副本

跨服玩法再复杂,资产账本只能有一个权威。北辰的规则是:角色资产归主服所有,跨服侧拿到的是有期限的临时副本;跨服可以计算、可以消耗副本,但不能直接改写主服账本。 结果通过结算单回传,由主服在自己的事务里落账。

按可写程度,跨服玩法分成三种归属模式:

模式跨服可见跨服可写回传内容典型场景
read_only只读快照否仅战绩,不写资产观战、表演赛、跨服试玩
frozen快照 + 冻结额度仅本场消耗解冻或扣除,不新增排位赛、跨服副本
bring_back快照 + 临时产出是(临时账本)积分与奖励,受上限约束跨服活动、赛季奖励

三种模式的差别不在技术,而在“最坏情况下谁承担损失”。read_only 最安全,跨服崩了也不影响主服资产;frozen 要求冻结与解冻使用同一个幂等键,否则会出现“扣了没解”或“解了没扣”;bring_back 最复杂,因为它允许跨服创造新资产,必须有上限和对账。

冻结不是扣款。冻结只标记额度不可用,所有权仍归主服;结算完成才决定解冻或扣除。把冻结做成直接扣款,跨服失败时就要走退款,而退款链路永远比解冻更容易出错。

结算回传是一份契约,不是一次调用

结算单是跨服回传的唯一凭证。它至少包含八个字段:

字段作用约束
settlement_id全局幂等键一场比赛/一次活动只生成一个
ticket_id关联报名票据必须存在且已消费
player_id归属角色与票据一致
home_server写回目标不接受跨服侧改写
mode归属模式决定能否写资产
points活动积分非负,带回模式才生效
rewards道具列表带回模式才生效,逐项受上限约束
produced_at跨服产出时间仅用于审计,不用于主服时间判断

回传是 at-least-once,重复不可避免。幂等责任在主服:Apply 先查 settlement_id,已存在就返回原记录,不重复加资产。幂等键由跨服侧生成,但必须稳定——不能用随机数或进程内自增,否则重试会变成新结算单。

状态只有四段:

produced --> in_transit --> applied --> reconciled
                  |
                  | 主服不可用/超时
                  v
               queued --> retry --> applied
                  |
                  | 达到重试上限
                  v
               escalated(转人工)

applied 表示主服事务已提交;reconciled 表示对账确认两侧一致。中间任何一步失败都不能跳过,尤其是“先发奖励、后写记录”——那会让幂等键失去意义。

带回上限是产品规则,不是防作弊

bring_back 模式必须回答一个问题:跨服产出的东西,能带回多少?

北辰的示例配置是:活动积分上限 2000,稀有道具上限 50,超出部分按 1:1 转成绑定货币并通过邮件发放。上限写进活动配置,赛季中途不修改;确需修改时,旧结算单按旧版本上限计算,新结算单按新版本,版本号随结算单一起回传。

为什么强调“产品规则”?因为上限的本质是经济调控,不是安全边界。把它当成防作弊,就会不断加码到玩家什么都带不回来;把它当成产品规则,才能在上限、参与感与工作室收益之间做取舍。

临时货币是另一个边界。跨服内的活动币、战场币只在跨服有效,活动结束清零,不进入主服账本。玩家看到的“临时余额”要明确标注“跨服专用”,否则会当成永久资产来投诉。

对账只报告差异,不自动补写

结算对账的比较对象是“结算单”和“已应用记录”。差异只有三类,但原因很多:

差异含义常见原因处理
missing_record有结算单、无记录回传丢失、队列积压重新入队,按幂等键重放
points_mismatch积分不一致重复应用、手工改账冻结角色,人工核对
items_mismatch道具数量不一致上限配置变更、部分发放对照配置版本重算

对账不自动补写,原因和 6.3 对账、补偿与定时任务 一样:差异的方向不确定。missing_record 可以安全重放,因为幂等键会吸收重复;points_mismatch 和 items_mismatch 必须人工判断是漏发、重发还是配置变更。

上限配置要有版本。对账时用结算单携带的版本重算期望值,而不是用当前配置。否则一次赛季中途调参,会把所有历史结算单都判成差异。

可运行片段:幂等落账与补偿队列

internal/crosssettle 把归属规则落成代码。crosssettle.go 负责结算单校验、上限裁剪与幂等落账;同包的 queue.go 提供补偿队列与对账,文章只展开前者。

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

import (
	"errors"
	"fmt"
	"sort"
	"time"
)

// Mode 决定跨服结果能写回主服的哪一部分。只读与冻结模式不产生资产转移,
// 带回模式才允许把奖励带回主服,并受上限约束。
type Mode string

const (
	ReadOnly  Mode = "read_only"
	Frozen    Mode = "frozen"
	BringBack Mode = "bring_back"
)

var ErrInvalidResult = errors.New("crosssettle: invalid result")

// Reward 是跨服产出的单项奖励。
type Reward struct {
	ItemID string
	Count  int64
}

// Result 是跨服实例回传的结算单。SettlementID 是全局幂等键,
// 由跨服侧一次比赛/一次活动只生成一个。
type Result struct {
	SettlementID string
	TicketID     string
	PlayerID     string
	HomeServer   string
	Mode         Mode
	Points       int64
	Rewards      []Reward
}

// Record 是主服已应用的结算记录,用于幂等查询与对账。
type Record struct {
	SettlementID string
	PlayerID     string
	Points       int64
	Items        map[string]int64
	Capped       []string
	AppliedAt    time.Time
}

// Normalize 校验结算单并执行带回上限。只读与冻结模式清零产出,
// 因此它们永远不能改变主服资产。
func Normalize(r Result, caps map[string]int64) (Result, []string, error) {
	switch r.Mode {
	case ReadOnly, Frozen, BringBack:
	default:
		return Result{}, nil, fmt.Errorf("%w: unknown mode %q", ErrInvalidResult, r.Mode)
	}
	if r.SettlementID == "" || r.PlayerID == "" || r.HomeServer == "" {
		return Result{}, nil, fmt.Errorf("%w: missing identity", ErrInvalidResult)
	}
	if r.Points < 0 {
		return Result{}, nil, fmt.Errorf("%w: negative points", ErrInvalidResult)
	}
	out := r
	out.Rewards = append([]Reward(nil), r.Rewards...)
	sort.Slice(out.Rewards, func(i, j int) bool { return out.Rewards[i].ItemID < out.Rewards[j].ItemID })

	if out.Mode != BringBack {
		out.Points = 0
		out.Rewards = nil
		return out, nil, nil
	}

	var notes []string
	for i := range out.Rewards {
		rw := &out.Rewards[i]
		if rw.Count < 0 {
			return Result{}, nil, fmt.Errorf("%w: negative reward %s", ErrInvalidResult, rw.ItemID)
		}
		limit, ok := caps[rw.ItemID]
		if ok && rw.Count > limit {
			notes = append(notes, fmt.Sprintf("capped %s %d->%d", rw.ItemID, rw.Count, limit))
			rw.Count = limit
		}
	}
	return out, notes, nil
}

// Job 是回传失败的补偿任务。同一 SettlementID 只允许一个任务。
type Job struct {
	SettlementID string
	Result       Result
	Attempts     int
	Max          int
	State        string
	NextAt       time.Time
	LastError    string
}

const (
	Pending   = "pending"
	Escalated = "escalated"
)

// Account 是主服角色账本的最小视图。
type Account struct {
	PlayerID string
	Points   int64
	Items    map[string]int64
}

// Store 持有已应用记录与补偿队列。单进程实现用于演示,
// 生产环境把两者落库,幂等键仍是 SettlementID。
type Store struct {
	records  map[string]Record
	accounts map[string]*Account
	queue    map[string]*Job
}

// NewStore 创建空账本。
func NewStore() *Store {
	return &Store{
		records:  make(map[string]Record),
		accounts: make(map[string]*Account),
		queue:    make(map[string]*Job),
	}
}

func (s *Store) account(playerID string) *Account {
	acc, ok := s.accounts[playerID]
	if !ok {
		acc = &Account{PlayerID: playerID, Items: make(map[string]int64)}
		s.accounts[playerID] = acc
	}
	return acc
}

// Apply 幂等写入主服账本。返回 applied=false 表示该结算单此前已应用,
// 本次调用没有产生任何变更。
func (s *Store) Apply(r Result, caps map[string]int64, now time.Time) (Record, bool, error) {
	normalized, notes, err := Normalize(r, caps)
	if err != nil {
		return Record{}, false, err
	}
	if rec, ok := s.records[normalized.SettlementID]; ok {
		return rec, false, nil
	}
	acc := s.account(normalized.PlayerID)
	acc.Points += normalized.Points
	items := make(map[string]int64, len(normalized.Rewards))
	for _, rw := range normalized.Rewards {
		acc.Items[rw.ItemID] += rw.Count
		items[rw.ItemID] += rw.Count
	}
	rec := Record{
		SettlementID: normalized.SettlementID,
		PlayerID:     normalized.PlayerID,
		Points:       normalized.Points,
		Items:        items,
		Capped:       notes,
		AppliedAt:    now,
	}
	s.records[normalized.SettlementID] = rec
	return rec, true, nil
}

// Account 返回只读快照,避免调用方直接修改账本。
func (s *Store) Account(playerID string) (Account, bool) {
	acc, ok := s.accounts[playerID]
	if !ok {
		return Account{}, false
	}
	items := make(map[string]int64, len(acc.Items))
	for k, v := range acc.Items {
		items[k] = v
	}
	return Account{PlayerID: acc.PlayerID, Points: acc.Points, Items: items}, true
}

Normalize 先按模式清零产出:read_only 与 frozen 无论跨服侧写了什么,回传的积分与奖励都归零,因此它们永远不可能改变主服资产。Apply 先查 records,命中就返回原记录与 applied=false;没命中才在同一个函数里更新账本并写记录。这个顺序是幂等的关键:先查后写,且查与写在同一个临界区。

queue.go 里的 Enqueue 按 settlement_id 去重,Fail 执行 1、5、15 分钟的重试预算,三次失败转人工;Reconcile 按上面的三类差异输出,不自动补写。

本机验证命令与结果:

cd .local/checks/gsarch-lab
gofmt -l internal/crosssettle
go vet ./internal/crosssettle/
go test -count=1 ./internal/crosssettle/
ok  	gsarchlab/internal/crosssettle

gofmt -l 无输出,go vet 退出码 0,测试退出码 0。该包有 6 个表驱动测试,覆盖模式与上限、幂等重复、不同结算单累加、对账差异、队列去重与重试升级。

失败模式与兜底

失败表现检测兜底
重复回传同一结算单到两次applied=false 计数上升幂等键吸收,不重复加资产
主服不可用回传持续超时补偿队列长度上升入队重试,1/5/15 分钟
回传乱序后一场先到结算单时间与记录时间不一致以 settlement_id 为准,不按到达顺序
上限配置变更历史单被判差异对账 items_mismatch 增多按结算单版本重算
票据未消费结算单找不到票据校验失败率上升拒绝落账,转人工
队列风暴同一比赛反复入队去重命中率接近 100%按结算单去重,不按玩家去重

最后一条值得展开:补偿队列的键必须是 settlement_id,不能是 player_id。按玩家去重会把同一玩家的多场比赛压成一条,导致只补一场;按结算单去重才能保证每场都被处理,同时避免同一场重复入队。

小结

跨服资产只有一条原则:主服唯一权威,跨服只持临时副本。归属模式按可写程度分成只读、冻结、带回三类;冻结不等于扣款,解冻与结算共用一个幂等键。结算回传是 at-least-once,幂等责任在主服,settlement_id 必须稳定。带回上限是产品规则,要版本化;临时货币不进入主服账本。对账只报告差异,只有确定性的缺失可以自动重放,金额与道具差异必须人工。

常见错误有四个:跨服直接写主服账本;用随机数当幂等键;把带回上限当成防作弊手段;补偿队列按玩家去重。前两个会制造资产错乱,后两个会让运营和客服替系统解释。

下一节处理更重的一次性动作:两个服合并时,角色 ID、重名、公会与排行榜如何迁移,玩家在停服窗口里看到什么。

阅读导航:上一节:7.1 跨服匹配与报名 · 下一节:7.3 合服、迁移与玩家可见性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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