Golang中由零值和gob庫(kù)特性引起B(yǎng)UG解析
起源
就在今年9月份,我負(fù)責(zé)的部門平臺(tái)項(xiàng)目發(fā)布了一個(gè)新版本,該版本同時(shí)上線了一個(gè)新功能,簡(jiǎn)單說(shuō)有點(diǎn)類似定時(shí)任務(wù)。頭一天一切正常,但第二天出現(xiàn)了極少數(shù)任務(wù)沒(méi)有正常執(zhí)行(已經(jīng)暫停的任務(wù)繼續(xù)執(zhí)行,正常的任務(wù)反而沒(méi)有執(zhí)行)的情況。
問(wèn)題的現(xiàn)象讓我和另一個(gè)同事的第一反應(yīng)是定時(shí)任務(wù)執(zhí)行的邏輯出現(xiàn)了問(wèn)題。但在我們耗費(fèi)了大量的時(shí)間去DEBUG、測(cè)試后,發(fā)現(xiàn)問(wèn)題的根本并不在功能邏輯,而是一段已經(jīng)上線了一年并且沒(méi)有動(dòng)過(guò)的底層公共代碼。這段代碼的核心就是本篇文章的主人公gob
,引發(fā)問(wèn)題的根源則是go
語(yǔ)言的一個(gè)特性:零值。
后文中我會(huì)用一個(gè)更簡(jiǎn)化的例子描述這個(gè) BUG。
1 gob 與零值
先簡(jiǎn)單介紹一下gob
和零值。
1.1 零值
零值是 Go 語(yǔ)言中的一個(gè)特性,簡(jiǎn)單說(shuō)就是:Go 語(yǔ)言會(huì)給一些沒(méi)有被賦值的變量提供一個(gè)默認(rèn)值。譬如下面這段代碼:
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) } /* 結(jié)果輸出 [] 0 map[] {name: gender:0 age:0} */
零值在很多時(shí)候確實(shí)為開(kāi)發(fā)者帶來(lái)了方便,但也有許多不喜歡它的人認(rèn)為零值的存在使得代碼從語(yǔ)法層面上不嚴(yán)謹(jǐn),帶來(lái)了一些不確定性。譬如我即將在后文中詳細(xì)描述的問(wèn)題。
1.2 gob
gob
是 Go 語(yǔ)言自帶的標(biāo)準(zhǔn)庫(kù),在encoding/gob
中。gob
其實(shí)是go binary
的簡(jiǎn)寫,因此從它的名稱我們也可以猜到,gob
應(yīng)當(dāng)與二進(jìn)制相關(guān)。
實(shí)際上gob
是 Go 語(yǔ)言獨(dú)有的以二進(jìn)制形式序列化和反序列化程序數(shù)據(jù)的格式,類似 Python 中的 pickle
。它最常見(jiàn)的用法是將一個(gè)對(duì)象(結(jié)構(gòu)體)序列化后存儲(chǔ)到磁盤文件,在需要使用的時(shí)候再讀取文件并反序列化出來(lái),從而達(dá)到對(duì)象持久化的效果。
例子我就不舉了,本篇也不是gob
的使用專題。這是它的**官方文檔**,對(duì)gob
用法不熟悉的朋友們可以看一下文檔中的Example
部分,或者直接看我后文中描述問(wèn)題用到的例子。
2 問(wèn)題
2.1 需求
在本文的開(kāi)頭,我簡(jiǎn)單敘述了問(wèn)題的起源,這里我用一個(gè)更簡(jiǎn)單的模型來(lái)展開(kāi)描述。
首先我們定義一個(gè)名為person
的結(jié)構(gòu)體:
type person struct { // 和 json 庫(kù)一樣,字段首字母必須大寫(公有)才能序列化 ID int Name string // 姓名 Gender int // 性別:男 1,女 0 Age int // 年齡 }
圍繞這個(gè)結(jié)構(gòu)體,我們會(huì)錄入若干個(gè)人員信息,每一個(gè)人員都是一個(gè)person
對(duì)象。但出于一些原因,我們必須使用gob
將這些人員信息持久化到本地磁盤,而不是使用 MySQL 之類的數(shù)據(jù)庫(kù)。
接著,我們有這樣一個(gè)需求:
遍歷并反序列化本地存儲(chǔ)的gob
文件,然后判斷男女性別的數(shù)量,并統(tǒng)計(jì)。
2.2 代碼
根據(jù)上面的需求和背景,代碼如下(為了節(jié)省篇幅,這里省略了 package, import, init()
等代碼):
- defines.go
// .gob 文件所在目錄 const DIR = "./persons" type person struct { // 和 json 庫(kù)一樣,字段首字母必須大寫(公有)才能序列化 ID int Name string // 姓名 Gender int // 性別:男 1,女 0 Age int // 年齡 } // 需要持久化的對(duì)象們 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 對(duì)象序列化后存儲(chǔ)到文件, // 文件名為 ./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 文件反序列化后存入指針參數(shù) 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) // 用一個(gè)臨時(shí)指針去作為文件中對(duì)象的載體,以節(jié)省新建對(duì)象的開(kāi)銷。 tmpP := &person{} for _, p := range persons { // 方便起見(jiàn),這里直接遍歷 persons ,但只取 ID 用于讀文件 id := p.ID filename := filepath.Join(DIR, fmt.Sprintf("%d.gob", id)) // 反序列化對(duì)象到 tmpP 中 unserialize(filename, tmpP) // 統(tǒng)計(jì)性別 counter[tmpP.Gender]++ } fmt.Printf("Female: %+v, Male: %+v\n", counter[0], counter[1]) }
執(zhí)行代碼后,我們得到了這樣的結(jié)果:
// 對(duì)象們 var persons = []person{ {0, "Mia", 0, 21}, {1, "Jim", 1, 18}, {2, "Bob", 1, 25}, {3, "Jenny", 0, 16}, {4, "Marry", 0, 30}, } // 結(jié)果輸出 Female: 1, Male: 4
嗯?1 個(gè)女性,4 個(gè)男性?BUG
出現(xiàn)了,這樣的結(jié)果顯然與我們的預(yù)設(shè)數(shù)據(jù)不符。是哪里出了問(wèn)題?
2.3 定位
我們?cè)?code>countGender()函數(shù)中的for
循環(huán)里添加一行打印語(yǔ)句,將每次讀取到的person
對(duì)象讀出來(lái),然后得到了這樣的結(jié)果:
// 添加行 fmt.Printf("%+v\n", tmpP) // 結(jié)果輸出 &{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
這一項(xiàng)外,其他所有的數(shù)據(jù)都正常!看到這一結(jié)果,如果大家和我一樣,平時(shí)經(jīng)常和 JSON、Yml 之類的配置文件打交道,很可能會(huì)想當(dāng)然地認(rèn)為:上面的 gob 文件讀取正常,應(yīng)當(dāng)是存儲(chǔ)出了問(wèn)題。
但gob
文件是二進(jìn)制文件,我們難以像 JSON 文件那樣用肉眼去驗(yàn)證。即便在 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 .*.
也許我們可以嘗試去硬解析這幾個(gè)二進(jìn)制文件,來(lái)對(duì)比它們之間的差異;或者反序列化兩個(gè)除了 Gender 外一模一樣的對(duì)象到gob
文件中,然后對(duì)比。大家如果有興趣的話可以嘗試一下。當(dāng)時(shí)的我們因?yàn)闀r(shí)間緊迫等原因,沒(méi)有嘗試這種做法,而是修改數(shù)據(jù)繼續(xù)測(cè)試。
2.4 規(guī)律
由于上文中出問(wèn)題的兩個(gè)數(shù)據(jù)都是女性,程序員的直覺(jué)告訴我這也許并不是巧合。于是我嘗試修改數(shù)據(jù)的順序,將男女完全分開(kāi),然后進(jìn)行測(cè)試:
// 第一組,先女后男 var persons = []person{ {0, "Mia", 0, 21}, {3, "Jenny", 0, 16}, {4, "Marry", 0, 30}, {1, "Jim", 1, 18}, {2, "Bob", 1, 25}, } // 結(jié)果輸出 &{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}, } // 結(jié)果輸出 &{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}
吊詭的現(xiàn)象出現(xiàn)了,先女后男時(shí),結(jié)果一切正常;先男后女時(shí),男性正常,女性全都不正常,甚至 Mia 原本為 0 的 ID 這里也變成了 2!
經(jīng)過(guò)反復(fù)地測(cè)試和對(duì)結(jié)果集的觀察,我們得到了這樣一個(gè)有規(guī)律的結(jié)論:所有男性數(shù)據(jù)都正常,出問(wèn)題的全是女性數(shù)據(jù)!
進(jìn)一步公式化描述這個(gè)結(jié)論就是:如果前面的數(shù)據(jù)為非 0 數(shù)字,同時(shí)后面的數(shù)據(jù)數(shù)字為 0 時(shí),則后面的 0 會(huì)被它前面的非 0 所覆蓋。
3 答案
再次審計(jì)程序代碼,我注意到了這一句:
// 用一個(gè)臨時(shí)指針去作為文件中對(duì)象的載體,以節(jié)省新建對(duì)象的開(kāi)銷。 tmpP := &person{}
為了節(jié)省額外的新建對(duì)象的開(kāi)銷,我用了同一個(gè)變量來(lái)循環(huán)加載文件中的數(shù)據(jù),并進(jìn)行性別判定。結(jié)合前面我們發(fā)現(xiàn)的 BUG 規(guī)律,答案似乎近在眼前了:所謂后面的數(shù)據(jù) 0 被前面的非 0 覆蓋,很可能是因?yàn)槭褂昧送粋€(gè)對(duì)象加載文件,導(dǎo)致前面的數(shù)據(jù)殘留。
驗(yàn)證的方法也很簡(jiǎn)單,只需要將那個(gè)公共對(duì)象放到下面的for
循環(huán)里,使每一次循環(huán)都重新創(chuàng)建一個(gè)對(duì)象用于加載文件數(shù)據(jù),以切斷上一個(gè)數(shù)據(jù)的影響。
我們修改一下代碼(省略了多余部分):
for _, p := range persons { // ... tmpP := &person{} // ... } // 結(jié)果輸出 &{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
對(duì)了!
結(jié)果確實(shí)如我們推想,是數(shù)據(jù)殘留的原因。但這里又有一個(gè)問(wèn)題了:為什么先 0 后非 0 (先女后男)的情況下,老方法讀取的數(shù)據(jù)又一切正常呢?以及,除了 0 會(huì)被影響外,其他的數(shù)字(年齡)又都不會(huì)被影響?
所有的問(wèn)題現(xiàn)在似乎都在指向 0 這個(gè)特殊數(shù)字!
直到此時(shí),零值這個(gè)特性才終于被我們察覺(jué)。于是我趕緊閱讀了gob
庫(kù)的**官方文檔**,發(fā)現(xiàn)了這么一句話:
If a field has the zero value for its type (except for arrays; see above), it is omitted from the transmission.
翻譯一下:
如果一個(gè)字段的類型擁有零值(數(shù)組除外),它會(huì)在傳輸中被省略。
這句話的前后文是在說(shuō)struct
,因此這里的field
指的也是結(jié)構(gòu)體中的字段,符合我們文中的例子。
根據(jù)我們前面得到的結(jié)論,以及官方文檔的說(shuō)明,我們現(xiàn)在終于可以得出一個(gè)完整的結(jié)論了:
gob
庫(kù)在操作數(shù)據(jù)時(shí),會(huì)忽略數(shù)組之外的零值。而我們的代碼一開(kāi)始使用一個(gè)公共對(duì)象來(lái)加載文件數(shù)據(jù),由于零值不被傳輸,因此原數(shù)據(jù)中為零值的字段就不會(huì)讀到,我們看到的實(shí)際上是上一個(gè)非零值的對(duì)象數(shù)據(jù)。
解決方法也很簡(jiǎn)單,就是我上面做的,不要使用公共對(duì)象去加載就好了。
4 回顧
文章開(kāi)頭我敘述的項(xiàng)目 BUG 里,我使用了 0 和 1 來(lái)表示一個(gè)定時(shí)任務(wù)的狀態(tài)(暫停、運(yùn)行)。就像上面 person.Gender
一樣,不同任務(wù)之間因?yàn)榱阒祮?wèn)題受到了干擾,從而造成了任務(wù)執(zhí)行異常,而不涉及零值的其他字段則一切正常。盡管是線上生產(chǎn)環(huán)境,但所幸問(wèn)題發(fā)現(xiàn)的早,處理的及時(shí),并沒(méi)有造成任何生產(chǎn)事故。但整個(gè)過(guò)程和最終的答案卻深深印在了我的腦海里。
后來(lái)我和我同事簡(jiǎn)單討論過(guò),為什么gob
選擇忽略零值?以我的角度來(lái)看,可能是為了節(jié)省空間。而我們一開(kāi)始編寫的代碼,也是為了節(jié)省空間而創(chuàng)建了一個(gè)公共對(duì)象,結(jié)果兩個(gè)節(jié)省空間的邏輯最終碰撞出了一個(gè)隱蔽的 BUG。
以上就是Golang中由零值和gob庫(kù)特性引起B(yǎng)UG解析的詳細(xì)內(nèi)容,更多關(guān)于Golang零值gob庫(kù)引起B(yǎng)UG的資料請(qǐng)關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
一文帶你了解Go語(yǔ)言中鎖特性和實(shí)現(xiàn)
Go語(yǔ)言中的sync包主要提供的對(duì)并發(fā)操作的支持,標(biāo)志性的工具有cond(條件變量)?once?(原子性)?還有?鎖,本文會(huì)主要向大家介紹Go語(yǔ)言中鎖的特性和實(shí)現(xiàn),感興趣的可以了解下2024-03-03Go語(yǔ)言學(xué)習(xí)之接口類型(interface)詳解
接口是用來(lái)定義行為的類型,定義的行為不由接口直接實(shí)現(xiàn),而由通過(guò)方法由定義的類型實(shí)現(xiàn),本文就來(lái)和大家詳細(xì)講講Go語(yǔ)言中接口的使用吧2023-03-03GoLang并發(fā)機(jī)制探究goroutine原理詳細(xì)講解
goroutine是Go語(yǔ)言提供的語(yǔ)言級(jí)別的輕量級(jí)線程,在我們需要使用并發(fā)時(shí),我們只需要通過(guò) go 關(guān)鍵字來(lái)開(kāi)啟 goroutine 即可。這篇文章主要介紹了GoLang并發(fā)機(jī)制goroutine原理,感興趣的可以了解一下2022-12-12Go利用反射reflect實(shí)現(xiàn)獲取接口變量信息
反射是通過(guò)實(shí)體對(duì)象獲取反射對(duì)象(Value、Type),然后可以操作相應(yīng)的方法。本文將利用Go語(yǔ)言中的反射reflect實(shí)現(xiàn)獲取接口變量信息,需要的可以參考一下2022-05-05如何讓shell終端和goland控制臺(tái)輸出彩色的文字
這篇文章主要介紹了如何讓shell終端和goland控制臺(tái)輸出彩色的文字的操作,具有很好的參考價(jià)值,希望對(duì)大家有所幫助。一起跟隨小編過(guò)來(lái)看看吧2021-05-05go 實(shí)現(xiàn)簡(jiǎn)易端口掃描的示例
該功能實(shí)現(xiàn)原理很簡(jiǎn)單,就是發(fā)送socket連接(IP+端口),如果能連接成功,說(shuō)明目標(biāo)主機(jī)開(kāi)放了某端口。當(dāng)要大量掃描端口時(shí),就需要寫并發(fā)編程了。2021-05-05go語(yǔ)言解決并發(fā)問(wèn)題小結(jié)
并發(fā)是GO最基本的功能了,但是在傳統(tǒng)的PHP中是比較困難的,如果不借助其它一些擴(kuò)展的話,是做不到并發(fā)的,這篇文章主要介紹了go語(yǔ)言如何解決并發(fā)問(wèn)題,需要的朋友可以參考下2024-05-05