导读
最近在工作中新写了一个工作流框架,事无巨细地记录了很多数据,因此数据量膨胀得有点厉害。为了避免磁盘过快被打满,准备考虑对其中占用最大的任务入参和出参做一轮压缩。
因此,本文主要讨论的是文本(json)的无损压缩。并提供一套方法论,来供大家选择适合自己的压缩算法。
溯源与原理
压缩算法可以分为有损压缩和无损压缩。有损压缩常见于视频、音频这类数据文件,且具有特殊的格式要求。而无损压缩则通常对内容的格式没有要求(虽然不同的格式会影响压缩率,但是通常都能压缩)。
目前主要的压缩算法(Zlib、Gzip)都以DEFLATE算法为底层算法。DEFLATE则再由两个算法构成:LZ77算法与哈夫曼编码(Huffman Coding) 。本文不会详细展开这些算法的细节,但是会对背后的思想做一些简单的概述,方便我们去理解和比较压缩算法。
LZ77
一句话概括:利用数据的重复结构信息来进行数据压缩
比如一个数据abcabc
,我们显然可以看出,abc
是重复的部分,因此我们可以用(abc,2)
来简短表述这段数据。
这当中的难点在于,如何找到重复的部分?又如何定义优化效率最高的重复字符串?比如abacbabcbadbcabc
,如果我们尝试去拆这个字符串的子字符串,再对所有的子字符串做比对,会发现这是一个计算量爆炸的过程。
因此LZ77定义了搜索窗口和匹配窗口,这是两个紧挨着的滑动窗口。我们使用匹配窗口去搜索窗口中做贪心匹配。
比如我们定义字符串s=abacbabcbabdcabc
:
搜索窗口为s[0:4]=abac
,匹配窗口为s[4:8]=babc
,贪心匹配,只能匹配到ba。所以我们定义结构(3,2,b)
,表示从当前位置向前3格{bac},2格内的字符都重复{ba},下一个字符为b。由此(3,2,b)
就表示了bab三个字符。
再比如:
搜索窗口为s[1:7]=bacbab
,匹配窗口为s[7:11]=cbab
,贪心匹配,可以全部匹配上。所以我们定义结构(4,4,d)
,表示从c往前4格,4格内所有字符都重复,且下一个字符为d。
通过这种方式,可以达到效率和压缩率一个比较好的平衡。
哈夫曼编码
一句话概括:越经常出现的内容,越要用少的内容来描述它。
简单介绍一下,我们有这么一个数组{0, 10, 110, 111}
,可以表示四个字符。当这四个二进制数无论以什么进行排列组合,总能唯一还原到这四个元素构成。比如0110111100
,总是可以且唯一还原为由上述四个二进制数构成的数组:{0, 110, 111, 10, 0}
。
当我们遇到CABCDDC
这个字符串后,我们统计出C出现3次,D出现2次,A和B各出现1次。因此对于最常出现的C,我们使用最短的二进制数去描述它:0。对DAB同理。因此我们对于CABCDDC
这个字符,我们可以简化为二进制流0110111010100
。
A | 110 |
B | 111 |
C | 0 |
D | 10 |
逛逛压缩算法市场
目前压缩率最好的算法是zlib( rfc1950)和gzip( rfc1952),他们都使用deflate( rfc1951)为底层算法,zlib和gzip只不过是对deflate的一层封装,用了不同的校验算法,定义了不同的Header。因此两者在各方面的差距在理论上都不太大。
另一些工业算法放弃了极致的压缩率,进而考虑如何达到压缩率和压缩速度的平衡。比如google开源的snappy,facebook开源的zstd,独立的lz4。这些算法通常压缩率会少个3-33%(甚至更多),但是压缩的速度能有几倍到几十倍的提升。
当然这些算法也参考了LZ77和哈夫曼编码,但是在具体的实现上做了一些优化和trade off。
因此对于压缩算法而言,压缩率主要和其理论与参数有关,这一点上deflate家族基本已经做到了极致,比较久没什么突破进展了。
而压缩算法的运行速度,则还有一些进展。但速度不仅和理论有关,更与实现有关,因此一些跑分上很快的压缩算法,换了一个语言实现,则可能性能就很差。因此对于每种算法,为了用在我们的项目中,终究要拿着对应语言版本的Lib来测试一下。
找一找测试数据集
因为不同特点的数据压缩情况可能有比较大的不同,因此我们最好拿着实际的数据去测试一下每个压缩算法。所以,我们先来构造一个我们自己的数据集。
首先我分析了一下我要压缩的数据字段的大小的分布。因为不同大小的数据,典型代表了不同的场景。进一步,按照28原理,其中最典型的20%的场景,占用了80%的存储。因此总计了不同大小的数据的占比。
select l, count(1) from (select length(result) div 1000 as l from results) as t group by l;
# ,40180
# 0,9781865
# 1,105842
# 2,522427
# 3,645176
# 4,1720512
# 5,1042843
# 6,6052
# 11,21
# 12,52211
# 13,2878
# 14,635600
# 15,10474
# 16,393020
# 17,2700916
# 18,12
# 49,1500
# 52,119454
然后统计出了图表,蓝色就是上面的数据行数,而橙色是估算的数据量。可以看到虽然<1000的数据行数最多,但是占用存储最大的,是17000-18000的数据。
因此下一步,我们就可以对几个出现比较多的数据进行采样,构建自己的数据集。这里我集中收集了0、4、5、14、16、17、52的数据。
拉出来遛一遛
demo代码
这里我专门写了internal包,用来做测试。首先定义统一的结构:
package internal
type Compress interface {
Zip(in []byte) []byte
Unzip(in []byte) []byte
}
然后去用多个算法去实现这个interface
,这一部分代码很简单,我尽量在写法上保持一致,避免自己实现导致的性能差异。在实际测试中,我基本把每个算法都实现了,这里只贴两个,一个zlib,其可以设置一些参数。另一个snappy,其不需要任何参数。
不过这里有些可以做得更好的点。有些Lib会在NewWriter
的时候就初始化好内存。因此之后复用这个Writer
能有更高的效率。这里把NewWriter
和func Write
的开销都放一起考虑了。
import (
"bytes"
"compress/flate"
"compress/gzip"
"io/ioutil"
"github.com/golang/snappy"
)
type Zlib struct {
level int
dict []byte
}
func (z Zlib) Zip(in []byte) []byte {
var b bytes.Buffer
w, err := zlib.NewWriterLevelDict(&b, z.level, z.dict)
if err != nil {
panic(err)
}
if _, err := w.Write(in); err != nil {
panic(err)
}
if err := w.Close(); err != nil {
panic(err)
}
return b.Bytes()
}
func (z Zlib) Unzip(in []byte) []byte {
r, err := zlib.NewReaderDict(bytes.NewReader(in), z.dict)
if err != nil {
panic(err)
}
out, err := ioutil.ReadAll(r)
if err != nil {
panic(err)
}
return out
}
type Snappy struct {}
func (s *Snappy) Zip(in []byte) []byte {
var b bytes.Buffer
w := snappy.NewBufferedWriter(&b)
if _, err := w.Write(in); err != nil {
panic(err)
}
if err := w.Close(); err != nil {
panic(err)
}
return b.Bytes()
}
func (s *Snappy) Unzip(in []byte) []byte {
r := snappy.NewReader(bytes.NewBuffer(in))
out, err := ioutil.ReadAll(r)
if err != nil {
panic(err)
}
return out
}
测试代码
我们使用表驱动测试,去做性能测试。先准备数据集。
// 测试用的数据集
var data = [][]byte{
// 0
[]byte(``),
// 4
[]byte(``),
// 5
[]byte(``),
// 14
[]byte(``),
// 16
[]byte(``),
// 17,因为17涉及到几个场景,所以各挑了一点数据。
[]byte(``),
[]byte(``),
[]byte(``),
// 52
[]byte(``),
}
然后写表驱动测试
func BenchmarkZip(b *testing.B) {
tests := []struct {
name string
c Compress
}{
{
name: "BestSpeed_nil",
c: Zlib{
level: flate.BestSpeed,
},
},
{
name: "Bestzip.Compression_nil",
c: Zlib{
level: flate.BestCompression,
},
}, {
name: "google",
c: &Snappy{},
},
// ... 还有多的算法,往这里加
}
for _, tt := range tests {
testZipUnZip(b, tt.c) // 先测试一下用得对不对
}
if b.Failed() {
return
}
bss := [][][]byte{}
for _, tt := range tests {
bs := benchmarkZip(b, tt.c, tt.name) // 测试一下压缩
bss = append(bss, bs)
}
fmt.Println()
for i, tt := range tests {
benchmarkUnzip(b, tt.c, tt.name, bss[i]) // 测试一下解压缩
}
}
func benchmarkZip(b *testing.B, c Compress, name string) [][]byte {
b.Run(handleName(c, name, "zip"), func(b *testing.B) {
for i, d := range data {
var out []byte
for j := 0; j < b.N; j++ {
out = c.Zip(d)
}
// 报告一下压缩率。因为我的数据集并不大,所以我报告了每个数据的压缩率
b.ReportMetric(float64(len(out))/float64(len(d))*100, "%_"+strconv.Itoa(i+1))
}
})
// 搞一个压缩好的数据,待会测试解压缩要用。
bs := make([][]byte, 0, len(data))
for _, d := range data {
out := c.Zip(d)
bs = append(bs, out)
}
return bs
}
func benchmarkUnzip(b *testing.B, c Compress, name string, bs [][]byte) {
b.Run(handleName(c, name, "unzip"), func(b *testing.B) {
for i := 0; i < b.N; i++ {
for _, d := range bs {
c.Unzip(d)
}
}
})
}
func testZipUnZip(b *testing.B, c Compress) {
for _, d := range data {
out := c.Unzip(c.Zip(d))
if len(out) != len(d) {
b.Fatal("length not equal", len(out), len(d))
}
if string(out) != string(d) {
b.Fatal("context not equal", len(out), len(d))
}
}
}
func handleName(c Compress, s, suffix string) string {
s = reflect.Indirect(reflect.ValueOf(c)).Type().Name() + "/" + s + "/" + suffix
return s + strings.Join(make([]string, 35-len(s)), "_")
}
benchmark决赛圈
因为压缩是一个CPU和内存密集的过程,因此我们压测,也主要看这两个方面。
对于单个Lib,如果有很多参数,我会先把所有参数都试一试,最终取一个最合我心意的参数组合,然后进入决赛圈。
对于单个算法的多种实现,我也会放一起跑一跑,然后挑一个效果最好的实现,进入决赛圈。
最后的结果:
运行 go test ./... -bench=. --benchmem >> benchresult.txt
goos: darwin
goarch: amd64
pkg: XXX/zip/internal
cpu: Intel(R) Core(TM) i7-9750H CPU @ 2.60GHz
BenchmarkZip/Zlib/Bestzip.Compression_nil/zip__-12 650 2083785 ns/op 209.1 %_1 53.18 %_2 11.09 %_3 9.178 %_4 67.32 %_5 14.80 %_6 22.77 %_7 5786847 B/op 168 allocs/op
BenchmarkZip/Gzip/Bestzip.Compression/zip______-12 626 1951439 ns/op 318.2 %_1 55.04 %_2 11.39 %_3 9.399 %_4 67.40 %_5 14.86 %_6 22.80 %_7 5787243 B/op 154 allocs/op
BenchmarkZip/Raw/Bestzip.Compression_nil/zip___-12 618 2134552 ns/op 154.5 %_1 52.25 %_2 10.95 %_3 9.067 %_4 67.28 %_5 14.76 %_6 22.76 %_7 5768891 B/op 142 allocs/op
BenchmarkZip/Snappy/google/zip_________________-12 9528 143693 ns/op 263.6 %_1 69.61 %_2 17.41 %_3 15.41 %_4 100.1 %_5 22.30 %_6 32.36 %_7 1073651 B/op 29 allocs/op
BenchmarkZip/Lz4/lz4/zip_______________________-12 210 6611195 ns/op 272.7 %_1 72.09 %_2 14.91 %_3 12.48 %_4 89.28 %_5 20.11 %_6 29.39 %_7 64295182 B/op 61 allocs/op
BenchmarkZip/Zsdt/Zsdt/zip_____________________-12 93 16045292 ns/op 218.2 %_1 54.42 %_2 10.95 %_3 9.123 %_4 67.68 %_5 14.89 %_6 22.16 %_7 129403853 B/op 495 allocs/op
BenchmarkZip/Zlib/Bestzip.Compression_nil/unzip-12 1872 586412 ns/op 688334 B/op 127 allocs/op
BenchmarkZip/Gzip/Bestzip.Compression/unzip____-12 1939 728654 ns/op 692673 B/op 120 allocs/op
BenchmarkZip/Raw/Bestzip.Compression_nil/unzip_-12 1936 547789 ns/op 687745 B/op 113 allocs/op
BenchmarkZip/Snappy/google/unzip_______________-12 7051 203699 ns/op 1435365 B/op 88 allocs/op
BenchmarkZip/Lz4/lz4/unzip_____________________-12 242 4894217 ns/op 59124137 B/op 81 allocs/op
BenchmarkZip/Zsdt/Zsdt/unzip___________________-12 4771 229709 ns/op 803240 B/op 252 allocs/op
PASS
ok code.byted.org/iaasng/rider/utils/zip/internal 19.743s
实际的测试结果更多,就不放出来了,这些说一些我测试出来的结论:
- lz4、zsdt、snappy,都说自己特别快。但是在golang的实现中,lz4、zsdt性能很差,都不如原生库。而snappy因为谷歌大力支持go,所以效果还不错。
github.com/klauspost/compress
声称自己对原生库做了优化,拥有更好的性能。实际测试下来也是。但是其代价是有更多的内存分配。- zlib、gzip、deflate因为底层实现都一样,所以确实在各方面拉不开差距。
- 对于特别小的数据,比如
"Succeeded"
,通常不如不压缩。 - 对于特定数据,压缩之后可能反而变得更大了。
- 通过对CPU进行分析,发现在压缩过程中的运行并不是开销最大的部分,内存分配才是。
基于1-5的结论,和我自己的场景,我选择了snappy作为最后的算法。其性能很好,并且压缩率也还不错,适合需要短时间内频繁读写的工作流。
优化单个算法到极致
既然开销最大的是内存分配,那么我们可以用sync.Pool
去优化一些。可以看到,我们每次都需要一个新的Writer
和Reader
。而对于Writer
,还需要一个新的Buffer
去承载结果。因此着重去复用这些内存。
type SnappyPool struct {
wp sync.Pool // writer pool
rp sync.Pool // reader pool
}
func NewSnappyPool() *SnappyPool {
return &SnappyPool{
wp: sync.Pool{New: func() interface{} {
b := bytes.NewBuffer(make([]byte, 0, 1024))
return []interface{}{snappy.NewBufferedWriter(b), b}
}},
rp: sync.Pool{New: func() interface{} {
return snappy.NewReader(&bytes.Buffer{})
}},
}
}
func (s *SnappyPool) Zip(in []byte) ([]byte, error) {
t := s.wp.Get().([]interface{})
w := t[0].(*snappy.Writer)
b := t[1].(*bytes.Buffer)
defer func() {
b.Reset() // buffer的reset不是真的reset,而是会复用数组内存,因此下面做了一个copy的动作
w.Reset(b)
s.wp.Put([]interface{}{w, b})
}()
if _, err := w.Write(in); err != nil {
return nil, err
}
if err := w.Close(); err != nil {
return nil, err
}
result := make([]byte, b.Len())
copy(result, b.Bytes())
return result, nil
}
func (s *SnappyPool) Unzip(in []byte) ([]byte, error) {
r := s.rp.Get().(*snappy.Reader)
defer s.rp.Put(r)
r.Reset(bytes.NewBuffer(in))
out, err := ioutil.ReadAll(r)
if err != nil {
return nil, err
}
return out, nil
}
优化的结果还是很明显的,内存分配减少了一半,耗时缩短了四分之三。每次压缩只花4微秒,0.004ms。
BenchmarkZip/Snappy/google/zip_________________-12 6678 168479 ns/op 1073650 B/op 29 allocs/op
BenchmarkZip/SnappyPool/google_pool/zip________-12 25138 48406 ns/op 41292 B/op 14 allocs/op
参考资料
www.rfc-editor.org/rfc/rfc1950
www.rfc-editor.org/rfc/rfc1952