0 起源

就在往年9月份,我负责的部门平台我的项目公布了一个新版本,该版本同时上线了一个新性能,简略说有点相似定时工作。头一天一切正常,但第二天呈现了极少数工作没有失常执行(曾经暂停的工作继续执行,失常的工作反而没有执行)的状况。

问题的景象让我和另一个共事的第一反馈是定时工作执行的逻辑呈现了问题。但在咱们消耗了大量的工夫去DEBUG、测试后,发现问题的基本并不在性能逻辑,而是一段曾经上线了一年并且没有动过的底层公共代码。这段代码的外围就是本篇文章的主人公gob,引发问题的本源则是go语言的一个个性:零值

后文中我会用一个更简化的例子形容这个 BUG。

1 gob 与零值

先简略介绍一下gob和零值。

1.1 零值

零值是 Go 语言中的一个个性,简略说就是:Go 语言会给一些没有被赋值的变量提供一个默认值。譬如上面这段代码:

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

零值在很多时候的确为开发者带来了不便,但也有许多不喜爱它的人认为零值的存在使得代码从语法层面上不谨严,带来了一些不确定性。譬如我行将在后文中详细描述的问题。

1.2 gob

gob是 Go 语言自带的规范库,在encoding/gob中。gob其实是go binary的简写,因而从它的名称咱们也能够猜到,gob该当与二进制相干。

实际上gobGo 语言独有的以二进制模式序列化和反序列化程序数据的格局,相似 Python 中的 pickle。它最常见的用法是将一个对象(构造体)序列化后存储到磁盘文件,在须要应用的时候再读取文件并反序列化进去,从而达到对象长久化的成果。

例子我就不举了,本篇也不是gob的应用专题。这是它的官网文档,对gob用法不相熟的敌人们能够看一下文档中的Example局部,或者间接看我后文中形容问题用到的例子。

2 问题

2.1 需要

在本文的结尾,我简略叙述了问题的起源,这里我用一个更简略的模型来开展形容。

首先咱们定义一个名为person的构造体:

type person struct {    // 和 json 库一样,字段首字母必须大写(私有)能力序列化    ID     int    Name   string // 姓名    Gender int    // 性别:男 1,女 0    Age    int    // 年龄}

围绕这个构造体,咱们会录入若干个人员信息,每一个人员都是一个person对象。但出于一些起因,咱们必须应用gob将这些人员信息长久化到本地磁盘,而不是应用 MySQL 之类的数据库。

接着,咱们有这样一个需要:

遍历并反序列化本地存储的gob文件,而后判断男女性别的数量,并统计。

2.2 代码

依据下面的需要和背景,代码如下(为了节俭篇幅,这里省略了 package, import, init() 等代码):

  • defines.go
// .gob 文件所在目录const DIR = "./persons"type person struct {    // 和 json 库一样,字段首字母必须大写(私有)能力序列化    ID     int    Name   string // 姓名    Gender int    // 性别:男 1,女 0    Age    int    // 年龄}// 须要长久化的对象们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}.gobfunc 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 ff82  7......person...0000010: 0001 0401 0249 4401 0400 0104 4e61 6d65  .....ID.....Name0000020: 010c 0001 0647 656e 6465 7201 0400 0103  .....Gender.....0000030: 4167 6501 0400 0000 0eff 8201 0201 034a  Age............J0000040: 696d 0102 0124 00                        im...$.>$ xxd persons/0.gob 0000000: 37ff 8103 0101 0670 6572 736f 6e01 ff82  7......person...0000010: 0001 0401 0249 4401 0400 0104 4e61 6d65  .....ID.....Name0000020: 010c 0001 0647 656e 6465 7201 0400 0103  .....Gender.....0000030: 4167 6501 0400 0000 0aff 8202 034d 6961  Age..........Mia0000040: 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.

翻译一下:

如果一个字段的类型领有零值(数组除外),它会在传输中被省略。

这句话的前后文是在说struct,因而这里的field指的也是构造体中的字段,合乎咱们文中的例子。

依据咱们后面失去的论断,以及官网文档的阐明,咱们当初终于能够得出一个残缺的论断了:

gob库在操作数据时,会疏忽数组之外的零值。而咱们的代码一开始应用一个公共对象来加载文件数据,因为零值不被传输,因而原数据中为零值的字段就不会读到,咱们看到的实际上是上一个非零值的对象数据。

解决办法也很简略,就是我下面做的,不要应用公共对象去加载就好了。

4 回顾

文章结尾我叙述的我的项目 BUG 里,我应用了 0 和 1 来示意一个定时工作的状态(暂停、运行)。就像下面 person.Gender 一样,不同工作之间因为零值问题受到了烦扰,从而造成了工作执行异样,而不波及零值的其余字段则一切正常。只管是线上生产环境,但所幸问题发现的早,解决的及时,并没有造成任何生产事变。但整个过程和最终的答案却深深印在了我的脑海里。

起初我和我共事简略探讨过,为什么gob抉择疏忽零值?以我的角度来看,可能是为了节俭空间。而咱们一开始编写的代码,也是为了节俭空间而创立了一个公共对象,后果两个节俭空间的逻辑最终碰撞出了一个荫蔽的 BUG。