一花一世界,一樹一菩提:編碼壓縮探索與實踐

2019-11-30     sandag

你可曾面臨這種問題,高並發條件下數據必須99%cache命中才能滿足性能需求?

你可曾面對這種場景,對cache集群不斷擴容是否讓你感到厭煩?

那麼是否有一種辦法在提高緩存命中率的同時,又能提高緩存集群鍵個數,甚至於提高整個集群的吞吐?

本文將從實踐出發,對編碼方式及壓縮算法進行介紹,並分享了在真實項目中使用緩存壓縮對性能的影響。

探索篇

提高緩存命中率有很多種方法:選擇好的緩存淘汰算法。固然有一些本地緩存支持LFU或者更新的Tiny LFU算法。不過對於分布式緩存而言,memcached和redis底層都使用了LRU算法,並不支持淘汰算法的替換。

由此,我們想到第二個思路:提升集群item數量。

在不擴容、不拆分item的前提下,便理所應當的想到了對item進行壓縮存儲。

那麼接下來需要確定的就是具體的壓縮方案:即對何種數據進行壓縮才是最有效的?採用何種壓縮算法才是最適合我們的系統的?

編碼方式

不同的壓縮算法之間區別最大的即在於編碼方式。所謂編碼其本質即把信息進行某種方式的轉換,以便讓信息能夠裝載進目標載體。從資訊理論的角度來說,編碼的本意並非改變信息的熵密度,而只是改變信息的表現形式。

遊程編碼:最易理解的編碼方式

該算法的實現是用當前數據元素以及該元素連續出現的次數來取代字符串中連續出現的數據部分。

aaaaaaaaaabbbaxxxxyyyzyx

字符串長度為24,使用遊程算法,我們用較短的字符串後加一個計數值來替換遊程對象。

a10b3a1x4y3z1y1x1

通過遊程編碼編碼後的字符串長度為17,只有原先的71%,但仍有優化的餘地,比如對於只出現一次的字符,不在後面追加1.

a10b3ax4y3zyx

這樣編碼得來的字符串只有13,只有原先的51%

熵編碼

熵編碼被人所熟知的一種即是Huffman編碼,它的原理是根據字符在原始串中出現的機率。通過構造一顆二叉樹來為每個字符產生對應的碼字,又稱最佳編碼。

算術編碼

算術編碼同樣是一種熵編碼,它同樣是基於字符在原始串中出現的機率。所不同的是Huffman編碼對每個字符產生碼字。而算術編碼通常將原始串編碼為一個介於0~1之間的小數。 比如說對於原始串 ARBER ,我們得出每個字符出現的機率表:

SymbolTimesPIntervalA10.20 - 0.2B10.20.2 - 0.4E10.20.4 - 0.6R20.40.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

Google出品的另一種壓縮算法實現,基於通用的LZ77和Huffman編碼,其比較特殊的地方是使用了二階上下文建模,可以簡單理解為根據上下文去判斷下一個字符出現的機率從而實現壓縮。在速度下和Deflate很接近,但卻提供了更高的壓縮比。目前主要的應用方向應該是web內容的壓縮。

另一個主要的特點的是brotli內置了常用詞的字典,已被證明可以增加壓縮比。

實踐篇

本次實踐的對象是約1.1KB的pb位元組對象。系統原先的存儲方式是Java pojo類轉為pb對象再轉為pb位元組對象存至memcached,讀寫比為35.這樣一種場景其實也符合大部分網際網路公司的實際業務場景,讀多寫少,開發人員更關心讀的吞吐和CPU的占用率。

因此,所使用的性能測試方案分為兩步:在本地使用jmh測試諸多壓縮算法的壓縮比和吞吐率;線上使用memcached測試集群mock大量讀請求,測試CPU占用和吞吐。

壓縮比

MethodmaxRatiominRatioaverageRatio使用zstd進行壓縮2.431.191.72使用同組數據訓練得到的dict進行zstd壓縮7.221.874.58使用不同組數據訓練得到的dict進行zstd壓縮5.401.704.03使用Gzip進行壓縮2.491.251.78使用Brotli進行壓縮3.001.302.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.35kzstd解壓讀745%17.42kzstd帶字典的解壓讀695%20.13k

結果分析

  • zstd使用訓練過的字典在壓縮比和吞吐率變現都十分出色
  • 字典面對與訓練集不同的輸入發生了退化現象
  • 使用壓縮後,單個對象體積變小也因此CPU等io的時間縮短。所以反而提高了qps

總結

所以本文寫到這裡,可以得出結論只有一個,選擇一個文本壓縮算法最有效的方式是實驗,不要輕人別人的測試結果。只能通過實驗,才能得出一個更為有效的算法以及參數的選擇。

另外,在為實際生產環境選擇開源項目時還有其他因素需要考慮。比如在筆者測試的過程中,不同壓縮算法對於語言的支持也不相同。比如zstd就比較好,第三方的java binding庫更新及時,對原生支持較好,封裝的api注釋清晰方便理解。而反觀brotli,官方隨提供了Java binding庫,但只提供了解壓,並未提供解壓。性能測試所使用的jbrotli庫已經數年未更新,且需要開發人員自己編譯安裝JNI庫。

文章來源: https://twgreatdaily.com/zh-tw/MCntvm4BMH2_cNUgAz_F.html