消息通讯是游戏服务器架构的核心命脉,需在可靠性、性能与可维护性间取得平衡。本架构设计围绕以下关键目标展开:
这里讨论的是业务服务器的架构。对于房间战斗类服务,可以直连到对应的服务器以获得更低延迟。
游戏服务器需同时满足有状态服务高频重启与高并发低延迟的双重挑战。传统RPC框架(gRPC/SRPC)在服务宕机时易丢失内存中的未处理消息,想要解决问题1和问题2,则必然需要引入消息队列。 主流消息队列(Kafka/RabbitMQ/RocketMQ)如下:
消息队列 | 吞吐量 | 消息删除机制 | 适用场景 |
---|---|---|---|
Kafka | 高(百万级) | 按时间/大小保留 | 大数据管道、日志流 |
RabbitMQ | 中(十万级) | ACK后删除 | 企业级异步任务 |
RocketMQ | 高(百万级) | 消费进度控制 | 金融级事务消息 |
NATS | 极高 | WorkQueue模式 | 实时消息分发 |
其中kafka, rabbitmq, rocketmq等mq要么因为性能不足,要么无法立即删除已经消费的消息浪费存储。因此并不适合作为游戏后端的消息通讯组件。此处仅探讨一下nats。
通过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服务/其他服务
为消除消息队列的中间层开销,我实现了一个基于UDP实现端到端可靠传输协议【hrpc】。
核心优势:
性能压测数据
消息大小 | 吞吐量(msg/s) | 网络带宽占用 |
---|---|---|
100字节 | 304,895 | ~29.7 MB/s |
1000字节 | 295,602 | ~288.6 MB/s |
基于hrpc的服务器架构则可以修改成:
客户端 ↔ Linker服务(tcp和hrpc服务器) ↔ Game服务/其他服务
优势对比:
上边是决定底层通讯协议, 接下来需要处理应用层之间的数据交互。以下是一个简单消息头参考,包含了消息传递过程中的必要信息。
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 |