6.1 把玩家匹配进了跨服战场,6.2 让跨服的消息找到了人。现在到了整卷最要命的一步:打完战场,奖励发到哪里?
跨服战场的特殊性在于——战斗发生在临时的战场实例上,玩家的资产却存在各自的母服里。战场实例可能随时崩溃、可能打完就销毁,它绝不是资产的权威。如果战场服自己给玩家加金币,那么它一崩溃,这笔账就无迹可查;如果它把加金币的指令发给母服却丢包,玩家赢了却没拿到奖励——两种事故都会直接击穿玩家信任。
本节给出跨服结算的完整模型:战场只产出「结算单」,母服才是账本;结算单用幂等键防重、用重试保达、用对账兜底。全部代码是 L1,go test 可以直接跑,并且断言「发奖幂等」与「账目守恒」两个不变式。
6.3.1 奖励算在哪个服:母服才是账本
归属规则只有一句:奖励最终由玩家的母服入账,跨服战场只负责「算出该发什么」。战场在结束时为每个参战玩家产出一张结算单(SettlementTicket),写明玩家 ID、母服号、以及奖励明细;这张单子被回传到母服,由母服兑现进玩家的钱包。
这样切分的好处是权威唯一:玩家的金币、物品永远只有一个地方在写——它的母服。战场服是无状态的、可丢弃的,它崩溃重启后最多丢一张还没回传的单子,而这张单子可以通过对账补回来。反过来,如果让战场服直接改玩家资产,你就要面对「两个服同时写同一个玩家」的分布式写冲突,那才是真正的灾难。
结算单长这样,关键是 TicketID 这个幂等键:
// snippet: internal/crossserver/ticket.go(结算单与幂等键生成)
// SettlementTicket 是跨服战场产出的结算单,由玩家「母服」兑现。
type SettlementTicket struct {
TicketID string // 幂等键:battleID:playerID:rewardHash
BattleID uint64
PlayerID uint64
HomeServer uint16 // 奖励归属的母服
Items []Reward
}
// NewTicket 按约定生成幂等键。奖励内容进 hash,改奖励就会换 key,避免旧单被顶掉。
func NewTicket(battleID, playerID uint64, home uint16, items []Reward) SettlementTicket {
h := uint64(1469598103934665603) // FNV-1a 64 起点
for _, r := range items {
h ^= uint64(r.ItemID)
h *= 1099511628211
h ^= uint64(r.Count)
h *= 1099511628211
}
return SettlementTicket{
TicketID: fmt.Sprintf("%d:%d:%016x", battleID, playerID, h),
BattleID: battleID,
PlayerID: playerID,
HomeServer: home,
Items: append([]Reward(nil), items...),
}
}
幂等键里为什么要带 rewardHash?因为如果只用 battleID:playerID,那么「同一场给同一个人补发一份不同奖励」会被当成重复单顶掉。把奖励内容也编进 key,奖励变了 key 就变,补发的单子才能被正确识别为「新单」。
整条结算链路的形状如下:
战场实例(无状态) 母服(账本,唯一写入方)
──────────────── ──────────────────────
结算:算出该发什么 ────ticket────► Apply(ticket)
BattleLedger.Issue ─┐ ├─ 幂等表命中? → 忽略(不重复发奖)
│ └─ 未命中 → 入账 + 记幂等表
▼
Reconcile:minted ──必须等于── credited
6.3.2 回传与幂等:重复投递不能重复发奖
回传链路上最危险的不是「消息丢了」,而是「消息重了」。网络抖动、重试、超时重发,都会让同一张结算单到达母服多次。如果母服每次都加金币,玩家就能靠一次网络重传白拿一份奖励——这是 MMO 里最经典的刷钱漏洞。
防线就是 TicketID:母服维护一张「已兑现」的幂等表,同一张单子第二次到达时直接返回「已处理」,不再入账。下面这段是 L1,go test ./internal/crossserver/ 可以直接跑:
// file: internal/crossserver/settler.go
// tier: L1
// verify: go test ./internal/crossserver/ -run TestIdempotentReward
package crossserver
import (
"errors"
"sync"
)
var (
ErrWrongServer = errors.New("crossserver: ticket not for this home server")
ErrServerDown = errors.New("crossserver: home server unavailable")
)
// Settler 在母服上兑现结算单。幂等:同一 TicketID 只入账一次。
type Settler struct {
serverID uint16
mu sync.Mutex
down bool
applied map[string]bool // 幂等表:已兑现的 TicketID
wallet map[uint32]uint64 // ItemID -> 累计入账
}
func NewSettler(serverID uint16) *Settler {
return &Settler{serverID: serverID, applied: make(map[string]bool), wallet: make(map[uint32]uint64)}
}
// Apply 兑现一张结算单,返回 (是否首次入账, error)。
// 重复的 TicketID 返回 (false, nil)——不报错、也不再入账。
func (s *Settler) Apply(t SettlementTicket) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.down {
return false, ErrServerDown
}
if t.HomeServer != s.serverID {
return false, ErrWrongServer
}
if s.applied[t.TicketID] {
return false, nil
}
for _, r := range t.Items {
s.wallet[r.ItemID] += uint64(r.Count)
}
s.applied[t.TicketID] = true
return true, nil
}
// SetDown 模拟母服不可用(测试用)。
func (s *Settler) SetDown(down bool) {
s.mu.Lock()
s.down = down
s.mu.Unlock()
}
func (s *Settler) Balance(itemID uint32) uint64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.wallet[itemID]
}
func (s *Settler) AppliedCount() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.applied)
}
有了幂等的 Apply,重试就变得安全了——重试任意多次都不会多发奖。下面这段也是 L1,go test ./internal/crossserver/ -run TestRetryThenSucceed 可以直接跑,回传器因此可以放心地「失败就重来」:
// file: internal/crossserver/delivery.go
// tier: L1
// verify: go test ./internal/crossserver/ -run TestRetryThenSucceed
package crossserver
import "errors"
var ErrNoSettler = errors.New("crossserver: no settler for home server")
// RetryingDeliverer 把结算单从战场侧回传到母服,失败重试。
// 关键点:因为 Settler.Apply 幂等,重试不会重复发奖——这是本卷资产安全的地基。
type RetryingDeliverer struct {
settlers map[uint16]*Settler
maxTries int
}
func NewRetryingDeliverer(maxTries int, settlers ...*Settler) *RetryingDeliverer {
m := make(map[uint16]*Settler, len(settlers))
for _, s := range settlers {
m[s.serverID] = s
}
return &RetryingDeliverer{settlers: m, maxTries: maxTries}
}
// Deliver 回传一张结算单,返回尝试次数。
// 只有「母服暂时不可用」才重试;语义错误(比如单子投错了服)立即返回,不浪费重试。
func (d *RetryingDeliverer) Deliver(t SettlementTicket) (int, error) {
s := d.settlers[t.HomeServer]
if s == nil {
return 0, ErrNoSettler
}
var lastErr error
for i := 0; i < d.maxTries; i++ {
if _, err := s.Apply(t); err == nil {
return i + 1, nil
} else if !errors.Is(err, ErrServerDown) {
return i + 1, err // 永久错误:不重试
} else {
lastErr = err
}
}
return d.maxTries, lastErr
}
注意重试的分寸:只有 ErrServerDown(母服暂时不可用)才值得重试;ErrWrongServer(单子投错了服)是永久错误,立即返回。对永久错误重试,只是把 bug 拖成延迟。
6.3.3 对账兜底:账目守恒
幂等解决了「重复发奖」,但解决不了「单子彻底丢了」。如果战场产出了 100 张单,只有 98 张到达母服,玩家就少了 2 份奖励。要抓这种漏,靠的是一条会计式的不变式——账目守恒:
战场侧产出的奖励总量,必须等于各母服入账总量之和。
战场侧用 BattleLedger 记录每一张产出的单子,母服用 Settler.Balance 报出各自入账了多少。两边一比,差额就是漏掉的量:
// snippet: internal/crossserver/ledger.go(对账不变式)
// Reconcile 对账:战场侧产出总量必须等于各母服入账总量之和。
// 差额非零 = 回传丢失或重复入账,是资产守恒必须为零的不变式。
func Reconcile(l *BattleLedger, settlers map[uint16]*Settler, itemID uint32) (minted, credited uint64, ok bool) {
minted = l.Minted(itemID)
for _, s := range settlers {
credited += s.Balance(itemID)
}
return minted, credited, minted == credited
}
对账是最后一道防线,它的价值在于把「不知道哪里丢了」变成「知道丢了多少」。线上跑对账任务,一旦 minted != credited,差额就直接指向需要人工修复的单子——这正是 10.2 那篇要展开的「对账与数据修复工单」。
本节小结 / 实测结论
三条可以带走的结论:
- 资产归属唯一:战场只产出结算单,母服才是账本。 战场服无状态、可丢弃;玩家的资产永远只由母服一处写入,避免分布式写冲突。
- 幂等键是回传链路的安全带。
TicketID = battleID:playerID:rewardHash让同一张单子重传多少次都只发一次奖;奖励内容进 hash,补发不同奖励时才不会被误判为重复。 - 重试要认错误类型,对账要守恒。 只有「暂时不可用」才重试,永久错误立即返回;
minted == credited是资产守恒必须为零的不变式,差额就是漏单的量。
实测输出(go test -race -count=1,覆盖幂等、守恒、重试、漏单检测):
=== RUN TestIdempotentReward
--- PASS: TestIdempotentReward (0.00s)
=== RUN TestAccountConservation
--- PASS: TestAccountConservation (0.00s)
=== RUN TestRetryThenSucceed
--- PASS: TestRetryThenSucceed (0.00s)
=== RUN TestReconcileDetectsLoss
--- PASS: TestReconcileDetectsLoss (0.00s)
=== RUN TestWrongServerRejected
--- PASS: TestWrongServerRejected (0.00s)
ok aegis/internal/crossserver 1.692s
一句话收束:跨服结算的安全来自三个「绝不」——绝不重复入账(幂等键)、绝不静默丢失(重试 + 对账)、绝不越界写(母服独享账本)。 第 7 章会把镜头转向这些结算单本身:怎么给它们分配全局唯一、且不会撞车的编号。
阅读导航:上一节:6.2 跨服聊天与消息路由 · 下一节:7.1 雪花算法从零实现 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。