11 辅助系统设计
本章目标:掌握匹配与评分、大厅与房间、中继、聊天、好友、玩家状态等辅助系统的设计要点——它们不直接产出玩法内容,却决定了玩家"每次交互的手感"。
参考书籍:《百万在线》的辅助系统框架、《游戏编程模式》(Robert Nystrom)的状态模式。
辅助系统是网络游戏的"管家团队"——它们不直接参与游戏战斗,但管理着玩家从登录到退出的每一个环节。匹配、大厅、聊天、好友、成就、支付……这些系统看似独立,实际上紧密交织,共同构成了玩家体验的基石。
一个常见的误区是把辅助系统当作"简单功能"来对待。事实上,辅助系统的设计质量直接决定了游戏的"手感"——匹配等待 30 秒还是 3 秒、聊天消息延迟 100ms 还是 1s、好友列表加载顺畅还是卡顿——这些细节累积起来,就是玩家"想不想继续玩"的答案。
辅助系统还有一个容易被低估的特征:它们是运营放大器。平时流量平稳时,它们看起来只是外围能力;一旦新赛季上线、活动开启、主播带量,匹配、聊天、好友这些系统就会同时变成流量中心、争议中心和故障中心。辅助系统的容量设计要按"运营峰值"而不是"日常均值"。
1. 辅助系统全景
1.1 系统分类与职责
辅助系统可以按功能分为四大类:
| 类别 | 系统 | 核心职责 | 为什么重要 |
|---|---|---|---|
| 社交连接 | 匹配、大厅、好友、聊天、语音 | 将玩家连接在一起 | 社交是留存的核心驱动力 |
| 数据管理 | 成就、排行榜、玩家状态 | 记录和展示玩家成长 | 成就感是玩家的底层需求 |
| 运营支撑 | 客户端更新、敏感词过滤、禁言 | 保障游戏运营 | 安全和合规是底线 |
| 商业变现 | 支付认证、虚拟货币 | 支撑商业模式 | 没有收入就没有后续开发 |
排行榜、活动、邮件这几个"运营强相关"的系统已在第 10 章从玩法视角展开,本章不再重复,聚焦剩下的"连接类"系统。
1.2 系统间的依赖关系
┌──────────────┐
│ 登录网关 │
└──────┬───────┘
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ 游戏大厅 │ │ 匹配系统 │ │ 状态系统 │
└─────┬────┘ └─────┬────┘ └────┬─────┘
│ │ │
┌──────┼──────┐ │ ┌─────────┼─────────┐
▼ ▼ ▼ ▼ ▼ ▼ ▼
┌──────┐┌──────┐┌──────┐┌──────────┐┌──────┐┌──────┐┌──────┐
│ 聊天 ││ 好友 ││ 公会 ││ 对战服务器││聊天去重││好友在线││禁言 │
└──────┘└──────┘└──────┘└──────────┘└──────┘└──────┘└──────┘关键依赖:状态系统是多个辅助系统的公共数据源——聊天需要它去重广播、好友列表需要它显示在线、匹配需要它判断玩家空闲。如果状态系统故障,聊天、好友、匹配会接连出现异常。
这也决定了状态系统的设计目标:它应该是可降级的公共依赖。状态查询失败时,聊天宁可多播一些消息(把离线玩家也当在线尝试),也不应该直接拒绝聊天功能——部分正确优于整体不可用。
陷阱:很多团队把辅助系统当作"附属品",在资源紧张时优先砍辅助系统的开发时间。这是短视的——一个匹配延迟高的 MOBA 游戏,核心玩法再好玩也会流失玩家。
2. 匹配与评分系统
2.1 匹配解决什么问题
匹配系统负责将水平相近的玩家组合在一起。它解决的核心矛盾是等待时间与匹配质量的平衡:等待越久,匹配范围可以越大,对局质量越可控;等待越短,可选范围越小,质量越随机。这个矛盾没有完美解,只有动态平衡。
玩家对匹配等待的容忍度有一个"心理阈值"——MOBA 游戏约 30 秒,FPS 游戏约 15 秒,回合制游戏约 60 秒。超过阈值后流失率明显上升。匹配系统的所有参数调优,最终都落在这个约束上。
匹配池的实现(动态扩展评分范围、分桶撮合)已在第 10 章第 3 节展开,本节聚焦另一半:玩家的水平怎么量化。
2.2 Elo:经典但有局限
Elo 系统是最经典的玩家评分算法,核心思想是:以弱胜强得高分,以强胜弱得低分。
期望胜率 = 1 / (1 + 10^((对手分-己方分)/400))。K 值决定每场比赛的分数波动幅度——K 越大波动越大,适合新玩家快速定位;K 越小波动越小,适合高水平玩家稳定排名。
// Elo 评分更新
func UpdateRatings(ratingA, ratingB, k, actualA float64) (newA, newB float64) {
expA := 1.0 / (1.0 + math.Pow(10, (ratingB-ratingA)/400.0))
newA = ratingA + k*(actualA-expA)
newB = ratingB + k*((1.0-actualA)-(1.0-expA))
return math.Max(newA, 100), math.Max(newB, 100) // 下限 100 分
}Elo 的局限:
- 假设玩家水平是固定的,不考虑状态波动
- 没有"不确定性"概念——新玩家和老玩家用同样的 K 值不合理
- 不适合团队游戏,无法区分"我带飞"和"躺赢"
- 长期不打游戏的玩家评分不衰减,回归后评分失真
2.3 Glicko-2:引入不确定性
Glicko-2 在 Elo 基础上引入两个关键概念:
- 评分偏差 RD(Rating Deviation):评分的不确定性。长期不打比赛的玩家 RD 增大,系统"不确定"其真实水平;RD 大的玩家单场评分变化也更大。
- 波动性 σ(Volatility):玩家表现的稳定性。表现忽高忽低的玩家 σ 大,其评分变化更快。
这意味着:新玩家和回归玩家的定位更快(RD 大),稳定玩家的评分更抗扰动(RD 小)——比 Elo 的固定 K 值合理得多。
Glicko-2 的完整更新是五步流程(官方规范约含十几个中间变量),工程上建议直接使用现成实现(各语言均有成熟库),关键是理解参数的业务含义:
| 参数 | 含义 | 常见取值 | 调整时机 |
|---|---|---|---|
| 初始 rating | 新玩家起始分 | 1500 | 数值团队定基准 |
| 初始 RD | 新玩家不确定性 | 350 | 越大定位越快但越抖 |
| RD 增长 | 每个结算周期增加量 | 与不活跃时长相关 | 赛季休赛期衰减 |
| τ(系统常数) | 波动性变化抑制 | 0.3-1.2 | 越小评分越稳定 |
一个实用的简化:如果团队只需要"新玩家快速定位 + 老玩家稳定评分"这两个特性,可以用分段 K 值的 Elo(场次 <50 时 K=64,<100 时 K=32,之后 K=16)近似 Glicko-2 的效果,实现成本低一个数量级。Glicko-2 值得引入的时机是竞技严肃度高(赛季天梯、职业赛事)且团队游戏需要拆分个人贡献时。
2.4 组队匹配的评分处理
组队匹配需要平衡队伍整体评分和单人评分。简单的平均分容易被"带分"利用——高分玩家带低分玩家组队,匹配到中等水平对手,轻松获胜。
可选方案:
- 加权平均:低分玩家权重更高,拉高队伍匹配分,防"带分"
- 取队伍最高分匹配:公平性最好,但高分玩家的等待时间变长
- 组队惩罚系数:组队玩家的有效评分上浮固定百分比
三种方案没有绝对优劣,取决于产品对"开黑友好度"与"公平性"的取舍。常见做法是组队上浮 5%-10% 的有效评分,既抑制带分又不过度惩罚开黑。
2.5 匹配质量评估
匹配质量可以用两个维度衡量:评分差(对局双方/双方的分差,越小越好)和等待时间(越短越好)。上线后建议持续监控两个指标:
- 评分差分布:P90 分差超过阈值说明匹配过松(扩展太快)
- 等待时间分布:P90 等待超过心理阈值说明匹配过紧或玩家基数不足
这两个指标是矛盾的此消彼长,调参就是在曲线上选工作点。
陷阱:匹配系统最常见的坑是"匹配震荡"——玩家 A 赢了升分,匹配到更强的对手 B 输了降分,又匹配到较弱的对手 C 赢了升分……如此循环,玩家体验极差。缓解方式是限制单场评分变化幅度、以及匹配时优先选择"近期状态相近"的对手(连胜不立刻匹配连败者)。
2.6 方案选择
| 场景 | 可选方案 | 原因 |
|---|---|---|
| 1v1 竞技 | Elo / Glicko-2 | 经典方案,简单有效 |
| 5v5 MOBA | 分段 K 的 Elo + 组队上浮 | 需要考虑团队因素,个人贡献拆分成本高 |
| 大逃杀(100 人) | 分段匹配 + 等待扩展 | 人数多,需要快速凑局 |
| 回合制卡牌 | 简单 Elo + 异步结算 | 不需要实时匹配 |
3. 游戏大厅与房间
3.1 大厅的双重角色
游戏大厅是玩家进入游戏后的第一个交互界面,承担着双重角色:信息展示中心(显示房间列表、在线玩家、活动信息)和社交枢纽(创建房间、邀请好友、开始匹配)。
大厅的设计直接影响玩家的"第一印象"。一个加载缓慢、信息混乱的大厅会让玩家在进入游戏前就失去兴趣。
架构上先回答一个问题:大厅服务和房间服务要不要分开?
- 合设(大厅进程同时承载房间):部署简单,适合房间数少的休闲游戏。缺点是房间计算量大时拖累大厅响应,故障域不隔离。
- 分离(大厅只管列表与调度,房间跑在独立房间服):大厅保持轻量稳定,房间服按负载弹性扩容。多人在线游戏的主流选择。
分离后,大厅的职责退化为房间注册表 + 匹配入口:创建房间时向房间服申请资源,把房间元信息(ID、人数上限、模式)登记到大厅;玩家加入时从注册表路由到对应房间服。
3.2 房间状态机与并发问题
房间管理需要解决几个关键问题:
- 房间状态一致性:玩家 A 看到房间还有空位,点击加入时房间已满——这是大厅最常见的并发问题
- 房主转移:房主离开房间时,自动转移给下一个玩家
- 房间清理:空房间及时清理,防止内存泄漏
type RoomState int
const (
RoomStateWaiting RoomState = iota // 等待中
RoomStateStarting // 启动中
RoomStatePlaying // 游戏中
RoomStateFinished // 已结束
)"看到有空位但加入失败"的根源是读到的列表与加入动作之间存在时间差。常见的处理方式不是追求列表绝对实时,而是:加入请求到达房间后做二次确认,满员时返回明确错误并触发客户端刷新列表。房间列表本来就是高扇出的读,为了它做分布式锁并不划算——把"偶尔加入失败"变成一次顺畅的自动重试,体验上反而更好。
房主转移和加入满员都要求房间内状态变更串行化——把所有房间操作投递到房间的单写者队列(第 5 章的单写者原则在房间维度的应用),状态机转换就不会有竞态。
3.3 幽灵房间
陷阱:大厅系统最常见的 bug 是"幽灵房间"——房间已经没有玩家,但因为清理逻辑的并发问题,房间没有被正确删除,持续占用内存并出现在列表里。解决方案是定期扫描兜底,而不是只依赖玩家离开时触发清理。
// 定期巡检:回收异常房间(大厅定时任务,每 30 秒)
func (h *Hall) sweepRooms(now time.Time) {
for id, r := range h.rooms.Snapshot() {
if r.MemberCount() == 0 && now.Sub(r.LastActive) > 2*time.Minute {
h.closeRoom(id, "empty_timeout") // 空置超时,强制回收
}
if r.State == RoomStatePlaying && now.Sub(r.StartedAt) > r.MaxDuration {
h.closeRoom(id, "overtime") // 对局超时(防止死循环对局)
}
}
}事件驱动的清理(最后一个玩家离开时删除)是快路径,定时扫描是慢路径兜底——两者都要有。这条原则适用于所有"资源生命周期管理"场景:连接、会话、临时文件。
4. 中继服务器
4.1 为什么需要中继
中继服务器用于 P2P 游戏中的 NAT 穿透和数据转发。当两个玩家无法直接建立 P2P 连接时(比如都在对称型 NAT 后面),中继服务器作为中间人转发数据包。
中继是 P2P 游戏的"保底方案"——优先尝试直连,失败时才使用中继。这兼顾了低延迟(直连)和高可用性(中继保底)。需要说明的是:追求公平竞技的对战游戏通常不采用 P2P 拓扑(服务端权威要求专用服务器),中继方案更多出现在合作类、休闲类 P2P 游戏中。
4.2 NAT 穿透策略
NAT 穿透的核心思想是:根据双方的 NAT 类型选择最优连接策略。
NAT 类型判断:
├── 双方都无 NAT(公网 IP)→ 直连(延迟最低)
├── 一方是完全锥形 → STUN 穿透(成功率高)
├── 双方都是受限锥形 → 同时打洞(概率穿透)
├── 一方是对称 NAT → 基本需要 TURN 中继
└── 双方都是对称 NAT → 必须 TURN 中继工程上不建议自己维护 NAT 类型库再手工决策——直接采用标准协议栈(STUN 探测 + ICE 候选排序 + TURN 保底,即 WebRTC 的连接建立流程),把"选路"交给成熟的 ICE 实现,自行开发的部分聚焦中继的部署与成本。
4.3 中继的成本控制
中继服务器的带宽成本高——每对玩家的中继流量都按双向流量计费。控制手段:
- 优先直连:中继使用率是核心监控指标(通常希望低于 15%),异常升高时排查 NAT 探测逻辑
- 会话超时:空闲会话自动释放
- 区域部署:中继节点靠近玩家群体,转发延迟可控
- 带宽告警:单节点带宽超过阈值时拒绝新会话,引导新会话到空闲节点
陷阱:中继服务器最常见的问题是"会话泄漏"——玩家异常退出时没有正确关闭会话,导致中继端口一直被占用。解决方案是心跳检测 + 超时清理,与房间回收的"定时扫描兜底"是同一个原则。
5. 聊天系统
5.1 频道模型
聊天系统需要支持多种频道(世界、公会、队伍、私聊)、实时消息传递、敏感词过滤、消息持久化。频道设计的核心是订阅关系的存储方式:
| 频道类型 | 成员集合 | 成员变化频率 | 订阅关系存储 |
|---|---|---|---|
| 世界频道 | 全服在线玩家 | 每次登录登出 | 不存成员表,按"当前在线"广播 |
| 公会频道 | 公会成员 | 低 | 随公会数据存储 |
| 队伍频道 | 队伍成员 | 中 | 随队伍数据存储 |
| 私聊 | 两个玩家 | - | 无需订阅表,按 ID 定向投递 |
| 系统频道 | 全服玩家 | - | 同世界频道 |
世界频道"不存成员表、按在线名单广播"是个重要的简化:在线名单本来就是状态系统维护的(第 8 节),聊天广播时直接取快照使用,避免了第二份成员数据的一致性问题。代价是广播粒度只能到"在线玩家",无法做"世界频道的部分订阅"——绝大多数游戏不需要这个能力。
5.2 广播的性能瓶颈
聊天系统最大的性能瓶颈是世界频道广播——在线玩家超过 10 万时,一条世界消息意味着 10 万次投递。同步广播会阻塞消息处理循环,需要异步化:
- 异步发送:先落库、再入广播队列,发送失败不回滚消息
- 分片广播:按网关连接分组,每个网关连接只需收到一份消息、由网关扇出给其上的所有玩家——广播量从"玩家数"降为"网关连接数",这是最大的优化
- 优先级队列:系统公告优先于普通聊天
- 客户端限频:玩家发言有冷却时间(如 5 秒一条),既是反刷屏也是服务端自保
"经网关扇出"值得强调:广播的扇出点应该尽量靠近客户端(网关层),而不是在逻辑服内逐个构造 10 万个发送任务。逻辑服只说"向世界频道广播这条消息",怎么扇出是网关的事。
5.3 敏感词过滤
敏感词过滤是聊天系统的安全底线,也是合规要求(相关监管要求见第 18 章)。核心挑战是性能与准确性的平衡:过滤太慢会阻塞消息发送,过滤太松会放过违规内容。
常见方案是 Trie 树 + 文本预处理:
// Trie 敏感词匹配:查询耗时与文本长度成正比,与词库大小无关
type node struct {
children map[rune]*node
end bool
}
type Filter struct{ root *node }
func (f *Filter) Match(text string) (hit bool) {
runes := []rune(normalize(text)) // 预处理:繁转简、全半角统一、去干扰符
for i := 0; i < len(runes); i++ {
cur := f.root
for j := i; j < len(runes) && cur.children != nil; j++ {
cur = cur.children[runes[j]]
if cur == nil {
break
}
if cur.end {
return true // 命中:实际项目中替换为 *** 并记录审计日志
}
}
}
return false
}预处理和匹配同等重要:微信 → 用繁体、全角、插入符号绕过是常见手段,词库需要在运营中持续补充变体。词库量大或变体规则复杂时,升级为 AC 自动机(一次遍历匹配所有词),或接入云厂商的内容安全 API 做兜底复核。
5.4 禁言与处罚
禁言设计的第一个问题是处罚粒度:封禁应区分账号、角色、设备三个层级——封角色只限制游戏内发言,封账号限制登录,封设备应对批量小号。聊天处罚通常落在角色层(禁言),严重违规升级到账号层。粒度混在一级(只有一个"封禁"字段)是后期客服和风控互相打架的常见根源。
陷阱:聊天系统最常见的安全问题是"消息注入"——玩家在聊天内容中嵌入恶意内容,利用客户端富文本解析漏洞实施攻击。聊天内容应按纯文本处理:服务端剥除控制字符与格式标记,客户端展示时统一转义;支持表情码的游戏用白名单解析,不做通用富文本。
6. 邮件系统(辅助系统视角)
邮件系统的存储模型(个人邮件与全服模板分离)、附件领取幂等、过期清理,已在第 10 章第 8 节完整展开,本节只补充辅助系统视角特有的两点。
发放来源要区分。邮件是奖励投递的最终出口,同一封补偿邮件可能来自三条路径:系统自动发放(活动结算)、运营后台发放(手动操作)、客服人工补发(工单处理)。流水里应记录来源标记——出现"超额发放"事故时,能立刻定位是配置错误、误操作还是脚本重放,也便于给三类操作配置不同的审批权限。
批量发送走队列而非同步。即使采用了全服模板方案,仍有需要逐人生成邮件的场景(按条件筛选的定向补偿)。这类批量任务应投递到后台队列按限速消费,并在运营平台展示进度(已发送/总数、失败重试次数)——"点下发送按钮后不知道发生了什么"是运营事故的温床。
7. 好友系统
7.1 关系模型
好友系统管理玩家之间的社交关系。核心设计决策:
- 单向还是双向:大多数游戏采用双向好友——A 加 B,B 自动成为 A 的好友,关系对称。数据上常见做法是只存一条边记录(owner, friend),查询"我的好友"按 owner 索引;也可以存双向两条记录换取查询效率。两者差别不大,关键是不要既存一条边又推导双向,导致合服、删好友时状态不一致。
- 数量上限:好友数量上限既是数值设计也是存储保护。常见 50-200。
- 申请队列:陌生人申请进入"申请列表"而非直接成为好友,列表有上限(如 50 条),防止刷申请骚扰。
7.2 在线状态同步
好友状态同步是好友系统的技术难点。假设 100 万在线玩家、人均 50 个好友,一次状态变化意味着 50 次推送——全服总推送量轻松达到每秒千万级。
优化策略按投入递增:
- 状态合并推送:不是每次状态变化都推送,而是定期(如每 5-10 秒)批量推送好友状态变化。大多数场景(好友列表页显示小绿点)对秒级延迟不敏感。
- 按需订阅:只有打开了好友列表/社交界面的玩家才接收实时推送,关闭界面后退订。把"永远在线的 50 人扇出"变成"少数活跃界面的扇出"。
- 状态分级:只推送"在线/离线/游戏中"三态,不推送更细的状态(地图、房间号)——细状态由客户端按需查询。
状态推送的黑名单检查必须在推送侧完成而不是展示侧:A 拉黑 B 后,"B 能否查到 A 在线"的判定应发生在 A 的状态推送链路上(B 不在 A 的推送目标里),而不是等 B 的客户端发起了查询再拒绝——后者会让 B 通过"查询不报错"推断 A 在线。
8. 玩家状态系统
8.1 核心职责
玩家状态系统管理玩家的在线状态、游戏状态和活动信息。它是多个系统的公共数据源——好友列表需要知道玩家是否在线,匹配系统需要知道玩家当前是否空闲,聊天需要知道玩家是否被禁言。
状态系统的数据一致性至关重要:如果状态系统报告玩家在线,但实际已经掉线,会导致消息投递失败、匹配占位等问题。反过来(实际在线却报离线)危害较小,最多是消息走了离线路径。
状态数据的 key 用角色还是账号?在线、位置、房间等"会话态"数据挂在角色维度(同一账号可能多角色/多区服);禁言、封禁等"处罚态"需要明确到账号或角色层级(见 5.4 节)。这个区分在合服、多角色产品里会反复被追问,设计期就应写进数据字典。
8.2 在线检测:心跳与 TTL
在线状态检测的核心是心跳机制:客户端定期(如每 30 秒)向服务器报告活跃,服务端更新"最后活跃时间";超过阈值(如 90 秒)未更新则判定离线。
用 Redis 实现时可以用 TTL 自动过期代替主动删除——每个在线玩家一个带 TTL 的 key,心跳即续期,断线后 key 自然过期:
// 心跳续期:key 的存活即"在线"
func (s *StatusService) Heartbeat(playerID uint64) error {
return s.redis.Set(s.ctx, onlineKey(playerID), s.now(),
90*time.Second).Err() // TTL 即离线判定阈值
}
func (s *StatusService) IsOnline(playerID uint64) bool {
n, err := s.redis.Exists(s.ctx, onlineKey(playerID)).Result()
return err == nil && n > 0
}TTL 方案的好处是离线判定不需要任何清理任务——连接异常断开(断电、杀进程)也不会留下"幽灵在线"。心跳频率的权衡:太频繁增加负载,太稀疏离线检测延迟高。30 秒心跳 + 90 秒超时(3 次心跳容错)是常见配置。
8.3 类型差异
| 游戏类型 | 状态复杂度 | 核心状态 | 特殊考虑 |
|---|---|---|---|
| MMO | 高 | 在线、地图、等级 | 地图切换、组队状态 |
| MOBA | 中 | 在线、匹配、游戏中 | 匹配状态、观战状态 |
| 卡牌 | 低 | 在线、大厅 | 回合制不需要实时状态 |
| 挂机 | 极低 | 在线、离线 | 离线收益计算 |
陷阱:状态系统最常见的问题是"状态不一致"——玩家在游戏中,但状态系统显示"在线"而不是"游戏中",好友看到错误的状态。缓解方式是状态变更事件化:进入/离开对局等关键转换由对局服主动上报(而不是客户端上报),并带版本号,乱序到达时旧版本被丢弃。
9. 代码示例:状态上报的乱序防御与好友推送的最小骨架
7.2 节算过一笔账:100 万在线、人均 50 个好友,一次状态变化就是千万级推送——所以合并推送、按需订阅、推送侧黑名单不是优化项,是生存项;8.3 节的陷阱又要求状态变更带版本号、乱序旧样本直接丢弃。这一节把这两件事装进最小 Go 演示实现(非摘自真实项目):一个带版本防御的状态中心,一个把变化攒起来批量扇出的好友推送器。示例是本章设计决策的最小演示(非摘自真实项目),小节内代码块合并后可通过 go vet 与 go build 编译,并受全库代码块回归测试约束。
状态中心:版本号丢弃乱序样本
// 状态中心:关键状态转换由对局服带版本号上报(8.3 节的纪律),
// 版本号不前进的上报一律丢弃——迟到与重试不会让状态回退
type PresenceHub struct {
mu sync.RWMutex
state map[uint64]string
ver map[uint64]uint32
}
var ErrStaleReport = errors.New("stale presence report")
// Report:上报玩家状态。ver 必须由上报方单调递增(如对局序号)
func (p *PresenceHub) Report(playerID uint64, ver uint32, state string) error {
p.mu.Lock()
defer p.mu.Unlock()
if cur, ok := p.ver[playerID]; ok && ver <= cur {
return ErrStaleReport // 乱序/重复样本:丢弃,状态不被旧值回退
}
p.ver[playerID] = ver
p.state[playerID] = state
return nil
}
// Snapshot:读某玩家当前状态(好友列表展示等读路径)
func (p *PresenceHub) Snapshot(playerID uint64) (string, bool) {
p.mu.RLock()
defer p.mu.RUnlock()
s, ok := p.state[playerID]
return s, ok
}好友推送器:合并扇出 + 推送侧黑名单
// FriendNotifier:把状态变化攒进缓冲,定期批量扇出(7.2 节的状态合并推送);
// 拉黑过滤发生在推送侧——被拉黑者的变化根本不进对方的待推送集合,
// 而不是等对方查询时再拒绝(避免借「查询不报错」反推在线)
type FriendNotifier struct {
mu sync.Mutex
subs map[uint64]map[uint64]struct{} // playerID → 订阅者集合
blocked map[uint64]map[uint64]struct{} // 订阅者 → 拉黑名单
pending map[uint64]map[uint64]string // 订阅者 → 待推送 {playerID: 最新状态}
flush func(observer uint64, updates map[uint64]string) // 投递出口(测试桩/网关队列)
}
func NewFriendNotifier(flush func(uint64, map[uint64]string)) *FriendNotifier {
return &FriendNotifier{
subs: make(map[uint64]map[uint64]struct{}),
blocked: make(map[uint64]map[uint64]struct{}),
pending: make(map[uint64]map[uint64]string),
flush: flush,
}
}
// Subscribe:订阅者关注某玩家的状态变化
func (n *FriendNotifier) Subscribe(observer, playerID uint64) {
n.mu.Lock()
defer n.mu.Unlock()
if n.subs[playerID] == nil {
n.subs[playerID] = make(map[uint64]struct{})
}
n.subs[playerID][observer] = struct{}{}
}
// Blacklist:observer 拉黑 target——target 的状态变化从此不再推给 observer
func (n *FriendNotifier) Blacklist(observer, target uint64) {
n.mu.Lock()
defer n.mu.Unlock()
if n.blocked[observer] == nil {
n.blocked[observer] = make(map[uint64]struct{})
}
n.blocked[observer][target] = struct{}{}
}
// Change:状态中心收到变化后编入各订阅者的待推送缓冲。
// 同一玩家短时间多次变化,缓冲里只保留最新一份——这正是「合并」的含义
func (n *FriendNotifier) Change(playerID uint64, state string) {
n.mu.Lock()
defer n.mu.Unlock()
for observer := range n.subs[playerID] {
if n.isBlocked(observer, playerID) {
continue
}
if n.pending[observer] == nil {
n.pending[observer] = make(map[uint64]string)
}
n.pending[observer][playerID] = state
}
}
func (n *FriendNotifier) isBlocked(observer, playerID uint64) bool {
b := n.blocked[observer]
if b == nil {
return false
}
_, hit := b[playerID]
return hit
}
// Flush:定期(如每 5 秒)批量投递,返回本轮实际推送的变更条数。
// 大多数订阅场景(好友列表的小绿点)对秒级延迟不敏感——5 秒一批,
// 推送量从「每次变化 × 好友数」降为「订阅界面数 × 批次数」
func (n *FriendNotifier) Flush() int {
n.mu.Lock()
defer n.mu.Unlock()
total := 0
for observer, updates := range n.pending {
if len(updates) == 0 {
continue
}
n.flush(observer, updates)
total += len(updates)
delete(n.pending, observer)
}
return total
}两段合起来看:PresenceHub 只负责「状态可信」(版本防御),FriendNotifier 只负责「投递经济」(合并 + 黑名单)——状态中心不关心谁订阅,推送器不裁决状态真假,边界与第 1 节的分工表一致。
10. 设计决策指南
何时选择 Elo vs Glicko-2
- Elo(或分段 K 的 Elo):简单场景、1v1 对战、不需要不确定性建模
- Glicko-2:竞技严肃度高、团队游戏需要个人贡献拆分、大量新玩家频繁进出
大厅与房间是否分离
- 房间数少、逻辑轻:合设,降低部署复杂度
- 房间计算重、需要弹性扩容:分离,故障域隔离
何时引入中继服务器
- P2P 产品且玩家 NAT 环境多样
- 直连成功率低于 80%
- 延迟收益高于中继带宽成本
聊天广播的扇出层级
- 在线数百人:逻辑服直接逐个投递即可
- 上万人起:经网关扇出,逻辑服只发一条广播指令
- 十万人以上:网关分片 + 优先级队列 + 客户端限频
状态存储选型
- 在线状态:Redis TTL key(自动过期,免清理)
- 复杂会话状态(房间、匹配进度):状态系统内存 + 快照,或直接挂在各业务系统
- 不要把高频变化的状态写关系库——写放大不划算
11. 常见陷阱总结
| 陷阱 | 表现 | 根本原因 | 解决方案 |
|---|---|---|---|
| 匹配震荡 | 玩家反复升降分 | 评分波动过大 | 限制单场变化幅度 + 状态相近匹配 |
| 幽灵房间 | 空房间占用资源 | 并发清理逻辑缺陷 | 事件清理 + 定时扫描兜底 |
| 加入满员 | 列表显示有空位但加入失败 | 列表与实时状态的时间差 | 加入二次确认 + 明确错误码 + 自动刷新 |
| 状态不一致 | 好友看到错误状态 | 客户端上报、无版本控制 | 服务端上报 + 版本号丢弃旧状态 |
| 邮件重复领取 | 玩家多次领取附件 | 幂等性缺失 | 领取标记 + 幂等键(见第 10 章) |
| 聊天注入 | 恶意内容通过聊天传播 | 内容校验不严 | 纯文本化 + 白名单表情 + 转义 |
| 敏感词绕过 | 变体字符绕过过滤 | 预处理缺失 | 繁简/全半角归一 + 词库持续运营 |
| 中继会话泄漏 | 中继端口被占满 | 异常退出未清理 | 心跳检测 + 超时清理 |
| 黑名单信息泄露 | 被拉黑者仍能推断对方在线 | 黑名单检查不在推送侧 | 推送侧过滤,而非展示侧拒绝 |
12. 实战案例:一次「一赢就排到怪物」的匹配震荡排查
案例为教学示例(非摘自真实项目),数字经过取整简化,聚焦排查思路与回填逻辑。
背景与现象
赛季更新后,玩家反馈集中在一句话:「一赢就排到打不过的,一输就排到随便打的」——胜负像钟摆。客诉 815 份;抽样 2000 场里胜负交替 1260 场,交替率 63%。匹配等待没有变长,玩家却觉得「系统在针对我」。
第一步:现象与口径
按 2.5 节的 2 个质量指标对口径,三个视角:
- 等待口径:P90 等待 28 秒,MOBA 心理阈值 30 秒以内——「匹配不慢,系统健康」;
- 分差口径:P90 分差 96 分,超阈值 60%——「匹配过松」;
- 评分流水口径:固定 K=64 覆盖全段位,均衡对局单场变化 32 分——评分本身在大幅摆动。
口径差:2 个质量指标,项目只监控了 1 个——等待进了面板、分差没进,匹配系统的健康定义缺了一半。
第二步:分层归因
匹配层 等待 P90 28 秒,在 30 秒阈值内——窗口扩展节奏没有失控
评分层 固定 K=64 全段位:均衡对局单场变化 32 分,评分围绕真实水平大幅摆动
窗口层 匹配按瞬时评分取窗,三场累计摆幅 96 分被如实撮合——分差是评分摆出来的,不是窗口扩出来的
观测层 等待 P90 进了面板,分差 P90 没进——质量的另一半从未被度量账本收敛:固定 K=64 下,期望 50% 的均衡对局单场变化 = 64 × 0.5 = 32 分;连续三场累计摆幅 32 × 3 = 96 分,正是实测 P90 分差。把 96 分差代入 2.2 节的期望公式:期望胜率从 50% 滑到 37%(1/(1+10^(96/400)))——赢完这一场,下一场 63% 概率连败,与实测交替率 1260/2000 = 63% 严丝合缝:不是系统针对谁,是 K 值把评分推到了「赢一场就该输一场」的位置。
第三步:根因
- 直接原因:固定 K=64 覆盖全段位——2.2 节 Elo 局限 4 条里第 2 条「新玩家和老玩家用同样的 K 值不合理」被直接踩中:新号该用大 K 快速定位,老玩家的 64 分预算全花在稳定段位的抖动上。
- 机制原因:2.5 节陷阱块的缓解 2 条一条没上——没有限制单场变化幅度,也没有「近期状态相近」匹配;单场 32 分的摆幅经三场扩散成 96 分的分差,窗口按瞬时评分如实撮合,震荡就是必然结果。
- 深层原因:2.5 节说两个指标此消彼长、调参是在曲线上选工作点,项目只把等待 P90 当北极星;第 11 节陷阱表 9 行的第 1 行「匹配震荡」——现象、根因、解决方案三列写得明明白白,但它躺在表里,没有对应的监控与门禁。
第四步:处置与回填
- 分 K 段:分段 K 三段:场次 <50 用 64、<100 用 32、其余用 16——新号快速定位、老玩家稳定排名,与 2.3 节的分段方案一致。
- 限幅 + 状态相近:单场评分变化设上限,匹配时优先「近期状态相近」的对手——2.5 节的缓解两条全部落地;老玩家段单场 8 分 × 3 场 = 24 分,P90 分差从 96 分 → 24 分,低于 60 分阈值。
- 双指标告警:分差 P90 阈值 60 分进告警,与等待 P90 同面板——调参的工作点由两个维度共同定义。
- 回归重定位:赛季休赛期按 2.3 节参数表启动 RD 增长,回归玩家进高不确定期快速重定位——评分不再「一次定终身」。
复核:客诉 815 份 → 0 份;复核抽样 2000 场,交替 1060 场 = 53%(代入 24 分差的期望公式,赢后连败概率 100 − 47 = 53%);等待 P90 仍 28 秒——分差降下来的同时,等待没有被调糟。
回填清单:
| 回填项 | 落点 | 回链 |
|---|---|---|
| 分段 K 与单场限幅落地 | 评分服务 | 第 11 讲 2.2、2.3 节 |
| 状态相近匹配进窗口策略 | 匹配策略 | 第 11 讲 2.5 节陷阱块 |
| 分差 P90 与等待 P90 双指标告警 | 监控告警 | 第 11 讲 2.5 节 / 第 13 讲 |
| 赛季休赛 RD 衰减进评分配置 | 评分配置 | 第 11 讲 2.3 节参数表 / 第 17 讲 |
案例的三个教训
- 矛盾的指标要一起看——等待与分差此消彼长,只盯一半,另一半的震荡就是账单(2.5 节的两个指标)。
- K 值是波动预算——固定 K=64 把全部预算花在老玩家的稳定段位上;分段 K 是按场次把预算分级(2.3 节的分段方案)。
- 对上公式才算定位——37% 与 63% 不是感觉,是 2.2 节期望公式代入 96 分差算出来的,再与实测 1260/2000 对上,根因才是「算出来的」而不是「猜出来的」。
本章小结
| 问题 | 简答 |
|---|---|
| 匹配系统的核心矛盾? | 等待时间与匹配质量的平衡,参数调优围绕玩家心理阈值选工作点 |
| Elo 和 Glicko-2 怎么选? | 简单场景分段 K 的 Elo 足够;竞技严肃、团队拆分需求时上 Glicko-2 |
| 大厅与房间要分离吗? | 房间计算重、需弹性扩容就分离;大厅退化为房间注册表 + 匹配入口 |
| 资源清理(房间/中继会话)的通用原则? | 事件驱动快路径 + 定时扫描慢路径兜底,两者缺一不可 |
| 十万人世界频道怎么广播? | 逻辑服发一条广播指令,网关层分片扇出——扇出点尽量靠近客户端 |
| 敏感词过滤的要点? | Trie 匹配(与词库大小无关)+ 预处理抗变体,词库持续运营 |
| 在线状态怎么判? | 心跳续期 Redis TTL key,过期即离线,免清理、抗异常断连 |
| 封禁为什么要分层级? | 账号/角色/设备粒度不同,混在一级会让客服与风控互相掣肘 |