跨服打完了,奖励算谁的
《裂隙战场》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 合服、迁移与玩家可见性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。