一花一世界,一树一菩提:编码压缩探索与实践

前言

你可曾面临这种问题,高并发条件下数据必须99%cache命中才能知足性能需求?html

你可曾面对这种场景,对cache集群不断扩容是否让你感到厌烦?java

那么是否有一种办法在提升缓存命中率的同时,又能提升缓存集群键个数,甚至于提升整个集群的吞吐?web

本文将从实践出发,对编码方式及压缩算法进行介绍,并分享了在真实项目中使用缓存压缩对性能的影响。redis

探索篇

提升缓存命中率有不少种方法:选择好的缓存淘汰算法。当然有一些本地缓存支持LFU或者更新的Tiny LFU算法。不过对于分布式缓存而言,memcached和redis底层都使用了LRU算法,并不支持淘汰算法的替换。算法

由此,咱们想到第二个思路:提高集群item数量。api

在不扩容、不拆分item的前提下,便理所应当的想到了对item进行压缩存储。缓存

那么接下来须要肯定的就是具体的压缩方案:即对何种数据进行压缩才是最有效的?采用何种压缩算法才是最适合咱们的系统的?bash

编码方式

不一样的压缩算法之间区别最大的即在于编码方式。所谓编码其本质即把信息进行某种方式的转换,以便让信息可以装载进目标载体。从信息论的角度来讲,编码的本意并不是改变信息的熵密度,而只是改变信息的表现形式。并发

游程编码:最易理解的编码方式

该算法的实现是用当前数据元素以及该元素连续出现的次数来取代字符串中连续出现的数据部分。 app

游程编码示例

aaaaaaaaaabbbaxxxxyyyzyx

字符串长度为24,使用游程算法,咱们用较短的字符串后加一个计数值来替换游程对象。

a10b3a1x4y3z1y1x1

经过游程编码编码后的字符串长度为17,只有原先的71%,但仍有优化的余地,好比对于只出现一次的字符,不在后面追加1.

a10b3ax4y3zyx

这样编码得来的字符串只有13,只有原先的51%

熵编码

熵编码被人所熟知的一种便是Huffman编码,它的原理是根据字符在原始串中出现的几率。经过构造一颗二叉树来为每一个字符产生对应的码字,又称最佳编码。

算术编码

算术编码一样是一种熵编码,它一样是基于字符在原始串中出现的几率。所不一样的是Huffman编码对每一个字符产生码字。而算术编码一般将原始串编码为一个介于0~1之间的小数。 好比说对于原始串ARBER,咱们得出每一个字符出现的几率表:

Symbol Times P Interval
A 1 0.2 0 - 0.2
B 1 0.2 0.2 - 0.4
E 1 0.2 0.4 - 0.6
R 2 0.4 0.6 - 1.0

经过这张几率表,咱们能够将0~1这个区间按照几率划分给不一样的字符。

编码器将当前的区间分红若干子区间,每一个子区间的长度与当前上下文下可能出现的对应符号的几率成正比。当前要编码的符号对应的子区间成为在下一步编码中的初始区间。

ARBER为例,由上图能够获得如下类推流:

A的区间是(0,0.2),这个区间成为下一步编码的初始区间。R的区间是(0.6,1.0),那么在初始区间(0,0.2)中取相对的(0,6,1.0)区间即(0,12,0.2)作这一步编码结果,也便是下一步编码的子区间。以此类推,最终获得了(0.14432,0.14456)做为结果区间。

这里咱们只是介绍算术编码的思想,使用十进制也是为了方便理解。在实际算法实现中,几率以及区间的表示都是使用二进制的小数去表示。极可能不是一精确的小数值,这必定程度上也会影响算法的编码效率。

从实用效果上,算术编码的压缩比通常要好于Huffman。但Huffman的性能要优于算术编码,二者都有自适应的算法,没必要依赖全文进行几率统计,但毕竟算术编码仍是须要更大的计算量。

字典编码

字典编码是指用符号代替一串字符,在编码中仅仅把字符串当作是一个号码,而不去管它来表示什么意义。

LZ77编码

LZ77是Abraham Lempel与Jacob Ziv在1977年以及1978年发表的论文中的一种无损压缩编码。

它经过使用编码器或者解码器中已经出现过的相应匹配数据信息替换当前数据从而实现压缩功能。这个匹配信息使用称为长度-距离对的一对数据进行编码。

编码器和解码器都必须保存必定数量的最近的数据,如最近2 KB、4 KB或者32 KB的数据。保存这些数据的结构叫做滑动窗口,由于这样因此LZ77有时也称做滑动窗口压缩。

关于LZ77编码,还有一点须要了解的是它如何表示被压缩数据。它并不是是对单个字符进行编码,而是对匹配的字符串进行编码。形式为(p,l,c).p表示匹配在滑动窗口的相对起始下标,l表示匹配到滑动窗口的字符串的长度,c表示第一个未匹配的字符。

ABABCBABABCAC举例,使用长度为8byte的滑动窗口,长度为4byte的前向缓冲区。

ANS编码

ANS是前两类编码算法战争的终结者。它在2014年被提出来,随后很快就获得了大量应用。本质上属于算术编码,但它成功地找到了一个用近似几率表示的表格,将原来的几率计算转换为查表。因此它是一个达到Huffman编码效率的算术编码方法。FSE(Finite State Entropy)是ANS最为著名的实现。

实际上,大多数压缩算法的实现每每不会只基于一种特定的编码方式,而是将多种编码方式组合起来使用。关于压缩算法和编码方式的关系,能够简单将其考虑成排序算法和各类实现类比起来。

无损压缩算法实现

压缩算法能够按照特定的编码机制用比未经编码少的数据位元(或者其它信息相关的单位)表示信息。如下所讨论的压缩算法都是无损压缩算法,本质并不会减小信息熵。

不一样的压缩算法针对不一样的测试集,在压缩比和吞吐率方向上有不一样的变相。为了尽量减少这种不一样测试集带来的偏差。Silesia压缩语料库提供了text, exe, pdf, html等常见的格式内容。所以,诸多的压缩算法都将Silesia语料库做为测试基准来测试其性能。

但须要注意的是:通用的标准并不必定适合你的数据。必定要根据本身的数据进行实测后选择压缩算法。

Deflate

Deflate基于LZ77和Huffman编码方式的变形,由于历史缘由,在不少地方获得了应用,好比zip,Gzip,png等处。

Gzip

一种广为人知的压缩算法。其提供了不一样的压缩级别,使用压缩比来交换吞吐率。但通常来讲,Gzip的压缩比较高,响应的吞吐率较差。咱们知道在http中,Accept-Encoding经常选择使用Gzip用来提升传输效率。

LZ4

lz4最大特色是其吞吐率特别高,提供了单核500MB/s的压缩速度,解压速度更是上GB/s,已经基本达到了多核系统的内存速度的上限。

Snappy

Google在2011年开源的通用压缩算法,使用C++实现。号称注重吞吐率但实际上跟lz4还有较大的差距。官方的意思大概是相比于Gzip有很大提高。

Zstandard

Facebook推出的实时压缩算法,全称为Zstandard,使用C实现,基于FSE熵编码。最大的特色是对小数据使用了“字典训练”的模式,在使用字典的条件下,其压缩比和吞吐率会有很大幅度的提升。它一样提供了22种压缩级别,官方默认的压缩级别为3,但实测在最低压缩级别下,其表现已经十分优秀。

Brotli

Silicon Valley

Google出品的另外一种压缩算法实现,基于通用的LZ77和Huffman编码,其比较特殊的地方是使用了二阶上下文建模,能够简单理解为根据上下文去判断下一个字符出现的几率从而实现压缩。在速度下和Deflate很接近,但却提供了更高的压缩比。目前主要的应用方向应该是web内容的压缩。

另外一个主要的特色的是brotli内置了经常使用词的字典,已被证实能够增长压缩比。

实践篇

本次实践的对象是约1.1KB的pb字节对象。系统原先的存储方式是Java pojo类转为pb对象再转为pb字节对象存至memcached,读写比为35.这样一种场景其实也符合大部分互联网公司的实际业务场景,读多写少,开发人员更关心读的吞吐和CPU的占用率。

所以,所使用的性能测试方案分为两步:在本地使用jmh测试诸多压缩算法的压缩比和吞吐率;线上使用memcached测试集群mock大量读请求,测试CPU占用和吞吐。

压缩比

Method maxRatio minRatio averageRatio
使用zstd进行压缩 2.43 1.19 1.72
使用同组数据训练获得的dict进行zstd压缩 7.22 1.87 4.58
使用不一样组数据训练获得的dict进行zstd压缩 5.40 1.70 4.03
使用Gzip进行压缩 2.49 1.25 1.78
使用Brotli进行压缩 3.00 1.30 2.00

吞吐率

Benchmark                                   Mode  Cnt   Score    Error   Units
CodecBenchmark.compressWithBrotli           thrpt    3   0.015 ±  0.001  ops/ms
CodecBenchmark.compressWithGzip             thrpt    3   0.773 ±  0.463  ops/ms
CodecBenchmark.compressWithDictZstd         thrpt    3   5.307 ±  1.827  ops/ms
CodecBenchmark.compressWithZstd             thrpt    3   2.740 ±  0.161  ops/ms
CodecBenchmark.decompressWithBrotli         thrpt    3   1.079 ±  0.154  ops/ms
CodecBenchmark.decompressWithGzip           thrpt    3   3.187 ±  0.568  ops/ms
CodecBenchmark.decompressWithDictZstd       thrpt    3   9.501 ±  2.447  ops/ms
CodecBenchmark.decompressWithZstd           thrpt    3   6.482 ±  1.330  ops/ms
CodecBenchmark.fromPbBytes                  thrpt    3  27.149 ± 14.017  ops/ms
CodecBenchmark.toPbBytes                    thrpt    3  20.594 ±  9.530  ops/ms

复制代码

线上性能测试

操做 CPU平均使用率 memcached QPS
无压缩读 431% 15.35k
zstd解压读 745% 17.42k
zstd带字典的解压读 695% 20.13k

结果分析

  • zstd使用训练过的字典在压缩比和吞吐率变现都十分出色
  • 字典面对与训练集不一样的输入发生了退化现象
  • 使用压缩后,单个对象体积变小也所以CPU等io的时间缩短。因此反而提升了qps

总结

因此本文写到这里,能够得出结论只有一个,选择一个文本压缩算法最有效的方式是实验,不要轻人别人的测试结果。只能经过实验,才能得出一个更为有效的算法以及参数的选择。

另外,在为实际生产环境选择开源项目时还有其余因素须要考虑。好比在笔者测试的过程当中,不一样压缩算法对于语言的支持也不相同。好比zstd就比较好,第三方的java binding库更新及时,对原生支持较好,封装的api注释清晰方便理解。而反观brotli,官方随提供了Java binding库,但只提供了解压,并未提供解压。性能测试所使用的jbrotli库已经数年未更新,且须要开发人员本身编译安装JNI库。

相关文章
相关标签/搜索