第11章:分布式跳转方案——无缝穿越虚拟世界
本章灵魂提问:当你在A服务器上打怪,走到地图边界,下一秒出现在B服务器上——这期间发生了什么?你的背包、血量、Buff,是怎么跟着你一起"穿越"的?
一、从一个场景说起:跨服副本的诞生
想象一下这个场景:你和公会50个兄弟约好今晚打世界Boss。问题是——你们分散在3个不同的地图服务器上。Boss刷新在"熔火之心",一个地图进程最多承载30人。怎么办?
早年的游戏会告诉你:"请排队"或者"该地图已满"。但现代MMO不允许这种粗暴的体验。于是就有了分布式跳转——让玩家像穿过一扇无形的门一样,从服务器A平滑地移动到服务器B,自己甚至感知不到这一切。
但这件事远比听起来复杂。你需要处理:
- 玩家在A服务器的所有状态(位置、血量、Buff、背包、任务进度……)
- 玩家在B服务器的"出生点"——不能卡在墙里,不能掉到地底下
- 网络连接的切换——A服务器的socket要断开,B服务器要建立新的连接
- 中间状态的并发安全——如果跳转过程中服务器宕机怎么办?
- 其他玩家的视角——你在A服务器消失,在B服务器出现,中间那几毫秒别人看你去了哪里?
这一章,我们就来拆解这套"空间传送"的工程实现。
二、资源数据配置:地图的"身份证"系统
在做跳转之前,你得先知道有哪些地图,它们在哪里,谁负责承载。这就像快递系统要知道全国有多少个仓库。游戏里,这套信息由资源管理器统一维护。
2.1 三层资源架构
// 核心数据结构:资源世界的层级关系
class ResourceManager {
public:
// 所有世界的"户口本"——从资源文件加载
std::unordered_map<int32_t, ResourceWorld*> m_worldResMap;
bool LoadWorldResources(const std::string& configPath) {
// 从XML/JSON配置读取所有地图定义
// 每个地图:ID、名字、大小、出生点、最大人数、关联的服务器组
}
};
class ResourceWorldMgr {
public:
// 按世界类型分组——主城、副本、野外、战场……
std::vector<ResourceWorld*> m_worldByType[WORLD_TYPE_MAX];
ResourceWorld* GetWorldByID(int32_t worldId) {
return m_worldResMap[worldId];
}
};
class ResourceWorld {
public:
int32_t m_worldId; // 地图唯一ID
std::string m_name; // "熔火之心"
int32_t m_maxPlayers; // 最大承载30人
float m_birthPosX, m_birthPosY, m_birthPosZ; // 默认出生点
int32_t m_spaceProcessID; // 负责承载的space进程ID(关键!)
bool m_isInstanced; // 是否是副本(每个队伍独立实例)
std::vector<int32_t> m_neighborWorlds; // 相邻地图(用于边界触发跳转)
};设计哲学:资源层只读、全局共享、启动加载。运行时地图数据(当前在线玩家数、动态出生点偏移)不放在这里,那是World运行时对象的职责。
2.2 配置分离的工程意义
为什么要把资源配置和运行时对象分开?我踩过这个坑。
曾经有个项目把地图配置直接写在World类里,结果策划改了一个出生点坐标,需要重启所有地图进程。后来我们做了Resource层的热更方案:配置改完,通知ResourceManager重新加载,运行时World对象用版本号判断是否需要刷新——就像数据库的乐观锁。
class ResourceWorld {
public:
uint32_t m_configVersion; // 配置版本号
bool IsOutdated() const {
return m_configVersion < g_ResourceMgr->GetLatestVersion(m_worldId);
}
};三、World与WorldProxy:真身与替身
这是本章最核心的设计。理解了World和WorldProxy的关系,你就理解了分布式MMO的一半架构智慧。
3.1 为什么要分两个类?
想象你在一座大城市里有两个身份:
- World = 你在家里的真实状态(银行卡余额、冰箱里有什么、真正的住址)
- WorldProxy = 你在公司登记的联系方式(同事找你通过这个号码,但你本人并不在公司)
在分布式游戏里:
- World存在于某个space进程中,拥有玩家的完整状态、所有NPC、碰撞检测、技能计算——它是"物理真实"。
- WorldProxy存在于其他space进程和gateway进程中,只保留"知道有这个地图存在"的最小信息——它是"逻辑引用"。
// 真身:只存在于承载该地图的space进程中
class World : public WorldProxy {
public:
// World拥有完整状态
std::unordered_map<uint64_t, Player*> m_players; // 所有在线玩家
std::unordered_map<uint64_t, NPC*> m_npcs; // 所有NPC
AOIEntityManager m_aoiMgr; // 空间划分
PhysicsEngine m_physics; // 碰撞检测
void Update(float deltaTime) {
// 真实的游戏逻辑:技能判定、伤害计算、物理模拟……
m_physics.Step(deltaTime);
ProcessSkillEffects();
BroadcastPlayerPositions();
}
};
// 替身:存在于所有需要"知道这张地图存在"的进程中
class WorldProxy {
public:
int32_t m_worldId; // 地图ID
uint32_t m_spaceProcessID; // 真身所在的space进程
bool m_isLocal; // 是否是本进程承载的真身
// 替身只维护"连接信息"——知道谁能把我送过去
struct GateRoute {
uint32_t gateID;
uint64_t sessionID; // 玩家在gateway上的会话
};
std::unordered_map<uint64_t, GateRoute> m_playerRoutes; // 本地图玩家路由表
// 替身没有实体,但可以转发消息
void ForwardToWorld(PlayerMsg& msg) {
if (!m_isLocal) {
// 通过进程间通信转发给真身
SendToSpaceProcess(m_spaceProcessID, msg);
}
}
// 替身也能处理"跳转请求"——这是它的核心职责之一
bool HandleTransferRequest(uint64_t playerID, int32_t targetWorldId) {
// 检查玩家是否真的在这个地图
auto it = m_playerRoutes.find(playerID);
if (it == m_playerRoutes.end()) return false;
// 向目标地图发起跳转协商
return RequestTransferToTarget(playerID, targetWorldId);
}
};3.2 WorldProxy的精妙之处
我第一次看到这种设计时,脑子里就一个字:妙。
设想没有这个Proxy层会怎样:gateway进程想知道"玩家要去熔火之心,该连哪个space进程",它要么自己维护一份映射表(重复数据),要么每次查数据库(太慢)。有了WorldProxy,任何进程都可以持有任何地图的引用,只是这个引用是"轻量级的替身"。
这意味着:
- Gateway持有所有WorldProxy——它不需要知道World内部有什么,只需要知道"把玩家消息路由到哪个space进程"。
- 其他Space进程也持有邻居地图的WorldProxy——玩家站在A地图边界,A地图需要知道B地图的space进程ID才能发起跳转。
- 全局视图进程(比如GM工具、匹配系统)持有全部WorldProxy——它们可以监控全服地图状态而不占用大量内存。
graph TB
GW[Gateway进程
持有全部WorldProxy] -->|"玩家A的消息
路由到Space-1"| S1[Space-1进程
World: 主城
WorldProxy: 副本A, 副本B]
GW -->|"玩家B的消息
路由到Space-2"| S2[Space-2进程
World: 副本A
WorldProxy: 主城, 副本B]
S1 -->|"跳转请求: 主城→副本A"| S2
S2 -->|"跳转确认+玩家数据"| S1四、分布式地图跳转完整流程
好了,理论讲完了。现在来看一次真实的跨服跳转,从触发到完成,每一步发生了什么。
4.1 触发跳转的时机
跳转不是随便发生的。常见触发源:
enum TransferTrigger {
TRIGGER_BORDER_CROSS, // 走到地图边界(最常见)
TRIGGER_NPC_DIALOG, // 和传送NPC对话
TRIGGER_ITEM_USE, // 使用传送卷轴
TRIGGER_MATCHMAKING, // 匹配系统分配副本
TRIGGER_GM_COMMAND, // GM强制传送
TRIGGER_PARTY_FOLLOW, // 跟随队长跳转
};以最常见的边界触发为例:
// 在World::Update中每帧检测
void World::CheckBorderTransfer(Player* player) {
if (player->posX > m_maxX || player->posX < m_minX ||
player->posZ > m_maxZ || player->posZ < m_minZ) {
// 查出界的方向对应哪个邻居地图
int32_t neighborWorldId = GetNeighborByDirection(player->posX, player->posZ);
if (neighborWorldId > 0) {
// 异步发起跳转——不能在Update线程阻塞
m_transferQueue.Push(player->id, neighborWorldId);
}
}
}关键设计:跳转是异步的。不能在主逻辑线程里等网络往返——那会卡住整个地图的所有玩家。
4.2 跳转数据定义:玩家状态的"快照"
跳转前要打包玩家的全部状态。这就像搬家时要列一张清单:
struct PlayerTransferData {
// ===== 身份标识(不可变) =====
uint64_t playerID;
uint32_t accountID;
std::string playerName;
// ===== 空间状态 =====
float posX, posY, posZ;
float rotY; // 朝向(到目标地图后保持面向一致)
float velocityX, velocityY; // 移动速度(防止落地时突然停下)
// ===== 战斗状态 =====
int32_t hp, maxHp;
int32_t mp, maxMp;
std::vector<BuffSnapshot> activeBuffs; // 持续时间>0的Buff
uint32_t currentSkillID; // 正在施放的技能(需要打断或继续?)
// ===== 背包与装备 =====
InventorySnapshot inventory; // 序列化的背包数据
EquipmentSnapshot equipment; // 当前装备(影响属性计算)
// ===== 任务与剧情 =====
std::vector<int32_t> activeQuests;
QuestProgressSnapshot questProgress;
// ===== 社交状态 =====
uint64_t partyID; // 队伍ID(跳转后需要重新加入队伍频道)
uint64_t guildID;
// ===== 时间戳(用于校验) =====
uint64_t transferStartTime; // 跳转发起时间
uint32_t transferSequence; // 序列号(防重放攻击)
};一个血泪教训:我们曾经忘记传"朝向"(rotY),结果玩家每次跳转后都面壁,体验极差。后来我们做了自动化测试:每个字段新增时,必须在测试服跑1000次随机跳转,校验所有字段的一致性。
4.3 完整跳转流程(核心!)
这里我画一个详细的时序图,然后逐段解释代码:
sequenceDiagram
participant Client as 客户端
participant GW as Gateway
participant S1 as Space-A
(源地图)
participant S2 as Space-B
(目标地图)
participant DB as 数据库
Note over Client,DB: Phase 1: 跳转协商
Client->>S1: 走到边界
S1->>S1: CheckBorderTransfer()
确认触发跳转
S1->>S2: TRANSFER_REQ(playerID, targetPos, transferData)
S2->>S2: ValidatePosition()
检查出生点合法性
S2->>DB: 预占坑位(可选)
DB-->>S2: 确认
S2-->>S1: TRANSFER_ACK(accept, birthPos)
Note over Client,DB: Phase 2: 状态迁移
S1->>S1: LockPlayerState()
冻结玩家操作
S1->>S1: SerializePlayer()
生成TransferData
S1->>S2: TRANSFER_DATA(PlayerTransferData)
S2->>S2: CreatePlayerStub()
创建玩家占位符
S2-->>S1: TRANSFER_DATA_ACK
S1->>S1: RemovePlayerFromWorld()
从源地图移除
Note over Client,DB: Phase 3: 网络切换
S1->>GW: NOTIFY_TRANSFER_READY
(playerID, newSpaceID)
GW->>GW: UpdateRouteTable()
修改玩家路由
GW->>Client: MSG_TRANSFER_JUMP
(newGateAddr, sessionToken)
Client->>Client: 显示Loading界面
Client->>GW: 建立新连接
GW->>S2: 新玩家连接到达
S2->>S2: UpgradeStubToFullPlayer()
占位符转正
S2-->>Client: 新地图数据包(AOI实体全量同步)
Client->>Client: 隐藏Loading,渲染新地图Phase 1:跳转协商(Reserve)
// 源Space进程发起
void World::InitiateTransfer(uint64_t playerID, int32_t targetWorldId) {
Player* player = GetPlayer(playerID);
// 1. 找到目标地图的Proxy——知道该找哪个space进程
WorldProxy* targetProxy = g_WorldMgr->GetWorldProxy(targetWorldId);
if (!targetProxy) {
player->Notify("目标地图暂时无法到达");
return;
}
// 2. 向目标space进程发送跳转请求
TransferRequestMsg req;
req.playerID = playerID;
req.preferredPos = CalculateExitPosition(player); // 根据出界方向算出生点
req.expectedArrivalTime = Now() + 500; // 预计500ms后到达
SendToSpaceProcess(targetProxy->m_spaceProcessID, req);
}
// 目标Space进程处理
void WorldProxy::OnTransferRequest(const TransferRequestMsg& req) {
// 1. 检查人数上限
World* realWorld = GetLocalWorld(); // 如果本进程就是真身
if (realWorld && realWorld->GetPlayerCount() >= m_maxPlayers) {
SendTransferReject(req.playerID, TRANSFER_FAIL_FULL);
return;
}
// 2. 校验出生点(不能出生在水里/墙里/空气墙外)
Vector3 adjustedPos = ClampToValidPosition(req.preferredPos);
// 3. 回复确认
TransferAckMsg ack;
ack.playerID = req.playerID;
ack.accepted = true;
ack.finalBirthPos = adjustedPos;
SendToSource(ack);
}Phase 2:状态迁移(Migration)
// 源Space:冻结→序列化→发送→移除
void World::ExecuteTransfer(uint64_t playerID, const TransferAckMsg& ack) {
Player* player = GetPlayer(playerID);
// Step 1: 冻结玩家——禁止一切输入和操作
player->SetState(PLAYER_STATE_TRANSFERING);
player->FlushPendingOperations(); // 把待处理的技能/移动请求全部执行或丢弃
// Step 2: 序列化完整状态
PlayerTransferData data;
data.playerID = player->id;
data.posX = ack.finalBirthPos.x; // 使用目标地图调整后的出生点
data.posY = ack.finalBirthPos.y;
data.posZ = ack.finalBirthPos.z;
data.hp = player->GetHP();
// ... 所有字段
player->SerializeInventory(data.inventory);
player->SerializeQuests(data.questProgress);
// Step 3: 发送给目标
TransferDataMsg dataMsg;
dataMsg.data = SerializeToBinary(data);
SendToTargetSpace(dataMsg);
// Step 4: 等确认后再移除(如果发送失败可以回滚)
// 这个ACK不是必须等,但建议等——网络不好时宁可让玩家卡一下也不要丢数据
WaitForTargetAck(playerID, timeout: 3s);
// Step 5: 从本世界移除
RemovePlayer(player);
BroadcastToAOI(player->pos, AOI_MSG_PLAYER_DISAPPEAR, player->id);
// Step 6: 通知Gateway更新路由
g_GatewayMgr->UpdatePlayerRoute(playerID, ack.targetSpaceID);
}
// 目标Space:接收数据→创建占位符→等待连接
void World::OnTransferData(const TransferDataMsg& msg) {
PlayerTransferData data = Deserialize(msg.data);
// 创建"Stub"——占位符玩家
PlayerStub* stub = new PlayerStub(data.playerID);
stub->SetPosition(data.posX, data.posY, data.posZ);
stub->SetHP(data.hp);
// ... 恢复所有状态
stub->SetTransferData(data); // 保留原始数据,等客户端连接后"激活"
m_pendingTransfers[data.playerID] = stub;
// 回复确认——源Space现在可以安心移除了
SendTransferDataAck(data.playerID);
}Phase 3:网络切换(Handover)
这是最容易出问题的环节。玩家的TCP连接本来连在Space-A,现在要连到Space-B。怎么做到"无缝"?
// Gateway的关键设计:路由表动态更新
class GatewayPlayerRoute {
public:
uint64_t playerID;
uint32_t currentSpaceID; // 当前玩家数据所在的space进程
uint64_t sessionID; // 客户端在gateway上的会话标识
void UpdateRoute(uint32_t newSpaceID) {
// 原子切换——客户端无感知
uint32_t oldSpaceID = currentSpaceID;
currentSpaceID = newSpaceID;
// 通知旧space:这个玩家我不管了
NotifySpaceDetach(oldSpaceID, playerID);
// 通知新space:准备接收连接
NotifySpaceAttach(newSpaceID, playerID);
}
};
// 客户端收到的跳转指令
struct TransferJumpMsg {
std::string newGatewayAddr; // 如果是跨机房的跳转,可能连gateway都要换
uint64_t sessionToken; // 新连接的认证令牌(防伪造)
uint32_t targetWorldId;
float birthPosX, birthPosY, birthPosZ;
};无缝的关键:gateway不切断连接,只修改内部路由表。客户端的socket保持不动,gateway把数据包转发的目标从Space-A改成Space-B。如果Space-A和Space-B在不同物理机上,才需要重连gateway——但即便如此,也可以设计成"gateway集群内的连接迁移"而非完全断开。
4.4 多进程下的玩家状态迁移:并发安全的噩梦
跳转过程中最可怕的事情是什么?玩家同时在两个地图存在——哪怕只有一毫秒。
这会引发什么?
- 双倍经验(两个地图都在给他算杀怪奖励)
- 装备复制(在A地图卖掉了剑,在B地图发现剑还在)
- 无限传送(利用时间差反复触发跳转逻辑)
我们的解决方案是**"三段式状态机"**:
enum PlayerTransferState {
PTS_IDLE, // 正常游戏
PTS_RESERVING, // 已发起跳转,等待目标确认——此时玩家可以动,但禁止发起新跳转
PTS_MIGRATING, // 目标已确认,正在传输数据——玩家被冻结,所有输入丢弃
PTS_PENDING_CONNECT,// 数据已到目标,等待客户端连上——玩家不在任何地图"可见"
PTS_ARRIVED, // 客户端已连接,目标转正——恢复正常
};
class Player {
std::atomic<PlayerTransferState> m_transferState;
bool CanPerformAction(ActionType action) {
auto state = m_transferState.load();
switch (state) {
case PTS_IDLE: return true;
case PTS_RESERVING: return action != ACTION_TRANSFER; // 不能重复跳转
case PTS_MIGRATING: return false; // 完全冻结
case PTS_PENDING_CONNECT: return false;
case PTS_ARRIVED: return true;
}
}
};关键原则:在PTS_MIGRATING状态下,玩家必须从源地图的AOI、碰撞、技能判定中完全移除,但在数据层面还不能删——如果目标Space进程挂了,要能回滚到源地图。
// 回滚机制——目标Space超时未收到客户端连接
void World::OnTransferTimeout(uint64_t playerID) {
auto it = m_pendingTransfers.find(playerID);
if (it != m_pendingTransfers.end()) {
// 目标地图的Stub删除
delete it->second;
m_pendingTransfers.erase(it);
// 通知源Space:回滚!把玩家放回去
SendRollbackToSource(playerID);
}
}五、代码实战:一次完整的跳转实现
下面是精简但完整的代码骨架,你可以对照上面的流程阅读:
// ==================== 源地图:发起跳转 ====================
void World::OnPlayerReachBorder(Player* player, int32_t neighborWorldId) {
// 1. 检查玩家是否已经在跳转中
if (player->GetTransferState() != PTS_IDLE) {
LOG_WARN("Player %lu already transferring", player->id);
return;
}
// 2. 获取目标地图的Proxy
WorldProxy* target = g_WorldProxyMgr->GetProxy(neighborWorldId);
if (!target) return;
// 3. 进入协商状态
player->SetTransferState(PTS_RESERVING);
// 4. 发送跳转请求(异步)
TransferRequest req;
req.playerID = player->id;
req.sourceWorldID = m_worldId;
req.preferredBirthPos = CalculateBorderExitPosition(player);
req.playerDataCRC = player->CalculateDataCRC(); // 用于最终校验
g_RPC->Call(target->GetSpaceProcessID(),
MSG_TRANSFER_REQUEST,
req,
// 回调:收到目标确认后的处理
[this, player](const TransferAck& ack) {
if (ack.accepted) {
this->ExecuteTransfer(player, ack);
} else {
player->SetTransferState(PTS_IDLE);
player->Notify("跳转失败:" + ack.reason);
}
});
}
void World::ExecuteTransfer(Player* player, const TransferAck& ack) {
// 1. 冻结
player->SetTransferState(PTS_MIGRATING);
player->Freeze();
// 2. 序列化
PlayerTransferData data;
player->SerializeTo(data);
data.birthPos = ack.adjustedBirthPos; // 用目标调整后的出生点
// 3. 发送并等待确认
g_RPC->Call(ack.targetSpaceProcessID,
MSG_TRANSFER_DATA,
data,
[this, player, ack](const TransferDataAck& dataAck) {
// 收到目标的数据确认——目标已经收到完整玩家状态
// 现在可以安全地从源地图移除
this->FinalizeRemoval(player, ack);
});
}
void World::FinalizeRemoval(Player* player, const TransferAck& ack) {
// 1. AOI广播:这个人消失了
m_aoiMgr.RemoveEntity(player);
BroadcastToNearbyPlayers(player, AOI_MSG_ENTITY_LEAVE, player->id);
// 2. 从玩家表中移除
m_players.erase(player->id);
// 3. 释放资源(但不delete——数据已经传给目标了)
player->DetachFromWorld();
// 4. 通知Gateway
g_GatewayMgr->PlayerLeftWorld(player->id, m_worldId);
}
// ==================== 目标地图:接收跳转 ====================
void World::OnTransferRequest(const TransferRequest& req) {
// 1. 校验人数
if (m_players.size() >= m_resource->m_maxPlayers) {
SendTransferReject(req.playerID, "地图已满");
return;
}
// 2. 调整出生点
Vector3 finalPos = m_physics.ClampToWalkable(req.preferredBirthPos);
// 3. 回复确认
TransferAck ack;
ack.accepted = true;
ack.targetSpaceProcessID = g_ProcessID; // 本进程的ID
ack.adjustedBirthPos = finalPos;
SendToSource(req.sourceWorldID, ack);
}
void World::OnTransferData(const PlayerTransferData& data) {
// 1. 创建占位符
Player* stub = new Player(data.playerID);
stub->SetTransferState(PTS_PENDING_CONNECT);
stub->DeserializeFrom(data);
stub->SetPosition(data.birthPos.x, data.birthPos.y, data.birthPos.z);
// 2. 加入pending表——还没连上客户端,不算正式玩家
m_pendingPlayers[data.playerID] = stub;
// 3. 通知Gateway:准备好接收这个玩家的连接了
g_GatewayMgr->ExpectPlayerConnection(data.playerID, g_ProcessID);
// 4. 启动超时计时器
m_timerMgr->SetTimeout([this, playerID = data.playerID]() {
this->OnPendingPlayerTimeout(playerID);
}, 10000); // 10秒没连上就清理
}
void World::OnPlayerConnected(uint64_t playerID, uint64_t sessionID) {
// 1. 从pending表中找到占位符
auto it = m_pendingPlayers.find(playerID);
if (it == m_pendingPlayers.end()) {
// 非法连接!这个玩家没有被预期
LOG_ERROR("Unexpected player connection: %lu", playerID);
KickSession(sessionID);
return;
}
Player* player = it->second;
m_pendingPlayers.erase(it);
// 2. 占位符转正
player->SetSessionID(sessionID);
player->SetTransferState(PTS_ARRIVED);
// 3. 加入正式玩家表
m_players[playerID] = player;
// 4. AOI加入——玩家"出现"在地图上
m_aoiMgr.AddEntity(player);
BroadcastToNearbyPlayers(player, AOI_MSG_ENTITY_ENTER, player->GetSnapshot());
// 5. 发送全量同步包——客户端需要渲染周围所有实体
SendFullAOISync(player);
// 6. 隐藏Loading界面
player->Send(MSG_TRANSFER_COMPLETE);
}六、工程陷阱与最佳实践
6.1 跳转中的数据一致性
// 错误示范:在AOI广播后修改数据
BroadcastToAOI(player, LEAVE_MSG);
player->inventory.AddItem(1001); // 这行在移除之后执行!
RemovePlayer(player);
// 结果:目标地图收到的inventory不包含item 1001
// 正确做法:先冻结所有修改,再做原子操作
player->FreezeAllStateChanges(); // 此后的所有Set操作被缓冲
// ... 序列化 ...
// ... 发送 ...
player->ApplyFrozenChangesOrDiscard(); // 要么全应用,要么全丢弃6.2 跨机房的跳转
如果源和目标在不同IDC,网络延迟可能达到50-100ms。这时需要预加载机制:
// 玩家还没走到边界,提前2秒通知目标进程准备
void World::PredictiveTransferHint(Player* player, float distToBorder) {
if (distToBorder < 50.0f) { // 距离边界50单位
// 发送轻量提示,让目标进程预热资源(加载地图数据、预分配内存)
g_RPC->FireAndForget(targetSpaceID, MSG_TRANSFER_HINT, player->id);
}
}6.3 跳转的Metrics
struct TransferMetrics {
uint64_t count; // 总跳转次数
uint64_t totalLatencyMs; // 总延迟(从触发到客户端完成)
uint64_t failureCount; // 失败次数
std::map<std::string, uint64_t> failureReasons;
void RecordSuccess(uint64_t latencyMs) {
count++;
totalLatencyMs += latencyMs;
}
double GetAverageLatency() const {
return count > 0 ? (double)totalLatencyMs / count : 0;
}
};
// 告警:平均跳转时间>500ms,或失败率>1%
// 实时监控面板上必须有一条线叫"Player Transfer P99"七、小结
分布式跳转是MMO架构的"分水岭技术"。
做不好这个,你的游戏永远只是"多个单服拼在一起"——玩家感受到的是加载界面、排队、地图人数上限。做好了,玩家感受到的是一个连续的、无边界的虚拟世界。
核心设计要点:
- WorldProxy让"知道一张地图存在"变得轻量,任何进程都可以参与跳转逻辑
- 三段式状态机(协商→迁移→切换)保证并发安全
- Stub转正机制让目标进程可以提前准备,客户端连上瞬间"激活"
- 回滚机制是最后的保险——网络失败时宁可让玩家卡在原地图,也不能丢到虚空里
老工程师的唠叨:跳转逻辑写出来不难,难的是测试。你至少需要这些测试:
- 1000次正常跳转的数据一致性校验
- 跳转中途目标进程kill -9的回滚测试
- 跳转中途网络分区(iptables -j DROP)的超时测试
- 100人同时从一个地图跳转到另一个地图的压测
- 跳转瞬间玩家释放技能/使用物品的边界测试
不要等上线后让玩家来帮你测。玩家的耐心很贵,bug的代价更高。🔥