服务器消息架构
创建:2025-04-24 22:26
更新:2025-04-24 23:29

# 游戏服务器消息架构设计核心目标

消息通讯是游戏服务器架构的核心命脉,需在可靠性、性能与可维护性间取得平衡。本架构设计围绕以下关键目标展开:

  1. 消息零丢失保障
    • 故障恢复机制:支持服务自动拉起、热更重启、手动维护及动态扩容场景下的消息持久化。
    • 状态一致性:确保有状态服务在异常中断时,内存中的未处理消息可完整恢复。
  2. 连接管理优化
    • 避免NxN连接风暴:通过中心化路由层(如Linker服务)收敛连接,降低维护复杂度。
  3. 弹性伸缩能力
    • 动态扩缩容:支持服务实例的秒级扩容与缩容,流量自动重平衡。
  4. 高性能传输
    • 低延迟:端到端通信延迟控制在毫秒级,满足游戏实时性需求。
    • 高吞吐:单节点百万级消息/秒处理能力,横向扩展无瓶颈。
  5. 开发友好性
    • 透明化接入:提供简洁的SDK接口,屏蔽底层通讯细节。
    • 全链路追踪:内置消息染色与日志追踪,快速定位问题链路。

这里讨论的是业务服务器的架构。对于房间战斗类服务,可以直连到对应的服务器以获得更低延迟。

# 持久化消息通讯方案对比与选型

游戏服务器需同时满足有状态服务高频重启与高并发低延迟的双重挑战。传统RPC框架(gRPC/SRPC)在服务宕机时易丢失内存中的未处理消息,想要解决问题1和问题2,则必然需要引入消息队列。 主流消息队列(Kafka/RabbitMQ/RocketMQ)如下:

消息队列 吞吐量 消息删除机制 适用场景
Kafka 高(百万级) 按时间/大小保留 大数据管道、日志流
RabbitMQ 中(十万级) ACK后删除 企业级异步任务
RocketMQ 高(百万级) 消费进度控制 金融级事务消息
NATS 极高 WorkQueue模式 实时消息分发

其中kafka, rabbitmq, rocketmq等mq要么因为性能不足,要么无法立即删除已经消费的消息浪费存储。因此并不适合作为游戏后端的消息通讯组件。此处仅探讨一下nats。

NATS JetStream性能验证

通过Go语言测试NATS JetStream在WorkQueue模式下的吞吐能力:

package main

import (
    "log"
    "time"

    "github.com/nats-io/nats.go"
)

func main() {
    nc, _ := nats.Connect("nats://localhost:4222")
    js, _ := nc.JetStream(nats.PublishAsyncMaxPending(1000)) // 控制并发量

    // 创建流(主题为test.>,内存存储)
    js.AddStream(&nats.StreamConfig{
        Name:      "perf-test",
        Subjects:  []string{"test.>"},
        Storage:   nats.FileStorage,     // 或nats.FileStorage
        Retention: nats.WorkQueuePolicy, // 关键:工作队列模式
        MaxMsgs:   -1,                   // 无数量限制
        MaxBytes:  -1,                   // 无存储限制
        MaxAge:    0,                    // 0 表示永不过期(对应 CLI 的 -1)
        Discard:   nats.DiscardOld,      // 旧消息丢弃策略
    })

    // 异步发送(每秒10万条)
    start := time.Now()
    for i := 0; i < 1000000; i++ {
        _, err := js.PublishAsync("test.data", []byte("curl -L https://go.dl/go1.23.4.linux-arm64.tar.gz -O go1.23.4.linux-arm64.tar.gz4.linux-arm64.tar.gz"))
        if err != nil {
            log.Fatal("发布失败:", err)
        }
    }

    // 等待所有ACK确认
    select {
    case <-js.PublishAsyncComplete():
        log.Printf("吞吐量: %.0f msg/s", 1000000/time.Since(start).Seconds())
    case <-time.After(5 * time.Second):
        log.Fatal("超时未完成")
    }
}

/*
# 启动nats-server
下载 https://github.com/nats-io/nats-server/releases/tag/v2.11.1
启动 ./nats-server -js -sd $(pwd)/data
*/

压测结果(5次连续测试):

测试轮次 吞吐量(msg/s) 网络带宽占用
1 179,732 ~17.1 MB/s
2 178,877 ~17.0 MB/s
3 185,031 ~17.6 MB/s
4 170,683 ~16.3 MB/s
5 167,066 ~15.9 MB/s

NATS JetStream在内存模式下可实现17万+/秒的稳定吞吐,满足多数游戏场景需求,但其中心化路由机制仍可能引入额外延迟。

使用nats作为通讯组件,则架构如下

客户端 ↔ Linker服务(tcp服务器) ↔ [ NATS JetStream ] ↔ Game服务/其他服务  

自主协议优化:HRPC(基于UDP协议实现高性能可靠持久化消息协议)

为消除消息队列的中间层开销,我实现了一个基于UDP实现端到端可靠传输协议【hrpc】

核心优势:

  • 减少路由拷贝:通过Linker服务直连目标节点,减少一次数据拷贝与路由查询。
  • 消息持久化:基于mmap内存映射的方式,确保程序崩溃或者热更重启是消息不丢失。且能够写入磁盘,方便节点迁移扩容配置也不丢失消息。

性能压测数据

消息大小 吞吐量(msg/s) 网络带宽占用
100字节 304,895 ~29.7 MB/s
1000字节 295,602 ~288.6 MB/s

基于hrpc的服务器架构则可以修改成:

客户端 ↔ Linker服务(tcp和hrpc服务器) ↔ Game服务/其他服务  

优势对比:

  • 延迟降低40%:端到端直连减少路由跳数。
  • 吞吐提升70%:hrpc本身优秀的吞吐量

应用层协议头设计

上边是决定底层通讯协议, 接下来需要处理应用层之间的数据交互。以下是一个简单消息头参考,包含了消息传递过程中的必要信息。

struct msg_head { // 40byte
    int flag; // 识别flag. 不同的版本可以用不同的识别标志例如 v1.1
    int func;  // 请求的函数
    unsigned long long gseq; // 全局唯一标识,用于日志染色
    unsigned long long key; // 目标服务状态key,例如uid, sid, aid(联盟id)
    int req_size; // 请求包大小
    int ctx_size; // 携带的context大小
    int to_nid; // 目标nid
    char from_type; // 来源: service, player, platform
    char compressed; // 是否压缩
    short check; // 包校验
};

消息序列化设计

常见的消息序列化方式有json, protobuf或者直接传输bin数据(直接传递结构体示例数据)。json可以更好的阅读,但是性能差于protobuff, protobuff序列化结果更小。在我经历的项目中,大多数会选择protobuf作为序列化方式。

以下是各个方式的优劣对比:

protobuf json bin
优点 1.速度快
2.体积小
1.可视化
2.游戏使用脚本修复数据容易
3.使用简单
1.序列化反序列化快
缺点 1.需要额外的proto文件,增加维护复杂度 1.序列化反序列化稍慢 1. 对动态数组不友好

我在自己的框架中选用json来做消息序列化对象。使用【jsonc】 根据定义的结构体直接生成json序列化反序列化。性能高,可视化好。特别是对使用共享内存或者mmap的 GameUser 来说,能够非常简单地完成序列化和反序列化,而protobuf还需要额外一一取值赋值一遍。

【jsonc】反序列化146kb大小的json数据性能如下:

jsonc yyjson
-O0 525us 418us
-O3 168us 141us