《游戏服务端架构·核心系统与契约》3.1 匹配算法与公平性

从《裂隙战场》的排队投诉出发,拆解匹配窗口的放宽顺序、可解释的质量指标和跨区延迟预算;对比竞技、SLG、休闲三款产品的不同取值,分析深夜人口不足时的兜底动作与复盘口径,并给出可运行的 Go 分段队列实现、party 不可拆分约束与表驱动测试。

一次排队,两种互相矛盾的投诉

《裂隙战场》的排位赛在周六晚出现了一组矛盾工单:

  • 钻石段位的玩家等了四分钟,系统把他和两个黄金段位塞进同一局,赛后投诉“这局不算”;
  • 同一时段,另一批玩家两秒就进了房,但对面是临时拼凑的五人,连败三局后投诉“匹配故意坑我”。

前者是等待时间太长,后者是窗口放得太快。匹配器不是“越公平越好”,也不是“越快越好”,而是在给定人口、段位分布和在线时段下,选择先牺牲哪一项。这一节回答三个问题:窗口按什么顺序放宽、放宽到什么程度必须停、以及怎样把“这局匹配得好不好”变成可以复盘的数字。房间创建之后的生命周期留给 3.2,实例落在哪台机器留给 3.3。

备选方案:四种放宽策略

北辰工作室评估过四种做法:

方案做法优点代价适用条件
固定严格窗口全程只允许 MMR 差 50 以内公平性最好解释深夜高段位几乎排不到人口充足、段位集中
立即全放宽超过 5 秒就允许任意段位等待最短质量断崖,投诉集中只适合无排位的休闲局
分阶段放宽按等待秒数逐级扩大 MMR、延迟、party 混排等待与质量可调需要可观测指标和回归测试大多数竞技产品
模型预测用历史对局预测胜负概率再配对理论上更准训练、漂移、可解释性成本高数据团队成熟之后

《裂隙战场》选择分阶段放宽,《泡泡工坊》只保留前两级,《群岛纪元》则根本不按个人 MMR 排队。原因不是实现难度,而是三款产品对“不公平”的定义不同:竞技玩家介意段位差,休闲玩家介意等待,SLG 玩家介意联盟与战区归属。

放宽顺序:先动最不伤人的那一维

分阶段放宽的关键不是“分几档”,而是“先动哪一维”。北辰的顺序是:

  1. 先放宽 MMR。段位差只影响对局质量,不影响能不能连上、能不能操作。
  2. 再放宽延迟。延迟差超过 80ms 后,玩家能明显感到“我开枪了但没判定”,体验损失比段位差更直接。
  3. 最后才允许 party 混排。把两个预组队塞进同一队,会改变语音、指挥和配合结构,是最难向玩家解释的一种放宽。

这三步对应代码里的三个字段:MMR、LatencyMS、MixParty。窗口只在最老的一张 ticket 上计算,避免“新来的玩家把老玩家的等待窗口重新拉回严格档”。

公平性要能被解释,而不只是一个分数

很多团队只记录“平均等待时长”,赛后无法回答“为什么这局质量差”。北辰的质量指标包含四项:

  • MMRGap:两队平均 MMR 的差;
  • LatencyGap:两队最高延迟的差;
  • WaitSec:触发这次匹配的等待秒数;
  • Score:由前三项折算出的 0–100 分,只用于排序和告警,不直接展示给玩家。

分数是诊断工具,不是真理。两支 MMR 相同但风格完全不同的队伍,分数依然很高;反过来,一次深夜的跨段匹配分数很低,却可能是当时唯一可行的选择。把分数和实际对局结果(投降率、双方经济差、投诉率)一起看,才能判断窗口参数是否合理。

三款产品的取值

产品首档等待最大 MMR 窗口延迟上限party 混排停止条件
《裂隙战场》10 秒200(示例值)120ms60 秒后才允许超过 90 秒转人机或提示扩区
《泡泡工坊》3 秒400(示例值)200ms始终允许15 秒直接开房
《群岛纪元》不按个人排队不适用战区固定不适用联盟/战区集结满员即开

表中的秒数是配置示例值,不是本机测量结果。三款产品共用同一套队列代码,但窗口阈值、队伍大小和停止条件分开配置;不要为了“统一”把竞技的严格窗口套到休闲产品上。

跨区匹配:先匹配还是先选服

  • 先选服再匹配:玩家被固定在一台服务器上,人口少的区等待爆炸;
  • 先匹配再选服:匹配很快,但可能把 30ms 的玩家和 180ms 的玩家放进同一局;
  • 先按延迟分区,再在分区内匹配,最后才跨区兜底。

北辰采用第三种。延迟预算是硬约束,不是匹配成功后再补救的软指标。实现上,队列在入队时记录玩家到各赛区的 RTT,匹配时先过滤掉超出预算的赛区,再在剩余赛区里比较 MMR。跨区兜底必须带明确的提示和可退出入口,不能静默发生。

代码:分段队列、窗口扩展与质量评估

下面的 internal/matchmaking/queue.go 把分段队列、party 不可拆分、窗口扩展和质量评估放在一个包里。now 由外部注入,测试可以精确控制等待秒数;TryMatch 以最老 ticket 为锚点,只按它的等待时间放宽窗口。

// file: internal/matchmaking/queue.go
// tier: L1
// verify: go test ./internal/matchmaking/
package matchmaking

import (
	"errors"
	"sort"
	"sync"
	"time"
)

// TeamSize is the 5v5 team size used by 《裂隙战场》.
const TeamSize = 5

var (
	ErrEmptyPlayer = errors.New("matchmaking: empty player id")
	ErrDuplicate   = errors.New("matchmaking: player already queued")
	ErrInvalidMMR  = errors.New("matchmaking: negative mmr or latency")
	ErrPartyTooBig = errors.New("matchmaking: party exceeds team size")
)

// Player is one queued participant; a non-empty Party is indivisible.
type Player struct {
	ID        string
	MMR       int
	LatencyMS int
	Party     string
}

// Window is the relaxation policy at one wait stage.
type Window struct {
	Stage     int
	MMR       int
	LatencyMS int
	MixParty  bool
}

func Expand(wait time.Duration) Window {
	switch sec := int(wait.Seconds()); {
	case sec < 10:
		return Window{Stage: 0, MMR: 50, LatencyMS: 30}
	case sec < 30:
		return Window{Stage: 1, MMR: 100, LatencyMS: 60}
	case sec < 60:
		return Window{Stage: 2, MMR: 200, LatencyMS: 120}
	default:
		return Window{Stage: 3, MMR: 400, LatencyMS: 200, MixParty: true}
	}
}

type Quality struct {
	MMRGap     int
	LatencyGap int
	WaitSec    int
	Score      int
}

func Evaluate(a, b Player, wait time.Duration) Quality {
	mmrGap := abs(a.MMR - b.MMR)
	latGap := abs(a.LatencyMS - b.LatencyMS)
	waitSec := int(wait.Seconds())
	score := 100 - mmrGap/10 - latGap/5 - waitSec/30
	if score < 0 {
		score = 0
	} else if score > 100 {
		score = 100
	}
	return Quality{MMRGap: mmrGap, LatencyGap: latGap, WaitSec: waitSec, Score: score}
}

type Match struct {
	TeamA   []string
	TeamB   []string
	Stage   int
	Quality Quality
}

type ticket struct {
	id      string
	players []Player
	joined  time.Time
}

func (t *ticket) mmr() int {
	sum := 0
	for _, p := range t.players {
		sum += p.MMR
	}
	return sum / len(t.players)
}

func (t *ticket) latency() int {
	max := 0
	for _, p := range t.players {
		if p.LatencyMS > max {
			max = p.LatencyMS
		}
	}
	return max
}

type Queue struct {
	mu       sync.Mutex
	now      func() time.Time
	tickets  []*ticket
	byTicket map[string]*ticket
	byPlayer map[string]string
}

func NewQueue(now func() time.Time) *Queue {
	if now == nil {
		now = time.Now
	}
	return &Queue{now: now, byTicket: map[string]*ticket{}, byPlayer: map[string]string{}}
}

// EnqueueParty adds an indivisible group; a retried party name is a duplicate.
func (q *Queue) EnqueueParty(party string, players []Player) error {
	if len(players) == 0 || len(players) > TeamSize {
		return ErrPartyTooBig
	}
	for _, p := range players {
		if p.ID == "" {
			return ErrEmptyPlayer
		}
		if p.MMR < 0 || p.LatencyMS < 0 {
			return ErrInvalidMMR
		}
	}
	id := party
	if id == "" {
		if len(players) != 1 {
			return ErrPartyTooBig
		}
		id = players[0].ID
	}
	q.mu.Lock()
	defer q.mu.Unlock()
	if _, ok := q.byTicket[id]; ok {
		return ErrDuplicate
	}
	for _, p := range players {
		if _, ok := q.byPlayer[p.ID]; ok {
			return ErrDuplicate
		}
	}
	t := &ticket{id: id, players: append([]Player(nil), players...), joined: q.now()}
	q.tickets = append(q.tickets, t)
	q.byTicket[id] = t
	for _, p := range players {
		q.byPlayer[p.ID] = id
	}
	return nil
}

func (q *Queue) Enqueue(p Player) error { return q.EnqueueParty(p.Party, []Player{p}) }

// TryMatch anchors on the oldest ticket and never splits a party.
func (q *Queue) TryMatch() (Match, bool) {
	q.mu.Lock()
	defer q.mu.Unlock()
	if len(q.tickets) < 2 {
		return Match{}, false
	}
	sort.Slice(q.tickets, func(i, j int) bool {
		if q.tickets[i].joined.Equal(q.tickets[j].joined) {
			return q.tickets[i].id < q.tickets[j].id
		}
		return q.tickets[i].joined.Before(q.tickets[j].joined)
	})
	anchor := q.tickets[0]
	wait := q.now().Sub(anchor.joined)
	w := Expand(wait)

	picked := []*ticket{anchor}
	total, parties := len(anchor.players), 0
	if len(anchor.players) > 1 {
		parties = 1
	}
	for _, t := range q.tickets[1:] {
		if total == 2*TeamSize {
			break
		}
		if len(t.players) > TeamSize || total+len(t.players) > 2*TeamSize {
			continue
		}
		if abs(t.mmr()-anchor.mmr()) > w.MMR || abs(t.latency()-anchor.latency()) > w.LatencyMS {
			continue
		}
		if !w.MixParty && len(t.players) > 1 && parties >= 1 {
			continue
		}
		picked = append(picked, t)
		total += len(t.players)
		if len(t.players) > 1 {
			parties++
		}
	}
	if total != 2*TeamSize {
		return Match{}, false
	}
	teamA, teamB, ok := divide(picked)
	if !ok {
		return Match{}, false
	}
	a, mmrA, latA := summary(teamA)
	b, mmrB, latB := summary(teamB)
	m := Match{TeamA: a, TeamB: b, Stage: w.Stage,
		Quality: Evaluate(Player{MMR: mmrA, LatencyMS: latA},
			Player{MMR: mmrB, LatencyMS: latB}, wait)}
	drop := make(map[*ticket]bool, len(picked))
	for _, t := range picked {
		drop[t] = true
		delete(q.byTicket, t.id)
		for _, p := range t.players {
			delete(q.byPlayer, p.ID)
		}
	}
	kept := q.tickets[:0]
	for _, t := range q.tickets {
		if !drop[t] {
			kept = append(kept, t)
		}
	}
	q.tickets = kept
	return m, true
}

// divide places whole tickets into two teams, highest MMR first.
func divide(tickets []*ticket) ([]*ticket, []*ticket, bool) {
	sort.Slice(tickets, func(i, j int) bool {
		if tickets[i].mmr() == tickets[j].mmr() {
			return tickets[i].id < tickets[j].id
		}
		return tickets[i].mmr() > tickets[j].mmr()
	})
	var a, b []*ticket
	nA, nB, sumA, sumB := 0, 0, 0, 0
	for _, t := range tickets {
		n := len(t.players)
		fitA, fitB := nA+n <= TeamSize, nB+n <= TeamSize
		switch {
		case fitA && (!fitB || sumA <= sumB):
			a, nA, sumA = append(a, t), nA+n, sumA+t.mmr()*n
		case fitB:
			b, nB, sumB = append(b, t), nB+n, sumB+t.mmr()*n
		default:
			return nil, nil, false
		}
	}
	if nA != TeamSize || nB != TeamSize {
		return nil, nil, false
	}
	return a, b, true
}

func summary(ts []*ticket) (ids []string, mmr, latency int) {
	n := 0
	for _, t := range ts {
		n += len(t.players)
		mmr += t.mmr() * len(t.players)
		if v := t.latency(); v > latency {
			latency = v
		}
		for _, p := range t.players {
			ids = append(ids, p.ID)
		}
	}
	sort.Strings(ids)
	if n > 0 {
		mmr /= n
	}
	return ids, mmr, latency
}

func abs(v int) int {
	if v < 0 {
		return -v
	}
	return v
}
  • ticket 是不可拆分的单位。五人 party 要么整队进同一侧,要么整队不匹配,绝不会被拆成 3+2。
  • 入队用 party 名或玩家 ID 做幂等键。客户端重试一次,不会让同一个玩家占两个位置。
  • 分队按“高 MMR 优先、进较轻的一侧”贪心装箱;party 大小不同时仍然先保证每侧正好五人。
  • 质量分数只由两队的聚合值计算,避免某个人的极端延迟把整局分数拉爆。

失败模式与兜底

  • 重复入队:网络重试或客户端连点会把同一玩家放进队列两次。兜底是 byPlayer 幂等表,重复请求返回 ErrDuplicate。
  • party 被拆散:为了凑满十人而拆分预组队,是最容易引发投诉的实现错误。兜底是 ticket 粒度匹配,凑不满就继续等。
  • 窗口无限扩大:没有上限时,凌晨四点的排队会把最强和最弱的玩家硬凑在一起。兜底是阶段上限加停止条件。
  • 锚点饥饿:永远用最老 ticket 做锚点,会让一个极难匹配的玩家拖住整条队列。兜底是给锚点设置最大等待,超时后走兜底通道。
  • 匹配成功但房间没起来:匹配侧要保留“匹配结果 → 房间创建”的重试凭据,不能把玩家从队列删除后就地失联。

常见错误与边界

  • 先匹配再检查延迟,导致跨区对局大量出现。
  • 用平均 MMR 代替队伍聚合,忽略 party 内部的段位跨度。
  • 窗口配置写死在代码里,运营无法按赛季调整。
  • 匹配成功后不记录质量指标,赛后无法定位问题局。

小结

匹配器的核心不是某个算法,而是放宽顺序和停止条件:先动 MMR,再动延迟,最后才动 party 结构;每一步都要有可解释的质量指标和明确的兜底动作。三款产品可以共用代码,但必须分别配置窗口和停止条件。

匹配成功只完成了前半程。下一节讨论房间从创建到回收的完整生命周期,以及那些“进程已经没了、租约还挂着”的幽灵房间如何被发现和清理。

阅读导航:上一节:2.3 登录排队、限流与开服洪峰 · 下一节:3.2 房间生命周期与幽灵房间 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查