这篇文章整合了我在游戏服务器开发过程中积累的几个核心主题的实践经验,时间跨度从 2018 到 2022 年。内容涵盖 3D 寻路、唯一 ID 生成、Go Plugin 热更新、通信安全加密,以及 Go 性能优化技巧。
一、3D 寻路:RecastNavigation 在游戏服务器中的应用
最初发表于 2018 年,记录了将 RecastNavigation 应用于游戏服务器的完整过程。
背景
RecastNavigation 是一个开源的 Navigation-mesh Toolset for Games,核心功能包括 3D 寻路、控制 Agent 行走/移动、动态添加阻挡、动态改变地形。
值得一提的是,RecastNavigation 其实就是 Unity3D 引擎自带的 Navigation 寻路模块的前身——这一点我曾在 Google Group 里和作者 memononen 确认过。
为什么要在服务器上做寻路
客户端做寻路很容易,现代游戏引擎都内置了这些工具。但在服务器上实现 3D 寻路,对于 AI 控制、反外挂验证等场景至关重要。RecastNavigation 用 C++ 实现,可以很方便地嵌入到 C++、Go、Python 等语言的服务器程序中。
从 Unity 导出地图
以 Unity 为客户端为例,有两种方式导出地图供 RecastNavigation 使用:
方式一:导出 Unity 已 bake 好的 NavMesh
使用导出脚本将 Unity 中生成好的 NavMesh 导出为 obj 文件,然后放入 RecastNavigation 生成寻路网格。这种方式最直接,导出的 NavMesh 与 Unity 中一致。
方式二:导出原始地图 obj 文件
直接导出地图场景的 obj 文件。视觉上和 Unity 原始地图一致,但有一个大坑:Unity 的 Terrain 没有 Mesh,需要先从 Terrain 生成 Mesh 再导出。地形越大,导出的 Mesh 与原始 Terrain 差距越大。
坐标系踩坑
Unity 默认使用左手坐标系,RecastNavigation 默认使用右手坐标系。如果不注意这个差异,会让你开始怀疑人生(亲身经历)。解决方法:翻转 x 坐标 pos.x = -pos.x。
工程实践建议
- 使用
Sample_TempObstacles模式(支持预处理地图、动态添加阻挡) - 先用 RecastNavigation 生成寻路网格,保存为 TileCache
- 服务器程序中使用 Load TileCache 的方式加载,可以节省大量载入时间
- 调整 Build 参数确保生成的寻路网格与 Unity 中基本一致
其他功能
RecastNavigation 还提供:生成随机可达点、判断距墙距离、动态添加障碍物、两点连通性检测、自动寻路(可用于 AI 实现)等。
二、全局唯一 ID 生成器:基于 Snowflake 改造
最初发表于 2021 年 12 月,解决游戏服务器中分布式唯一 ID 的需求。
需要唯一 ID 的场景
- 游戏场景唯一 ID
- 场景实体(NPC、Monster、Building、Resource…)的唯一 ID
- 业务系统中的唯一实体 ID
Snowflake 算法回顾
Twitter 提出的 Snowflake 算法使用 64 bit 整型数据根据时间生成 ID:
| 区段 | 位数 | 说明 |
|---|---|---|
| 符号位 | 1 bit | 最高位不使用 |
| 时间戳 | 41 bit | 精确到毫秒,可用约 69 年 |
| 机器位 | 10 bit | 支持 1024 个节点 |
| 序列号 | 12 bit | 每毫秒最多 4096 个 ID |
Sony 在此基础上改造了 Sonyflake,变更了各段位数。
我们的改造方案
针对游戏服务器场景,我们基于 Sonyflake 进一步调整:
| 区段 | 位数 | 说明 |
|---|---|---|
| 时间戳 | 36 bit | 以 10ms 为单位,可用约 21 年 |
| 机器位 | 13 bit | 支持 8192 个节点 |
| 序列号 | 14 bit | 每 10ms 最多 16384 个 ID |
设计考量:
- 36 bit 时间位:
1 << 36 = 68719476736,按 10ms 为单位计算68719476736 / 8640000 = 7953 天 ≈ 21 年 - 13 bit 机器位:游戏服务器架构节点数一般不会超过 8192
- 14 bit 序列号:10ms 内产生 16384 个 ID 足够
核心实现
// NextID generates a next unique ID.
func (sf *Idgenerator) NextID() (uint64, error) {
const maskSequence = uint32(1<<BitLenSequence - 1)
sf.mutex.Lock()
defer sf.mutex.Unlock()
current := currentElapsedTime(sf.startTime)
if sf.elapsedTime < current {
sf.elapsedTime = current
sf.sequence = 0
} else { // sf.elapsedTime >= current
sf.sequence = (sf.sequence + 1) & maskSequence
if sf.sequence == 0 {
sf.elapsedTime++
overtime := sf.elapsedTime - current
time.Sleep(sleepTime(overtime))
}
}
return sf.toID()
}
func (sf *Idgenerator) toID() (uint64, error) {
if sf.elapsedTime >= 1<<BitLenTime {
return 0, errors.New("over the time limit")
}
return uint64(sf.elapsedTime)<<(BitLenSequence+BitLenMachineID) |
uint64(sf.sequence)<<BitLenMachineID |
uint64(sf.machineID), nil
}三、Go 游戏服务器热更新:Plugin 机制实战
最初发表于 2021 年 12 月,介绍 Go 语言 Plugin 包在游戏服务器热更新中的应用。
为什么需要热更新
- 服务程序需要 7x24 小时不间断服务
- 不停服的情况下动态修改程序逻辑
- 频繁停服维护对游戏非常不友好
热更新的基本原理
服务程序 = 数据结构 + 算法。热更新通常只能更新算法部分,数据结构很难热更(内存布局会变化)。
主流方案:
| 方案 | 说明 |
|---|---|
| C/C++ 动态库替换 | .so / .dll 替换 |
| C/C++ 内嵌 Lua/Python | 运行时编译器 + 动态脚本 |
| Node.js | 模块热替换 |
| Go + Plugin | 编译为插件动态加载 |
Go Plugin 基本用法
插件代码 (plugin/main.go):
package main
func main() {}
func PluginAdd(a, b int) int {
return a + b
}宿主程序 (main.go):
func loadPlugin(pname string) {
plug, err := plugin.Open(pname)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
addf, err := plug.Lookup("PluginAdd")
if err != nil {
fmt.Println(err)
os.Exit(1)
}
sum := addf.(func(a, b int) int)(1, 2)
fmt.Printf("plugin func a+b=%v \n", sum)
}编译(仅 Linux 支持):
# Plugin 编译
go build -buildmode=plugin -o plugin.so
# 宿主程序编译
go build -o plugintest注意:Windows 不支持
-buildmode=plugin。
避坑指南
坑 1:同一个插件只会加载一次
在宿主程序的整个生命周期内,同名插件只加载一次,即使 .so 文件已经被重新编译。
坑 2:同目录编译的插件被视为同一个插件
即使改了文件名(plugin.so -> plugin1.so),如果编译目录相同,仍会报 plugin already loaded。
解决方法:每次编译时将插件代码复制到一个临时目录进行编译。
坑 3:公共代码库必须一致
插件和宿主程序共用的 package 如果发生变更,插件无法加载:
plugin.Open("./plugin2/plugin2"): plugin was built with a different
version of package plugintest/common同理,插件和宿主程序必须在同一个 Go 版本环境下编译。
工程目录结构参考
service
├── lobby
│ └── plugin
│ ├── dbproc/hotswapbureau
│ ├── msgproc/hotswapbureau
│ ├── nsqproc/hotswapbureau
│ └── rpcproc/hotswapbureau
├── login
│ └── plugin
│ └── httpproc/hotswapbureau
└── scene
└── plugin
├── msgproc/hotswapbureau
└── rpcproc每个服务的 plugin 目录包含各类消息处理器的热更代码,通过 hotswapbureau 管理加载和重载流程。
推荐阅读:edwingeng/hotswap — 为 Go 语言代码热更提供了一套完整的解决方案。
四、游戏服务器防外挂:通信安全
最初发表于 2022 年 1 月,讨论客户端/服务器通信数据的加密与校验。
一切来源于客户端的数据皆不可靠
这是游戏服务器开发的座右铭。外挂程序的作弊手段:
- 修改客户端内存篡改通信数据
- 拦截收发协议包进行二次篡改
- 脚本/程序模拟客户端与服务器通信
核心症结:通信数据处于暴露状态,没有加密或校验。
加密 + 校验方案
使用 AES 对称加密 + HMAC-MD5 摘要校验:
- AES 加密将明文转为密文
- HMAC-MD5 校验数据是否被篡改
- 双方持有相同的 Key 和算法才能通信
AES 配置项:
| 配置 | 选项 |
|---|---|
| 加密模式 | ECB, CBC, CTR, OFB, CFB |
| 填充方式 | PKCS5, PKCS7, ZERO |
| 输出格式 | BASE64, HEX |
密文结构
密文 = MD5签名(24B) + Code(2B) + Version(4B) + IV(24B) + AES密文(nB)Code:状态码Version:版本号IV:AES CBC 模式的初始向量(Base64 格式)MD5:对 Code+Version+IV+密文 做 HMAC-MD5 校验
核心代码
// SafeCommEncode 将原文加密为密文
func SafeCommEncode(origin []byte) (ciphertext []byte, err error) {
iv := randutil.RandBytes(16)
aes := NewAESCrypto(&CryptoData{
Mode: &CBCMode{},
Padding: &PKCS5Padding{},
Format: &HexFormat{},
})
cipher, err := aes.Encrypt(origin, aesKey, HamcMd5(iv, hamcMd5Key))
if err != nil {
return ciphertext, err
}
ciphertext = make([]byte, extLen+len(cipher))
binary.LittleEndian.PutUint16(ciphertext[codeBytes[0]:codeBytes[1]], okCode)
copy(ciphertext[vBytes[0]:vBytes[1]], version)
base64Fmt := &Base64Format{}
ivbase64 := base64Fmt.Encode(iv)
copy(ciphertext[ivBytes[0]:ivBytes[1]], ivbase64)
copy(ciphertext[extLen:], cipher)
md5Sign := base64Fmt.Encode(HamcMd5(ciphertext[md5Bytes[1]:], hamcMd5Key))
copy(ciphertext[md5Bytes[0]:md5Bytes[1]], md5Sign)
return ciphertext, nil
}
// SafeCommDecode 将密文解密为原文,同时校验完整性
func SafeCommDecode(cipher []byte) ([]byte, error) {
if len(cipher) < extLen {
return nil, errors.New("input cipher text is invalid: length too short")
}
// 校验 Code 和 Version
c := binary.LittleEndian.Uint16(cipher[codeBytes[0]:codeBytes[1]])
if c != okCode {
return nil, errors.New("input cipher text is invalid: code error")
}
// HMAC-MD5 完整性校验
base64Fmt := &Base64Format{}
recvMd5Sign := cipher[md5Bytes[0]:md5Bytes[1]]
md5Code := base64Fmt.Encode(HamcMd5(cipher[md5Bytes[1]:], hamcMd5Key))
if string(recvMd5Sign) != string(md5Code) {
return nil, errors.New("data is modified")
}
// AES 解密
aes := NewAESCrypto(&CryptoData{
Mode: &CBCMode{},
Padding: &PKCS5Padding{},
Format: &HexFormat{},
})
iv, _ := base64Fmt.Decode(cipher[ivBytes[0]:ivBytes[1]])
return aes.Decrypt(cipher[extLen:], aesKey, HamcMd5(iv, hamcMd5Key))
}防外挂终究是一场拉锯战——只能不断提升外挂开发的难度,理论上无法真正杜绝。
五、Go 性能优化快速指南
最初发表于 2020 年 11 月,翻译整理自 Simple techniques to optimise Go programs,附个人实践注解。
开始之前:先建立基准
在修改程序之前,先用 Go 的 benchmark 和 pprof 建立基准测试。推荐使用 benchcmp 对比不同版本的性能。
技巧一:使用 sync.Pool 重用对象
var bufpool = sync.Pool{
New: func() interface{} {
buf := make([]byte, 512)
return &buf
},
}
bp := bufpool.Get().(*[]byte)
b := *bp
defer func() {
*bp = b
bufpool.Put(bp)
}()注意:Put 回池之前必须重置数据字段,否则会造成安全隐患(返回其他用户的数据):
func (a *AuthenticationResponse) reset() {
a.Token = ""
a.UserID = ""
}
rsp := authPool.Get().(*AuthenticationResponse)
defer func() {
rsp.reset()
authPool.Put(rsp)
}()技巧二:避免大 map 中使用含指针的 key
GC 会扫描包含指针的对象。string 底层包含指针,所以 map[string]int 会导致 GC 开销巨大:
map[string]int (1000万元素): gc took: ~100ms
map[int]int (1000万元素): gc took: ~4ms消除了 97% 的 GC 时间。在生产环境中,尽量将 string key 转换为 int。
技巧三:代码生成替代运行时反射
json.Marshal / json.Unmarshal 依赖反射,性能较差。使用 easyjson 等代码生成工具可以提升 3 倍序列化性能。
easyjson -all file.go
# 生成 file_json.go,自动实现 json.Marshaller 接口注意:结构体变更后需要重新生成代码。
技巧四:使用 strings.Builder
BenchmarkStringBuildNaive-8 5000000 255 ns/op 216 B/op 8 allocs/op
BenchmarkStringBuildBuilder-8 20000000 54.9 ns/op 64 B/op 1 allocs/opstrings.Builder 快 4.7 倍,内存分配减少 87.5%。
技巧五:strconv 替代 fmt
BenchmarkStrconv-8 30000000 39.5 ns/op 32 B/op 1 allocs/op
BenchmarkFmt-8 10000000 143 ns/op 72 B/op 3 allocs/opstrconv 比 fmt 快 3.5 倍。原因:fmt 使用 interface{} 参数,导致额外的堆分配。
技巧六:make 时指定容量
// 差:从容量 0 开始,8 个元素会触发 5 次扩容
var userIDs []string
for _, bar := range rsp.Users {
userIDs = append(userIDs, bar.ID)
}
// 好:预分配容量,0 次扩容
userIDs := make([]string, 0, len(rsp.Users))
for _, bar := range rsp.Users {
userIDs = append(userIDs, bar.ID)
}如果不确定确切容量,给一个 90% 的估算值也好过从 0 开始。此建议同样适用于 map。
技巧七:使用 AppendFormat 类方法
标准库中很多方法提供了 Append 版本(如 time.AppendFormat、strconv.AppendFloat),允许你传入自己的 []byte buffer(可以从 sync.Pool 获取),避免每次都分配新内存。
总结
有价值的性能优化非常依赖实际情况。如果服务响应 10ms 但网络传输 90ms,把响应从 10ms 优化到 5ms 价值不大——你可能需要换个角度去优化。
本文整合自 2018-2022 年间发表的多篇游戏服务器开发文章,作为从 WordPress 迁移到 Hugo 的内容归档。