如何挑选一个压缩算法

3,909 阅读9分钟

导读

最近在工作中新写了一个工作流框架,事无巨细地记录了很多数据,因此数据量膨胀得有点厉害。为了避免磁盘过快被打满,准备考虑对其中占用最大的任务入参和出参做一轮压缩。

因此,本文主要讨论的是文本(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三个字符。

image-20220920160728387.png

再比如:

搜索窗口为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

A110
B111
C0
D10

逛逛压缩算法市场

目前压缩率最好的算法是zlibrfc1950)和gziprfc1952),他们都使用deflaterfc1951)为底层算法,zlibgzip只不过是对deflate的一层封装,用了不同的校验算法,定义了不同的Header。因此两者在各方面的差距在理论上都不太大。

image-20220919140547483.png

另一些工业算法放弃了极致的压缩率,进而考虑如何达到压缩率和压缩速度的平衡。比如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的数据。

image-20220920171836511.png

因此下一步,我们就可以对几个出现比较多的数据进行采样,构建自己的数据集。这里我集中收集了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能有更高的效率。这里把NewWriterfunc 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

实际的测试结果更多,就不放出来了,这些说一些我测试出来的结论:

  1. lz4zsdtsnappy,都说自己特别快。但是在golang的实现中,lz4、zsdt性能很差,都不如原生库。而snappy因为谷歌大力支持go,所以效果还不错。
  2. github.com/klauspost/compress声称自己对原生库做了优化,拥有更好的性能。实际测试下来也是。但是其代价是有更多的内存分配。
  3. zlibgzipdeflate因为底层实现都一样,所以确实在各方面拉不开差距。
  4. 对于特别小的数据,比如"Succeeded",通常不如不压缩。
  5. 对于特定数据,压缩之后可能反而变得更大了。
  6. 通过对CPU进行分析,发现在压缩过程中的运行并不是开销最大的部分,内存分配才是。

基于1-5的结论,和我自己的场景,我选择了snappy作为最后的算法。其性能很好,并且压缩率也还不错,适合需要短时间内频繁读写的工作流。

优化单个算法到极致

既然开销最大的是内存分配,那么我们可以用sync.Pool去优化一些。可以看到,我们每次都需要一个新的WriterReader。而对于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

参考资料

string.quest/read/409564…

segmentfault.com/a/119000001…

segmentfault.com/a/119000001…

www.rfc-editor.org/rfc/rfc1950

www.rfc-editor.org/rfc/rfc1952

www.rfc-editor.org/rfc/rfc1951

zh.wikipedia.org/zh-sg/%E9%9…

blog.csdn.net/yydcj/artic…

zzjw.cc/post/snappy…

www.jianshu.com/p/824e1cf4f…