这篇文章整合了我在游戏服务器开发过程中积累的几个核心主题的实践经验,时间跨度从 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/op

strings.Builder4.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/op

strconvfmt3.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.AppendFormatstrconv.AppendFloat),允许你传入自己的 []byte buffer(可以从 sync.Pool 获取),避免每次都分配新内存。

总结

有价值的性能优化非常依赖实际情况。如果服务响应 10ms 但网络传输 90ms,把响应从 10ms 优化到 5ms 价值不大——你可能需要换个角度去优化。


本文整合自 2018-2022 年间发表的多篇游戏服务器开发文章,作为从 WordPress 迁移到 Hugo 的内容归档。