Compression Algorithms

Why storage engines compress

Compression trades CPU time for fewer bytes. In a storage engine, fewer bytes means:

  • More data per cache byte, if the cache holds compressed blocks (the OS page cache does).
  • Fewer bytes read and written to disk, which also lowers the cost of write amplification: every compaction rewrite moves compressed bytes.
  • Less disk space, often the largest cost of a big deployment.

The cost is CPU on every write (compress) and every cache-miss read (decompress). Choosing an algorithm is choosing a point on that trade-off, and that requires knowing how the algorithms work.

Two building blocks

Nearly every general-purpose compressor combines two ideas:

  1. Dictionary (LZ77) matching removes repetition: replace a sequence of bytes that appeared recently with a reference (distance back, length).
  2. Entropy coding (Huffman, arithmetic/ANS) removes skew in symbol frequency: give frequent symbols short codes and rare ones long codes.
Algorithm LZ matching Entropy coding
LZ4, Snappy Yes, fast and simple None
DEFLATE (zlib, gzip) Yes, 32 KB window Huffman
Zstandard (zstd) Yes, large windows, better match finding Huffman for literals + FSE (tANS) for sequences
LZMA (xz) Yes, large window, optimal parsing Range (arithmetic) coding

LZ77: replacing repetition with back-references

The compressor walks the input; at each position it looks for the longest earlier occurrence (within a window) of the upcoming bytes.

Worked example. Input (29 bytes):

user:1001,user:1002,user:1003
literals "user:1001,"                 (10 bytes, no earlier match)
match    (distance 10, length 8)      → copies "user:100" from 10 bytes back
literals "2,"
match    (distance 10, length 8)      → "user:100"
literal  "3"

Ten literal bytes plus a few tokens represent the whole string. Decompression is just: copy literals, and for each match copy length bytes from distance bytes back in the output already produced. That is a tight loop of memcpy-like operations, which is why LZ decompression runs at gigabytes per second.

A match may overlap its own output: (distance 1, length 10) after an a produces ten more as — a run-length encoding for free.

Finding good matches is the expensive, compression-side part:

  • Fast compressors (LZ4, Snappy) hash the next 4 bytes, look up the single most recent position with that hash, and take the match if it exists. One probe per position.
  • Stronger compressors keep hash chains or binary trees of many candidate positions and search them (zstd's higher levels, zlib -9).
  • Optimal parsing (zstd's highest levels, LZMA) considers that taking a shorter match now might allow a much longer one later, and chooses the cheapest overall encoding by dynamic programming.

The window size limits how far back matches can reach: 64 KB for LZ4, 32 KB for DEFLATE, up to megabytes or more for zstd and LZMA. For a 4 KB SSTable block compressed on its own, the window is effectively the block itself — which is why per-block compression of small blocks compresses worse than compressing a whole file.

Entropy coding: Huffman

After LZ matching, the remaining literals are still bytes from 0–255 with an uneven distribution (in text, e and space are common, Q is rare). Huffman coding builds an optimal prefix code from symbol frequencies:

  1. Make each symbol a leaf weighted by its frequency.
  2. Repeatedly merge the two lightest nodes into a parent whose weight is their sum.
  3. The path from the root to a leaf (left = 0, right = 1) is the symbol's code.

Worked example. Frequencies: a:45, b:13, c:12, d:16, e:9, f:5 (100 symbols).

merge f(5)+e(9)=14; c(12)+b(13)=25; 14+d(16)=30; 25+30=55; a(45)+55=100

codes:  a = 0       (1 bit)
        c = 100     b = 101
        f = 1100    e = 1101    d = 111
total = 45·1 + 12·3 + 13·3 + 5·4 + 9·4 + 16·3 = 224 bits

Fixed 3-bit codes would need 300 bits; Huffman saves ~25%. Huffman must use a whole number of bits per symbol, which wastes space when a symbol's ideal length is fractional (a symbol with probability 0.9 ideally costs 0.15 bits, but gets 1). Arithmetic coding and ANS (asymmetric numeral systems, used as FSE in zstd) encode fractional bits and come within a hair of the entropy limit, with ANS running nearly as fast as Huffman.

The algorithms engines actually use

Rough characteristics on typical database blocks (actual numbers vary a lot with data; measure on your own):

Algorithm Compression speed Decompression speed Ratio on text/JSON-like data
LZ4 ~500–800 MB/s per core ~3–5 GB/s ~2x
Snappy ~400–600 MB/s ~1.5–2 GB/s ~2x
zstd level 1–3 ~300–500 MB/s ~1–1.5 GB/s ~2.5–3x
zstd level 19 ~5–20 MB/s ~1–1.5 GB/s ~3.5x+
zlib level 6 ~30–80 MB/s ~300–400 MB/s ~3x

Two observations drive most choices:

  • LZ4 and zstd decompress at roughly the same order of speed no matter the compression level. The level only changes how hard the compressor searches. So data written once and read many times can afford a high level.
  • zlib is dominated by zstd at almost every point (faster and better ratio), which is why newer systems default to zstd.

Per-level compression in an LSM

In leveled compaction, about 90% of the data sits in the bottom level, which is rewritten least often; upper levels are small and rewritten constantly.

Level Typical choice Reason
L0–L1 None or LZ4 Rewritten on every compaction; hot for reads; small
Middle levels LZ4 Cheap CPU per rewrite
Bottom level zstd (level 3–6) Holds most bytes; written least often; maximizes space savings

RocksDB exposes this as compression_per_level and bottommost_compression. Since compression happens during flush and compaction — background threads — a heavier algorithm mostly costs background CPU, not foreground latency. Decompression happens on every block-cache miss, in the foreground.

Dictionary compression

Small blocks of similar records (4 KB of JSON documents) compress poorly because each block is compressed independently and starts with an empty window: every block repeats the same field names as literals. Dictionary compression primes the compressor with a shared dictionary of common substrings, trained from samples (zstd --train, or RocksDB's max_dict_bytes / zstd_max_train_bytes, which trains a dictionary per SST file from its own data). For small-record workloads, ratios can improve substantially — the field names and common values become references into the dictionary instead of literals.

Lightweight encodings: compressing by structure

General-purpose compressors know nothing about the data. Knowing its structure often wins more, at lower CPU cost — this is what columnar formats do before (or instead of) LZ compression:

Encoding Idea Good for
Prefix (delta) encoding of keys Store bytes shared with the previous key once Sorted keys — every SSTable's data blocks (SSTables and Bloom Filters)
Run-length encoding (RLE) (value, count) for repeated values Sorted or low-cardinality columns
Dictionary encoding Replace values by small integer IDs into a table of distinct values Strings with few distinct values (country, status)
Delta encoding Store differences between consecutive numbers Timestamps, sequence numbers, sorted IDs
Bit packing Use only as many bits as the maximum value needs Small integers, dictionary IDs
Varint Small numbers in fewer bytes Lengths, IDs (Binary Encoding and Checksums)
Gorilla / XOR float encoding XOR consecutive floats; store only the changed bits Time-series metrics (Prometheus, Facebook Gorilla)

Worked example — timestamps. 1,000 event timestamps in milliseconds, one per second: 1731229200000, 1731229201000, .... Raw: 8 bytes each = 8,000 bytes. Delta: first value + 999 deltas of 1000. Delta-of-delta: all zeros after the second value. With RLE or bit packing, the column shrinks to a few dozen bytes — far beyond what LZ4 would achieve on the raw bytes.

Where to compress, and costs to keep in mind

  • Block size vs. ratio. Bigger blocks compress better but cost more to read for a point lookup (decompress 64 KB to get one 100-byte value). Point-lookup engines use ~4–16 KB blocks; analytics use much larger units.
  • Incompressible data. Encrypted or already-compressed values (images, gzip payloads) don't shrink; compressing them wastes CPU. LevelDB stores a block raw if compression saves less than 12.5%.
  • Compressed vs. uncompressed caching. Caching uncompressed blocks saves CPU per hit; caching compressed blocks holds more data per byte. RocksDB supports both tiers.
  • Checksums cover the stored (compressed) bytes, so corruption is detected before the decompressor sees malformed input — decompressors should still be treated as parsers of untrusted input.

Common pitfall

Picking an algorithm from a published benchmark table instead of from your own data. Compression ratio depends almost entirely on the data: the same zstd level that gives 4x on JSON logs gives 1.05x on UUID keys with random binary values, where it only burns CPU. Before choosing, run zstd -b1 -e19 and lz4 -b on a few real SST files or a dump of real values to see actual ratio and speed per level, and check the block size you will actually use — compressing a 4 KB block is very different from compressing the 1 GB sample file the benchmark used.