i007.cc

i007.cc

优先队列-降维打击

05.价值资料

讲讲长连接的游戏服务器最佳设计

长连接游戏服务器的本质挑战

游戏服务器和普通 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 为例)

cpp
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)

这是游戏服务器和普通服务器最大的区别。游戏世界需要定期推进状态,不能只响应客户端事件。

cpp
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 算法(小包合并),对实时游戏有害,必须关掉:

cpp
socket.set_option(tcp::no_delay(true));

 

写缓冲背压

当客户端收包慢(弱网)时,服务器写缓冲会积压。必须设上限,超过则断开,否则内存会无限增长:

cpp
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)

游戏里很多小消息(位置更新)可以合并成一个包发送,减少系统调用次数和包头开销:

cpp
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 通信。

发表回复