前五章把一台服务器拆成了一群服务器:网关接连接、场景服跑地图、世界服管全局。但玩家的社交关系并不会跟着服务器边界走——A 服的玩家想和 B 服的公会打一场跨服战场,这是 MMO 里最赚钱、也最容易出事故的一类玩法。
跨服玩法的第一性问题很朴素:玩家的数据留在各自的母服,可撮合需要全局视角。 如果为了配一场 3v3,把六个玩家的完整存档都搬到跨服中心,你就把「跨服」做成了「合服」——数据量爆炸、一致性问题成堆。正确的做法是只搬「撮合够用」的那一点信息,剩下的留在母服。
本节从零写一个 internal/crossserver 包,把报名、撮合、通知三段链路跑通,并真的起三个「服」的模拟进程,断言每条撮合结果都回对了自己的服。全部代码是 L2,连的是本机 127.0.0.1 的回环 TCP,go test 可以直接跑。
本章与站内既有内容的分工
站内已经有一组文章讲跨服玩法的整体架构与报名、邮件路由方案,它们负责「怎么设计」。本节不复述结论,只做一件事:把跨服报名与撮合从零写成能跑的进程,并证明通知回对了服。分工如下。
| 既有文章 | 它负责什么 | 本节只做什么 |
|---|---|---|
/game-server-architecture-for-cross-server-events/ | 跨服活动的整体架构与数据边界 | 只实现报名→撮合→通知这一条主链路 |
/game-server-cross-server-event-registration-architecture/ | 跨服报名的资格校验与名额控制 | 只做报名载荷的编码与入队 |
/cross-server-chat-system-backend-design/ | 跨服聊天与消息系统的后端设计 | 只做撮合结果的通知,不碰聊天(见 6.2) |
/game-server-cross-shard-mail-routing-architecture/ | 跨服邮件的路由与投递 | 只做「结果推回母服」,奖励结算见 6.3 |
6.1.1 报名落到跨服队列:带服号与快照
玩家在 A 服点「报名」,A 服要做的事只有一件:把一条报名记录(CmdCrossSignupReq 0x0D01)发到跨服中心。这条记录不搬存档,只带撮合需要的最小信息——玩家 ID、所属服号、批次号、战力分数,以及一份报名快照。快照是母服在报名那一刻拍下的阵容/等级摘要,撮合中心只读它,绝不回查母服,否则 N 个服会互相 RPC 成一团。
载荷沿用卷一定稿的 wire 格式,字段顺序写死:
// snippet: 报名载荷 SignupReq 的字节布局(大端)
PlayerID(8) | ServerID(2) | BatchNo(4) | Score(4) | len(2) | Snapshot(len)
// 服号 ServerID 是关键:撮合结果要靠它找到「回哪条连接」。
跨服中心收到报名后回一个 CmdCrossSignupResp 0x0D02 表示受理,Seq 与请求一致便于母服配对。真正的撮合是异步的——受理只是「收到」,不代表「配上」。
6.1.2 跨服匹配队列:分桶 + 蛇形分队
匹配队列的坑在于「谁来等谁」。如果按报名先后凑满一场就开打,先报名的低战力玩家会和后来报名的顶级玩家被塞进同一局,体验崩坏。所以队列按 BatchNo(赛事批次)分桶,桶内按分数排序后蛇形分队:升序第 0、2、4 名进一队,第 1、3、5 名进另一队,两队总分差最小。
下面这段是 L1,go test ./internal/crossserver/ 可以直接跑:
// file: internal/crossserver/queue.go
// tier: L1
// verify: go test ./internal/crossserver/
package crossserver
import "sort"
// 一场对局的规模:两队、每队 3 人。
const (
TeamSize = 3
PlayersPerBout = TeamSize * 2
)
// MatchMember 是撮合结果里的一个成员,带服号——通知要靠它找到「回哪条连接」。
type MatchMember struct {
PlayerID uint64
ServerID uint16
TeamID uint8
}
// MatchResult 一次撮合的产物。
type MatchResult struct {
MatchID uint64
Members []MatchMember
}
// MatchQueue 是跨服报名队列:按 BatchNo(赛事批次)分桶,
// 桶内按 Score 升序排列后「蛇形」分到两队,保证两队战力接近。
type MatchQueue struct{ buckets map[uint32][]SignupReq }
func NewMatchQueue() *MatchQueue { return &MatchQueue{buckets: make(map[uint32][]SignupReq)} }
func (q *MatchQueue) Enqueue(s SignupReq) { q.buckets[s.BatchNo] = append(q.buckets[s.BatchNo], s) }
// Pending 返回队列里还剩多少人。
func (q *MatchQueue) Pending() int {
n := 0
for _, l := range q.buckets {
n += len(l)
}
return n
}
// Match 从任意一个够人的桶里取 PlayersPerBout 人。
// 桶内按分数升序后下标 0/2/4 归一队、1/3/5 归另一队,两队总分差最小。
func (q *MatchQueue) Match(matchID uint64) (MatchResult, bool) {
for batch, list := range q.buckets {
if len(list) < PlayersPerBout {
continue
}
sort.SliceStable(list, func(i, j int) bool { return list[i].Score < list[j].Score })
res := MatchResult{MatchID: matchID}
for i, s := range list[:PlayersPerBout] {
team := uint8(1)
if i%2 == 1 {
team = 2
}
res.Members = append(res.Members, MatchMember{PlayerID: s.PlayerID, ServerID: s.ServerID, TeamID: team})
}
q.buckets[batch] = list[PlayersPerBout:]
return res, true
}
return MatchResult{}, false
}
Match 每次消费掉 6 人,剩下的留在桶里等下一场。不够一场就返回 false,队列不动——这正是 6.1.3 的测试要验证的「不满一场绝不撮合」。
6.1.3 撮合结果怎么通知回各自的服
撮合出结果后,难题是把「你被分到第 1 场第 2 队」这条通知(CmdCrossMatchFoundNotify 0x0D03)送回玩家所在的服。做法是让每个母服向跨服中心开一条 TCP 长连接,中心用一张 map[ServerID]*peer 记住「哪个服在哪条连接上」,撮合完成后遍历结果里的每个成员,按它的 ServerID 把通知写回对应连接。
下面这段是 L2,真的起 TCP 回环连接跑:
// file: internal/crossserver/hub.go
// tier: L2
// verify: go test ./internal/crossserver/ -run TestCrossServerSignupAndMatch
package crossserver
import (
"net"
"sync"
)
// peer 包住一条母服连接,并给它配一把写锁。
// 同一连接的读与写可并发,但两次写绝不能并发——否则帧会交错。
type peer struct {
c net.Conn
mu sync.Mutex
}
func (p *peer) write(f Frame) error {
p.mu.Lock()
defer p.mu.Unlock()
return WriteFrame(p.c, f)
}
// Hub 是跨服中心:每个母服开一条 TCP 长连接接进来,报名经此汇总,
// 撮合结果沿「玩家报名时那条连接」推回各自的服。
type Hub struct {
ln net.Listener
queue *MatchQueue
mu sync.Mutex
conns map[uint16]*peer // ServerID -> 该服的长连接
nextID uint64
notified int
wg sync.WaitGroup
done chan struct{}
}
func NewHub() (*Hub, error) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return nil, err
}
return &Hub{ln: ln, queue: NewMatchQueue(), conns: make(map[uint16]*peer), done: make(chan struct{})}, nil
}
func (h *Hub) Addr() string { return h.ln.Addr().String() }
func (h *Hub) Serve() {
for {
c, err := h.ln.Accept()
if err != nil {
return
}
h.wg.Add(1)
go h.handleConn(c)
}
}
func (h *Hub) handleConn(c net.Conn) {
p := &peer{c: c}
defer c.Close()
defer h.wg.Done()
for {
f, err := ReadFrame(c)
if err != nil {
return
}
if f.Cmd != CmdCrossSignupReq {
continue
}
s, err := DecodeSignupReq(f.Payload)
if err != nil {
continue
}
h.register(s.ServerID, p)
_ = p.write(Frame{Cmd: CmdCrossSignupResp, Seq: f.Seq, Flags: f.Flags}) // 报名受理
h.enqueue(s)
}
}
func (h *Hub) register(serverID uint16, p *peer) {
h.mu.Lock()
h.conns[serverID] = p
h.mu.Unlock()
}
func (h *Hub) enqueue(s SignupReq) {
h.mu.Lock()
h.queue.Enqueue(s)
h.mu.Unlock()
h.tryMatch()
}
// tryMatch 尝试撮合一场;够人就沿各成员的服号把通知推回去。
func (h *Hub) tryMatch() {
h.mu.Lock()
defer h.mu.Unlock()
res, ok := h.queue.Match(h.nextID + 1)
if !ok {
return
}
h.nextID++
for _, m := range res.Members {
p := h.conns[m.ServerID]
if p == nil {
continue // 该服连接已断,通知留给重连后补投(见 6.3 的对账)
}
n := MatchFoundNotify{MatchID: res.MatchID, PlayerID: m.PlayerID, ServerID: m.ServerID, TeamID: m.TeamID}
if p.write(Frame{Cmd: CmdCrossMatchFoundNotify, Payload: n.Encode()}) == nil {
h.notified++
}
}
}
// Notified 返回已成功推回母服的通知条数。
func (h *Hub) Notified() int {
h.mu.Lock()
defer h.mu.Unlock()
return h.notified
}
func (h *Hub) Close() {
close(h.done)
_ = h.ln.Close()
h.mu.Lock()
for _, p := range h.conns {
_ = p.c.Close()
}
h.mu.Unlock()
h.wg.Wait()
}
写这个 Hub 时踩到一个真实的并发坑,值得单独说:同一连接的两次写绝不能并发。 第一版里,handleConn 直接 WriteFrame 回报名受理,而 tryMatch(由另一个服的 goroutine 触发)也会往这条连接推通知。两条 goroutine 同时写同一 net.Conn,11 字节的头和载荷交错在一起,对端的 ReadFrame 立刻解析出一帧长度荒谬的数据并断开。修法就是上面的 peer.write:给每条连接配一把写锁,所有出站帧走同一把锁。测试跑起来,三服共收到 6 条通知、两队各 3 人、且成员来自三个不同的服——真跨服。
本节小结 / 实测结论
三条可以带走的结论:
- 报名只搬撮合需要的信息,不搬存档。
SignupReq带服号与快照,撮合中心只读快照、不回查母服,这是「跨服」没有退化成「合服」的关键。 - 撮合队列要按批次分桶、按分数蛇形分队。 先到先得会把体验拉开;桶内排序后交替分队,两队总分差最小。
- 通知回投靠「连接目录」,而不是靠广播。 每个服一条长连接,
map[ServerID]*peer把结果精确投回母服;连接断开时的补投留给 6.3 的对账。
实测输出(go test -race -count=1,三个模拟服进程):
=== RUN TestCrossServerSignupAndMatch
--- PASS: TestCrossServerSignupAndMatch (0.02s)
=== RUN TestNoMatchUntilQuorum
--- PASS: TestNoMatchUntilQuorum (0.16s)
=== RUN TestPayloadRoundTrip
--- PASS: TestPayloadRoundTrip (0.00s)
ok aegis/internal/crossserver 1.692s
一句话收束:跨服的第一条链路是「报名→撮合→回投」,它把全局视角和本地数据解耦——中心只做决定,母服才做落地。 6.2 会把同样的「谁订阅了谁」问一遍,但对象换成消息。
阅读导航:上一节:目录 · 下一节:6.2 跨服聊天与消息路由 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。