第7章:游戏系统框架——从登录到游戏世界
"十年前我第一次写游戏服务器的时候,把所有逻辑塞在一个进程里。玩家登录、战斗计算、世界状态……全在一起。那天上线测试,200人同时登录,进程直接卡死。我才明白:服务器不是越集中越好,而是越分离越能活。"
7.1 为什么需要三层架构?——先讲个故事
想象你开了一家装潢华丽的大型游乐场。
**门口检票员(Gate)**只干一件事:验票、指路、维持排队秩序。他不卖票,也不修过山车。如果门口排了长龙,你只需多派几个检票员——不需要把整个游乐场复制一遍。
**售票处(Login)**只负责查你是不是买了票、有没有被拉黑。它不关心游乐场里哪个项目最火。如果有人在门口争执"我明明买了票",售票处的人专门处理这类纠纷,不影响里面玩家的体验。
**游乐场本身(World)**才是真正运行游戏逻辑的地方。过山车的轨道、旋转木马的音乐、鬼屋的机关——都在这里。这里最复杂,也最脆弱,所以必须被保护起来,不能随便让外人直接闯进来。
这就是 Gate / Login / World 三层架构 的直觉。
在现实中,直接让玩家客户端连到 World 服务是灾难性的:
- 安全问题:World 暴露在外网,每一个恶意请求都可能攻击核心逻辑
- 扩容问题:World 承载游戏状态,很难水平扩展;Login 和 Gate 无状态,随便加机器
- 耦合问题:登录验证、连接管理、业务逻辑混在一起,改个登录流程可能把战斗系统搞崩
分离之后,每一层只跟相邻层说话,各尽其职,各死各的。
7.2 三层架构的职责边界
flowchart LR
Client["玩家客户端"] -->|"TCP长连接"| Gate["Gate进程
(网络接入层)"]
Gate -->|"内部协议"| Login["Login进程
(验证调度层)"]
Login -->|"验证通过后转发"| World["World进程
(游戏逻辑层)"]
Gate -->|"验证通过后直接转发"| World7.2.1 Gate:门卫与路由器
Gate 是玩家客户端接触的第一个服务器进程。它的核心职责可以概括为四个字:"接进来,送出去"。
具体来说:
- 连接管理:维护成千上万的 TCP 长连接,处理断线重连、心跳检测
- 协议转发:把客户端的包往 Login 或 World 送,把服务器的包往客户端送
- 负载均衡:一个 Gate 扛不住时,新开 Gate 进程,客户端连到任意 Gate 都能工作
- 安全防护:防洪水攻击、包大小校验、频率限制——替 World 挡子弹
Gate 是无状态的。它不记住某个玩家是谁、在哪个地图、有多少金币。这意味着 Gate 进程可以随意重启、扩容、缩容,不影响任何玩家数据。这是架构设计上最宝贵的特性之一。
7.2.2 Login:验票员与调度中心
Login 进程处理一切跟"你是谁"有关的事情:
- 账号验证:用户名密码、第三方 OAuth、短信验证码——都在这里校验
- Token 颁发:验证通过后,生成一个短期有效的 Token,玩家凭此 Token 去连接 World
- 选服/分流:根据负载情况,告诉玩家"你去 World-3 吧,那边人少"
- 账号状态查询:是否被封禁、是否需要强制改密码、是否有多端登录冲突
Login 也是无状态的(或者说,只依赖外部存储如 Redis/MySQL)。它可以横向扩展,10万并发登录?开 20 个 Login 进程,前面挂个负载均衡器就行。
7.2.3 World:游乐场的心脏
World 是有状态的,也是最复杂的。它持有:
- 游戏世界状态:地图、NPC、怪物刷新、天气系统
- 玩家实时数据:位置、血量、背包、技能 CD
- 实时交互逻辑:战斗计算、技能释放、掉落分配、组队同步
World 进程通常按"场景"或"地图"拆分。比如:
- World-A 负责新手村和主城
- World-B 负责副本"暗黑地牢"
- World-C 负责 PVP 竞技场
玩家跨场景时,World 之间通过内部协议移交玩家状态——就像游乐场里从一个园区走到另一个园区,你的"游玩记录"跟着你一起走。
7.3 基于 ECS 框架的 Gate 和 World 工程
7.3.1 为什么 ECS?
ECS(Entity-Component-System)不是 Unity 的专利,它是一种数据导向的设计思想,在服务端同样威力巨大。
传统的 OOP 写法:
class Player {
string name;
int hp;
int mp;
void castSkill() { ... }
void moveTo() { ... }
};问题在哪?Player 类越来越肥,继承树越来越深。一个"能战斗的 NPC"该继承 NPC 还是 CombatUnit?ECS 的回答是:别继承,组合。
ECS 的核心思想:
- Entity:只是一个 ID,一个空壳
- Component:纯数据,没有逻辑。比如 PositionComponent {x, y, z}、HealthComponent {hp, maxHp}
- System:纯逻辑,没有数据。比如 MovementSystem 遍历所有有 PositionComponent 和 VelocityComponent 的 Entity,更新位置
7.3.2 Gate 中的 ECS 设计
在 Gate 里,Entity 可能不是"玩家",而是**"连接"**。
// 连接实体
struct ConnectObj {
uint32 connId; // 连接唯一标识
};
// 组件:TCP 连接信息
struct TcpConnComponent {
int fd;
string ip;
uint16 port;
time_t lastHeartbeat;
};
// 组件:认证状态
struct AuthComponent {
uint64 accountId;
string token;
bool isVerified;
time_t authTime;
};
// 组件:路由目标
struct RouteComponent {
uint32 targetWorldId; // 该连接应该转发到哪个 World
uint32 targetLoginId; // 该连接应该转发到哪个 Login
};System 的例子——心跳检测系统:
class HeartbeatSystem {
public:
void Update(float dt) {
auto& conns = ecs.Query<TcpConnComponent>();
for (auto& conn : conns) {
if (now - conn.lastHeartbeat > HEARTBEAT_TIMEOUT) {
// 断开连接,清理组件
ecs.DestroyEntity(conn.entityId);
CloseSocket(conn.fd);
}
}
}
};7.3.3 World 中的 ECS 设计
World 里的 Entity 就是真正的游戏对象:玩家、NPC、道具、陷阱。
// 位置组件
struct PositionComponent {
float x, y, z;
float facing; // 朝向
};
// 移动组件
struct VelocityComponent {
float vx, vy, vz;
float speed;
};
// 玩家属性组件
struct PlayerAttrComponent {
uint64 playerId;
string nickname;
uint16 level;
uint64 exp;
};
// 战斗组件
struct CombatComponent {
int hp;
int maxHp;
int mp;
int maxMp;
float attack;
float defense;
};
// 技能组件
struct SkillComponent {
vector<Skill> skills;
map<uint32, float> cooldowns; // skillId -> 剩余CD
};关键洞察:ECS 让 World 可以按需加载组件。一个"静止的装饰性 NPC"不需要 VelocityComponent 和 CombatComponent,内存就省下来了。10000 个 Entity 里,只有 500 个在战斗中,MovementSystem 和 CombatSystem 只需要处理这 500 个——缓存友好,性能爆炸。
7.4 World 和 Login 服务类设计
7.4.1 World 服务类
class WorldService : public NetworkService {
public:
bool Init(uint32 worldId, const WorldConfig& config);
void Update(float dt);
void Shutdown();
// 玩家进入/离开
bool OnPlayerEnter(uint64 playerId, const PlayerSnapshot& snapshot);
void OnPlayerLeave(uint64 playerId);
// 接收来自 Gate 的消息
void OnMessageFromGate(uint32 gateId, uint64 connId, PacketPtr packet);
// 内部广播
void BroadcastToPlayers(const vector<uint64>& playerIds, PacketPtr packet);
void BroadcastToArea(float x, float y, float radius, PacketPtr packet);
private:
uint32 m_worldId;
ECSWorld m_ecs; // ECS 世界
AreaManager m_areaMgr; // AOI 区域管理
TimerManager m_timerMgr; // 定时器(技能CD、BUFF持续时间)
map<uint64, EntityId> m_playerEntities; // playerId -> entityId
};WorldService 的 Update 是主循环的心跳,每帧执行:
- 接收并分发网络消息
- 执行所有 System(移动、战斗、AI、BUFF)
- 同步状态变化给客户端
- 清理已销毁的 Entity
7.4.2 Login 服务类
class LoginService : public NetworkService {
public:
bool Init(const LoginConfig& config);
void Update(float dt);
void Shutdown();
// 来自 Gate 的认证请求
void OnAuthRequest(uint32 gateId, uint64 connId, const AuthRequest& req);
// 来自 World 的玩家下线通知
void OnPlayerLogout(uint64 playerId, uint32 worldId);
private:
// 账号验证(可配置为本地数据库或 HTTP 远程验证)
void VerifyAccount(const string& account, const string& password,
function<void(bool success, const AccountInfo& info)> callback);
// 生成 Token
string GenerateToken(uint64 accountId);
// 查询推荐 World
uint32 SelectBestWorld();
map<string, TokenInfo> m_activeTokens; // token -> {accountId, expiry}
ThreadPool m_verifyThreads; // 多线程验证线程池
};7.5 Player 和 Account 组件类
7.5.1 Account 组件——"你是谁"
Account 是跨 World 的,它回答"这个账号是否合法、是否被封禁"。
struct AccountComponent {
uint64 accountId; // 账号唯一ID
string username;
int accountStatus; // 0=正常, 1=封禁, 2=临时冻结
time_t createTime;
time_t lastLoginTime;
string lastLoginIp;
set<uint32> ownedWorlds; // 该账号在哪些 World 有角色
};Account 数据存储在持久化数据库(MySQL/PostgreSQL),Login 进程读取,但不缓存太多——登录验证完就释放,省内存。
7.5.2 Player 组件——"你在游戏里是谁"
Player 是World 内的,一个 Account 可以在多个 World 有多个 Player(角色)。
struct PlayerComponent {
uint64 playerId; // 角色唯一ID
uint64 accountId; // 所属账号
string nickname;
uint16 level;
uint32 professionId; // 职业
uint64 exp;
int64 gold;
int64 diamond;
// 位置信息(也作为单独的 PositionComponent 存在)
uint32 mapId;
float x, y, z;
// 在线状态
bool isOnline;
time_t loginTime;
uint32 gateId; // 当前从哪个 Gate 接入
uint64 connId; // 在 Gate 上的连接ID
};设计原则:PlayerComponent 只放运行时必要的数据。背包里的每一件物品?那是 InventorySystem 管理的,不直接塞在 PlayerComponent 里,否则这个结构体将膨胀到几百字节,ECS 的缓存优势就毁了。
7.6 多线程账户验证
7.6.1 为什么必须多线程?
账号验证通常涉及:
- 查数据库(账号密码比对)
- 查 Redis(Token 是否有效、登录频率限制)
- 查第三方平台(微信/QQ OAuth 校验)
- 查风控系统(异地登录检测、设备指纹)
这些操作IO 密集,如果放在 Login 的主线程里,一个外部 API 超时 3 秒,整个 Login 服务就卡住 3 秒——后面排队的几千个登录请求全等着。
7.6.2 线程池设计
class ThreadPool {
public:
ThreadPool(size_t numThreads) {
for (size_t i = 0; i < numThreads; ++i) {
m_workers.emplace_back([this] {
while (true) {
function<void()> task;
{
unique_lock<mutex> lock(m_queueMutex);
m_condition.wait(lock, [this] { return m_stop || !m_tasks.empty(); });
if (m_stop && m_tasks.empty()) return;
task = move(m_tasks.front());
m_tasks.pop();
}
task();
}
});
}
}
template<typename F>
void Enqueue(F&& f) {
{
unique_lock<mutex> lock(m_queueMutex);
m_tasks.emplace(forward<F>(f));
}
m_condition.notify_one();
}
private:
vector<thread> m_workers;
queue<function<void()>> m_tasks;
mutex m_queueMutex;
condition_variable m_condition;
bool m_stop = false;
};7.6.3 Login 中的异步验证流程
void LoginService::OnAuthRequest(uint32 gateId, uint64 connId, const AuthRequest& req) {
// 第一步:把验证任务丢进线程池,主线程立刻返回
m_verifyThreads.Enqueue([this, gateId, connId, req]() {
// 在线程池中执行 IO 密集型验证
bool success = false;
AccountInfo info;
// 1. 查数据库验证账号密码
success = Database::QueryAccount(req.account, req.password, info);
// 2. 查风控(示例:异地登录检测)
if (success) {
auto riskLevel = RiskControl::CheckLogin(req.account, req.ip, req.deviceId);
if (riskLevel == BLOCK) {
success = false;
}
}
// 验证完成后,把结果投递回主线程(通过消息队列或回调)
m_mainThreadQueue.Push([this, gateId, connId, success, info]() {
if (success) {
string token = GenerateToken(info.accountId);
uint32 worldId = SelectBestWorld();
SendAuthSuccess(gateId, connId, token, worldId, info);
} else {
SendAuthFail(gateId, connId, ERROR_INVALID_CREDENTIALS);
}
});
});
}关键坑点:
- 线程安全:验证线程不能直接接触主线程的数据结构,必须通过无锁队列或消息投递
- 超时处理:如果线程池任务 10 秒没返回,必须能取消或丢弃,不能让僵尸任务堆积
- 资源泄漏:验证失败时,数据库连接、Redis 连接要及时释放
7.7 TcpClient 连接设计
7.7.1 PacketId 分析
在 Gate / Login / World 之间通信,每个包需要唯一的标识来追踪。
enum class PacketIdType : uint16 {
// Gate <-> Client
C2G_LOGIN_REQ = 1001, // 客户端请求登录
G2C_LOGIN_RES = 1002, // Gate 回复登录结果
C2G_HEARTBEAT = 1003, // 客户端心跳
G2C_HEARTBEAT = 1004, // Gate 心跳回复
// Gate <-> Login
G2L_AUTH_REQ = 2001, // Gate 转发认证请求
L2G_AUTH_RES = 2002, // Login 回复认证结果
G2L_DISCONNECT_NOTIFY = 2003, // Gate 通知玩家断线
// Gate <-> World
G2W_PLAYER_ENTER = 3001, // 玩家进入 World
W2G_PLAYER_ENTER_RES = 3002, // World 确认
G2W_FORWARD_PACKET = 3003, // Gate 转发客户端包给 World
W2G_BROADCAST = 3004, // World 广播给玩家
// World <-> World
W2W_PLAYER_TRANSFER = 4001, // 玩家跨 World 迁移
W2W_ENTITY_SYNC = 4002, // 实体状态同步
};编号规则:
- 1xxx:客户端与 Gate
- 2xxx:Gate 与 Login
- 3xxx:Gate 与 World
- 4xxx:World 与 World
这样一眼就能看出包在跟谁说话,排错时定位极快。
7.7.2 ConnectObj 设计
ConnectObj 是内部服务器之间的连接抽象:
class ConnectObj {
public:
ConnectObj(int fd, uint32 connectId, ConnectType type);
bool Send(PacketPtr packet); // 发送数据包
void OnRecv(const char* data, size_t len); // 接收回调
void Close(); // 主动断开
uint32 GetConnectId() const { return m_connectId; }
ConnectType GetType() const { return m_type; }
bool IsConnected() const { return m_state == CONNECTED; }
private:
int m_fd;
uint32 m_connectId; // 本连接在本进程内的唯一ID
ConnectType m_type; // 对端是什么(GATE/LOGIN/WORLD)
ConnectState m_state;
PacketCodec m_codec; // 粘包/拆包编解码器
queue<PacketPtr> m_sendQueue; // 发送队列
mutex m_sendMutex;
};ConnectId 的分配策略:
class ConnectIdGenerator {
public:
uint32 Alloc() {
// 自增 + 回绕检测
uint32 id = m_nextId.fetch_add(1);
if (id == 0) id = m_nextId.fetch_add(1); // 跳过 0(保留值)
return id;
}
private:
atomic<uint32> m_nextId{1};
};7.7.3 TcpClient 实现
内部服务器作为客户端去连接其他服务器:
class TcpClient {
public:
TcpClient(EventLoop* loop, const Endpoint& endpoint, PacketHandler handler);
bool Connect();
void Reconnect(); // 断线重连
void Send(PacketPtr packet);
void Disconnect();
// 状态回调
function<void()> onConnected;
function<void()> onDisconnected;
function<void(int errorCode)> onConnectFailed;
private:
void OnSocketConnected(int fd);
void OnSocketClosed();
void OnSocketError(int err);
void StartReconnectTimer(); // 指数退避重连
EventLoop* m_loop;
Endpoint m_endpoint;
PacketHandler m_handler;
unique_ptr<Socket> m_socket;
ConnectObjPtr m_connObj;
// 重连策略
int m_reconnectAttempts = 0;
const int MAX_RECONNECT_DELAY_MS = 30000; // 最大 30 秒
};断线重连的指数退避:
void TcpClient::StartReconnectTimer() {
int delay = min(1000 * (1 << m_reconnectAttempts), MAX_RECONNECT_DELAY_MS);
m_reconnectAttempts++;
m_loop->SetTimer(delay, [this]() {
LOG_INFO("Reconnecting to {}:{}, attempt {}",
m_endpoint.ip, m_endpoint.port, m_reconnectAttempts);
Connect();
});
}为什么指数退避? 如果 World 挂了,所有 Gate 同时疯狂重连,1 秒重试一次,100 个 Gate 就是每秒 100 个 TCP 连接冲击。指数退避让大家都慢下来,给 World 重启留口气。
7.8 完整登录流程——一个玩家的旅程
sequenceDiagram
participant C as 客户端
participant G as Gate
participant L as Login
participant W as World
participant DB as 数据库
C->>G: TCP 连接建立
G->>C: 下发服务器列表/版本号
C->>G: C2G_LOGIN_REQ (账号密码)
G->>L: G2L_AUTH_REQ (转发)
L->>DB: 查询账号信息
DB-->>L: 返回结果
L->>L: 生成 Token,选择 World
L->>G: L2G_AUTH_RES (Token + World地址)
G->>C: G2C_LOGIN_RES (Token)
C->>G: 断开(或直接复用连接)
C->>G: 用 Token 连接 Gate
G->>W: G2W_PLAYER_ENTER (Token验证)
W->>W: 加载玩家数据
W->>G: W2G_PLAYER_ENTER_RES
G->>C: 进入游戏世界!7.9 实际工程建议——老工程师的碎碎念
1. Gate 进程数 = CPU 核数 * 2
Gate 是 IO 密集型(大量并发连接),不是 CPU 密集型。进程数太多反而导致上下文切换开销。实测下来,等于或略高于 CPU 核数是最佳点。
2. Login 进程必须支持"优雅降级"
如果数据库挂了,Login 不能全挂。至少要让已在线玩家继续玩,只拒绝新登录。设计一个开关:bool allowNewLogin = true; 数据库异常时置 false。
3. World 进程的内存预分配
ECS 的 Entity 池、Component 池、System 的临时缓冲区——全部在启动时预分配。运行时 new/delete 是性能杀手,也是碎片化的温床。
4. 内部通信用 protobuf,客户端通信用 flatbuffers
- 内部服务器之间:protobuf 压缩率高,带宽宝贵
- 客户端:flatbuffers 零拷贝解析,CPU 宝贵
5. 每根连接都必须有"最后活动时间"
if (now - conn.lastActivity > 5 * 60) {
LOG_WARN("Connection {} idle for 5min, closing", conn.id);
conn.Close();
}僵尸连接不清理,FD 泄漏,最终 EMFILE(Too many open files),整个进程拒绝服务。
6. 日志里必须打印 ConnectId
LOG_INFO("[Conn:{}] Player {} enter world {}", connectId, playerId, worldId);出问题的时候,从客户端报错的截图里拿到玩家ID,反查日志链:Conn:12345 在 Gate-A 上,转发给 Login-B,验证通过,转发给 World-C……全程追踪,10 秒定位。
7.10 本章小结
| 层次 | 职责 | 状态 | 扩展性 |
|---|---|---|---|
| Gate | 连接接入、协议转发、安全防护 | 无状态 | 极佳(随意加机器) |
| Login | 账号验证、Token颁发、选服 | 无状态 | 极佳(随意加机器) |
| World | 游戏逻辑、状态维护、实时交互 | 有状态 | 按场景/地图拆分 |
ECS 框架让 World 的游戏对象管理变得清晰、缓存友好、易于扩展。Player 和 Account 的分离让玩家数据与账号数据解耦,支持一个账号多角色、跨 World 迁移。
多线程账户验证是 Login 的保命设计——别让数据库查询阻塞主循环。TcpClient 的断线重连和指数退避让分布式系统在故障面前有韧性,而不是一碰就碎。
"三层架构不是炫技,是血与泪的教训。每一个分离的边界,都曾经是一次线上事故的墓碑。记住这些边界,尊重它们,你的服务器才能在凌晨三点的流量洪峰里活下来。"
本章完