就在今年9月份,我负责的部门平台项目发布了一个新版本,该版本同时上线了一个新功能,简单说有点类似定时任务。头一天一切正常,但第二天出现了极少数任务没有正常执行(已经暂停的任务继续执行,正常的任务反而没有执行)的情况。
gobgo
后文中我会用一个更简化的例子描述这个 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
gobencoding/gobgobgo binarygob
gobpickle
gobgobExample
2 问题2.1 需求
在本文的开头,我简单叙述了问题的起源,这里我用一个更简单的模型来展开描述。
person
type person struct {
// 和 json 库一样,字段首字母必须大写(公有)才能序列化
ID int
Name string // 姓名
Gender int // 性别:男 1,女 0
Age int // 年龄
}
persongob
接着,我们有这样一个需求:
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
BUG
2.3 定位
countGender()forperson
// 添加行
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}
Gender
gobxxd
>$ 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 .*.
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.
翻译一下:
如果一个字段的类型拥有零值(数组除外),它会在传输中被省略。
structfield
根据我们前面得到的结论,以及官方文档的说明,我们现在终于可以得出一个完整的结论了:
gob
解决方法也很简单,就是我上面做的,不要使用公共对象去加载就好了。
4 回顾person.Gender
gob