Golang 中由零值和 gob 库的特性引起的 BUG

0 起源 就在今年9月份,我负责的部门平台项目发布了一个新版本,该版本同时上线了一个新功能,简单说有点类似定时任务。头一天一切正常,但第二天出现了极少数任务没有正常执行(已经暂停的任务继续执行,正常的任务反而没有执行)的情况。
问题的现象让我和另一个同事的第一反应是定时任务执行的逻辑出现了问题。但在我们耗费了大量的时间去DEBUG、测试后,发现问题的根本并不在功能逻辑,而是一段已经上线了一年并且没有动过的底层公共代码。这段代码的核心就是本篇文章的主人公gob,引发问题的根源则是go语言的一个特性:零值。
后文中我会用一个更简化的例子描述这个 BUG。
1 gob 与零值 先简单介绍一下gob和零值。
1.1 零值 零值是 Go 语言中的一个特性,简单说就是:Go 语言会给一些没有被赋值的变量提供一个默认值。譬如下面这段代码:

package mainimport ( "fmt" )type person struct { namestring gender int ageint }func main() { p := person{} var list []byte var f float32 var s string var m map[string]intfmt.Println(list, f, s, m) fmt.Printf("%+v", p) }/* 结果输出 [] 0map[] {name: gender:0 age:0} */

零值在很多时候确实为开发者带来了方便,但也有许多不喜欢它的人认为零值的存在使得代码从语法层面上不严谨,带来了一些不确定性。譬如我即将在后文中详细描述的问题。
1.2 gob gob是 Go 语言自带的标准库,在encoding/gob中。gob其实是go binary的简写,因此从它的名称我们也可以猜到,gob应当与二进制相关。
实际上gob是 Go 语言独有的以二进制形式序列化和反序列化程序数据的格式,类似 Python 中的 pickle。它最常见的用法是将一个对象(结构体)序列化后存储到磁盘文件,在需要使用的时候再读取文件并反序列化出来,从而达到对象持久化的效果。
例子我就不举了,本篇也不是gob的使用专题。这是它的官方文档,对gob用法不熟悉的朋友们可以看一下文档中的Example部分,或者直接看我后文中描述问题用到的例子。
2 问题 2.1 需求 在本文的开头,我简单叙述了问题的起源,这里我用一个更简单的模型来展开描述。
首先我们定义一个名为person的结构体:
type person struct { // 和 json 库一样,字段首字母必须大写(公有)才能序列化 IDint Namestring // 姓名 Gender int// 性别:男 1,女 0 Ageint// 年龄 }

围绕这个结构体,我们会录入若干个人员信息,每一个人员都是一个person对象。但出于一些原因,我们必须使用gob将这些人员信息持久化到本地磁盘,而不是使用 MySQL 之类的数据库。
接着,我们有这样一个需求:
遍历并反序列化本地存储的gob文件,然后判断男女性别的数量,并统计。
2.2 代码 根据上面的需求和背景,代码如下(为了节省篇幅,这里省略了 package, import, init() 等代码):
  • defines.go
// .gob 文件所在目录 const DIR = "./persons"type person struct { // 和 json 库一样,字段首字母必须大写(公有)才能序列化 IDint Namestring // 姓名 Gender int// 性别:男 1,女 0 Ageint// 年龄 }// 需要持久化的对象们 var persons = []person{ {0, "Mia", 0, 21}, {1, "Jim", 1, 18}, {2, "Bob", 1, 25}, {3, "Jenny", 0, 16}, {4, "Marry", 0, 30}, }

  • serializer.go
// serialize 将 person 对象序列化后存储到文件, // 文件名为 ./persons/${p.id}.gob func serialize(p person) { filename := filepath.Join(DIR, fmt.Sprintf("%d.gob", p.ID)) buffer := new(bytes.Buffer) encoder := gob.NewEncoder(buffer) _ = encoder.Encode(p) _ = ioutil.WriteFile(filename, buffer.Bytes(), 0644) }// unserialize 将 .gob 文件反序列化后存入指针参数 func unserialize(path string, p *person) { raw, _ := ioutil.ReadFile(path) buffer := bytes.NewBuffer(raw) decoder := gob.NewDecoder(buffer) _ = decoder.Decode(p) }

  • main.go
func main() { storePersons() countGender() }func storePersons() { for _, p := range persons { serialize(p) } }func countGender() { counter := make(map[int]int) // 用一个临时指针去作为文件中对象的载体,以节省新建对象的开销。 tmpP := &person{} for _, p := range persons { // 方便起见,这里直接遍历 persons ,但只取 ID 用于读文件 id := p.ID filename := filepath.Join(DIR, fmt.Sprintf("%d.gob", id)) // 反序列化对象到 tmpP 中 unserialize(filename, tmpP) // 统计性别 counter[tmpP.Gender]++ } fmt.Printf("Female: %+v, Male: %+v\n", counter[0], counter[1]) }

执行代码后,我们得到了这样的结果:
// 对象们 var persons = []person{ {0, "Mia", 0, 21}, {1, "Jim", 1, 18}, {2, "Bob", 1, 25}, {3, "Jenny", 0, 16}, {4, "Marry", 0, 30}, }// 结果输出 Female: 1, Male: 4

嗯?1 个女性,4 个男性?BUG出现了,这样的结果显然与我们的预设数据不符。是哪里出了问题?
2.3 定位 我们在countGender()函数中的for循环里添加一行打印语句,将每次读取到的person对象读出来,然后得到了这样的结果:
// 添加行 fmt.Printf("%+v\n", tmpP)// 结果输出 &{ID:0 Name:Mia Gender:0 Age:21} &{ID:1 Name:Jim Gender:1 Age:18} &{ID:2 Name:Bob Gender:1 Age:25} &{ID:3 Name:Jenny Gender:1 Age:16} &{ID:4 Name:Marry Gender:1 Age:30}

好家伙,Jenny 和 Marry 都给变成男人了!但神奇的是,除了 Gender 这一项外,其他所有的数据都正常!看到这一结果,如果大家和我一样,平时经常和 JSON、Yml 之类的配置文件打交道,很可能会想当然地认为:上面的 gob 文件读取正常,应当是存储出了问题。
gob文件是二进制文件,我们难以像 JSON 文件那样用肉眼去验证。即便在 Linux 下使用xxd之类的工具,也只能得到这样一种模棱两可的输出:
>$ xxd persons/1.gob 0000000: 37ff 8103 0101 0670 6572 736f 6e01 ff827......person... 0000010: 0001 0401 0249 4401 0400 0104 4e61 6d65.....ID.....Name 0000020: 010c 0001 0647 656e 6465 7201 0400 0103.....Gender..... 0000030: 4167 6501 0400 0000 0eff 8201 0201 034aAge............J 0000040: 696d 0102 0124 00im...$.>$ xxd persons/0.gob 0000000: 37ff 8103 0101 0670 6572 736f 6e01 ff827......person... 0000010: 0001 0401 0249 4401 0400 0104 4e61 6d65.....ID.....Name 0000020: 010c 0001 0647 656e 6465 7201 0400 0103.....Gender..... 0000030: 4167 6501 0400 0000 0aff 8202 034d 6961Age..........Mia 0000040: 022a 00.*.

也许我们可以尝试去硬解析这几个二进制文件,来对比它们之间的差异;或者反序列化两个除了 Gender 外一模一样的对象到gob文件中,然后对比。大家如果有兴趣的话可以尝试一下。当时的我们因为时间紧迫等原因,没有尝试这种做法,而是修改数据继续测试。
2.4 规律 由于上文中出问题的两个数据都是女性,程序员的直觉告诉我这也许并不是巧合。于是我尝试修改数据的顺序,将男女完全分开,然后进行测试:
// 第一组,先女后男 var persons = []person{ {0, "Mia", 0, 21}, {3, "Jenny", 0, 16}, {4, "Marry", 0, 30}, {1, "Jim", 1, 18}, {2, "Bob", 1, 25}, }// 结果输出 &{ID:0 Name:Mia Gender:0 Age:21} &{ID:3 Name:Jenny Gender:0 Age:16} &{ID:4 Name:Marry Gender:0 Age:30} &{ID:1 Name:Jim Gender:1 Age:18} &{ID:2 Name:Bob Gender:1 Age:25}

// 第二组,先男后女 var persons = []person{ {1, "Jim", 1, 18}, {2, "Bob", 1, 25}, {0, "Mia", 0, 21}, {3, "Jenny", 0, 16}, {4, "Marry", 0, 30}, }// 结果输出 &{ID:1 Name:Jim Gender:1 Age:18} &{ID:2 Name:Bob Gender:1 Age:25} &{ID:2 Name:Mia Gender:1 Age:21} &{ID:3 Name:Jenny Gender:1 Age:16} &{ID:4 Name:Marry Gender:1 Age:30}

吊诡的现象出现了,先女后男时,结果一切正常;先男后女时,男性正常,女性全都不正常,甚至 Mia 原本为 0 的 ID 这里也变成了 2!
经过反复地测试和对结果集的观察,我们得到了这样一个有规律的结论:所有男性数据都正常,出问题的全是女性数据!
进一步公式化描述这个结论就是:如果前面的数据为非 0 数字,同时后面的数据数字为 0 时,则后面的 0 会被它前面的非 0 所覆盖。
3 答案 再次审计程序代码,我注意到了这一句:
// 用一个临时指针去作为文件中对象的载体,以节省新建对象的开销。 tmpP := &person{}

为了节省额外的新建对象的开销,我用了同一个变量来循环加载文件中的数据,并进行性别判定。结合前面我们发现的 BUG 规律,答案似乎近在眼前了:所谓后面的数据 0 被前面的非 0 覆盖,很可能是因为使用了同一个对象加载文件,导致前面的数据残留。
验证的方法也很简单,只需要将那个公共对象放到下面的for循环里,使每一次循环都重新创建一个对象用于加载文件数据,以切断上一个数据的影响。
我们修改一下代码(省略了多余部分):
for _, p := range persons { // ... tmpP := &person{} // ... }// 结果输出 &{ID:0 Name:Mia Gender:0 Age:21} &{ID:1 Name:Jim Gender:1 Age:18} &{ID:2 Name:Bob Gender:1 Age:25} &{ID:3 Name:Jenny Gender:0 Age:16} &{ID:4 Name:Marry Gender:0 Age:30} Female: 3, Male: 2

对了!
结果确实如我们推想,是数据残留的原因。但这里又有一个问题了:为什么先 0 后非 0 (先女后男)的情况下,老方法读取的数据又一切正常呢?以及,除了 0 会被影响外,其他的数字(年龄)又都不会被影响?
所有的问题现在似乎都在指向 0 这个特殊数字!
直到此时,零值这个特性才终于被我们察觉。于是我赶紧阅读了gob库的官方文档,发现了这么一句话:
If a field has the zero value for its type (except for arrays; see above), it is omitted from the transmission.
【Golang 中由零值和 gob 库的特性引起的 BUG】翻译一下:
如果一个字段的类型拥有零值(数组除外),它会在传输中被省略。
这句话的前后文是在说struct,因此这里的field指的也是结构体中的字段,符合我们文中的例子。
根据我们前面得到的结论,以及官方文档的说明,我们现在终于可以得出一个完整的结论了:
gob库在操作数据时,会忽略数组之外的零值。而我们的代码一开始使用一个公共对象来加载文件数据,由于零值不被传输,因此原数据中为零值的字段就不会读到,我们看到的实际上是上一个非零值的对象数据。
解决方法也很简单,就是我上面做的,不要使用公共对象去加载就好了。
4 回顾 文章开头我叙述的项目 BUG 里,我使用了 0 和 1 来表示一个定时任务的状态(暂停、运行)。就像上面 person.Gender 一样,不同任务之间因为零值问题受到了干扰,从而造成了任务执行异常,而不涉及零值的其他字段则一切正常。尽管是线上生产环境,但所幸问题发现的早,处理的及时,并没有造成任何生产事故。但整个过程和最终的答案却深深印在了我的脑海里。
后来我和我同事简单讨论过,为什么gob选择忽略零值?以我的角度来看,可能是为了节省空间。而我们一开始编写的代码,也是为了节省空间而创建了一个公共对象,结果两个节省空间的逻辑最终碰撞出了一个隐蔽的 BUG。

    推荐阅读