讲讲长连接的游戏服务器最佳设计
游戏服务器和普通 HTTP 服务器有根本性的区别:
- HTTP:请求→响应→断开,无状态,任意扩容
- 游戏长连接:连接建立→持续双向通信→维护状态→处理实时事件
这带来三个核心难题:状态管理(玩家数据在哪)、同步(多玩家数据一致性)、实时性(毫秒级延迟)。
一、整体架构
一个中等规模(万级并发)的游戏服务器架构分三层:
┌────────────────────────────────────────┐
客户端 │ Mobile / PC / Console Client │
└───────────────┬────────────────────────┘
│ TCP / WebSocket / KCP
┌───────────────▼────────────────────────┐
接入层 │ Gateway / Connector │ ← 只管连接,不管逻辑
│ 维护 session,转发消息,心跳检测 │
└───────────────┬────────────────────────┘
│ 内网 RPC / MQ
┌────────────────────┼────────────────────────┐
逻辑层│ │ │
│ ┌────────────┐ ┌─▼──────────┐ ┌────────┐│
│ │ Room/Scene │ │ Player │ │ Match ││
│ │ Server │ │ Service │ │ Maker ││
│ └────────────┘ └────────────┘ └────────┘│
└────────────────────────────────────────────┘
│
┌───────────────▼────────────────────────┐
存储层 │ Redis (热数据) │ MySQL/PG (持久化) │
└────────────────────────────────────────┘
接入层和逻辑层分离是最重要的架构决策,原因后面详说。
二、网络 I/O 模型
协议选型
| 协议 | 延迟 | 可靠性 | 适用场景 |
|---|---|---|---|
| TCP | 中 | 有序可靠 | 卡牌、回合制、MMO |
| WebSocket | 中 | 有序可靠 | 网页游戏、跨平台 |
| UDP (KCP/QUIC) | 低 | 自控 | FPS、MOBA、实时对战 |
纯 UDP 适合对延迟极度敏感的场景,但你要自己处理丢包重传、乱序。KCP 是目前最常用的”可靠 UDP”协议,在 UDP 之上实现了 ARQ,延迟比 TCP 低约 30%~40%。
Reactor 模型(单线程事件循环)
这是所有高性能网络服务器的基础:
┌─────────────────────────────┐
│ Event Loop │
│ │
网络 I/O ──────► │ epoll_wait() │
│ │ │
定时器 ──────► │ ▼ │
│ dispatch to handlers │
信号 ──────► │ │ │
│ ▼ │
│ 执行 handler(非阻塞!) │
└─────────────────────────────┘
黄金法则:事件循环线程里永远不能有阻塞操作。 数据库查询、文件 I/O、任何 sleep(),必须扔到其他线程或用异步版本。
典型的连接处理代码结构(以 Asio 为例)
class GameSession : public std::enable_shared_from_this<GameSession> {
public:
explicit GameSession(tcp::socket socket, io_context& ctx)
: socket_(std::move(socket))
, heartbeat_timer_(ctx)
, session_id_(generate_id()) {}
void start() {
update_heartbeat();
co_spawn(socket_.get_executor(),
[self = shared_from_this()]() { return self->read_loop(); },
detached);
}
private:
awaitable<void> read_loop() {
try {
while (true) {
// 先读 4 字节包头(包含消息长度)
uint32_t header;
co_await async_read(socket_,
buffer(&header, 4), use_awaitable);
auto msg_len = ntohl(header) & 0x00FFFFFF; // 低24位是长度
auto msg_cmd = (ntohl(header) >> 24); // 高8位是命令类型
if (msg_len > MAX_MSG_SIZE) {
disconnect("oversized packet");
co_return;
}
std::vector<uint8_t> body(msg_len);
co_await async_read(socket_,
buffer(body), use_awaitable);
handle_message(msg_cmd, body);
}
} catch (std::exception&) {
on_disconnect();
}
}
void handle_message(uint8_t cmd, const std::vector<uint8_t>& body) {
reset_heartbeat(); // 收到消息,重置心跳计时
dispatcher_.dispatch(cmd, shared_from_this(), body);
}
void update_heartbeat() {
heartbeat_timer_.expires_after(30s);
heartbeat_timer_.async_wait([self = shared_from_this()](auto ec) {
if (!ec) {
self->disconnect("heartbeat timeout");
}
});
}
tcp::socket socket_;
steady_timer heartbeat_timer_;
uint64_t session_id_;
};
三、消息协议设计
包头格式
0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 ├─────────────────────────────────────────────────────────────────┤ │ Magic (2B) │ Version (1B) │ Flag (1B) │ Length (4B) │ ├─────────────────────────────────────────────────────────────────┤ │ CmdId (2B) │ Seq (2B) │ Timestamp (8B) │ ├─────────────────────────────────────────────────────────────────┤ │ Body (Length bytes, Protobuf / FlatBuffers / MessagePack) │ └─────────────────────────────────────────────────────────────────┘
几个关键字段的设计原因:
- Magic:快速过滤非法连接,节省解析开销
- Seq(序列号):客户端发出的请求携带 seq,服务器响应时带上同一个 seq,客户端用来匹配请求和响应(做超时重试时非常重要)
- Timestamp:用于延迟统计和反外挂检测
- Flag:压缩标志位,body 超过阈值时启用压缩
序列化格式选择
Protobuf:字段有 ID,向后兼容好,生态成熟,首选。
FlatBuffers:零拷贝读取,适合对象很大、需要随机访问字段的场景(比如地图数据)。
MessagePack:动态类型,适合快速迭代的小团队或脚本语言。
四、游戏主循环(Game Loop / Tick)
这是游戏服务器和普通服务器最大的区别。游戏世界需要定期推进状态,不能只响应客户端事件。
class Room {
public:
void start_game_loop() {
// 典型游戏:66ms tick(约15帧/秒)
// 竞技游戏:33ms tick(约30帧/秒)
// FPS:16ms tick(约60帧/秒)
tick_timer_.expires_after(TICK_INTERVAL);
tick_timer_.async_wait([this](auto ec) {
if (!ec) tick();
});
}
private:
void tick() {
auto now = Clock::now();
auto dt = now - last_tick_;
last_tick_ = now;
// 1. 处理本帧积累的所有输入
process_pending_inputs();
// 2. 更新游戏状态
update_physics(dt);
update_ai(dt);
update_skills(dt);
check_collisions();
// 3. 向所有客户端广播本帧状态
broadcast_world_state();
// 4. 调度下一帧
tick_timer_.expires_at(tick_timer_.expiry() + TICK_INTERVAL);
tick_timer_.async_wait([this](auto ec) {
if (!ec) tick();
});
}
void process_pending_inputs() {
// 输入队列:网络线程收到消息后 push,游戏线程 tick 时 pop
// 这是线程安全的关键边界
InputMsg msg;
while (input_queue_.try_pop(msg)) {
apply_player_input(msg.player_id, msg.action);
}
}
void broadcast_world_state() {
// 不是每帧都全量广播,而是差量(delta)广播
auto delta = world_.compute_delta(last_broadcast_state_);
if (!delta.empty()) {
for (auto& [id, session] : players_) {
session->send(serialize(delta));
}
last_broadcast_state_ = world_.snapshot();
}
}
static constexpr auto TICK_INTERVAL = std::chrono::milliseconds(66);
steady_timer tick_timer_;
ConcurrentQueue<InputMsg> input_queue_; // 唯一的线程间通信点
WorldState world_;
WorldState last_broadcast_state_;
};
关键原则:游戏逻辑在单一线程里顺序执行,完全避免锁。 网络线程只负责把消息推入队列,游戏线程在 tick 时统一处理。
五、状态管理与线程模型
Actor 模型
每个 Room/Scene 是一个独立的 Actor,拥有自己的状态和消息队列,不共享任何可变数据:
网络线程池 逻辑线程池 ──────────── ────────────────────────────── Thread-0: ──msg──► Room-1 (single thread) Thread-1: ──msg──► Room-2 (single thread) Thread-2: ──msg──► Room-3 (single thread) ...
这个模型的好处:
- Room 内部完全无锁,逻辑简单
- Room 之间天然隔离,一个 Room 崩溃不影响其他 Room
- 水平扩展简单,把 Room 迁移到其他进程/机器即可
玩家状态分层
L1: 会话内存(GameSession)
└─ 连接状态、序列号、未发送的消息缓冲
L2: 房间内存(Room)
└─ 当前位置、血量、技能 CD 等实时状态(每帧变化)
L3: Redis(热数据)
└─ 背包、货币、好友列表(分钟级持久化)
L4: MySQL(冷数据)
└─ 历史对战记录、账单、长期统计(异步落库)
写入时遵循:先改 L2 内存,异步写 L3 Redis,异步写 L4 MySQL。
读取时遵循:L2 → L3 → L4,逐层穿透。
六、接入层与逻辑层分离的价值
这是架构里最重要的决策,不做这个分离会面临灾难:
不分离时的问题:
- 逻辑服重启 → 玩家全部断线
- 逻辑服扩容 → 需要重新负载均衡,老连接全断
- 一个 bug 崩了逻辑 → 连接全丢
分离后:
玩家连接 → Gateway(长期稳定,几乎不重启)
│
│ 内网短连接 / 消息队列
▼
Logic Server(可以随意重启、扩缩容、滚动更新)
Gateway 只维护 session_id → logic_server_addr 的映射表,逻辑服重启时,玩家连接不断,Gateway 把消息路由到新的逻辑服即可实现热更新。
七、断线重连设计
客户端 服务器 │ │ │ 断线(TCP FIN / 超时) │ │ │ ← session 不立即销毁 │ │ 启动30秒倒计时 │ 重新连接,携带 session_token │ │ ──────────────────────────────►│ │ │ ← 验证 token,恢复状态 │ 断线期间积累的事件(增量同步) │ │ ◄──────────────────────────────│ │ │ │ 继续游戏 │
实现要点:
- Session token 要有有效期(一般 30~60 秒),过期后状态清理
- 断线期间发生的游戏事件要记录在环形缓冲区里,重连后重放
- 客户端要有本地状态快照,结合服务器增量数据做对账
八、同步策略:如何处理多玩家一致性
这是游戏服务器最有技术深度的问题,主要有两种流派:
状态同步(State Sync)
服务器是权威,定期把最终状态广播给所有客户端。
服务器每帧:
1. 收集所有玩家输入
2. 计算新的世界状态
3. 广播 {position, hp, animation_state, ...} 给所有人
客户端:
收到状态 → 插值(Interpolation)平滑显示
本地预测 → 发出输入时立即本地执行(客户端预测)
收到权威状态 → 对账,如有偏差则回滚修正(Reconciliation)
优点:防外挂能力强,服务器校验一切。
缺点:带宽消耗大,延迟敏感。
帧同步(Lockstep / Frame Sync)
服务器只转发所有玩家的输入,客户端用相同的输入自己计算状态,保证所有端计算结果一致。
服务器每帧:
收集所有玩家的操作指令
广播 {frame_id, player_inputs[]} 给所有人
客户端:
用同一套确定性逻辑,用同样的输入,算出同样的结果
要求:随机数种子相同,浮点数用定点数替代,物理引擎确定性
优点:带宽极低(只传输入),战斗录像天然支持(重放输入序列即可)。
缺点:任何不确定性(浮点误差、平台差异)都会导致不同步;断线重连需要从头重算。
MOBA(王者荣耀、Dota)通常用帧同步,MMO 通常用状态同步。
九、几个必须解决的细节问题
大包分片与 Nagle 算法
TCP 默认开启 Nagle 算法(小包合并),对实时游戏有害,必须关掉:
socket.set_option(tcp::no_delay(true));
写缓冲背压
当客户端收包慢(弱网)时,服务器写缓冲会积压。必须设上限,超过则断开,否则内存会无限增长:
void send(std::vector<uint8_t> data) {
if (send_queue_.size() > MAX_QUEUE_SIZE) {
disconnect("send buffer overflow");
return;
}
send_queue_.push(std::move(data));
if (!writing_) do_write();
}
消息合并(Batch)
游戏里很多小消息(位置更新)可以合并成一个包发送,减少系统调用次数和包头开销:
void flush() {
// 把本帧所有待发消息合并成一个 iovec,一次 writev 调用
std::vector<asio::const_buffer> buffers;
while (!pending_.empty()) {
buffers.push_back(asio::buffer(pending_.front()));
pending_.pop();
}
async_write(socket_, buffers, ...);
}
十、监控与可观测性
游戏服务器的监控指标要比普通服务精细得多:
连接层指标: 在线人数、每秒新增连接、断线率、断线原因分布 网络层指标: RTT 分布(P50/P95/P99)、丢包率、重传率 入站带宽、出站带宽、每连接平均带宽 逻辑层指标: Tick 耗时(必须 < TICK_INTERVAL)、Tick 延迟率 消息队列积压深度、消息处理耗时分布 每种消息的 QPS 业务指标: 房间数、平均房间人数、匹配等待时间 战斗时长分布、异常退出率
Tick 耗时是最关键的健康指标。如果 66ms 的 tick 耗时超过 66ms,游戏逻辑开始追不上实时,玩家会感受到明显卡顿。
十一、压测时的特殊注意事项
HTTP 服务压测很简单,发请求就行。游戏长连接压测难得多,需要模拟真实的客户端行为:
- 每个虚拟用户需要维护状态(不是无状态的请求)
- 需要模拟心跳、登录流程、房间匹配
- 需要模拟弱网(随机延迟、丢包)下的表现
- 压测时间要够长(至少几十分钟),找内存泄漏
常用工具:自研机器人客户端(复用游戏客户端的网络层),或者 Locust 配合自定义协议插件。
最后:技术栈推荐
| 层次 | C++ 选型 | Go 选型 |
|---|---|---|
| 网络框架 | Asio / libuv | net / gnet |
| 序列化 | Protobuf / FlatBuffers | Protobuf |
| 内部 RPC | gRPC / nanomsg | gRPC |
| 热数据 | Redis (hiredis) | go-redis |
| 持久化 | MySQL + sqlpp11 | GORM |
| 日志 | spdlog | zap |
| 监控 | Prometheus + Grafana | 同左 |
C++ 适合对性能极度敏感的战斗服(FPS/MOBA 核心逻辑),Go 适合快速开发大部分业务逻辑(大厅、匹配、社交)。很多团队的实践是:战斗服 C++,其他服 Go,通过 Protobuf + gRPC 通信。
