「远港 Online」上线前最后一次评审,运维问了一个问题:晚高峰一个位面预计 3000 人,那台 64 核服务器扛得住吗?你翻出第 24 章的实测曲线:单进程 100 人时 tick 已经吃掉 27ms。3000 人意味着单帧需要 750ms——而预算只有 33ms。这是数量级的差距,调优补不了这个窟窿。更要命的是后半句:这个进程一旦崩了,3000 人同时掉线,世界在他们的屏幕上集体消失。这一章就干一件事:把一个进程拆成一群进程,并且让玩家察觉不到裂缝。
本章地图
mindmap
root((MMO 架构))
单进程必死
容量账 100人对3000人
内存与带宽账
故障域 一崩全掉
分层拆解
网关 连接与协议伪装
登录服 凭证与排队
Zone 按空间切地图
World 全局仲裁者
聊天与DB代理 旁路服务
无缝跨区
缓冲带预热
三次握手移交写权
三条失败回滚路径
残影清理
分线调度
开线与合线
选线优先级
跨线聚合玩法
容量模型
世界容量公式
跨区速率上限
服务发现与熔断单进程必死:一笔容量账和一笔故障账
先把第 24 章的结论摆上桌。一台 64 核 Dedicated Server,30Hz tick,中等战斗密度下单玩家服务器成本 0.2-0.3ms,100 个玩家就把 33.3ms 的帧预算吃到 20-30ms。那个状态叫走钢丝:再来一波团战 AOE、一波技能特效触发的属性重算,tick 超预算,全服一起卡。
现在把「远港 Online」的设计目标代进去:晚高峰一张公共地图 3000 人在线,对标 WOW 主城加周边野外的密度。
超了 22 倍。有人会说降 tick 率:降到 10Hz,预算变成 100ms,还是差 7 倍——而且 10Hz 的手感和 AI 反应速度先崩给你看。也有人会说砍密度:把玩家用副本和相位隔开。可以,但那叫回避问题,WOW 的主城广场就是几千人互相可见,这是 MMO 的卖点本身,不能砍。
内存账同样难看。8km×8km 的 World Partition 地图全量常驻,加上 3000 个玩家 Pawn、约五万个 NPC 与可交互物件,UWorld 的内存占用是几十 GB 量级(取决于资产密度)。单进程的句柄数、socket 缓冲区、物理页,全都顶到天花板。
网络账:3000 条连接,战斗高峰平均每连接出站 20-50KB/s,合计 60-150MB/s。单进程做收发包,内核态到用户态的拷贝就吃掉一个核,留给游戏逻辑的预算更少。
比容量更致命的是故障域。单进程意味着单点:进程段错误,3000 人同时掉线;发版更新,3000 人全部踢下去等重启;宿主机电源故障,整个世界消失几分钟。MMO 玩家可以容忍排队,可以容忍回档半小时,不能容忍"世界消失"——那是第二天上社区头条的事故等级。而且别忘了运维面:一个进程承载了全世界,你连灰度发布、滚动重启这些基本动作都做不了,每次更新都是一次全服事件。
三堵独立的墙——CPU、内存带宽、故障域——指向同一个动词:拆。拆法只有三种维度,这一整章都是它们的展开:
- 按职能垂直拆:登录、聊天、数据库访问各自独立成服务。聊天服挂了,战斗照打;登录服满了,已在游戏里的人无感。
- 按空间水平拆:一张大地图切成几块,一个 Zone 进程管一块,块内玩家密度回到 DS 扛得住的区间。
- 整体复制:同一张地图开出多条平行线路(分线),人口分流到平行世界。
拆完长什么样?先给全书最重要的那张图——「远港 Online」的服务端全景。后面每一节都是这张图某个局部的放大。
classDiagram
class Client["游戏客户端 (UE5.8)"] {
只持有一条对外长连接
不知道Zone的存在
}
class Gateway["网关层 Gateway ×N"] {
+终结玩家TCP/UDP长连接
+协议加解密与防重放
+按消息类型路由
+会话粘滞 绑定玩家到Zone
+限流与异常踢线
}
class LoginSvc["登录服 Login"] {
+账号凭证校验
+排队与准入控制
+签发会话票据
}
class WorldSvc["世界服 World"] {
+全局实体注册表
+跨区迁移唯一仲裁
+分线人口调度
+Zone健康裁决
}
class ZoneSvc["Zone服 ×4/线"] {
+4km×4km区域权威模拟
+战斗/AI/技能/GAS结算
+边界上报与幽灵镜像
+UE Dedicated Server进程
}
class ChatSvc["聊天服 Chat"] {
+频道订阅与路由
+敏感词与限频
+私聊/组队/公会/世界频道
}
class DBProxy["数据库代理 DBProxy"] {
+读写排队与合并
+热数据缓存
+回存批量管道
}
Client --> Gateway : 唯一长连接(公网)
Gateway --> LoginSvc : 登录消息
Gateway --> ZoneSvc : 玩法消息(会话粘滞)
Gateway --> ChatSvc : 聊天消息
LoginSvc --> DBProxy : 账号与角色查询
ZoneSvc --> WorldSvc : 迁移请求/边界上报/心跳
WorldSvc --> ZoneSvc : 接管指令/调度决策
ZoneSvc --> DBProxy : 存档读写
ChatSvc --> DBProxy : 离线消息落盘读这张图记住三句话:玩家只认识网关;Zone 之间互不通信,World 是唯一中间人;数据库前面永远隔着代理,没有任何服务直连 DB。这三条纪律后面会反复用到。
网关层:玩家只认门牌号,不认房间
拆开之后,第一问是"玩家连谁"——"怎么拆"是第二问。3000 个客户端如果直连 Zone,会同时引爆三件事:Zone 的 IP 暴露给全体玩家(互喷之后互打 DDoS,是行业里真实发生过的事故类型);跨区迁移时 TCP 必须断开重连,"无缝"直接死亡;每个 Zone 进程要维持几千条 socket,连接管理本身就吃掉一截 tick 预算。
答案是把连接层独立出来:网关(Gateway)。它的职责清单:
- 连接终结:全服唯一对外的门牌号。玩家从登录到下线,只持有这一条长连接。
- 协议伪装:加解密、压缩、序列号防重放。玩家侧是密文,内网是明文;攻击者能拿到的只有网关地址,Zone 和 DB 的拓扑对外不可见。
- 路由与会话粘滞:登录消息去登录服,聊天消息去聊天服,玩法消息按"这个玩家当前在哪个 Zone"粘滞转发。跨区迁移时玩家与网关的连接纹丝不动,网关在内网换一个上游——这是"无缝"的第一块基石。
- 削峰与看门:限流、踢异常连接、排队期的心跳保持。网关是全服唯一面向公网的进程,抗 D 设备和 WAF 也只罩它一个。
网关自身必须近似无状态:会话路由表是易失数据,崩了可以让玩家重连后从 World 重建。无状态换来两个礼物——可以水平堆数量(一台网关扛几千条连接,不够再加,前面挂负载均衡),可以随便重启(全服唯一能滚动升级的进程)。有状态的 Zone 享受不到这两个待遇。
工程上有个必须坦白的事实:UE 的 NetDriver/NetConnection 模型假设 DS 直接面对玩家,引擎没有内置网关层。路线有两条:
| 路线 | 做法 | 代价 |
|---|---|---|
| 透明转发 | 网关原样搬运 UE 的 UDP 包,Zone 跑标准 NetDriver,复制系统全保留 | 握手 challenge 与 AESGCMHandlerComponent 的密钥协商要网关参与,耦合深,细节以官方文档 PacketHandler 词条为准 |
| 自研外层协议 | 客户端↔网关走自研协议与序列化,UE 复制只跑在网关与 Zone 之间的内网段 | 牺牲一部分属性复制的便利,换协议层完全控制;多数 MMO 项目走这条 |
本书不替你做这个选择,但请记住网关是协议校验的天然位置——第 28 章的反作弊会回到这里。
玩家从启动客户端到踩进世界,走的是下面这条链路。注意网关在每个环节的位置:它从不"处理"业务,只做转发,但每一跳都经过它。
flowchart TD
A[客户端启动] --> B[连接网关: 就近接入点]
B --> C{网关上已有会话?}
C -- 有 --> K[恢复旧路由绑定]
C -- 无 --> D[网关转发到登录服]
D --> E{凭证校验 + 排队}
E -- 排队中 --> F[返回队列位置
网关保持心跳不掉线]
F --> E
E -- 通过 --> G[签发会话票据
拉取角色列表]
G --> H[玩家选择角色]
H --> I[World仲裁: 角色落在
哪条线的哪个Zone]
I --> J[Zone加载角色数据
spawn实体]
J --> K
K --> L[网关绑定会话到该Zone]
L --> M[客户端收初始快照
进入世界]路由表是网关的核心数据结构,它决定了每条消息的去向。下面这段伪代码是它的最小形态:
# 伪代码:网关的会话与路由表
class GatewaySession:
player_id: int
client_conn: Connection # 面向玩家的长连接
upstream: ServiceAddr # 当前上游:login / zone_X / chat
seq_send, seq_recv: int # 收发序号,防重放
ROUTE_TABLE = {
MsgType.LOGIN: "login-svc",
MsgType.CHAT: "chat-svc",
MsgType.GAMEPLAY: None, # None = 按会话粘滞,转发到 session.upstream
}
def on_client_packet(sess, pkt):
if pkt.seq <= sess.seq_recv: # 重放或乱序
drop(pkt); return
sess.seq_recv = pkt.seq
target = ROUTE_TABLE[pkt.type] or sess.upstream
forward(target, sess.player_id, pkt) # 内网段可明文
def rebind(sess, new_zone_addr): # World 发起跨区切换
sess.upstream = new_zone_addr # 玩家连接不动,只换上游要点就三行:序号过滤挡重放攻击;GAMEPLAY 类消息没有静态路由,永远跟着会话走;rebind 是跨区迁移的最后一棒,后面会看到谁在什么时候调用它。
Zone 拆分:把 8 公里地图切成四个进程
空间维度的拆法,直觉一句话:一个 Zone 进程管一块地。真正的设计问题是——切多大。
承接第 16 章的设定:「远港 Online」主大陆 8km×8km,World Partition 流送格子 256m。全图 32×32 = 1024 格。切成 2×2 四块,每块 4km×4km = 16×16 = 256 格,一个 Zone 一块,由世界服 World 统一看管。
为什么不是 32×32 每格一个 Zone?这里有一笔此消彼长的账。Zone 切得越小,边界总长越长,玩家跨区越频繁,跨区迁移(下一节的主角)的频率和失败面就越大,World 仲裁线程被迁移请求淹没;Zone 切得越大,单 Zone 内的实体密度越逼近单进程的老毛病。4km 是经验甜点:坐骑全速横穿一块要两三分钟,普通玩家半小时也未必跨一次区;而单 Zone 内 400 人分散在 16 平方公里上,密度回到 DS 扛得住的区间。这个尺寸怎么来的?拿迁移频率和单点密度两头算出来的平衡点——它只是验算结果,无所谓定不定理。
每个 Zone 就是一个 UE Dedicated Server 进程,加载同一张主大陆地图,但只激活自己那块格子。World Partition 的格子坐标系天然就是分区依据:配合 Data Layer(见第 16 章)把"本 Zone 管辖范围"做成启动参数,进程启动时只流送自己的 16×16 格加外圈缓冲。Zone 进程之间没有任何直连通道——它们唯一认识的人是 World。
重叠缓冲区是 Zone 拆分里最容易被砍、砍掉必出事的设计。每个 Zone 实际"看到"的范围,比管辖范围大一圈:向外扩 500m 的环带。三件事逼着这个环带必须存在:
- 复制距离跨边界。边界上玩家的兴趣半径(见第 21 章的相关性)会伸进隔壁 Zone。看不见对面的人,边界就是一堵空气墙——你能看到 500m 外的树,却看不到 50m 外站在"另一个世界"的玩家。
- 远程技能与弹道会飞过去。一箭射过边界,命中判定归谁管,得有实体在对端接着飞。
- 迁移要预热。角色贴到边界才联系对端,spawn 和数据拉取必然来不及。缓冲带就是提前量。
重叠区里的实体在两个 Zone 各有一份表示:实体当前所在 Zone 是权威方,负责模拟和写;邻居 Zone 持有一个只读镜像,行话叫幽灵(ghost)。幽灵不跑 AI、不参与命中判定、不接受 RPC,只做一件事:让边界两侧的玩家互相看得见,并在接管那一刻直接"转正"。
坐标处理有个工程惯例。UE5 的 Large World Coordinates 让 8km 地图不再需要原点重定位(见第 16 章),但网络同步不必背着双精度全世界跑:包体里用「Zone ID + 区内局部坐标」,32 位浮点足够,跨区时天然解耦,还省带宽。
下面这张图把"1024 个格子怎么落到 4 个进程"画清楚,右侧是边界缓冲带的剖面放大。
记住这张图的读法:实线是管辖权的硬边界,虚线圈出的红带是"两个世界互相偷看"的窗口。没有这条红带,后面所有跨区机制都没有立足点。
无缝跨区:角色过边界的三次握手与三条退路
全章技术密度最高的一段。玩家在地图上只是走了一步,服务端要完成的是一次分布式事务:写权从旧 Zone 原子地移交给新 Zone,连接路由在内网切换,旧影像清除。任何一步出错,只有两种下场——状态分裂(两边都以为自己是权威),或者玩家掉线。先把四个角色认清楚:
- 旧 Zone:让位方,直到移交完成前都是权威。
- 新 Zone:接管方,先当幽灵,再转正。
- World:仲裁者,唯一可信第三方,持有"当前权威是谁"的账本。
- 网关:连接切换的执行者,听 World 指挥。
sequenceDiagram
autonumber
participant P as 玩家客户端
participant GW as 网关
participant ZA as 旧Zone A
participant WS as World服
participant ZB as 新Zone B
participant DB as DB代理
P->>GW: 移动包(持续)
GW->>ZA: 按会话粘滞转发
ZA->>WS: 玩家进入缓冲带(距边界500m)
WS->>ZB: PrepareEntity(角色快照摘要)
ZB->>DB: 拉最新持久化快照(技能/Buff/任务)
DB-->>ZB: 快照数据
ZB-->>WS: Ready: 幽灵实体已占位
Note over WS,ZB: 失败A: ZB超时或拒绝
→ WS中止迁移, ZA继续权威
玩家软减速, 无感知
ZA->>WS: 玩家越过移交线, 申请正式移交
WS->>ZA: Freeze: 冻结该实体写权限
ZA->>WS: FinalDelta: 冻结时刻全量增量
WS->>ZB: Commit: 应用增量并激活
ZB-->>WS: Activated: 我已是权威
Note over WS,ZB: 失败B: Commit失败
→ 以ZA冻结检查点为准, 解冻恢复
重试, 3连败则标记"卡边界"
WS->>GW: Rebind: 会话上游切到ZB
GW-->>P: 连接不动, 仅换内网路由(无感知)
GW->>ZB: 后续玩法包
WS->>ZA: Release: 移交确认
ZA->>ZA: 残影保留3秒后销毁
Note over P,ZB: 失败C: 切换后ZB崩溃
→ World用心跳发现, 快照+增量日志
在健康Zone重建, 玩家回弹数百ms逐步拆开看。步骤 3 的"提前 500m"是有账可算的:坐骑全速约 15m/s,500m 给出半分钟提前量,而 World 给预热定的 SLA 只有 2 秒。余量差了一个数量级——这个余量就是用来吸收失败的:新 Zone 忙、DB 慢、快照大,都还有时间重试。
步骤 4-7 是占位。新 Zone 从 DB 代理拉角色的最新持久化快照——技能冷却、Buff 剩余时间、任务进度、背包摘要——创建幽灵实体并回报 Ready。此刻它的职责只有一个:投影。不模拟、不持有权威。注意它拉的是 DB 快照而不是旧 Zone 的内存态:内存态要到移交那一刻才有意义,提前拉内存态只会拉到一份注定过期的数据。
步骤 8-13 是移交本体,一次经典的三次握手:申请(旧 Zone 报告玩家越线)→ 冻结(World 指令旧 Zone 停止该实体的一切写操作,玩家输入改由网关短暂缓冲)→ 提交(冻结时刻的最终增量经 World 转发,新 Zone 应用后激活权威)。
为什么不让两个 Zone 直接对话,而要 World 居中转手?因为仲裁者持有唯一账本。两个 Zone 直接协商,遇上网络分区就是双权威——两边的玩家都看到自己的角色在动,状态从此分裂,神仙难救。任何时刻,一个实体在全服有且只有一个权威写者,这是 MMO 状态正确性的第一戒律,所有协议细节都是它的注脚。
步骤 14-17 是收尾。网关执行 rebind(就是上一节伪代码里那个函数),玩家连接纹丝不动,画面连续——"无缝"的全部秘密其实就是这一句:客户端根本不知道世界换了一半。旧 Zone 把残影保留 3 秒再销毁,因为客户端的插值缓冲(见第 20 章)里还放着旧位置数据,立刻销毁会造成视觉瞬跳。
协议设计得再漂亮,线上跑起来看的都是失败路径。三条退路必须提前写好,一条都不能省:
- 失败 A:预热失败(新 Zone 超时、内存不足、版本不匹配)。World 中止迁移,旧 Zone 继续权威。玩家在缓冲带里被软性减速——表现为一层雾墙加"区域加载中"——绝不强推。预热失败率本身就是信号:它升高通常意味着新 Zone 快满了,该触发扩容。
- 失败 B:Commit 失败。新 Zone 占位成功但应用增量出错。状态以旧 Zone 冻结前的检查点为准,解冻恢复,重试计数加一。连续 3 次失败,把玩家标记为"卡边界",引导回城或走传送点。宁可体验受损,不可状态分裂。
- 失败 C:切换后新 Zone 崩溃。World 心跳发现后,用最近快照加增量日志在健康 Zone 上重建实体,玩家画面回弹几百毫秒。前提是一条纪律:旧 Zone 的冻结检查点必须保留到新 Zone 稳定运行一个确认窗口(约 5 秒)才能删。这条纪律值一次全服事故。
把实体在这段旅程里的所有状态画成状态机,回滚路径就一目了然:
stateDiagram-v2
[*] --> Stable: 在Zone内正常tick
Stable --> Buffering: 进入边界缓冲带
Buffering --> GhostReady: 对端幽灵占位完成
Buffering --> Stable: 折返离开缓冲带(中止)
GhostReady --> Transferring: 越过移交线
GhostReady --> Stable: 折返离开(销毁幽灵)
Transferring --> Stable: Commit成功, 新Zone权威
Transferring --> RollingBack: Commit失败/超时
RollingBack --> Stable: 解冻旧Zone, 玩家回弹
RollingBack --> Quarantined: 连续3次失败
Quarantined --> Stable: 引导回城/传送后解除
Transferring --> Recovering: 切换后新Zone崩溃
Recovering --> Stable: 快照+日志重建完成
Recovering --> [*]: 重建失败, 强制下线重登下面这段伪代码是 World 侧的接管流程,对照时序图的步骤编号读:
# 伪代码:World 侧的跨区接管状态机
def handover(player, za, zb):
token = begin_migration(player) # 登记迁移单, 全局唯一
# 预热(对应步骤4-7): 失败走路径A
ok = zb.prepare_entity(player.snapshot_meta(), timeout=2.0)
if not ok:
abort(token) # ZA 继续权威, 玩家软减速
za.set_soft_slow(player, True); return FAIL_PREPARE
# 移交(步骤8-13): 三次握手
za.freeze(player) # 停写, 输入由网关缓冲
delta = za.final_delta(player) # 冻结时刻的最终增量
if not zb.commit(player, delta): # 失败走路径B
za.unfreeze(player, rollback_to=delta.checkpoint)
return retry_or_quarantine(token) # 重试, 3连败则隔离
# 切路(步骤14): 网关换上游, 玩家无感知
gateway.rebind(player.session, zb.addr)
zb.activate(player) # ZB 成为权威
# 收尾(步骤16-17): 检查点保留到确认窗口结束
za.release_ghost(player, grace_sec=3.0)
schedule(token, 5.0, lambda: za.drop_checkpoint(player))
return OK三处易错点:final_delta 必须包含冻结瞬间的全部脏状态,少一个 Buff 层数都是线上事故;rebind 必须在 activate 之前完成,否则出现"权威已切换但包还在送旧 Zone"的窗口期;drop_checkpoint 是延时动作,绝不允许同步执行——新 Zone 活过确认窗口之前,旧检查点就是玩家的救命索。
角色不是一个人过边界,他身后还拖着一串附属实体:飞在半空的弹道、召唤物、正在追他的 NPC、放在地上的图腾。生产级协议必须给它们各自一个交代,原则是一条依附链:弹道的权威跟随其"当前所在空间"——箭越过移交线,命中判定权随空间移交,由新 Zone 的弹道实体接管飞行(这也是为什么缓冲带里幽灵要提前存在);召唤物与宠物跟随主人,打包进主人的 final_delta 一起迁移,不允许独立跨区;追击中 NPC 的处理最反直觉——NPC 属于旧 Zone 的模拟资产,不能跟玩家走,所以边界就是 NPC 的"脱战线":追到缓冲带边缘就脱离仇恨折返,这是 WOW 玩家都熟悉的"怪追你到地图边就回去了"的实现根源。图腾类地面物件留在旧 Zone,到期自然销毁。一句话:人走,状态走;世界的东西,留下。
分线:同一个世界的平行副本怎么调人口
Zone 解决"地图太大",分线(Channel,玩家口中的"线",WOW 里叫位面)解决"人太多":把整张地图连同它的 Zone 集群整体复制一份,人口平行分流。「远港 Online」晚高峰按 8 条线设计,每条线都是一套完整的 4 Zone 加外围服务。
分线和分区经常被混为一谈,分工其实很清楚:
| 维度 | Zone(分区) | Channel(分线) |
|---|---|---|
| 切什么 | 空间:一张地图切成几块 | 整体:整张地图复制几份 |
| 玩家感知 | 无感,走着走着就过去了 | 有感,登录时面对选线界面 |
| 解决什么 | 单进程装不下地图 | 单张地图装不下人 |
| 数据共享 | 同一个世界,边界互相可见 | 平行世界,原则上互不共享 |
开线策略:某条线负载超过 85% 并持续几分钟,开新线,新登录玩家优先导向新线。注意负载的算法——全线人头数是骗人的,真正该看的是各 Zone 的最坏密度。一条线总共 800 人听起来宽松,但如果主城 Zone 挤了 350 人,这条线就该停止导流了。单 Zone 密度才是承载公式的真实输入,全线的平均数会骗你。
合线策略:负载低于 20% 持续 10 分钟,把线标记为"合线候选",停止新玩家进入,发公告引导,等自然流失归零再回收进程。为什么不能直接踢人合并?因为玩家在一条线上有社交上下文:约好的队友、谈到一半的交易、蹲了两小时的稀有怪。WOW 的位面系统在主城把人换来换去被玩家骂了很多年,破坏的就是"我刚看到的那个人去哪了"的社交连续性。合线只能等,不能推——这条线的成本(多开几组进程)和玩家的信任比,便宜得多。
flowchart TD
A[World每30s扫描各线负载] --> B{最坏Zone密度
超过85%?}
B -- 是 --> C[开新线或唤醒备用线
新登录玩家导向新线]
B -- 否 --> D{全线负载低于20%
且持续10分钟?}
D -- 否 --> E[维持现状]
D -- 是 --> F[标记合线候选
停止新玩家进入]
F --> G{候选线人数
自然归零?}
G -- 否 --> H[公告引导换线
继续等待]
H --> G
G -- 是 --> I[回收进程入线池
保留快速唤醒能力]玩家在选线界面看到的是"1 线(繁忙)"四个字,背后跑的是这段优先级逻辑:
# 伪代码:选线策略
def pick_channel(player):
candidates = [c for c in alive_channels() if not c.merging]
# 1. 社交优先: 队伍/好友/公会成员所在的线
for ch in candidates:
if ch.has_social_link(player) and ch.worst_zone_load() < 0.9:
return ch
# 2. 会话粘性: 回上次所在线, 避免玩家世界"变脸"
last = player.last_channel
if last in candidates and last.worst_zone_load() < 0.9:
return last
# 3. 负载最低且未满的线
ch = min(candidates, key=lambda c: c.worst_zone_load(), default=None)
if ch and ch.worst_zone_load() < 1.0:
return ch
# 4. 全线满载: 进排队队列, 位置实时下发
return queue.enqueue(player)四个优先级一句人话:跟朋友在一起 > 回自己熟悉的家 > 去人少的 > 都满了就排队。合线候选线(merging)在任何一条里都不参与分配——只出不进,直到归零。
还有一类玩法绕开分线的隔离:跨线组队副本、战场的实现是"按需聚合"——匹配完成后,把整队人临时拉进一条专门的副本线,打完放回原线。玩家感觉世界是一个整体,实现上是无数个可组合的格子间。这套机制同时也是分线的泄压阀:主城再挤,副本体验永远是独享的。
分线有一个经济面的例外要提前埋好:拍卖行这类全服唯一经济设施不能按线复制,否则同一物品在 8 条线出现 8 种价格,商人立刻跨线套利。做法是拍卖行挂在 World 层,物品挂单时从角色背包扣进 World 托管,成交再跨线投递——第 27 章的经济系统会接着这个话题展开。
远港 Online 总图:容量公式与补课·服务发现熔断
前五节拼完,收进一个公式。一个 MMO 世界的总容量:
代入「远港 Online」:4 个 Zone × 单 Zone 承载 400 人 × 8 条线 = 12800 人/世界。
马上有人发现矛盾:第 24 章明明说单进程 100 人走钢丝,这里单 Zone 凭什么 400 人?差异在密度假设。100 人那个数字来自"主城挤成一团互相可见"的最坏情况,每个玩家的兴趣集合接近全量,单玩家成本 0.25ms。Zone 拆分之后,野外 400 人分散在 16 平方公里,人均可见实体降到几十个,单玩家成本落到 0.08ms 量级:33.3ms ÷ 0.08ms ≈ 400。主城怎么办?单独占一个 Zone,摊位和 NPC 密度做进预算,逼近 500 人就让分线先顶着分流——永远不要让任何一个 Zone 回到第 24 章那条钢丝上。这个公式给出的是规划值,上线前要用真实密度压测校准——上了线再拿它当承诺就晚了。
容量公式只管"装多少人",还有一个隐藏约束管"人能多自由地流动"——跨区切换速率上限:
World 的迁移处理线程 8 个,单次正式移交约 200ms,吞吐上限就是 40 次/秒。日常跑图离这个上限很远,但 40 人团战对冲同时越界就是一次脉冲。缓冲带预热把重活(拉快照、建幽灵)全部异步化了,正式移交只剩冻结、增量、切路三个轻动作,就是为了把这种脉冲压进吞吐上限。如果压不住,排队迁移的玩家会在边界上集体减速——你宁可要减速,也不要 World 线程被冲垮后的全线迁移停摆。
把全图各服务的工程属性收成一张表,架构选型一目了然:
| 服务 | 有/无状态 | 崩溃影响面 | 扩容方式 | 数量级(远港8线) |
|---|---|---|---|---|
| 网关 | 无(表可重建) | 该网关玩家重连,秒级恢复 | 水平堆,负载均衡 | 8-16 |
| 登录服 | 无 | 新登录排队变长 | 水平堆 | 2-4 |
| World | 有(注册表) | 迁移停摆,战斗不中断 | 主备,难扩 | 2(主+备) |
| Zone | 有(世界模拟) | 该区域玩家回弹/救援 | 按图按线固定 | 4×8=32 |
| 聊天服 | 弱状态 | 聊天中断,玩法无感 | 水平堆 | 4 |
| DB代理 | 无(缓存易失) | 存档读写排队 | 水平堆 | 4-8 |
表里只有 World 用"主备"而不用"水平堆",值得单独说两句。World 持有全局实体注册表和迁移账本,是有状态服务,没法靠加机器分担。它的保命方式是老派的主备热同步:主 World 把每条注册表变更以操作日志的形式实时喂给备机,备机只做一件事——追平。主机心跳消失,备机带着几秒前的账本接管。接管的代价是这几秒窗口内的迁移请求全部作废重发,玩家侧表现为跨区动作重试了一次,无感。为什么不学网关做成无状态?因为"当前权威是谁"这本账必须有一个单点写者,分布式共识(Raft 那一套)能解,但为它付出的复杂度远超主备——MMO 架构里,能用主备解决的问题,不要上共识协议。
补课:本节补分布式基础的两个直觉——服务发现与熔断。做过 K8s 或微服务的读者可以跳着看,但注意游戏服务器和 Web 服务的两处关键差异:长连接和强状态。
服务发现的直觉:所有进程启动时向注册中心(Consul、etcd 或自研,原理相同)登记一个三元组——我是谁、我在哪、我还活着。网关不读写死的 IP 列表,启动时从注册表拉 Zone 地址,定时刷新订阅。Zone 扩容、换机器、IP 变更,网关和 World 自动跟上,不需要改任何配置、不需要重启任何人。类比 K8s:注册中心是 etcd,网关就是 kube-proxy。区别在于 Web 服务发现管的是短连接请求路由,游戏网关管的是上万条长连接的粘滞——节点下线时远不止"下次请求换个上游"——玩家连接是粘滞的,必须主动通知 World 安排整批玩家迁移或救援。
健康感知要两条路互补。注册中心心跳是秒级的,管进程死活;网关对上游的主动探活是百毫秒级的,管业务真假活。Zone 主循环卡死 3 秒在 MMO 里就是事故,而进程心跳照样跳着——只有业务探活能发现"活着但不动"的僵尸进程。
熔断的直觉:网关对某 Zone 连续 N 次探活失败,熔断器从闭合跳到断开——停止向它转发新包,把它标记为"待救援",World 启动上一节的失败 C 流程。熔断器周期性放进一个试探包(半开状态),确认恢复即闭合——断开保护了线路,但故事还没完,半开才是"推上去试试"。类比家里的空气开关:断开是保护线路,半开是"推上去试试"。
玩家视角的 Zone 崩溃长什么样:不掉线。网关保持连接,画面进入"区域加载中",几秒内角色在邻区重建完成。这是第 20 章 CAP 补课的落地——Zone 故障时选可用性,一致性靠快照追平。也是网关无状态化的回报:连接和状态分离,状态死了,连接还活着,世界就还能救回来。
小结
单进程的死因是三个独立的墙:CPU(3000 人需 750ms/tick,超预算 22 倍)、内存带宽、故障域。拆开是生存需要,拿优化当理由就低估了它的分量。架构全景三条纪律:玩家只认识网关;Zone 互不通信,World 是唯一仲裁;数据库前面永远隔着代理。无缝跨区的本质是写权的原子移交:缓冲带预热、三次握手、检查点延时销毁。三条失败路径(预热失败/提交失败/切后崩溃)必须在设计阶段写好,线上补不了。分线管人口,分区管地图;开线看最坏 Zone 密度,合线只能等不能推。容量公式 管装人,跨区速率上限 管流动——两个都要算,算完要用真实密度压测校准。
上手任务
- 容量演算:给你的项目设定目标密度(主城聚集 X 人、野外分散 Y 人),算出单 Zone 承载、需要的 Zone 数和分线数。验收标准:给出完整算式链(单玩家成本 → 单 Zone 承载 → 世界总容量),每个数字注明假设,数量级自洽。
- 迁移时序复刻:不看本章时序图,自己画一遍跨区接管序列,标注每一步的失败回滚分支,再回来对照。验收标准:不少于 5 个状态、3 条回滚路径,且能口头说清"为什么 World 必须经手而不能两个 Zone 直连"。
- 网关救援演练:在第 24 章的三进程环境(1 服 2 客户端)前面加一个本地 UDP 转发器当"假网关"。运行中杀掉转发器并用新实例顶替(恢复路由表)。验收标准:客户端 3 秒内恢复收包,角色位置无跳变,日志能完整还原"断开—重建—恢复"三段。
下一章
状态在进程之间飞来飞去,网关转发它、Zone 模拟它、World 仲裁它——但所有这一切的终点,是把它安全可靠地写进磁盘。第 26 章讲持久化:热温冷三层数据、回存管道,以及写丢一件装备之后该怎么收拾。
延伸阅读
- 官方文档:Unreal Engine → Networking → Replication Graph——单进程内相关性管理的上限方案,看懂它能理解为什么它解决不了跨进程问题。
- 官方文档:Unreal Engine → World Partition → Data Layers——本章 Zone 按格子圈定管辖范围的引擎地基。
- GDC Vault 检索 "EVE Online single shard" 与 "Destiny network model":两个极端样本——一个把全宇宙塞进单一世界集群,一个把世界切成海量活动实例。对照看最能建立架构直觉。
- Michael Nygard《Release It!》熔断器与超时模式章节:本章熔断三态的出处,Web 语境但直觉完全通用。
- 源码:
Engine/Source/Runtime/Engine/Classes/Engine/NetDriver.h与同目录PacketHandler——UE 传输层的扩展点,评估"透明转发"路线时从这里读起。