encoding/gob — Go 原生二进制编码
gob 是 Go 专有的自描述二进制序列化格式。它只服务一个场景:Go 代码把带类型的值写成字节、再由 Go 代码读回来——RPC 载荷、本地缓存、离线任务中间态、工具链之间传数据。它不是跨语言方案(没有 schema 文件、没有别的语言的解码器),也不追求人类可读。
它值得单独一篇的原因很实际:gob 的兼容性规则和 JSON 的直觉正好相反。很多人以为"gob 按字段顺序编码,所以不能动字段顺序",实际上 gob 按字段名匹配,改顺序完全没事,而改字段名会静默丢数据。本文把这套规则讲清楚,所有结论都在 go1.27.0 上实测过(引用的错误文本与字节数是真实程序输出)。
近期版本对 gob 没有实质改动(Go 1.24~1.27 的发布说明里没有它的条目),所以这篇内容对 1.21+ 都适用。
1. 基础用法
type User struct {
ID int
Name string
Tags []string
}
func basic() {
var buf bytes.Buffer
encoder := gob.NewEncoder(&buf)
decoder := gob.NewDecoder(&buf)
user := User{ID: 1, Name: "Alice", Tags: []string{"go", "dev"}}
if err := encoder.Encode(user); err != nil {
log.Fatal(err)
}
var decoded User
if err := decoder.Decode(&decoded); err != nil {
log.Fatal(err) // 输入耗尽时返回 io.EOF
}
fmt.Printf("%+v\n", decoded) // {ID:1 Name:Alice Tags:[go dev]}
}三个用法层面的要点:
- Encoder/Decoder 是流对象,不是一次性函数。 包文档原话:“is most efficient when a single
Encoderis used to transmit a stream of values, amortizing the cost of compilation.” 类型定义只在第一次用到该类型时发送,所以每条长连接、每个文件都应复用同一个 Encoder,而不是每次Encode都新建。新建 Encoder 的代价是重新编译类型信息,实测编码一个全零值的结构体也要 35 字节——那基本全是类型定义。 Decode的入参必须是指针(Decode(&v)),传值会返回错误。输入到 EOF 时Decode返回io.EOF且不修改目标对象(go doc原文),所以for { err := dec.Decode(&v); if err == io.EOF { break } }是标准读法。- 一个 stream 可以混装不同类型。 实测把
Small{A:1}和Big{Yes:2}连续编码到同一个 buffer(共 76 字节),再用同一个 Decoder 依次Decode到各自类型,都成功。类型是自描述的,读取顺序必须与写入顺序一致,但类型不必相同——这也是net/rpc能拿同一连接收发多种请求的基础。
2. 设计意图:自描述 + 稀疏编码
底层原理
gob 的线上格式围绕两个决定展开:
(1)类型信息与数据分离,类型自带。 包文档:“A stream of gobs is self-describing. Each data item in the stream is preceded by a specification of its type, expressed in terms of a small set of predefined types.” 也就是说线上只有"有符号整数/无符号整数/浮点/布尔/字符串/字节数组/array/struct"这几类抽象类型,Go 的 int8、uint16、MyInt 都被归一到这几类。
// 一个 struct 的发送形式是 (字段号, 字段值) 序列
// 字段号做 delta 编码,字段必须按号递增发送,最后用一个 00 标记结束(2)字段号只是压缩手段,跨类型匹配一律用字段名。 文档里对接收端能接受哪些形态写得很直白(struct { A, B int } 的接收端):
struct { A, B int } // the same
struct { B, A int } // ordering doesn't matter; matching is by name
struct { A, B, C int } // extra field (C) ignored
struct { B int } // missing field (A) ignored; data will be dropped而这几类会直接报解码错误:
struct { A int; B uint } // change of signedness for B
struct { A int; B float } // change of type for B
struct { } // no field names in common
struct { C, D int } // no field names in common(3)稀疏:零值不上线。 文档:“If a field has the zero value for its type (except for arrays; see above) or it’s a pointer to a zero value, it is omitted from the transmission.” 但 slice/array/map 内部所有元素都要发,“even zero-valued elements”。这条不对称是很多"大小不符合直觉"的来源:一个只有几个零值字段的 struct 极小,而 []int{0,0,0,0} 一个字节都省不掉。
(4)指针被展平,nil 指针等于"不存在"。 文档:“Pointers are not transmitted, but the things they point to are transmitted; that is, the values are flattened. Nil pointers are not permitted, as they have no value.” 所以 *User 和 User 编出来是同一个东西;nil 指针字段则因为零值而被省略。
(5)递归类型没问题,递归的值有问题。 文档原话:“Recursive types work fine, but recursive values (data with cycles) are problematic. This may change.” 链表树这类类型可以,但一个自引用的图会出事——不要把 gob 当通用对象图持久化工具。
为什么这样设计
对比 JSON 就明白了:JSON 把 schema 摊在代码里(结构体字段名即键名),所以每个值都要重复写键名;gob 把类型定义抽出来单独发一次,之后的值只发"字段号 + 值",因此同一类型的连续传输非常省字节。代价是它必须有类型系统,也就只能是 Go 专有格式。
3. 接口值:必须注册
gob 是唯一内建支持多态(接口字段/接口切片)的标准库序列化格式,代价是要显式注册具体类型:
type Animal interface {
Speak() string
}
type Dog struct {
Name string
Breed string
}
func (d Dog) Speak() string { return "Woof! " + d.Name }
func main() {
gob.Register(Dog{}) // 关键:注册具体类型
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
animals := []Animal{Dog{Name: "Rex", Breed: "Labrador"}}
if err := enc.Encode(animals); err != nil {
log.Fatal(err)
}
var decoded []Animal
if err := gob.NewDecoder(&buf).Decode(&decoded); err != nil {
log.Fatal(err)
}
for _, a := range decoded {
fmt.Println(a.Speak())
}
}注意:忘记注册时,错误发生在编码端,不是解码端。实测
gob.NewEncoder(&buf).Encode([]Iface{Dog{...}})直接返回gob: type not registered for interface: main.Dog。旧说法"只有运行时解码才能发现"是不准确的——发送方就已经失败了,这其实是好事(问题不会跑到对端)。
默认类型名由包路径加类型名拼成(GOROOT 源码 type.go:star + rt.PkgPath() + "." + rt.Name())。这意味着重构包路径或改类型名会让老数据/老对端解不出来。需要长期稳定的接口类型,用 gob.RegisterName("dog.v1", Dog{}) 把名字钉死:
func init() { gob.RegisterName("svc.animal.dog", Dog{}) }4. 版本兼容性:改名字才是事故
这是本文最需要纠正的一节。gob 不按字段位置匹配,因此"字段必须留在原位置"是误解。实测(发送端固定为 struct { Port int; Host string; Name string },值为 {8080, "example.com", "svc"}):
| 接收端结构体的变更 | 实测结果 |
|---|---|
字段重新排列(Name, Host, Port) |
正常,{Name:svc Host:example.com Port:8080} |
删掉中间字段、新增末尾字段(Name, New, Host2) |
正常,{Name:svc New:0 Host2:}——Host 的数据被丢弃,New 得零值 |
把 Host 改名成 Host2 |
同上,静默丢数据,无任何警告 |
Port int → Port string |
解码报错 gob: wrong type (string) for received field CfgV1.Port |
Port int → Port uint |
解码报错 gob: wrong type (uint) for received field CfgV1.Port(改符号也不行) |
Port int → Port int8,值 8080 |
解码报错 value for "Port" out of range(窄化本身允许,超范围才失败) |
| 结构体字段全是非导出 | 编码报错 gob: type main.Unexported has no exported fields |
字段是 chan |
静默忽略,编码成功(35 字节),解出来是 nil |
结论:
-
加字段、删字段、调顺序都是兼容的,不需要"新字段只能加末尾"这种纪律(这点和 Protobuf 的编号纪律不同)。
-
改字段名是最危险的操作,因为它静默成功、数据无声丢失。类型变更至少会报错。
-
想安全地重命名,走"两个名字并存一段时间":
type Config struct { Host string // 老名字,继续接收 HostNew string // 新名字 } // 写代码的人负责同步,读的时候 cmp.Or(cfg.HostNew, cfg.Host) -
struct tag 对 gob 完全无效。 包源码里没有任何
StructTag读取逻辑,实测A int \gob:"-"`` 照样被编码(42 字节,往返成功)。想把字段排除出去,只能把它拆到另一个类型里,或者用不导出的字段(非导出字段会被跳过,但整个结构体不能一个导出字段都没有)。
5. 陷阱:Decode 是"装配",不是"覆盖"
因为零值字段不上线,Decode 也不会先把目标清零,于是得到一条容易被忽略的语义:stream 里没出现的字段,保持目标对象原有的值。
实测(Small{A int; B string}):
// 只编码 B
var stream bytes.Buffer
_ = gob.NewEncoder(&stream).Encode(Small{B: "new"}) // A=0 被省略
dst := &Small{A: 99, B: "old"}
err := gob.NewDecoder(&stream).Decode(dst)
// err=<nil>,dst = {A:99 B:new} ← A 没有被重置为 0!同类问题在容器上更隐蔽。实测把一个只含 {"keep":1} 的 map 解进一个已有 {old:9} 的 map:
dstM := M{M: map[string]int{"old": 9}, S: []int{1, 2, 3}}
// 编码值:M{M: map[string]int{"keep": 1}, S: []int{7}}
// 解码结果:dstM = {M:map[keep:1 old:9] S:[7] P:<nil>}- map 是合并的(旧的
old:9还在); - slice 是整体替换的(
[1 2 3]变成[7]); - 目标里已有、源里是 nil 指针的字段保持原值(实测解"全 nil 的值"到已填充对象,
M=map[k:1] S=[5]原封不动)。
陷阱:把
Decode用作"复用对象减少分配"的池化模式(sync.Pool+ 复用结构体)在 gob 下会得到脏数据。正确做法是每轮新建零值对象,或者在Decode前手工clear(dstMap)(Go 1.21+ 内建clear)。
其他几条实测/文档级陷阱:
-
nilmap 与空 map 可区分:nil字段被省略(往返后仍是 nil),空 map/slice 会被传输(往返后是非 nil 的空容器)。两者编码大小也不同(实测同类结构体 91 vs 93 字节)。对端若用len(x) == 0判空没问题,用x == nil判"字段缺失"则要注意 struct 之外(map/slice 的空与非空都会上线)。 -
截断的 stream 报的不是
io.EOF。 实测:正常读完一份 36 字节的 gob 后再读一次得到io.EOF(err == io.EOF为 true);但把同一份数据从任意中间位置截断,Decode全部返回unexpected EOF,且errors.Is(err, io.EOF)为 false。这直接击穿了"见到 EOF 就当读完"的写法——网络抖动、进程被杀、文件没写完都可能留下半截流,被静默当成正常结束。正确判法:for { var v S err := dec.Decode(&v) switch { case err == nil: handle(v) case errors.Is(err, io.EOF): return nil // 真正读完 default: return fmt.Errorf("gob stream truncated or corrupt: %w", err) // 含 io.ErrUnexpectedEOF } } -
类型定义只在 stream 里发一次:如果你把一批 gob 数据分段存储(例如每个 chunk 单独写文件),必须保证每段都从新的 Encoder/Decoder 开始;把半截 stream 单独解出来会得到"未知类型"错误。
-
gob 不做语义校验:同一个字段名在两端类型一致但含义变了(比如
Port从"偏移量"改成"绝对值"),gob 完全不会提醒。它是格式,不是契约。
6. gob / JSON / Protobuf 对比
| 特性 | gob | JSON(encoding/json) |
Protobuf |
|---|---|---|---|
| 跨语言 | 仅 Go | 任意语言 | 任意语言 |
| 人类可读 | 二进制 | 文本 | 二进制 |
| 体积 | 小(同类型续传几乎零开销) | 大 | 最小 |
| 需要工具链 | 不需要 | 不需要 | 需要 .proto + 生成代码 |
| 接口/多态 | 内建(Register) |
需自己写类型字段 | 需 oneof/Any |
| 兼容性依据 | 字段名匹配 | 键名匹配 | 字段编号 |
| 改名后果 | 静默丢数据 | 静默丢数据 | 报错(编号不变即可) |
| 自描述 | 是(类型随 stream 发送) | 否 | 否(需 schema) |
选型上没什么含糊的:对外接口一律 JSON(或跨语言场景的 Protobuf);纯 Go 内部且需要传接口值、不想引第三方依赖时 gob 很顺手;有明确跨团队契约、需要长期演进的大规模 RPC,Protobuf/FlatBuffers 更合适,因为它们的兼容性基于编号而不是名字。
现实还要补一句:net/rpc 已经冻结(包文档原文:“The net/rpc package is frozen and is not accepting new features."),历史上 gob 最主要的舞台因此淡出。今天还在用 gob 的场景多是:本地磁盘上的对象缓存、Go 进程间的快速传值、不想引第三方库的内部服务。工具链里它也还在岗——go/analysis 的 fact 数据就是 gob 编码后塞进导出文件的(GOROOT 里 x/tools/internal/facts 引了 encoding/gob),cmd/dist 也用它,这些是"gob 只在 Go 世界内部流转"的典型例子。
7. 实战建议
把它当"带类型的 Go 值传输”,不要当持久化格式。 gob 没有 schema 版本概念:它不会告诉你"这批数据是老版本写的",只能在类型不兼容时报错。真正需要长期保存的对象(数据库里的 blob、跨年的归档文件),要么用 JSON 明确留字段名,要么用 Protobuf/Avro 这类带 schema 演进的格式。
接口值必须注册,且把注册集中在 init 里。 分散注册的直接后果是"某条代码路径没执行到 Register",表现为一行 type not registered for interface 的错误。把整包所有可能上线的具体类型统一在 init 注册,并用 RegisterName 钉住名字,重命名类型或移动包时不会突然解不开。
验证兼容性变更,用真实字节而不是重新编译。 危险变更(改名、改类型)不一定在开发期暴露。稳妥做法:把一份当前版本的 gob 样本存成测试数据,改动结构体后跑 go test 解一遍——按第 4 节的表,改名会让字段静默变零值,只有断言具体值才能发现。
不要复用同一个变量接收 Decode。 第 5 节合并语义的修复成本极低(每次新建变量),收益是杜绝一类"偶发脏数据"故障。性能敏感处再考虑 clear() 或对象池,但要显式清零所有容器。
注意 Encoder/Decoder 的复用与并发边界。 两者都持有"这条流上已经发过哪些类型定义"的内部状态,官方文档没有给出并发安全承诺,因此按非并发安全处理:多个 goroutine 共享一条连接时,要么加锁串行化 Encode,要么让每个 goroutine 用自己的 Encoder(代价是每条流都要重发类型定义,小消息明显变胖)。
判断体积收益前先量一次。 因为类型定义要占字节(实测一个空结构体也要 35 字节),gob 在小消息、一次性传输上未必比 JSON 省多少;它省的是"同类型连续大量传输"。别凭"二进制一定更小"下结论。
相关阅读:encoding-json.md(同目录,字段名匹配的另一套故事、omitempty/omitzero 与 1.27 的 v2 引擎)、../06-系统与测试/runtime-debug.md(对象池与复用变量导致的脏数据排查)、../05-文件与系统IO/compress-archive.md(gob 输出配 gzip 做本地缓存压缩)。