结构到磁盘的高效 Go 序列化
Efficient Go serialization of struct to disk
我的任务是将 C++ 代码替换为 Go,我对 Go API 还很陌生。我正在使用 gob 将数百个 key/value 条目编码到磁盘页面,但是 gob 编码有太多不需要的膨胀。
package main
import (
"bytes"
"encoding/gob"
"fmt"
)
type Entry struct {
Key string
Val string
}
func main() {
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
e := Entry { "k1", "v1" }
enc.Encode(e)
fmt.Println(buf.Bytes())
}
这会产生很多我不需要的膨胀:
[35 255 129 3 1 1 5 69 110 116 114 121 1 255 130 0 1 2 1 3 75 101 121 1 12 0 1 3 86 97 108 1 12 0 0 0 11 255 130 1 2 107 49 1 2 118 49 0]
我想序列化每个字符串的 len 后跟原始字节,如:
[0 0 0 2 107 49 0 0 0 2 118 49]
我正在保存数百万个条目,因此编码中的额外膨胀会使文件大小增加大约 10 倍。
如何在不手动编码的情况下将其序列化为后者?
使用 protobuf 高效编码您的数据。
https://github.com/golang/protobuf
您的主要内容如下所示:
package main
import (
"fmt"
"log"
"github.com/golang/protobuf/proto"
)
func main() {
e := &Entry{
Key: proto.String("k1"),
Val: proto.String("v1"),
}
data, err := proto.Marshal(e)
if err != nil {
log.Fatal("marshaling error: ", err)
}
fmt.Println(data)
}
你创建一个文件,example.proto像这样:
package main;
message Entry {
required string Key = 1;
required string Val = 2;
}
您通过 运行ning 从 proto 文件生成 go 代码:
$ protoc --go_out=. *.proto
如果需要,您可以检查生成的文件。
您可以运行并查看结果输出:
$ go run *.go
[10 2 107 49 18 2 118 49]
如果您压缩一个名为 a.txt
的文件,其中包含文本 "hello"
(即 5 个字符),则压缩结果将约为 115 个字节。这是否意味着 zip 格式不能有效地压缩文本文件?当然不是。存在 开销 。如果文件包含 "hello"
一百次(500 字节),压缩它会导致文件为 120 字节! 1x"hello"
=> 115 字节,100x"hello"
=> 120 字节!我们增加了 495 字节,但压缩后的大小只增加了 5 字节。
encoding/gob
包也发生了类似的事情:
The implementation compiles a custom codec for each data type in the stream and is most efficient when a single Encoder is used to transmit a stream of values, amortizing the cost of compilation.
当您“首先”序列化一个类型的值时,该类型的 定义 也必须包含/传输,以便解码器可以正确解释和解码流:
A stream of gobs is self-describing. Each data item in the stream is preceded by a specification of its type, expressed in terms of a small set of predefined types.
让我们return举个例子:
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
e := Entry{"k1", "v1"}
enc.Encode(e)
fmt.Println(buf.Len())
它打印:
48
现在让我们再编码一些相同类型:
enc.Encode(e)
fmt.Println(buf.Len())
enc.Encode(e)
fmt.Println(buf.Len())
现在输出是:
60
72
在 Go Playground 上试试。
分析结果:
相同 Entry
类型的其他值仅花费 12 字节,而第一个是 48
字节,因为类型定义也包括在内(大约 26 字节),但这是 一次性 开销。
所以基本上你传输 2 string
s: "k1"
和 "v1"
是 4 个字节,string
s 的长度也必须包括在内,使用4
字节(32 位体系结构上 int
的大小)为您提供 12 个字节,这是“最小值”。 (是的,您可以使用更小的长度类型,但这会有其局限性。对于小数字,可变长度编码是更好的选择,请参阅 encoding/binary
包。)
总而言之,encoding/gob
可以很好地满足您的需求。不要被最初的印象所迷惑。
如果一个 Entry
的 12 个字节对您来说太多了,您可以随时将流包装到 compress/flate
or compress/gzip
写入器中以进一步减小大小(以换取较慢的 encoding/decoding 和进程的内存要求略高)。
示范:
让我们测试以下 5 种解决方案:
- 使用“裸”输出(无压缩)
- 使用
compress/flate
压缩encoding/gob
的输出
- 使用
compress/zlib
压缩encoding/gob
的输出
- 使用
compress/gzip
压缩encoding/gob
的输出
- 使用
github.com/dsnet/compress/bzip2
压缩encoding/gob
的输出
我们将写入一千个条目,更改每个条目的键和值,分别为 "k000"
、"v000"
、"k001"
、"v001"
等。这意味着未压缩的大小Entry
是 4 字节 + 4 字节 + 4 字节 + 4 字节 = 16 字节(2x4 字节文本,2x4 字节长度)。
代码如下所示:
for _, name := range []string{"Naked", "flate", "zlib", "gzip", "bzip2"} {
buf := &bytes.Buffer{}
var out io.Writer
switch name {
case "Naked":
out = buf
case "flate":
out, _ = flate.NewWriter(buf, flate.DefaultCompression)
case "zlib":
out, _ = zlib.NewWriterLevel(buf, zlib.DefaultCompression)
case "gzip":
out = gzip.NewWriter(buf)
case "bzip2":
out, _ = bzip2.NewWriter(buf, nil)
}
enc := gob.NewEncoder(out)
e := Entry{}
for i := 0; i < 1000; i++ {
e.Key = fmt.Sprintf("k%3d", i)
e.Val = fmt.Sprintf("v%3d", i)
enc.Encode(e)
}
if c, ok := out.(io.Closer); ok {
c.Close()
}
fmt.Printf("[%5s] Length: %5d, average: %5.2f / Entry\n",
name, buf.Len(), float64(buf.Len())/1000)
}
输出:
[Naked] Length: 16036, average: 16.04 / Entry
[flate] Length: 4120, average: 4.12 / Entry
[ zlib] Length: 4126, average: 4.13 / Entry
[ gzip] Length: 4138, average: 4.14 / Entry
[bzip2] Length: 2042, average: 2.04 / Entry
在 Go Playground 上试试。
如您所见:“裸”输出 16.04 bytes/Entry
,仅略高于计算大小(由于上面讨论的一次性微小开销)。
当您使用 flate、zlib 或 gzip 压缩输出时,您可以将输出大小减小到大约 4.13 bytes/Entry
,大约是理论大小的 ~26%,我相信这会让您满意.如果没有,您可以使用像 bzip2 这样提供更高效率压缩的库,在上面的示例中导致 2.04 bytes/Entry
,是理论大小的 12.7%!
(请注意,对于“真实”数据,压缩率可能会高很多,因为我在测试中使用的键和值非常相似,因此可压缩性非常好;静止率应该在 50% 左右与现实生活中的数据)。
"Manual coding",你很害怕,在 Go 中使用标准 encoding/binary
package.
很容易完成
您似乎将字符串长度值存储为大端格式的 32 位整数,因此您可以继续在 Go 中执行此操作:
package main
import (
"bytes"
"encoding/binary"
"fmt"
"io"
)
func encode(w io.Writer, s string) (n int, err error) {
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(s)))
n, err = w.Write(hdr[:])
if err != nil {
return
}
n2, err := io.WriteString(w, s)
n += n2
return
}
func main() {
var buf bytes.Buffer
for _, s := range []string{
"ab",
"cd",
"de",
} {
_, err := encode(&buf, s)
if err != nil {
panic(err)
}
}
fmt.Printf("%v\n", buf.Bytes())
}
请注意,在此示例中,我正在写入一个字节缓冲区,但这仅用于演示目的——因为 encode()
写入一个 io.Writer
,您可以将一个打开的文件传递给它,一个网络套接字和任何其他实现该接口的东西。
我的任务是将 C++ 代码替换为 Go,我对 Go API 还很陌生。我正在使用 gob 将数百个 key/value 条目编码到磁盘页面,但是 gob 编码有太多不需要的膨胀。
package main
import (
"bytes"
"encoding/gob"
"fmt"
)
type Entry struct {
Key string
Val string
}
func main() {
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
e := Entry { "k1", "v1" }
enc.Encode(e)
fmt.Println(buf.Bytes())
}
这会产生很多我不需要的膨胀:
[35 255 129 3 1 1 5 69 110 116 114 121 1 255 130 0 1 2 1 3 75 101 121 1 12 0 1 3 86 97 108 1 12 0 0 0 11 255 130 1 2 107 49 1 2 118 49 0]
我想序列化每个字符串的 len 后跟原始字节,如:
[0 0 0 2 107 49 0 0 0 2 118 49]
我正在保存数百万个条目,因此编码中的额外膨胀会使文件大小增加大约 10 倍。
如何在不手动编码的情况下将其序列化为后者?
使用 protobuf 高效编码您的数据。
https://github.com/golang/protobuf
您的主要内容如下所示:
package main
import (
"fmt"
"log"
"github.com/golang/protobuf/proto"
)
func main() {
e := &Entry{
Key: proto.String("k1"),
Val: proto.String("v1"),
}
data, err := proto.Marshal(e)
if err != nil {
log.Fatal("marshaling error: ", err)
}
fmt.Println(data)
}
你创建一个文件,example.proto像这样:
package main;
message Entry {
required string Key = 1;
required string Val = 2;
}
您通过 运行ning 从 proto 文件生成 go 代码:
$ protoc --go_out=. *.proto
如果需要,您可以检查生成的文件。
您可以运行并查看结果输出:
$ go run *.go
[10 2 107 49 18 2 118 49]
如果您压缩一个名为 a.txt
的文件,其中包含文本 "hello"
(即 5 个字符),则压缩结果将约为 115 个字节。这是否意味着 zip 格式不能有效地压缩文本文件?当然不是。存在 开销 。如果文件包含 "hello"
一百次(500 字节),压缩它会导致文件为 120 字节! 1x"hello"
=> 115 字节,100x"hello"
=> 120 字节!我们增加了 495 字节,但压缩后的大小只增加了 5 字节。
encoding/gob
包也发生了类似的事情:
The implementation compiles a custom codec for each data type in the stream and is most efficient when a single Encoder is used to transmit a stream of values, amortizing the cost of compilation.
当您“首先”序列化一个类型的值时,该类型的 定义 也必须包含/传输,以便解码器可以正确解释和解码流:
A stream of gobs is self-describing. Each data item in the stream is preceded by a specification of its type, expressed in terms of a small set of predefined types.
让我们return举个例子:
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
e := Entry{"k1", "v1"}
enc.Encode(e)
fmt.Println(buf.Len())
它打印:
48
现在让我们再编码一些相同类型:
enc.Encode(e)
fmt.Println(buf.Len())
enc.Encode(e)
fmt.Println(buf.Len())
现在输出是:
60
72
在 Go Playground 上试试。
分析结果:
相同 Entry
类型的其他值仅花费 12 字节,而第一个是 48
字节,因为类型定义也包括在内(大约 26 字节),但这是 一次性 开销。
所以基本上你传输 2 string
s: "k1"
和 "v1"
是 4 个字节,string
s 的长度也必须包括在内,使用4
字节(32 位体系结构上 int
的大小)为您提供 12 个字节,这是“最小值”。 (是的,您可以使用更小的长度类型,但这会有其局限性。对于小数字,可变长度编码是更好的选择,请参阅 encoding/binary
包。)
总而言之,encoding/gob
可以很好地满足您的需求。不要被最初的印象所迷惑。
如果一个 Entry
的 12 个字节对您来说太多了,您可以随时将流包装到 compress/flate
or compress/gzip
写入器中以进一步减小大小(以换取较慢的 encoding/decoding 和进程的内存要求略高)。
示范:
让我们测试以下 5 种解决方案:
- 使用“裸”输出(无压缩)
- 使用
compress/flate
压缩encoding/gob
的输出
- 使用
compress/zlib
压缩encoding/gob
的输出
- 使用
compress/gzip
压缩encoding/gob
的输出
- 使用
github.com/dsnet/compress/bzip2
压缩encoding/gob
的输出
我们将写入一千个条目,更改每个条目的键和值,分别为 "k000"
、"v000"
、"k001"
、"v001"
等。这意味着未压缩的大小Entry
是 4 字节 + 4 字节 + 4 字节 + 4 字节 = 16 字节(2x4 字节文本,2x4 字节长度)。
代码如下所示:
for _, name := range []string{"Naked", "flate", "zlib", "gzip", "bzip2"} {
buf := &bytes.Buffer{}
var out io.Writer
switch name {
case "Naked":
out = buf
case "flate":
out, _ = flate.NewWriter(buf, flate.DefaultCompression)
case "zlib":
out, _ = zlib.NewWriterLevel(buf, zlib.DefaultCompression)
case "gzip":
out = gzip.NewWriter(buf)
case "bzip2":
out, _ = bzip2.NewWriter(buf, nil)
}
enc := gob.NewEncoder(out)
e := Entry{}
for i := 0; i < 1000; i++ {
e.Key = fmt.Sprintf("k%3d", i)
e.Val = fmt.Sprintf("v%3d", i)
enc.Encode(e)
}
if c, ok := out.(io.Closer); ok {
c.Close()
}
fmt.Printf("[%5s] Length: %5d, average: %5.2f / Entry\n",
name, buf.Len(), float64(buf.Len())/1000)
}
输出:
[Naked] Length: 16036, average: 16.04 / Entry
[flate] Length: 4120, average: 4.12 / Entry
[ zlib] Length: 4126, average: 4.13 / Entry
[ gzip] Length: 4138, average: 4.14 / Entry
[bzip2] Length: 2042, average: 2.04 / Entry
在 Go Playground 上试试。
如您所见:“裸”输出 16.04 bytes/Entry
,仅略高于计算大小(由于上面讨论的一次性微小开销)。
当您使用 flate、zlib 或 gzip 压缩输出时,您可以将输出大小减小到大约 4.13 bytes/Entry
,大约是理论大小的 ~26%,我相信这会让您满意.如果没有,您可以使用像 bzip2 这样提供更高效率压缩的库,在上面的示例中导致 2.04 bytes/Entry
,是理论大小的 12.7%!
(请注意,对于“真实”数据,压缩率可能会高很多,因为我在测试中使用的键和值非常相似,因此可压缩性非常好;静止率应该在 50% 左右与现实生活中的数据)。
"Manual coding",你很害怕,在 Go 中使用标准 encoding/binary
package.
您似乎将字符串长度值存储为大端格式的 32 位整数,因此您可以继续在 Go 中执行此操作:
package main
import (
"bytes"
"encoding/binary"
"fmt"
"io"
)
func encode(w io.Writer, s string) (n int, err error) {
var hdr [4]byte
binary.BigEndian.PutUint32(hdr[:], uint32(len(s)))
n, err = w.Write(hdr[:])
if err != nil {
return
}
n2, err := io.WriteString(w, s)
n += n2
return
}
func main() {
var buf bytes.Buffer
for _, s := range []string{
"ab",
"cd",
"de",
} {
_, err := encode(&buf, s)
if err != nil {
panic(err)
}
}
fmt.Printf("%v\n", buf.Bytes())
}
请注意,在此示例中,我正在写入一个字节缓冲区,但这仅用于演示目的——因为 encode()
写入一个 io.Writer
,您可以将一个打开的文件传递给它,一个网络套接字和任何其他实现该接口的东西。