《MMO 服务器开发》11.1 跨服战场端到端

把第 6 章的跨服报名匹配、第 4 章的网关路由、第 5 章的实例调度、第 10 章的结算补偿串成一条链路:两个服的玩家报名、匹配成队、按服号路由进同一战场实例、打完把奖励回传各自的服,并用幂等结算证明账目守恒。全部四个进程真跑,给出可执行断言。

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 多进程编排与验收 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

  1. 《MMO 服务器开发》工程化与运营 · 目录
  2. 《MMO 服务器开发》9.3 上线检查清单
  3. 《MMO 服务器开发》9.2 性能预算达标