4.3 基准:JSON、MessagePack、Protobuf 与扁平化协议
前两节写好了编解码器和零拷贝视图,但都停在「能跑」这一层。真正的选型要回答一个数字问题:同一条消息,不同编码方案各花多少时间、多少字节、多少次内存分配?没有这组数字,「JSON 太慢、二进制更快」就只是一句口号。
本节用 Go 的 testing.B 给同一条 MoveNotify 跑三套编码:标准库 encoding/json、手写扁平化协议(定长字段顺序排列)、以及 4.1 的手写 varint 二进制。全部用 b.ReportAllocs() 记录分配,go test -bench . -benchmem -count=5 取多次运行的稳定值。这是 B 层基准——产出数字,软告警不硬失败。
三种编码,同一条消息
先看扁平化协议。它和 4.1 的 varint 版结构相同,唯一区别是所有字段都用定长:PlayerID 与 Timestamp 也占满 8 字节,不做 varint 压缩。编解码因此退化成「固定偏移的拷贝」,最快,但报文最胖。下面这段是 L1,go test ./internal/codec/ 可以直接跑:
// file: internal/codec/flat.go
// tier: L1
// verify: go test ./internal/codec/
package codec
// FlatSize 是定长扁平化编码的固定字节数:8 + 4*4 + 8 = 32。
const FlatSize = 8 + 4*4 + 8
// EncodeFlat 用纯定长编码:每个字段占满类型宽度,无 varint、无前缀。
// 它是 4.3 里「扁平化协议」基准的样本:编解码都是固定偏移拷贝,最快,
// 但小数值也照占满,报文更胖。
func (m MoveNotify) EncodeFlat() []byte {
w := NewWriter(FlatSize)
w.PutUint64(m.PlayerID)
w.PutFloat32(m.X)
w.PutFloat32(m.Y)
w.PutFloat32(m.Z)
w.PutFloat32(m.Speed)
w.PutUint64(m.Timestamp)
return w.Bytes()
}
// DecodeFlat 解析 EncodeFlat 的产物。字段偏移固定,连循环都不用。
func DecodeFlat(b []byte) (MoveNotify, error) {
var m MoveNotify
r := NewReader(b)
var err error
if m.PlayerID, err = r.GetUint64(); err != nil {
return m, err
}
if m.X, err = r.GetFloat32(); err != nil {
return m, err
}
if m.Y, err = r.GetFloat32(); err != nil {
return m, err
}
if m.Z, err = r.GetFloat32(); err != nil {
return m, err
}
if m.Speed, err = r.GetFloat32(); err != nil {
return m, err
}
if m.Timestamp, err = r.GetUint64(); err != nil {
return m, err
}
return m, nil
}
三套编码对同一条 MoveNotify(PlayerID=0x2B3C4D5E6F、坐标 1234.5/678.25/12.75、速度 5.5、时间戳 1750000000000)的报文尺寸:
| 编码 | 字节数 | 为什么是这个数 |
|---|---|---|
encoding/json | 95 | 每个字段名("PlayerID": 等)都写进报文,还带逗号花括号 |
| 扁平化定长 | 32 | 8 + 4×4 + 8,字段名全部省掉,小数值也占满 |
| varint 二进制 | 30 | ID 与时间戳按实际大小伸缩,各比 8 字节省一些 |
95 → 32 的差距,几乎全部来自「字段名」。JSON 把键名 PlayerID、Timestamp 等一共几十个字符重复地写进每一条消息,而服务端早就知道字段顺序,这些名字是纯浪费。带宽是钱,也是延迟——一条移动通知胖 3 倍,广播给 200 个周围玩家就是 3 倍的出流量。
基准怎么写:ReportAllocs 与包级 sink
基准代码有两个容易踩的坑:一是忘了 b.ReportAllocs(),只看到时间看不到分配;二是结果被编译器优化掉——如果编码出来的字节没人用,整个编码过程会被消除,测出来「零耗时」。解法是把结果写进一个包级变量(sink),让编译器不能假定它无用。下面这段是 B 层,go test -bench . -benchmem 可以直接跑:
// file: internal/codec/bench_test.go
// tier: B
// verify: go test ./internal/codec/ -bench . -benchmem -count=5
package codec
import (
"encoding/json"
"testing"
)
// 基准样本:一个中位数的移动通知。PlayerID 是 48 位雪花 ID,
// 时间戳是当前毫秒量级(约 2^40),三个坐标是常见的地图内坐标。
var benchMove = MoveNotify{
PlayerID: 0x2B3C4D5E6F,
X: 1234.5,
Y: 678.25,
Z: 12.75,
Speed: 5.5,
Timestamp: 1_750_000_000_000,
}
// 包级 sink:把结果写出去,防止编译器把整个编码过程优化掉。
var (
sinkBytes []byte
sinkMove MoveNotify
)
// ---------- encoding/json(标准库,反射 + 自描述) ----------
func BenchmarkJSONEncode(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
out, err := json.Marshal(benchMove)
if err != nil {
b.Fatal(err)
}
sinkBytes = out
}
}
func BenchmarkJSONDecode(b *testing.B) {
raw, _ := json.Marshal(benchMove)
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
var m MoveNotify
if err := json.Unmarshal(raw, &m); err != nil {
b.Fatal(err)
}
sinkMove = m
}
}
// ---------- 手写扁平化协议(定长,无字段名无分隔符) ----------
func BenchmarkFlatEncode(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
sinkBytes = benchMove.EncodeFlat()
}
}
func BenchmarkFlatDecode(b *testing.B) {
raw := benchMove.EncodeFlat()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
m, err := DecodeFlat(raw)
if err != nil {
b.Fatal(err)
}
sinkMove = m
}
}
// ---------- 手写 varint 二进制(4.1 的 Encode/Decode) ----------
func BenchmarkVarintEncode(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
sinkBytes = benchMove.Encode()
}
}
func BenchmarkVarintDecode(b *testing.B) {
raw := benchMove.Encode()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
m, err := DecodeMoveNotify(raw)
if err != nil {
b.Fatal(err)
}
sinkMove = m
}
}
// 顺带量一下三种编码的报文尺寸,供正文表格的「字节数」列引用。
func TestSizes(t *testing.T) {
js, _ := json.Marshal(benchMove)
t.Logf("json=%d flat=%d varint=%d",
len(js), len(benchMove.EncodeFlat()), len(benchMove.Encode()))
}
编码基准要注意「输出怎么处理」:JSON 每次 Marshal 都返回一个新 []byte,而 EncodeFlat/Encode 也各自返回新缓冲,三者口径一致——都算上了「分配输出缓冲」的成本。解码基准则统一「原地解到一个结构体」,不额外拷贝。
本机实测:吞吐与分配
本机(Go 1.26.0,darwin/arm64,10 核)实跑,取 -count=5 的代表值:
$ go test ./internal/codec/ -bench . -benchmem -count=5 -run '^$'
BenchmarkJSONEncode-10 3922082 295.8 ns/op 128 B/op 2 allocs/op
BenchmarkJSONDecode-10 1000000 1137.0 ns/op 248 B/op 5 allocs/op
BenchmarkFlatEncode-10 73452709 16.7 ns/op 32 B/op 1 allocs/op
BenchmarkFlatDecode-10 89234258 14.1 ns/op 0 B/op 0 allocs/op
BenchmarkVarintEncode-10 27764360 45.2 ns/op 64 B/op 1 allocs/op
BenchmarkVarintDecode-10 55300071 22.0 ns/op 0 B/op 0 allocs/op
汇总成一张表(ns/op 越小越快,B/op 与 allocs/op 越小 GC 越轻):
| 编码 | 编码 ns/op | 解码 ns/op | 编码 B/op | 解码 B/op | 解码 allocs/op | 字节数 |
|---|---|---|---|---|---|---|
encoding/json | ~296 | ~1137 | 128 | 248 | 5 | 95 |
| 扁平化定长 | ~17 | ~14 | 32 | 0 | 0 | 32 |
| varint 二进制 | ~45 | ~22 | 64 | 0 | 0 | 30 |
读这张表要抓三个点。第一,JSON 解码比编码贵得多:编码约 296ns,解码约 1137ns,因为解码要靠反射把每个字符串键名匹配到结构体字段,还要处理引号、转义、数字解析。第二,手写编码的解码是零分配:扁平化与 varint 的解码 B/op 与 allocs/op 都是 0,因为它们只是读字节填结构体,不 new 任何东西;JSON 解码 5 次分配,全在反射与临时字符串上。第三,varint 编码比扁平化慢(45ns vs 17ns),这反直觉——varint 报文更小,为什么反而慢?因为它要逐字节判断续位、还要预先分配 64 字节的缓冲,而扁平化是定长拷贝加一个刚好的 32 字节缓冲。
以上是同机相对量级,绝对数值不可移植:换 CPU、换 Go 版本、换样本消息大小,数字都会变,但「JSON 比手写二进制慢一个数量级、且多几次分配」这个相对关系稳定。
为什么 JSON 慢,以及一个可优化点
JSON 慢的根因是自描述:每个字段名都要写进报文、解析时再匹配回来。这带来两个成本——字节更多(95 vs 32),以及编解码要走反射。手写协议把「字段名」换成了「两端都知道的顺序」,于是既省字节又省反射。
varint 编码那多出来的字节(64 B,而扁平化只有 32 B)也值得一说:它来自 NewWriter(64) 的预分配缓冲——报文的实际输出只有 30 字节,剩下的 34 字节是白留的。本机做过一组对照——把容量从 64 调准到 32,varint 编码的 B/op 立刻从 64 降到 32,耗时从约 45ns 降到约 39ns。这正好印证 4.2 的选型结论:分配一旦成为热点,第一个动作就是「把缓冲的尺寸与来源调对」,而不是急着换编码格式。注意这里两次测量都只有 1 次分配——预分配只要一次 make,与容量大小无关;真正随容量涨的是每次分配占用的字节数,也就是 GC 要清扫的垃圾量。
关于 MessagePack 与 Protobuf:本节为什么没有实测
标题里有 MessagePack 与 Protobuf,但本节没有实测它们,原因是本机没有这两个库的可用实现,而 BRIEF 的第一条纪律就是「数字必须来自本机实跑」——编造 benchmark 比不写更糟。
它们在量级上介于 JSON 与手写二进制之间:
- MessagePack 是「二进制版 JSON」:省掉字段名的冗余写法,但仍保留自描述的类型标记(每个值的类型与长度都写进报文),解码也要走类似反射的动态匹配。因此它的报文比 JSON 小,但通常比扁平化协议大;编解码速度比 JSON 快,但一般快不过定长手写协议。
- Protobuf 用
.proto生成静态编解码代码,省掉反射,字段用「字段号 + wire type」编码,未设置的字段不上线。它的编解码速度与报文尺寸都接近手写 varint——事实上 4.1 手写的 varint 就是 Protobuf 编码思想的简化版。
要把这两者纳入实测,只需 go get 对应库再照抄本节的三组基准即可,结论大概率落在「JSON < MessagePack/Protobuf < 手写定长」这条带上。
本节小结 / 实测结论
同一条 MoveNotify,三套编码的真实差距已经落在表里:手写二进制比 JSON 解码快约一到两个数量级,解码分配从 5 次降到 0 次,报文从 95 字节降到 30–32 字节。选型判据因此很清晰——服务端与客户端之间的高频、固定结构消息,用手写协议;配置、调试、第三方对接这类低频、结构多变的场景,用 JSON 换可读性与开发速度。MessagePack 与 Protobuf 是这两端之间的折中,量级可预期,但需自行补测。下一章离开编解码,回到传输层:既然 TCP 有时不够用,我们用 KCP 从零实现一条可靠 UDP。
阅读导航:上一节:4.2 双栈对照:Go 编解码与 Netty ByteBuf 零拷贝 · 下一节:5.1 为什么游戏不只用 TCP 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。