11.1 跨服战场端到端
前面十章各自造好了一个零件:第 4 章的网关路由目录、第 5 章的实例调度、第 6 章的跨服报名匹配、第 10 章的跨服结算补偿。这一节不引入任何新机制,只做一件事——把它们串成一条链,然后证明这条链真的能跑完。
「跨服能打一场」是这个卷子反复提到的终点。它听起来像一句话,拆开却是一串容易各说各话的接口:报名要带上玩家归属的服号,匹配要把不同服的玩家凑成一队,路由要让两个网关把各自的玩家指向同一个战场实例,战斗结束后奖励要按归属回传到玩家自己的服,最后还要保证重复结算不会多发一次。任何一环松了,玩家看到的就是「奖励没到账」或「多发了一份」。
本节用四个真进程(一个 hub、两个网关、一个战斗实例)把这条链跑通,并落下一组可执行的断言。它们分别证明四件事:两个服的玩家被路由进了同一个实例;每份奖励落在正确的服;账目守恒;重放结算不会改动任何账本。
分工表:本节与站内既有架构文
本节是综合,不重讲架构取舍。关于集群拓扑与跨地域延迟这两个前提,站内已有专门文章讲透了,本节的定位是「把方案拼起来并验收」:
| 站内既有文章 | 它负责 | 本节负责 |
|---|---|---|
/game-server-cluster-topology-design/ | 集群拓扑怎么设计,网关 / 场景 / 战斗 / 世界 / 目录五类节点的职责边界与部署形态 | 不再讨论拓扑选型,直接复用「网关 + hub + 战斗」的三段结构把链路跑通 |
/game-server-cross-region-latency-routing/ | 跨地域部署时延迟从哪来、按地域就近路由的代价与权衡 | 本机单机多进程,不涉真实跨地域延迟;延迟数字见 11.3 |
一句话分工:拓扑与跨地域路由读上面两篇,本卷只回答「零件装起来之后还能不能跑、账对不对」。
一条链路,四段拼图
先看清这条链的全貌。两个玩家分属不同的服,各自连着自己服的网关,跨服报名后由 hub 撮合,再被路由进同一个战斗实例:
玩家A(服1) 玩家B(服2)
│ 报名 │ 报名
▼ ▼
gateway-1 ───┐ ┌─── gateway-2 第 4 章:网关按服号维护路由目录
│ │ │ │
│ signup ▼ ▼ signup │
└───────► hub ◄───────┘ 第 6 章:跨服报名与匹配队列
│
│ 匹配成队 + 分配实例
▼
battle-0 ◄── 两个服的玩家被路由到同一个实例
│
│ 战斗结果(按服号回传奖励)
▼
hub ── 幂等结算 ──► gateway-1 / gateway-2 第 10 章:补偿与幂等
四段拼图各自对应前文的一节,这一节要做的只是让它们之间的接口对齐:
- 第 6 章的报名与匹配:
SignupReq里必须带ServerID,因为奖励最终要按它回传。报名先入队列,攒够一队(本例两人)才撮合。 - 第 4 章的网关路由:网关收到匹配结果后,为自己的玩家登记一条路由
玩家 → 战场实例地址。关键断言是两个网关登记的是同一个地址。 - 第 5 章的实例调度:hub 从已注册的战斗实例里按轮询挑一个来跑这场匹配,等价于一个极简的分配 Broker。
- 第 10 章的结算补偿:战斗把结果报给 hub,hub 拆成「每人一条奖励」再回传各自的服;结算以
BattleID为幂等键。
匹配与幂等结算:hub 干的两件事
hub 是这条链的中枢,它只干两件事:撮合,和把战斗结果拆成按服归属的结算。这两件事都在下面这个文件里,纯逻辑、无 I/O,是 L1,go test 直接能跑:
// file: internal/battlecluster/hub.go
// tier: L1
// verify: go test ./internal/battlecluster/
package battlecluster
import (
"errors"
"sync"
)
// ErrDuplicateSettlement is returned when a battle result is reported twice.
var ErrDuplicateSettlement = errors.New("battlecluster: battle already settled")
// Hub is the cross-server coordinator. It holds the signup queues, matches
// players into a team, allocates a battle instance, and turns a battle result
// into per-server settlements. It is the piece that decides "which server's
// reward goes where".
type Hub struct {
mu sync.Mutex
teamSize int
queues map[string][]SignedPlayer // battle type -> waiting players
battleAddr []string // battle instance addresses
rr int // round-robin cursor over battleAddr
nextID BattleID
settled map[BattleID]bool // idempotency keys
minted int64 // total gold the hub has ordered settled
}
// NewHub builds a hub with a fixed team size and a pool of battle addresses.
func NewHub(teamSize int, battleAddrs []string) *Hub {
return &Hub{
teamSize: teamSize,
queues: make(map[string][]SignedPlayer),
battleAddr: append([]string(nil), battleAddrs...),
settled: make(map[BattleID]bool),
}
}
// Signup records a player's cross-server request. If enough players of the same
// battle type are now waiting, it forms a match, allocates a battle instance by
// round-robin, and returns it. The bool is true exactly when a match formed.
func (h *Hub) Signup(req SignupReq) (MatchFound, bool) {
h.mu.Lock()
defer h.mu.Unlock()
p := SignedPlayer{ServerID: req.ServerID, PlayerID: req.PlayerID}
h.queues[req.Battle] = append(h.queues[req.Battle], p)
if len(h.queues[req.Battle]) < h.teamSize {
return MatchFound{}, false
}
team := h.queues[req.Battle][:h.teamSize]
h.queues[req.Battle] = h.queues[req.Battle][h.teamSize:]
h.nextID++
addr := h.battleAddr[h.rr%len(h.battleAddr)]
h.rr++
return MatchFound{
BattleID: h.nextID,
Addr: addr,
Players: append([]SignedPlayer(nil), team...),
}, true
}
// Report turns a battle result into settlements. It is idempotent: reporting the
// same BattleID twice returns ErrDuplicateSettlement and no settlements, so a
// retried report can never double-pay.
func (h *Hub) Report(res BattleResult) ([]SettleReward, error) {
h.mu.Lock()
defer h.mu.Unlock()
if h.settled[res.BattleID] {
return nil, ErrDuplicateSettlement
}
h.settled[res.BattleID] = true
out := make([]SettleReward, 0, len(res.Rewards))
for _, r := range res.Rewards {
h.minted += r.Gold
out = append(out, SettleReward{BattleID: res.BattleID, Reward: r})
}
return out, nil
}
// Minted is the total gold the hub has ordered settled across all battles. It is
// the reference side of the conservation check: what gateways credited must
// equal what the hub minted.
func (h *Hub) Minted() int64 {
h.mu.Lock()
defer h.mu.Unlock()
return h.minted
}
Report 是整条链最该盯住的方法。它做幂等的方式很朴素:settled 里记下每个已结算的 BattleID,重复报告直接返回 ErrDuplicateSettlement、不产生任何结算。这样一来,无论战斗实例重试几次上报、hub 重启后重放几次日志,同一场战斗的奖励只会发一次。
奖励按服回传落在网关侧(完整实现在 internal/battlecluster/gateway.go,L1,go test ./internal/battlecluster/)。每个网关只持有自己服玩家的账本,Credit 以 (BattleID, PlayerID) 为幂等键,并且拒绝结算不属于自己服的玩家——这一条把「发错服」变成一次可失败的检查,而不是一句约定。
于是有两道幂等闸门叠在一起:hub 侧按 BattleID 去重,网关侧按 (BattleID, PlayerID) 去重。任一侧收到重放,账本都不动。这一节后面的端到端断言 4 专门验证第二道闸门。
端到端真跑:四个进程,一组断言
把零件拼起来跑才算数。下面这段是 L2,go test -run TestEndToEndCrossServerBattle 会真起四个进程——一个 hub、两个网关、一个战斗实例——然后驱动整条链:
// file: internal/battlecluster/e2e_test.go
// tier: L2
// verify: go test -run TestEndToEndCrossServerBattle ./internal/battlecluster/
package battlecluster
import (
"bufio"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
type nodeProc struct {
cmd *exec.Cmd
addr string
}
// build builds one cmd package into dir and returns the binary path.
func build(t *testing.T, dir, pkg string) string {
t.Helper()
out := filepath.Join(dir, filepath.Base(pkg))
cmd := exec.Command("go", "build", "-o", out, pkg)
cmd.Env = append(os.Environ(), "GOPROXY=https://goproxy.cn,direct", "GOTOOLCHAIN=auto")
if b, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("build %s: %v\n%s", pkg, err, b)
}
return out
}
// start runs a binary and reads its first stdout line for the listen address.
func start(t *testing.T, bin string, args ...string) *nodeProc {
t.Helper()
cmd := exec.Command(bin, args...)
stdout, err := cmd.StdoutPipe()
if err != nil {
t.Fatal(err)
}
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
p := &nodeProc{cmd: cmd}
if sc := bufio.NewScanner(stdout); sc.Scan() {
if f := strings.Fields(sc.Text()); len(f) >= 2 && f[0] == "LISTEN" {
p.addr = f[1]
}
}
t.Cleanup(func() { _ = cmd.Process.Kill() })
return p
}
// inspect dials a gateway's control endpoint and reads its state snapshot.
func inspect(t *testing.T, addr string) InspectReply {
t.Helper()
c, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
peer := NewPeer(c, nil, nil)
defer peer.Close()
var out InspectReply
if err := peer.Call(CmdInspectState, nil, &out, 2*time.Second); err != nil {
t.Fatal(err)
}
return out
}
func TestEndToEndCrossServerBattle(t *testing.T) {
dir := t.TempDir()
hub := start(t, build(t, dir, "aegis/cmd/hub"))
gw1 := start(t, build(t, dir, "aegis/cmd/gateway"), "-hub", hub.addr, "-server", "1")
gw2 := start(t, build(t, dir, "aegis/cmd/gateway"), "-hub", hub.addr, "-server", "2")
_ = start(t, build(t, dir, "aegis/cmd/battle"), "-hub", hub.addr, "-id", "battle-0")
time.Sleep(300 * time.Millisecond) // let registrations land
p1, p2 := dial(t, gw1.addr), dial(t, gw2.addr)
defer p1.Close()
defer p2.Close()
var r1, r2 SignupReply
_ = p1.Call(CmdCrossSignupReq, SignupReq{ServerID: 1, PlayerID: 11, Battle: "arena"}, &r1, 2*time.Second)
_ = p2.Call(CmdCrossSignupReq, SignupReq{ServerID: 2, PlayerID: 22, Battle: "arena"}, &r2, 2*time.Second)
if !r1.Queued || r2.Match == nil {
t.Fatalf("match not formed: r1=%+v r2=%+v", r1, r2)
}
// Wait for both settlements to land.
var s1, s2 InspectReply
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
s1, s2 = inspect(t, gw1.addr), inspect(t, gw2.addr)
if s1.Total > 0 && s2.Total > 0 {
break
}
time.Sleep(50 * time.Millisecond)
}
// 1) Both servers routed their player into the SAME battle instance.
if s1.Routes[11] == "" || s1.Routes[11] != s2.Routes[22] {
t.Fatalf("players not routed to one instance: %q vs %q", s1.Routes[11], s2.Routes[22])
}
// 2) Each reward landed on its own server, and nowhere else.
if s1.Balances[11] <= 0 || s2.Balances[22] <= 0 || s1.Balances[22] != 0 || s2.Balances[11] != 0 {
t.Fatalf("reward on wrong server: %+v / %+v", s1.Balances, s2.Balances)
}
// 3) Conservation: credited total equals what the battle minted.
if want := ParticipationGold*2 + WinnerBonusGold; s1.Total+s2.Total != want {
t.Fatalf("conservation broken: %d != %d", s1.Total+s2.Total, want)
}
// 4) Idempotency: replay the same battle result to the hub, ledger holds.
replay := BattleResult{BattleID: r2.Match.BattleID, Winner: 1,
Rewards: []Reward{{ServerID: 1, PlayerID: 11, Gold: 99999}}}
var ok map[string]bool
if err := dial(t, hub.addr).Call(CmdBattleResult, replay, &ok, 2*time.Second); err != nil {
t.Fatal(err)
}
if got := inspect(t, gw1.addr).Total; got != s1.Total {
t.Fatalf("replay changed gw1 ledger: %d != %d", got, s1.Total)
}
}
func dial(t *testing.T, addr string) *Peer {
t.Helper()
c, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
return NewPeer(c, nil, nil)
}
本机实跑(go test -run TestEndToEndCrossServerBattle -v ./internal/battlecluster/):
=== RUN TestEndToEndCrossServerBattle
--- PASS: TestEndToEndCrossServerBattle (5.38s)
PASS
ok aegis/internal/battlecluster 5.4s
四条断言逐条对应链路的一个风险点。断言 1 证明「按服号路由进同一实例」成立:两个网关的路由目录里,player 11 和 player 22 指向同一个战场地址,服务器号没有把玩家拆散。断言 2 证明奖励按归属回传:player 11 的奖励只出现在服 1 的网关,player 22 的只出现在服 2,交叉检查两边都不为 0。断言 3 是账目守恒:两服合计入账 = 参与奖 100×2 + 胜方加成 150 = 350,与战斗铸出的金币一致。断言 4 把同一场战斗的结果重放给 hub,两边账本合计不变——幂等生效。
顺带说明一处口径:断言 2 里那名胜者是 player 22(服 2),这是确定性战斗用 seed = battleID * 0x9E3779B97F4A7C15 + 1 掷出来的结果,换个 BattleID 胜负会变,但守恒断言恒成立。
本节小结 / 实测结论
- 本节把第 4/5/6/10 章的零件串成一条跨服战场链路,不引入新机制,只对齐接口并验收。
- 两个幂等闸门:hub 按
BattleID去重、网关按(BattleID, PlayerID)去重;任一侧重放都不动账本。 - 奖励按归属回传:网关只记自己服玩家的账本,并拒绝结算不属于自己的玩家,把「发错服」变成可失败的检查。
- 本机四个真进程实跑,四条断言全绿:路由指向同一实例、奖励落在正确的服、账目守恒(350)、重放不改账本。
- 所有数字同机相对量级,绝对数值不可移植;本例的承载与延迟数字见 11.3 集群承载数字 。
下一节把「手动起四个进程」升级成「一条命令拉起整簇、按顺序验收、崩了自动重启」。
阅读导航:上一节:10.3 事件溯源与审计 · 下一节:11.2 多进程编排与验收 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。