fifo 是一个面向游戏场景的通用匹配引擎,核心职责是:将不断涌入的匹配请求(Ticket),按照可配置的规则,分组成若干队伍(Team),再将队伍两两/多方对阵后输出匹配结果。
匹配系统在游戏服务器中面临几个核心矛盾:
- 并发写入 vs. 单线程匹配:玩家请求在任意时刻从多个 goroutine 进入,但匹配算法本身涉及大量有状态的遍历和标记,多线程做匹配需要极其复杂的同步逻辑。
- 多样的匹配规则 vs. 统一的引擎:不同的玩法(如不同赛季、不同战力段)需要不同的筛选条件,但匹配框架应保持一致。
- 队内组队 vs. 队间对阵:匹配不仅要把散人凑成一队,还要把已组好的队伍配对对阵。
fifo 的设计针对以上矛盾做了如下选择:
┌─────────────────────────────────────────────────┐
│ Matcher │
│ │
Submit() ──►│ inputQ (mutex) ──mergeQ──► Cache (串行) │
Cancel() ──►│ │ │
│ ┌────────┼────────┐ │
│ Pool A Pool B ... │ │
│ │ │ │ │
│ match() match() │ │
│ │ │ │ │
│ ▼ ▼ │ │
│ ResultSubmitter callback │
└─────────────────────────────────────────────────┘
inputQ是一个sync.Mutex保护的 map,任意 goroutine 可以随时调用Submit()/Cancel()往里写。- 每个 Tick,匹配主循环调用
mergeQ()把inputQ中的所有待处理项 一次性批量取出 合并到Cache中,之后inputQ清空,不再持有对这些 Ticket 的引用。 - 匹配算法只操作
Cache,是完全单线程的,无需加锁。
为什么? 这样做把"并发安全"的边界收窄到 inputQ 一个点上。匹配算法可以随意修改 Ticket 的 used 标记等内部状态,不用担心并发问题。mergeQ() 每次是全量快照,语义清晰且不会丢数据。
一个 Matcher 实例包含多个 Pool。每个 Pool 有自己的过滤器(Filter),每次 Tick 时,每张 Ticket 会被分发到所有满足条件的 Pool 中。
为什么? 实际场景中,同一批玩家可能同时满足多个匹配规则。例如,一个战力 9,000,000 的玩家既属于"低段位"Pool(范围 0~10,000,000)又属于"中段位"Pool(范围 8,000,000~25,000,000)。让 Pool 之间有重叠,可以增大匹配成功率,避免卡在边界的玩家长时间等待。另外,可以利用 $wait 过滤器实现"先尝试精确匹配,等待超时后放宽条件"的渐进式策略。
匹配算法通过 RegisterMatchFunction(name, func) 注册,MatchProfile.Algorithm 字段指定使用哪个。内置了 fifo 算法,业务可以注册自定义算法。
为什么? 不同玩法对匹配质量/速度的权衡不同。有的场景需要 ELO 平衡,有的只需 FIFO 快速凑人。算法可插拔让框架和策略解耦。
type Ticket struct {
TicketId string // 整个匹配场里全局唯一 ID
Members []Member // 这张票包含的玩家列表(至少 1 人)
StringArgs []StringArg // 字符串类型的匹配参数
IntArgs []IntArg // 整数类型的匹配参数
FloatArgs []FloatArg // 浮点类型的匹配参数
BlackList []int64 // 黑名单,不会跟指定 member 匹配到同 team
}一张 Ticket 代表一次匹配请求,可以是单人也可以是已组队的多人。例如:3 个人一起组队进入匹配,提交一张包含 3 个 Member 的 Ticket。
type Member struct {
MemberId string // 全局唯一的玩家 ID
BlackId int64 // 用于黑名单匹配的标识(必须为正数,0 值不合法)
Extra []byte // 额外数据,随匹配结果透传
Sort int // >0 表示可选成员,匹配不成功时可被裁剪
}Sort 字段的设计意图:当一个 Ticket 里有 4 人但队伍只需 3 人时,Sort > 0 的成员会被优先裁掉(Sort 值越大越优先裁掉)。Sort == 0 表示该成员是必选的。
type MatchProfile struct {
Name string // 匹配场名字,唯一标识
Algorithm string // 使用的算法名(如 "fifo")
Tick string // 匹配频率(如 "0.5s", "500ms", "2s")
Pools []PoolProfile // 匹配池列表
}type PoolProfile struct {
Name string // 池名
BetweenTeamAntiAffinity string // 队间反亲和性(按 StringArg 的 key 来区分)
StringFilters []StringFilter // 字符串过滤器
IntFilters []IntFilter // 整数过滤器
FloatFilters []FloatFilter // 浮点过滤器
Teams []string // 需要匹配出多少支队伍(每个元素是队伍名)
TeamMembers int // 每队人数
MaxMatchPerRound int // 每轮最多匹配出多少组(必须 > 0)
AllowCut bool // 是否允许裁剪队伍(丢弃可选成员)
AllowHate bool // 是否考虑玩家黑名单
}Teams 的含义:如果 Teams = ["blue", "red"],表示每次匹配需要组出 2 支队伍对阵。如果 Teams = ["1"],表示只需凑满一支队伍即为成功(如 PvE 场景)。
BetweenTeamAntiAffinity:指定一个 StringArg 的 key,拥有相同 value 的 Ticket 不会被分配到对阵的不同队伍中。典型场景:同服玩家不对阵。
type MatchResult struct {
PoolName string // 来源于哪个池子
Teams []TeamResult // 匹配出的队伍
}
type TeamResult struct {
TeamName string // 对应 PoolProfile.Teams 中的名字
TicketId []string // 由哪些 Ticket 组成
Members []MemberResult // 最终成员
CutMembers []MemberResult // 被裁剪掉的成员
}Tick 支持以下时间单位后缀:
| 格式 | 含义 | 示例 |
|---|---|---|
ms |
毫秒 | "500ms" → 500 毫秒 |
s |
秒 | "0.5s" → 500 毫秒 |
m |
分钟 | "0.5m" → 30 秒 |
h |
小时 | "1h" → 1 小时 |
支持浮点数。解析失败时默认为 1 秒。
{"arg": "season", "op": "=", "value": "s1"}op支持"="和"!="- 当 Ticket 缺少对应的 StringArg 时:
"="返回 false,"!="返回 true
{"arg": "power", "min": 8000000, "max": 25000000, "excludes": []}min和max均为可选字段(指针类型*int64):- 同时设置:Ticket 的参数必须满足
min ≤ value ≤ max - 仅设置
min:只检查下界value ≥ min - 仅设置
max:只检查上界value ≤ max - 均不设置:不做范围检查(仅检查参数存在性和
excludes)
- 同时设置:Ticket 的参数必须满足
excludes:参数值不能在该列表中- 特殊内置参数:
"$wait":当前时间 - Ticket 开始匹配时间(毫秒),用于实现"等待一段时间后才进入某个更宽松的 Pool""$member":Ticket 的成员数量,用于按组队人数过滤
{"arg": "score", "min": 1.0, "max": 100.0}- 与 IntFilter 类似,
min和max均为可选字段(指针类型*float64),支持单边过滤,无 excludes
int64Ptr := func(v int64) *int64 { return &v }
m, err := fifo.NewMatcher("my-game", fifo.MatchProfile{
Name: "ranked-5v5",
Algorithm: "fifo",
Tick: "0.5s",
Pools: []fifo.PoolProfile{
{
Name: "normal",
Teams: []string{"blue", "red"},
TeamMembers: 5,
MaxMatchPerRound: 100,
StringFilters: []fifo.StringFilter{
{Arg: "season", Op: fifo.EqualOp, Value: "s1"},
},
IntFilters: []fifo.IntFilter{
{Arg: "power", Min: int64Ptr(0), Max: int64Ptr(50_000_000)},
},
BetweenTeamAntiAffinity: "alliance",
},
{
Name: "relaxed",
Teams: []string{"blue", "red"},
TeamMembers: 5,
MaxMatchPerRound: 100,
IntFilters: []fifo.IntFilter{
{Arg: "$wait", Min: int64Ptr(5000), Max: int64Ptr(3_600_000)},
},
AllowCut: true,
},
},
}, func(result fifo.MatchResult) {
// 处理匹配结果
// 注意:回调在匹配主循环中同步执行,不应进行阻塞操作(如网络 IO、数据库写入等)
fmt.Printf("匹配成功: pool=%s, teams=%d\n", result.PoolName, len(result.Teams))
for _, team := range result.Teams {
fmt.Printf(" 队伍 %s: %v\n", team.TeamName, team.TicketId)
}
}, func(ticket fifo.Ticket) {
// 可选:匹配超时失败回调,当 Ticket 因超时被移除时触发
// 注意:回调在匹配主循环中同步执行,不应进行阻塞操作
fmt.Printf("匹配超时: ticket=%s\n", ticket.TicketId)
})
if err != nil {
log.Fatal(err)
}ctx, cancel := context.WithCancel(context.Background())
go m.Start(ctx)
// 需要停止时
cancel()Start() 是一个阻塞方法,会在独立的 goroutine 中持续运行,直到 ctx 被取消。
// 单人匹配
m.Submit(fifo.Ticket{
TicketId: "ticket-001",
Members: []fifo.Member{{MemberId: "player-A"}},
StringArgs: []fifo.StringArg{{Key: "season", Value: "s1"}},
IntArgs: []fifo.IntArg{{Key: "power", Value: 15_000_000}},
})
// 多人组队匹配(3 人组队,其中 1 人为可选)
m.Submit(fifo.Ticket{
TicketId: "ticket-002",
Members: []fifo.Member{
{MemberId: "player-B", Sort: 0}, // 必选
{MemberId: "player-C", Sort: 0}, // 必选
{MemberId: "player-D", Sort: 1}, // 可选,可被裁剪
},
})
// 带延迟和超时的匹配
m.SubmitWithTime(ticket, 2000, 30000) // 延迟 2 秒开始匹配,最多匹配 30 秒
// 取消匹配
m.Cancel("ticket-001")fifo.RegisterMatchFunction("my-elo", func(
profile fifo.PoolProfile,
teams map[string]*fifo.Ticket,
now int64,
r fifo.ResultSubmitter,
) {
// 你的自定义匹配逻辑
// 通过调用 r(result) 提交每一组匹配结果
})
// 在 MatchProfile 中引用
profile := fifo.MatchProfile{
Algorithm: "my-elo",
// ...
}fifo 是内置的先到先服务匹配算法。它的核心思路是贪心:优先从大组队开始凑人,尽可能快速匹配。
第一阶段:队内组人 (search)
━━━━━━━━━━━━━━━━━━━━━━━━━━
1. 将 Pool 中所有 Ticket 按成员数量分桶:
bucket[1] = [单人票据...]
bucket[2] = [双人票据...]
...
bucket[N] = [N人票据...]
每个桶内按 startMatch(进入时间)升序排列
2. 从最大桶(N人)开始遍历到最小桶(1人):
- 取一个未使用的 Ticket 作为种子
- 贪心搜索:从剩余空位数量对应的桶开始,寻找能填满队伍的 Ticket
- 如果启用 AllowHate,检查黑名单
- 如果启用 AllowCut,只要必选成员数 ≥ 需求人数即可成队
- 凑满 → 生成一个 candidate(候选队伍)
- 每轮结束清理已使用的 Ticket
第二阶段:队间对阵 (searchTeam)
━━━━━━━━━━━━━━━━━━━━━━━━━━━
(仅当 Teams 数量 > 1 时执行)
1. 如果配置了 BetweenTeamAntiAffinity,提取每个 candidate 的反亲和标签
2. 遍历 candidate 列表,尝试找到 N 个互不冲突的队伍
3. 冲突判定:如果两个 candidate 存在相同的 anti 标签,则不能对阵
4. 成功配对 → 通过 ResultSubmitter 输出结果
为什么从大桶开始? 大组队的 Ticket 更难匹配(可选组合更少),优先处理可以避免它们被后面的小票据"挤掉"空位。
AllowCut 的工作原理: 每个 Ticket 的成员分为两类:
Sort == 0:必选成员,贡献low和high计数Sort > 0:可选成员,只贡献high计数
当 AllowCut = true 时,搜索空位的算法使用 need - low(还需要多少必选成员)来决定要找多大的票据;最终只要 low ≤ need ≤ high 即可成队。被裁剪的成员按 Sort 升序排序后截断,出现在结果的 CutMembers 中。
黑名单机制:
当 AllowHate = true 时:
- Ticket A 加入 candidate 后,记录自己所有成员的
BlackId以及自己的BlackList - 新 Ticket B 要加入时,检查 B 的成员
BlackId不在 candidate 已有的hates中,且 B 的BlackList不包含 candidate 中已有成员的BlackId
匹配失败时的回退: 如果一个种子 Ticket 开始的搜索无法凑满队伍:
- 若非黑名单原因失败:释放所有已标记的 Ticket(
used = false),让它们参与后续轮次 - 若因黑名单导致失败:不释放,因为这些 Ticket 可能对其他种子也有冲突
过滤器决定一张 Ticket 是否进入某个 Pool。每个 Tick 时,所有 Ticket 会被重新分发到各 Pool,因此 过滤条件是动态评估的(这对 $wait 这样的时间相关过滤器很重要)。
| 参数名 | 类型 | 含义 |
|---|---|---|
$wait |
IntFilter | now - ticket.startMatch,单位毫秒。用于渐进式放宽 |
$member |
IntFilter | len(ticket.Members),用于按组队人数筛选 |
利用多 Pool + $wait 实现"先精确后宽松":
int64Ptr := func(v int64) *int64 { return &v }
Pools: []fifo.PoolProfile{
{
Name: "strict",
IntFilters: []fifo.IntFilter{
{Arg: "power", Min: int64Ptr(20_000_000), Max: int64Ptr(30_000_000)},
},
TeamMembers: 5,
// ...
},
{
Name: "relaxed",
IntFilters: []fifo.IntFilter{
{Arg: "power", Min: int64Ptr(10_000_000), Max: int64Ptr(50_000_000)},
{Arg: "$wait", Min: int64Ptr(5000), Max: int64Ptr(3_600_000)}, // 等待 5 秒后进入
},
TeamMembers: 5,
AllowCut: true, // 允许缺人
// ...
},
}效果:前 5 秒只在精确段位匹配;5 秒后同时进入宽松池,增大匹配概率。
Submit()和Cancel()是并发安全的,可以从任意 goroutine 调用。Start()必须在单独一个 goroutine 中运行,不要对同一个 Matcher 实例多次调用Start()。Cache字段虽然是导出的(大写),但不要在Start()运行期间从外部直接读写它,它没有锁保护。
TicketId必须在整个匹配场中全局唯一。重复的 TicketId 会导致后提交的覆盖先提交的。Cancel()使用 TicketId 标识要取消的票据。
Submit()会复制 Ticket 值(参数是值类型),提交后修改原始 Ticket 不会影响匹配。但Members中的Extra字段([]byte)是切片,底层数组是共享的——如果需要在提交后修改 Extra,请提前 copy。
- 如果
AllowCut = false,Sort字段无实际效果,所有成员都视为必选。 - 如果
AllowCut = true但所有 Member 的Sort都是 0,则效果和AllowCut = false一样——因为所有人都是必选的,不会有人被裁。 - 被裁剪的成员会出现在
TeamResult.CutMembers中,业务侧需要自行处理这些玩家(如通知他们匹配失败或重新排队)。
- Pool 按配置中的顺序依次执行匹配。前面的 Pool 匹配成功的 Ticket 会从 Cache 中移除,不再参与后续 Pool 的匹配。
- 因此,越严格的 Pool 应该放在前面,越宽松的 Pool 放在后面,避免宽松规则"抢走"应该精确匹配的玩家。
- 该值限制的是 FIFO 算法中第一阶段凑出的 candidate 数量,不是最终输出的 MatchResult 数量。
- 设置合理的值可以防止单次 Tick 消耗过多 CPU,在大量玩家涌入时尤为重要。
SubmitWithTime(ticket, delay, timeout)中的delay和timeout单位均为毫秒。delay:Ticket 在 delay 毫秒后才会开始参与匹配(通过startMatch时间戳实现,Pool 的Allow()方法会检查now >= startMatch——注意:$wait过滤器的值为now - startMatch,delay 期间该值为负数,不会通过Min > 0的过滤器)。timeout:从开始匹配算起超过 timeout 毫秒后,Ticket 会在mergeQ()时被自动移除。timeout = 0表示永不超时。
注册自定义 MatchFunction 时需遵守以下约定:
- 通过调用
r(result)提交每一组匹配成功的结果(可以调用多次)。 - 框架会自动根据结果中的
TicketId从 Cache 中移除已匹配的 Ticket,算法不需要自行清理。 tickets参数是该 Pool 的 Ticket 视图,算法可以修改 Ticket 的used标记,但不要从 map 中删除元素。- 算法执行时已经过
recover保护,panic 不会崩溃整个 Matcher,但会跳过当前 Pool 本轮的匹配。