《MMO 服务器开发》6.1 跨服报名与匹配

玩家在 A 服报名,却要和 B、C 服的对手同场。本节从零写一个跨服中心:报名带服号与快照落到跨服队列,按战力分桶、蛇形分队完成撮合,结果再沿报名时那条长连接精确推回各自的母服。最后真起三个模拟服进程,断言通知回对了服,并给出并发写同一连接导致帧交错的真实踩坑与修法。

前五章把一台服务器拆成了一群服务器:网关接连接、场景服跑地图、世界服管全局。但玩家的社交关系并不会跟着服务器边界走——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 人、且成员来自三个不同的服——真跨服。

本节小结 / 实测结论

三条可以带走的结论:

  1. 报名只搬撮合需要的信息,不搬存档。 SignupReq 带服号与快照,撮合中心只读快照、不回查母服,这是「跨服」没有退化成「合服」的关键。
  2. 撮合队列要按批次分桶、按分数蛇形分队。 先到先得会把体验拉开;桶内排序后交替分队,两队总分差最小。
  3. 通知回投靠「连接目录」,而不是靠广播。 每个服一条长连接,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 跨服聊天与消息路由 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「mmo」更多文章

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