多人在线游戏架构实战第7章:游戏系统框架——从登录到游戏世界

📑 目录

第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 -->|"验证通过后直接转发"| World

7.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主循环的心跳,每帧执行:

  1. 接收并分发网络消息
  2. 执行所有 System(移动、战斗、AI、BUFF)
  3. 同步状态变化给客户端
  4. 清理已销毁的 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);
            }
        });
    });
}

关键坑点

  1. 线程安全:验证线程不能直接接触主线程的数据结构,必须通过无锁队列或消息投递
  2. 超时处理:如果线程池任务 10 秒没返回,必须能取消或丢弃,不能让僵尸任务堆积
  3. 资源泄漏:验证失败时,数据库连接、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 的断线重连和指数退避让分布式系统在故障面前有韧性,而不是一碰就碎。

"三层架构不是炫技,是血与泪的教训。每一个分离的边界,都曾经是一次线上事故的墓碑。记住这些边界,尊重它们,你的服务器才能在凌晨三点的流量洪峰里活下来。"


本章完