多人在线游戏架构实战第8章:网络标识与协议设计——分布式系统的身份证

📑 目录

第8章:网络标识与协议设计——分布式系统的身份证

"2016年我排查一个诡异bug:玩家A充了100块,玩家B收到了钻石。查了三天日志,最后发现是两个并发的登录请求包在Login进程里被调换了顺序。那时候我才深刻理解:在分布式系统里,没有唯一标识的包,就像没有身份证的人——谁知道你是谁?"


8.1 为什么需要网络标识?——从一次生产事故说起

假设这样一个场景:

  1. 玩家"龙傲天"在客户端点击登录
  2. 客户端发了一个登录包给 Gate-A
  3. Gate-A 转发给 Login-1
  4. Login-1 验证成功,生成 Token,告诉 Gate-A:"让他去 World-3"
  5. 同时,玩家手贱又点了一次登录按钮
  6. Gate-A 又发了一个登录包给 Login-1
  7. 这次 Login-1 把 Token 发给了 Gate-B(负载均衡随机分配)
  8. Gate-B 收到 Token,准备去连 World-3
  9. 但 Gate-A 收到第一个 Token 后也准备去连 World-3

现在 World-3 看到两个 Gate 都带着同一个玩家的 Token 过来说"我要登录"。World-3 应该让谁进?第一个到的?第二个到的?还是都拒绝?

如果没有网络标识来区分"这是同一个玩家的两次不同请求",系统只能瞎猜。猜错了,就是数据错乱、玩家投诉、运维半夜被叫醒。

网络标识的本质作用

  • 追踪请求链路:一个请求从客户端出发,经过 Gate、Login、World,最终响应回来,全程靠标识串起来
  • 防止请求混淆:同一连接上的并发请求不会互相覆盖
  • 超时与重试的语义:重发的请求带上原标识,服务端知道"这是重复请求,不是新请求"
  • 日志排查:出了问题,从任意节点的日志里搜同一个标识,还原完整链路

8.2 map类型数据结构——标识的容器

8.2.1 为什么用 map?

分布式系统里,各种对象都需要唯一标识:

  • 连接(ConnectId)
  • 玩家(PlayerId)
  • 请求包(PacketId / SequenceId)
  • 会话(SessionId)
  • Token(TokenId)

这些标识到对象的映射,天然适合用 map(或 unordered_map)存储。

8.2.2 标识查找的性能

// 连接标识 -> 连接对象
unordered_map<uint32, ConnectObjPtr> m_connections;

// 玩家标识 -> 玩家实体
unordered_map<uint64, EntityId> m_playerEntities;

// Token -> Token信息
unordered_map<string, TokenInfo> m_activeTokens;

// 请求标识 -> 请求上下文(用于异步回调匹配)
unordered_map<uint32, RequestContext> m_pendingRequests;

性能数据:unordered_map 平均 O(1) 查找,但在以下情况会退化:

  • 哈希冲突严重时(自定义哈希函数质量差)
  • 频繁 rehash(元素数量剧烈波动)

游戏服务器的优化技巧:

// 预分配桶数量,避免运行时 rehash
m_connections.reserve(10000);

// 使用自定义哈希,让 uint32 均匀分布
struct Uint32Hash {
    size_t operator()(uint32 x) const noexcept {
        // FNV-1a 或 splitmix64,比 std::hash 均匀
        x = (x ^ (x >> 16)) * 0x45d9f3b;
        x = (x ^ (x >> 16)) * 0x45d9f3b;
        x = x ^ (x >> 16);
        return x;
    }
};

unordered_map<uint32, ConnectObjPtr, Uint32Hash> m_connections;

8.2.3 带生命周期的 map——防止标识泄漏

标识不能无限增长。一个连接断了,它的 ConnectId 应该从 map 里删除;一个 Token 过期了,应该从 m_activeTokens 里清理。

class ConnectionManager {
public:
    ConnectObjPtr Get(uint32 connectId) {
        auto it = m_connections.find(connectId);
        if (it != m_connections.end()) return it->second;
        return nullptr;
    }

    void Add(uint32 connectId, ConnectObjPtr conn) {
        m_connections[connectId] = conn;
    }

    void Remove(uint32 connectId) {
        auto it = m_connections.find(connectId);
        if (it != m_connections.end()) {
            // 清理相关资源
            it->second->Close();
            m_connections.erase(it);
        }
    }

    // 定时清理超时连接
    void CleanupExpired() {
        vector<uint32> expired;
        for (auto& [id, conn] : m_connections) {
            if (conn->IsExpired()) {
                expired.push_back(id);
            }
        }
        for (uint32 id : expired) {
            Remove(id);
        }
    }

private:
    unordered_map<uint32, ConnectObjPtr, Uint32Hash> m_connections;
};

8.3 PacketIdType 枚举——协议的户口本

8.3.1 协议号的设计原则

enum class PacketIdType : uint16 {
    // === 客户端 <-> Gate (1xxx) ===
    C2G_LOGIN_REQ           = 1001,   // 登录请求
    G2C_LOGIN_RES           = 1002,   // 登录响应
    C2G_ENTER_WORLD_REQ     = 1003,   // 进入世界请求
    G2C_ENTER_WORLD_RES     = 1004,   // 进入世界响应
    C2G_HEARTBEAT           = 1005,   // 心跳包
    G2C_HEARTBEAT           = 1006,   // 心跳响应
    C2G_MOVE_REQ            = 1011,   // 移动请求
    G2C_MOVE_NTF            = 1012,   // 移动广播(给周围玩家)

    // === Gate <-> Login (2xxx) ===
    G2L_AUTH_REQ            = 2001,   // 认证请求
    L2G_AUTH_RES            = 2002,   // 认证响应
    G2L_LOGOUT_NOTIFY       = 2003,   // 玩家下线通知
    L2G_KICK_NOTIFY         = 2004,   // 强制踢人(封号/顶号)

    // === Gate <-> World (3xxx) ===
    G2W_PLAYER_ENTER        = 3001,   // 玩家进入
    W2G_PLAYER_ENTER_RES    = 3002,   // 进入确认
    G2W_PLAYER_LEAVE        = 3003,   // 玩家离开
    W2G_BROADCAST           = 3004,   // World 广播包
    G2W_FORWARD_PACKET      = 3005,   // Gate 转发客户端包
    W2G_SYNC_ENTITY         = 3006,   // 实体状态同步

    // === Login <-> World (4xxx) ===
    L2W_VERIFY_TOKEN        = 4001,   // 验证 Token 有效性
    W2L_VERIFY_TOKEN_RES    = 4002,   // 验证结果

    // === World <-> World (5xxx) ===
    W2W_PLAYER_TRANSFER     = 5001,   // 玩家跨 World 迁移
    W2W_ENTITY_SNAPSHOT     = 5002,   // 实体快照同步
};

设计规则

  • 千位分段:1xxx客户端,2xxx Gate-Login,3xxx Gate-World,4xxx Login-World,5xxx World-World
  • 奇偶区分:奇数是请求,偶数是响应。看到 2001 就知道它的回复是 2002
  • 预留空间:每个区间末尾留 50 个空位,后续版本加协议不冲突

8.3.2 协议号的集中管理

别让每个服务自己定义自己的协议号。用一个 protocol.h 全项目共享,编译时检查冲突:

// protocol.h —— 全项目唯一协议定义文件
#pragma once
#include <cstdint>

enum class PacketIdType : uint16 {
    // 由代码生成工具从 Excel/Protobuf 定义自动生成
    // 人工不许直接改这个文件!
    ...
};

// 编译期检查:确保请求-响应对的奇偶关系
constexpr bool IsRequest(PacketIdType id) {
    return static_cast<uint16>(id) % 2 == 1;
}
constexpr bool IsResponse(PacketIdType id) {
    return static_cast<uint16>(id) % 2 == 0;
}

8.4 账号 Token 验证流程

8.4.1 Token 的设计

Token 是登录成功后颁发的临时通行证。好的 Token 设计要满足:

  • 不可伪造:用 HMAC-SHA256 签名,不知道密钥的人伪造不了
  • 短期有效:通常 5-15 分钟,过期作废,降低被盗后的风险窗口
  • 一次性使用:验证后立即标记为已用,防止重放攻击
  • 包含最小信息:只放 accountId + 时间戳 + 随机数,别放密码
struct TokenInfo {
    uint64 accountId;           // 归属账号
    uint32 tokenId;             // Token 自身标识(用于追踪)
    time_t createTime;          // 创建时间
    time_t expireTime;          // 过期时间
    bool isUsed;                // 是否已使用
    string signature;           // HMAC签名
};

8.4.2 Token 生成

string LoginService::GenerateToken(uint64 accountId) {
    uint32 tokenId = m_tokenIdGen.Alloc();
    time_t now = time(nullptr);
    time_t expiry = now + TOKEN_VALID_SECONDS;  // 比如 300 秒

    // 构造待签名字符串
    string data = fmt::format("{}:{}:{}:{}", accountId, tokenId, now, expiry);

    // HMAC-SHA256 签名
    unsigned char hmac[32];
    HMAC(EVP_sha256(), m_secretKey.data(), m_secretKey.size(),
         (unsigned char*)data.c_str(), data.length(), hmac, nullptr);

    string signature = Base64Encode(hmac, 32);

    // 组装 Token:accountId:tokenId:expiry:signature
    string token = fmt::format("{}:{}:{}:{}", accountId, tokenId, expiry, signature);

    // 存储到活跃 Token 表
    TokenInfo info{accountId, tokenId, now, expiry, false, signature};
    m_activeTokens[token] = info;

    return Base64Encode(token);  // 最终再 Base64 一层,方便 HTTP 传输
}

8.4.3 Token 验证流程

sequenceDiagram
    participant C as 客户端
    participant G as Gate
    participant L as Login
    participant W as World

    C->>G: 登录请求(账号/密码)
    G->>L: 转发认证
    L->>L: 查数据库验证密码
    L->>L: 生成 Token(有效期5分钟)
    L->>G: 返回 Token + World地址
    G->>C: 登录成功,下发 Token

    C->>G: 用 Token 请求进入世界
    G->>W: 转发 Token
    W->>L: 验证 Token 是否有效(L2W_VERIFY_TOKEN)
    L->>L: 查 m_activeTokens
    L->>W: Token 有效 + 账号信息
    W->>W: 加载玩家数据,创建角色
    W->>G: 进入成功
    G->>C: 进入游戏世界!

8.4.4 World 中的 Token 验证实现

void WorldService::OnPlayerEnterRequest(uint32 gateId, uint64 connId, const string& token) {
    // 发送给 Login 验证(异步)
    uint32 requestId = m_requestIdGen.Alloc();

    VerifyTokenReq req;
    req.requestId = requestId;
    req.token = token;
    req.gateId = gateId;
    req.connId = connId;

    // 保存请求上下文,等回调回来匹配
    m_pendingRequests[requestId] = PendingRequest{
        .type = REQ_VERIFY_TOKEN,
        .startTime = now,
        .gateId = gateId,
        .connId = connId
    };

    SendToLogin(PacketIdType::L2W_VERIFY_TOKEN, req.Serialize());
}

void WorldService::OnVerifyTokenResponse(const VerifyTokenRes& res) {
    auto it = m_pendingRequests.find(res.requestId);
    if (it == m_pendingRequests.end()) {
        LOG_ERROR("Unknown verify requestId:{}", res.requestId);
        return;
    }

    auto& pending = it->second;

    if (res.success) {
        // Token 有效,创建玩家
        CreatePlayer(pending.gateId, pending.connId, res.accountId, res.playerData);
    } else {
        // Token 无效或过期,踢掉
        KickPlayer(pending.gateId, pending.connId, ERROR_INVALID_TOKEN);
    }

    m_pendingRequests.erase(it);
}

关键设计:requestId 是 World 本地生成的唯一标识,用来匹配"发出去的请求"和"收到的响应"。没有它,异步回调就是大海捞针。


8.5 使用 HTTP 验证账号

8.5.1 为什么需要 HTTP?

不是所有账号体系都自建。很多游戏接入:

  • 平台账号(Steam、Epic、AppStore GameCenter)
  • 社交账号(微信、QQ、Facebook)
  • 发行商账号(渠道 SDK)

这些第三方验证都是 HTTP/HTTPS API。

8.5.2 HTTP 验证封装

class HttpVerifier {
public:
    HttpVerifier(EventLoop* loop);

    // 异步验证接口
    void VerifyOAuth(const string& platform, const string& accessToken,
                     function<void(bool, const OAuthResult&)> callback);

    void VerifyLocalAccount(const string& account, const string& password,
                            function<void(bool, const AccountInfo&)> callback);

private:
    void OnHttpResponse(uint32 requestId, int httpCode, const string& body);

    EventLoop* m_loop;
    HttpClient m_httpClient;
    uint32 m_nextRequestId = 1;

    // 请求ID -> 回调函数
    unordered_map<uint32, function<void(bool, const string&)>> m_callbacks;
};

8.5.3 具体实现:请求 HTTP 数据

void HttpVerifier::VerifyOAuth(const string& platform, const string& accessToken,
                               function<void(bool, const OAuthResult&)> callback) {
    uint32 reqId = m_nextRequestId++;

    // 构造请求
    string url = fmt::format("https://api.platform.com/v1/verify?token={}",
                             UrlEncode(accessToken));

    HttpRequest req;
    req.method = "GET";
    req.url = url;
    req.headers["Authorization"] = fmt::format("Bearer {}", m_appSecret);
    req.timeoutMs = 5000;  // 5秒超时

    // 保存回调
    m_callbacks[reqId] = [callback](bool ok, const string& body) {
        if (!ok) {
            callback(false, {});
            return;
        }
        // 解析 JSON 响应
        OAuthResult result;
        auto json = nlohmann::json::parse(body);
        result.uid = json["uid"];
        result.nickname = json["nickname"];
        result.avatar = json["avatar_url"];
        callback(true, result);
    };

    // 发送异步 HTTP 请求
    m_httpClient.AsyncRequest(reqId, req,
        [this](uint32 id, int code, const string& body) {
            OnHttpResponse(id, code, body);
        });
}

void HttpVerifier::OnHttpResponse(uint32 requestId, int httpCode, const string& body) {
    auto it = m_callbacks.find(requestId);
    if (it == m_callbacks.end()) {
        LOG_WARN("HTTP callback for unknown requestId:{}", requestId);
        return;
    }

    bool success = (httpCode == 200);
    auto callback = move(it->second);
    m_callbacks.erase(it);

    callback(success, body);
}

8.5.4 HTTP 分块传输

如果 HTTP 响应体很大(比如批量查询账号信息),一次性接收可能撑爆内存。分块传输(Chunked Transfer-Encoding)是救命设计。

class HttpClient {
public:
    // 分块接收回调
    function<void(const char* data, size_t len, bool isLastChunk)> onChunkReceived;

    void OnRead(const char* data, size_t len) {
        m_buffer.append(data, len);

        // 解析 chunked 编码
        while (true) {
            // 格式:hex-size\r\n data \r\n ... 0\r\n\r\n(结束)
            auto pos = m_buffer.find("\r\n");
            if (pos == string::npos) break;  // 不够解析,等更多数据

            size_t chunkSize = stoul(m_buffer.substr(0, pos), nullptr, 16);
            if (chunkSize == 0) {
                // 最后一个 chunk,结束
                onChunkReceived(nullptr, 0, true);
                m_buffer.clear();
                break;
            }

            if (m_buffer.length() < pos + 2 + chunkSize + 2) break;  // 数据不够

            const char* chunkData = m_buffer.c_str() + pos + 2;
            onChunkReceived(chunkData, chunkSize, false);

            // 消费掉这个 chunk
            m_buffer = m_buffer.substr(pos + 2 + chunkSize + 2);
        }
    }

private:
    string m_buffer;
};

坑点:Chunked 编码的每个 chunk 末尾有 \r\n,最后一个 chunk 是 0\r\n\r\n。解析时很容易少读或多读两个字节,导致粘包或丢包。


8.6 为 Packet 定义新的网络标识

8.6.1 Packet 结构体

struct PacketHeader {
    uint16 packetId;            // 协议号(PacketIdType)
    uint32 packetLen;           // 包体长度(不含头)
    uint32 sequenceId;          // 序列号(用于请求-响应匹配)
    uint64 timestamp;           // 发送时间戳(毫秒)
    uint32 srcConnId;           // 源连接标识(谁发的)
    uint32 dstConnId;           // 目标连接标识(发给谁,0=广播)
    uint16 flags;               // 标志位(压缩、加密、优先级)
};

struct Packet {
    PacketHeader header;
    vector<byte> body;
};

sequenceId 的设计

  • 客户端发请求时,sequenceId 自增(1, 2, 3…)
  • 服务端回响应时,原样带回 sequenceId
  • 客户端收到响应后,用 sequenceId 匹配是哪个请求的回复
  • 超时时:客户端发现 sequenceId=5 的请求 10 秒没回来,重发,sequenceId 不变(或服务端识别为重复请求)

8.6.2 网络标识的生成策略

class NetworkIdGenerator {
public:
    // 连接标识:每个进程独立分配,不全局唯一,只在本地有意义
    uint32 AllocConnectId() {
        return m_connIdCounter.fetch_add(1);
    }

    // 序列号:按连接独立分配
    uint32 AllocSequenceId(uint32 connectId) {
        return m_seqCounters[connectId].fetch_add(1);
    }

    // 请求标识:全局唯一,用于跨进程异步请求匹配
    uint32 AllocRequestId() {
        // 组合:高16位=进程ID,低16位=本地自增
        uint32 localId = m_reqIdCounter.fetch_add(1) & 0xFFFF;
        return (m_processId << 16) | localId;
    }

private:
    atomic<uint32> m_connIdCounter{1};
    atomic<uint32> m_reqIdCounter{1};
    uint16 m_processId;                     // 本进程唯一编号
    unordered_map<uint32, atomic<uint32>> m_seqCounters;
};

全局唯一 requestId 的妙用

  • 低 16 位本地自增,每进程每秒 6 万个不重复
  • 高 16 位进程 ID,不同进程绝对不会冲突
  • 日志里搜一个 requestId,能精确定位到是哪台机器的哪个请求

8.7 使用网络标识创建连接、发送数据、请求 HTTP

8.7.1 创建连接时的标识绑定

bool GateService::ConnectToWorld(uint32 worldId, const Endpoint& ep) {
    auto client = make_unique<TcpClient>(m_loop, ep,
        [this, worldId](PacketPtr packet) {
            OnWorldPacket(worldId, packet);
        });

    client->onConnected = [this, worldId]() {
        LOG_INFO("[WorldConn:{}] Connected to World-{}", worldId, worldId);
        m_worldConnections[worldId] = client.get();
    };

    client->onDisconnected = [this, worldId]() {
        LOG_WARN("[WorldConn:{}] Disconnected from World-{}", worldId, worldId);
        m_worldConnections.erase(worldId);
        // 启动重连
        ScheduleReconnect(worldId);
    };

    if (client->Connect()) {
        m_internalClients.push_back(move(client));
        return true;
    }
    return false;
}

8.7.2 发送数据时的标识传递

void GateService::ForwardToWorld(uint32 worldId, uint64 connId, PacketPtr packet) {
    auto it = m_worldConnections.find(worldId);
    if (it == m_worldConnections.end()) {
        LOG_ERROR("[Conn:{}] World-{} not available", connId, worldId);
        KickClient(connId, ERROR_WORLD_UNAVAILABLE);
        return;
    }

    // 在包头上标记:这是从哪个客户端连接转发的
    packet->header.srcConnId = static_cast<uint32>(connId & 0xFFFFFFFF);

    // 序列号由 Gate 重新分配(内部协议独立于客户端序列号)
    packet->header.sequenceId = m_idGen.AllocSequenceId(INTERNAL_CONN);

    it->second->Send(packet);

    LOG_DEBUG("[Conn:{}] Forward packet {} to World-{}",
              connId, static_cast<uint16>(packet->header.packetId), worldId);
}

8.7.3 完整的数据流转标识追踪

flowchart LR
    subgraph Client["客户端"]
        C1["序列号 seq=7"]
    end

    subgraph Gate["Gate进程"]
        G1["连接标识 connId=1234"]
        G2["内部序列号 intSeq=42"]
    end

    subgraph Login["Login进程"]
        L1["请求标识 reqId=0x00030015"]
    end

    subgraph World["World进程"]
        W1["实体标识 entityId=5678"]
    end

    C1 -->|"pkt(seq=7)"| G1
    G1 -->|"pkt(intSeq=42, srcConn=1234)"| L1
    L1 -->|"pkt(intSeq=99, reqId=0x00030015)"| W1

日志追踪示例

[2024-01-15 14:23:01] [Conn:1234] Recv C2G_LOGIN_REQ seq=7 from 192.168.1.100
[2024-01-15 14:23:01] [Conn:1234] Forward G2L_AUTH_REQ intSeq=42 to Login-2
[2024-01-15 14:23:02] [Req:0x00030015] Login verify success, account=88888
[2024-01-15 14:23:02] [Req:0x00030015] Forward L2W_VERIFY_TOKEN to World-3
[2024-01-15 14:23:02] [Entity:5678] Player enter world, load data
[2024-01-15 14:23:02] [Conn:1234] Send G2C_LOGIN_RES seq=7, token=xxx

出问题的时候,搜 Conn:1234Req:0x00030015,整条链路一目了然。


8.8 机器人测试批量登录

8.8.1 为什么要做机器人?

上线前必须回答几个问题:

  • 1 万个并发登录,Login 撑得住吗?
  • 1000 个玩家同时进入同一张地图,World 卡吗?
  • 断线重连的场景下,Gate 的 FD 会泄漏吗?
  • 内存会无限增长吗?

真人测试做不到这个规模。机器人是唯一的答案。

8.8.2 机器人客户端设计

class RobotClient {
public:
    RobotClient(uint32 robotId, const Endpoint& gateEp);

    void Start() {
        // 1. 连接 Gate
        m_tcpClient->Connect();
    }

    void OnConnected() {
        // 2. 发送登录
        LoginReq req;
        req.account = fmt::format("robot_{}", m_robotId);
        req.password = "test1234";
        Send(PacketIdType::C2G_LOGIN_REQ, req);
    }

    void OnLoginRes(const LoginRes& res) {
        if (res.success) {
            // 3. 进入世界
            EnterWorldReq req;
            req.token = res.token;
            Send(PacketIdType::C2G_ENTER_WORLD_REQ, req);
        }
    }

    void OnEnterWorldRes(const EnterWorldRes& res) {
        if (res.success) {
            // 4. 开始行为模拟
            StartBehaviorSimulation();
        }
    }

    void StartBehaviorSimulation() {
        // 模拟随机行为
        m_behaviorTimer = m_loop->SetInterval(1000, [this]() {
            int action = rand() % 100;
            if (action < 30) {
                // 30% 概率:随机移动
                MoveReq req;
                req.x = RandomFloat(0, 1000);
                req.y = RandomFloat(0, 1000);
                Send(PacketIdType::C2G_MOVE_REQ, req);
            } else if (action < 40) {
                // 10% 概率:发送聊天
                ChatReq req;
                req.msg = fmt::format("Hello from robot {}!", m_robotId);
                Send(PacketIdType::C2G_CHAT_REQ, req);
            }
            // 60% 概率:什么都不做(模拟真实玩家的"发呆")
        });
    }

private:
    uint32 m_robotId;
    TcpClientPtr m_tcpClient;
    TimerId m_behaviorTimer;
    bool m_isLoggedIn = false;
};

8.8.3 批量启动与监控

class RobotManager {
public:
    void StartBatchLogin(uint32 count, float rampUpSeconds) {
        float interval = rampUpSeconds / count;  // 渐进式加压

        for (uint32 i = 0; i < count; ++i) {
            m_loop->SetTimer(i * interval * 1000, [this, i]() {
                auto robot = make_unique<RobotClient>(i, m_gateEndpoint);
                m_robots[i] = move(robot);
                m_robots[i]->Start();
            });
        }
    }

    void PrintStats() {
        uint32 connected = 0, loggedIn = 0, inWorld = 0;
        for (auto& [id, robot] : m_robots) {
            if (robot->IsConnected()) connected++;
            if (robot->IsLoggedIn()) loggedIn++;
            if (robot->IsInWorld()) inWorld++;
        }

        LOG_INFO("Robot Stats: total={}, connected={}, loggedIn={}, inWorld={}",
                 m_robots.size(), connected, loggedIn, inWorld);
    }

private:
    unordered_map<uint32, unique_ptr<RobotClient>> m_robots;
};

渐进式加压(Ramp-up):别一次性启动 1 万个机器人。每秒启动 100 个,观察系统的响应曲线。如果 CPU 在某个点突然飙升,那个点就是系统的瓶颈。

8.8.4 压测中的关键监控指标

struct PressureMetrics {
    // 连接层
    uint32 activeConnections;        // 当前活跃连接
    uint32 connFailureRate;          // 连接失败率(应 < 1%)
    float avgConnLatencyMs;          // 平均连接建立延迟

    // 登录层
    uint32 loginSuccessRate;         // 登录成功率(应 > 99%)
    float avgLoginLatencyMs;         // 平均登录延迟(应 < 2s)
    uint32 loginTimeoutCount;        // 登录超时数

    // World 层
    float worldFrameTimeMs;          // World 帧耗时(应 < 50ms)
    uint32 entityCount;              // 实体数量
    uint32 packetQueueSize;          // 待处理包队列长度(报警阈值:>1000)

    // 资源层
    uint64 memoryUsageMB;            // 内存占用
    uint32 cpuUsagePercent;          // CPU 使用率
    uint32 fdUsageCount;             // 文件描述符使用数
};

压测不是目的,发现瓶颈才是。如果 5000 机器人时 World 的 packetQueueSize 持续增长,说明 World 的处理能力不够,要么优化逻辑,要么拆分 World。


8.9 实际工程建议——标识设计的铁律

1. 标识必须全局唯一,或者边界内唯一 + 上下文明确

ConnectId 只在 Gate 进程内唯一就够了,因为包头上会带 GateId,别的服务知道"这是 Gate-5 的连接 1234"。但 RequestId 必须全局唯一,因为它在多个进程之间流转。

2. 标识分配器不能阻塞主线程

用原子变量(std::atomic)或者预分配池,别让 ID 分配成为性能瓶颈。

3. 日志里必须打印关键标识

每个日志行至少包含:时间戳 + 进程名 + 连接ID/请求ID + 协议号 + 简要描述。出了问题,grep 一个 ID 就能串起整条链路。

4. 标识要防回绕

uint32 每秒分配 1 万个,49 天就回绕了。回绕后,旧标识和新标识冲突,异步回调就匹配错了。解决方案:

  • 用 uint64(够你用到宇宙热寂)
  • 或者回绕时清空所有 pending 请求(粗暴但有效)

5. Token 必须设置短有效期 + 单次使用

5 分钟过期 + 验证后立即作废,被盗的 Token 攻击窗口极小。别图省事给 24 小时有效期。

6. HTTP 超时必须比游戏协议超时更短

游戏协议(Gate-World 之间)超时 10 秒是合理的。HTTP 验证超时 3 秒就够了——外部 API 卡住不应该拖死你的服务器。

7. 机器人测试必须覆盖"异常路径"

不只测"正常登录",还要测:

  • 登录到一半断网(Gate 怎么处理半开连接?)
  • Token 过期后进入 World(World 怎么拒绝?)
  • 同一个账号从两个客户端登录(顶号逻辑正确吗?)
  • 批量登录到 Login 挂掉(Gate 的排队机制有效吗?)

8.10 本章小结

网络标识是分布式系统的"身份证体系"。没有它,请求会迷路、重复、错乱;有了它,整个系统的每一次通信都可追踪、可审计、可排错。

标识类型作用域生成方式主要用途
ConnectId单进程原子自增连接管理、消息转发
SequenceId单连接原子自增请求-响应匹配、超时重传
RequestId全局进程ID+本地自增跨进程异步请求追踪
Token全局HMAC签名临时身份验证、防伪造
PlayerId全局数据库分配玩家唯一标识、数据归属

Token 验证流程是安全的第一道防线:生成时加密签名,传递时 Base64 编码,验证时查库比对,使用后立即作废。HTTP 验证接入第三方平台时,异步 + 超时 + 回调匹配缺一不可。

机器人测试是上线前的"体检":渐进式加压、监控关键指标、覆盖异常路径。别等玩家来帮你发现"5000 人同时登录就崩"的问题。

"标识看起来枯燥——一堆数字而已。但当你凌晨两点盯着日志,看着 Req:0x00030015 从 Gate 到 Login 到 World 再到返回,每一步都清清楚楚,你会感谢当初认真设计这些数字的自己。"


本章完