一次排队,两种互相矛盾的投诉
《裂隙战场》的排位赛在周六晚出现了一组矛盾工单:
- 钻石段位的玩家等了四分钟,系统把他和两个黄金段位塞进同一局,赛后投诉“这局不算”;
- 同一时段,另一批玩家两秒就进了房,但对面是临时拼凑的五人,连败三局后投诉“匹配故意坑我”。
前者是等待时间太长,后者是窗口放得太快。匹配器不是“越公平越好”,也不是“越快越好”,而是在给定人口、段位分布和在线时段下,选择先牺牲哪一项。这一节回答三个问题:窗口按什么顺序放宽、放宽到什么程度必须停、以及怎样把“这局匹配得好不好”变成可以复盘的数字。房间创建之后的生命周期留给 3.2,实例落在哪台机器留给 3.3。
备选方案:四种放宽策略
北辰工作室评估过四种做法:
| 方案 | 做法 | 优点 | 代价 | 适用条件 |
|---|---|---|---|---|
| 固定严格窗口 | 全程只允许 MMR 差 50 以内 | 公平性最好解释 | 深夜高段位几乎排不到 | 人口充足、段位集中 |
| 立即全放宽 | 超过 5 秒就允许任意段位 | 等待最短 | 质量断崖,投诉集中 | 只适合无排位的休闲局 |
| 分阶段放宽 | 按等待秒数逐级扩大 MMR、延迟、party 混排 | 等待与质量可调 | 需要可观测指标和回归测试 | 大多数竞技产品 |
| 模型预测 | 用历史对局预测胜负概率再配对 | 理论上更准 | 训练、漂移、可解释性成本高 | 数据团队成熟之后 |
《裂隙战场》选择分阶段放宽,《泡泡工坊》只保留前两级,《群岛纪元》则根本不按个人 MMR 排队。原因不是实现难度,而是三款产品对“不公平”的定义不同:竞技玩家介意段位差,休闲玩家介意等待,SLG 玩家介意联盟与战区归属。
放宽顺序:先动最不伤人的那一维
分阶段放宽的关键不是“分几档”,而是“先动哪一维”。北辰的顺序是:
- 先放宽 MMR。段位差只影响对局质量,不影响能不能连上、能不能操作。
- 再放宽延迟。延迟差超过 80ms 后,玩家能明显感到“我开枪了但没判定”,体验损失比段位差更直接。
- 最后才允许 party 混排。把两个预组队塞进同一队,会改变语音、指挥和配合结构,是最难向玩家解释的一种放宽。
这三步对应代码里的三个字段:MMR、LatencyMS、MixParty。窗口只在最老的一张 ticket 上计算,避免“新来的玩家把老玩家的等待窗口重新拉回严格档”。
公平性要能被解释,而不只是一个分数
很多团队只记录“平均等待时长”,赛后无法回答“为什么这局质量差”。北辰的质量指标包含四项:
MMRGap:两队平均 MMR 的差;LatencyGap:两队最高延迟的差;WaitSec:触发这次匹配的等待秒数;Score:由前三项折算出的 0–100 分,只用于排序和告警,不直接展示给玩家。
分数是诊断工具,不是真理。两支 MMR 相同但风格完全不同的队伍,分数依然很高;反过来,一次深夜的跨段匹配分数很低,却可能是当时唯一可行的选择。把分数和实际对局结果(投降率、双方经济差、投诉率)一起看,才能判断窗口参数是否合理。
三款产品的取值
| 产品 | 首档等待 | 最大 MMR 窗口 | 延迟上限 | party 混排 | 停止条件 |
|---|---|---|---|---|---|
| 《裂隙战场》 | 10 秒 | 200(示例值) | 120ms | 60 秒后才允许 | 超过 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 房间生命周期与幽灵房间 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。