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

杜秉軒發表於2021-12-04

0 起源

就在今年9月份,我負責的部門平臺專案釋出了一個新版本,該版本同時上線了一個新功能,簡單說有點類似定時任務。頭一天一切正常,但第二天出現了極少數任務沒有正常執行(已經暫停的任務繼續執行,正常的任務反而沒有執行)的情況。

問題的現象讓我和另一個同事的第一反應是定時任務執行的邏輯出現了問題。但在我們耗費了大量的時間去DEBUG、測試後,發現問題的根本並不在功能邏輯,而是一段已經上線了一年並且沒有動過的底層公共程式碼。這段程式碼的核心就是本篇文章的主人公gob,引發問題的根源則是go語言的一個特性:零值

後文中我會用一個更簡化的例子描述這個 BUG。

1 gob 與零值

先簡單介紹一下gob和零值。

1.1 零值

零值是 Go 語言中的一個特性,簡單說就是:Go 語言會給一些沒有被賦值的變數提供一個預設值。譬如下面這段程式碼:

package main

import (
    "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}.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 ff82  7......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 034a  Age............J
0000040: 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.....Name
0000020: 010c 0001 0647 656e 6465 7201 0400 0103  .....Gender.....
0000030: 4167 6501 0400 0000 0aff 8202 034d 6961  Age..........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.

翻譯一下:

如果一個欄位的型別擁有零值(陣列除外),它會在傳輸中被省略。

這句話的前後文是在說struct,因此這裡的field指的也是結構體中的欄位,符合我們文中的例子。

根據我們前面得到的結論,以及官方文件的說明,我們現在終於可以得出一個完整的結論了:

gob庫在運算元據時,會忽略陣列之外的零值。而我們的程式碼一開始使用一個公共物件來載入檔案資料,由於零值不被傳輸,因此原資料中為零值的欄位就不會讀到,我們看到的實際上是上一個非零值的物件資料。

解決方法也很簡單,就是我上面做的,不要使用公共物件去載入就好了。

4 回顧

文章開頭我敘述的專案 BUG 裡,我使用了 0 和 1 來表示一個定時任務的狀態(暫停、執行)。就像上面 person.Gender 一樣,不同任務之間因為零值問題受到了干擾,從而造成了任務執行異常,而不涉及零值的其他欄位則一切正常。儘管是線上生產環境,但所幸問題發現的早,處理的及時,並沒有造成任何生產事故。但整個過程和最終的答案卻深深印在了我的腦海裡。

後來我和我同事簡單討論過,為什麼gob選擇忽略零值?以我的角度來看,可能是為了節省空間。而我們一開始編寫的程式碼,也是為了節省空間而建立了一個公共物件,結果兩個節省空間的邏輯最終碰撞出了一個隱蔽的 BUG。

相關文章