第8章:网络标识与协议设计——分布式系统的身份证
"2016年我排查一个诡异bug:玩家A充了100块,玩家B收到了钻石。查了三天日志,最后发现是两个并发的登录请求包在Login进程里被调换了顺序。那时候我才深刻理解:在分布式系统里,没有唯一标识的包,就像没有身份证的人——谁知道你是谁?"
8.1 为什么需要网络标识?——从一次生产事故说起
假设这样一个场景:
- 玩家"龙傲天"在客户端点击登录
- 客户端发了一个登录包给 Gate-A
- Gate-A 转发给 Login-1
- Login-1 验证成功,生成 Token,告诉 Gate-A:"让他去 World-3"
- 同时,玩家手贱又点了一次登录按钮
- Gate-A 又发了一个登录包给 Login-1
- 这次 Login-1 把 Token 发给了 Gate-B(负载均衡随机分配)
- Gate-B 收到 Token,准备去连 World-3
- 但 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:1234 或 Req: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 再到返回,每一步都清清楚楚,你会感谢当初认真设计这些数字的自己。"
本章完