Skip to content
Go
encoding/gob — Go 原生二进制编码

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 Encoder is 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)。

其他几条实测/文档级陷阱:

  • nil map 与空 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 做本地缓存压缩)。