直觉告诉我们:压缩数据要消耗 CPU,查询会变慢。但现实恰恰相反——在 I/O 受限的场景下,压缩让查询更快。1GB 的未压缩数据从磁盘读取需要 10 秒;压缩到 300MB 后,加上解压的 CPU 开销,总耗时可能只要 4 秒。用 CPU 换 I/O,在磁盘是瓶颈时是一笔划算的交易。
压缩不只是缩小文件体积。RLE、字典编码、Delta 编码各有擅长的数据模式;Snappy、ZSTD、LZ4 在压缩率与速度之间各有取舍。选对编码和算法,查询性能可以提升数倍。
一、压缩的动机
1.1 为什么压缩
| 场景 | I/O 延迟 | CPU 延迟 | 压缩是否值得 |
|---|---|---|---|
| HDD 随机读 | 10 ms | 0.01 ms | 非常值得 |
| SATA SSD | 0.1 ms | 0.01 ms | 值得 |
| NVMe SSD | 0.01 ms | 0.01 ms | 取决于压缩比 |
| 内存 | 0.001 ms | 0.01 ms | 不值得 |
压缩的核心权衡:CPU 开销 vs I/O 节省。在 I/O 延迟远大于 CPU 延迟时(HDD、SATA SSD),压缩几乎总是值得的。在 NVMe SSD 上,需要根据压缩比和 CPU 核心数权衡。
1.2 压缩的层次
| 层次 | 说明 | 典型实现 | 压缩比 |
|---|---|---|---|
| 行级压缩 | 压缩单行数据 | InnoDB COMPRESSED 行格式 | 2–5x |
| 页级压缩 | 压缩整个页面 | InnoDB 页压缩、RocksDB Block 压缩 | 2–10x |
| 列级压缩 | 压缩同一列的值 | Parquet/ORC 列编码 | 5–100x |
| 块级压缩 | 压缩固定大小块 | LZ4/ZSTD/Snappy | 2–5x |
二、通用压缩算法
2.1 算法对比
| 算法 | 压缩速度 | 解压速度 | 压缩比 | CPU 占用 | 适用场景 |
|---|---|---|---|---|---|
| Snappy | ~500 MB/s | ~1.5 GB/s | ~2x | 极低 | 实时写入(RocksDB 默认) |
| LZ4 | ~700 MB/s | ~2 GB/s | ~2.5x | 极低 | 实时写入(Kafka 默认) |
| ZSTD-1 | ~400 MB/s | ~1 GB/s | ~3x | 低 | 均衡场景 |
| ZSTD-3 | ~200 MB/s | ~1 GB/s | ~3.5x | 中 | 存储归档 |
| ZSTD-9 | ~50 MB/s | ~1 GB/s | ~4x | 高 | 冷数据 |
| Gzip-6 | ~50 MB/s | ~200 MB/s | ~5x | 高 | 日志归档 |
| Gzip-9 | ~20 MB/s | ~200 MB/s | ~6x | 很高 | 最大压缩 |
# 压缩算法性能基准测试import timeimport snappyimport lz4.frameimport zstandard as zstd
data = b'a' * 100_000_000 # 100MB 测试数据
# Snappystart = time.time()compressed = snappy.compress(data)snappy_time = time.time() - startsnappy_ratio = len(data) / len(compressed)
# LZ4start = time.time()compressed = lz4.frame.compress(data)lz4_time = time.time() - startlz4_ratio = len(data) / len(compressed)
# ZSTDcctx = zstd.ZstdCompressor(level=3)start = time.time()compressed = cctx.compress(data)zstd_time = time.time() - startzstd_ratio = len(data) / len(compressed)
print(f"Snappy: {snappy_time:.3f}s, ratio={snappy_ratio:.1f}x")print(f"LZ4: {lz4_time:.3f}s, ratio={lz4_ratio:.1f}x")print(f"ZSTD-3: {zstd_time:.3f}s, ratio={zstd_ratio:.1f}x")选择压缩算法时最容易犯的错误:只看压缩比,忽略解压速度。在 NVMe SSD 上,Gzip-6 的解压速度(~200 MB/s)远低于磁盘带宽(~5 GB/s),压缩后反而让查询更慢。务必确保解压速度 > 磁盘顺序读取速度。
在写入密集的 OLTP 场景中使用高压缩比算法(如 ZSTD-9、Gzip),会导致写入延迟飙升和 CPU 资源争抢,严重影响在线请求的尾部延迟。热数据应始终使用 LZ4/Snappy,冷数据才用高压缩比算法。
2.2 数据库中的压缩选择
| 数据库 | 默认压缩 | 底层压缩 | 说明 |
|---|---|---|---|
| InnoDB | 无 | zlib(页压缩) | COMPRESSED 行格式 |
| RocksDB | LZ4 | ZSTD(底层) | 快速压缩 + 底层高压缩比 |
| Parquet | Snappy | ZSTD/Gzip | 可配置 |
| Kafka | LZ4 | Snappy/ZSTD | 生产者压缩 |
| MongoDB | Snappy | ZSTD | WiredTiger 引擎 |
2.3 压缩比与速度权衡
选择压缩算法时,压缩比和速度往往不可兼得。以下是在 TPC-H lineitem 表(7.2GB,混合数据类型)上的实测对比:
| 算法 | 压缩后大小 | 压缩比 | 压缩耗时 | 解压耗时 | 压缩+解压总耗时 | 综合评价 |
|---|---|---|---|---|---|---|
| Snappy | 2.8 GB | 2.6x | 1.4s | 0.5s | 1.9s | 最快,适合热数据 |
| LZ4 | 2.9 GB | 2.5x | 1.0s | 0.4s | 1.4s | 极速解压,适合传输 |
| ZSTD-3 | 2.1 GB | 3.4x | 3.6s | 0.7s | 4.3s | 均衡,适合温数据 |
| ZSTD-9 | 1.9 GB | 3.8x | 14.4s | 0.7s | 15.1s | 高压缩比,适合冷数据 |
| Gzip-6 | 1.7 GB | 4.2x | 14.4s | 3.6s | 18.0s | 最高压缩比,仅归档 |
在 NVMe SSD 上,I/O 带宽约 5GB/s,此时 Snappy/LZ4 的解压速度(>1GB/s)不会成为瓶颈。但 Gzip 解压仅 200MB/s,反而比直接读未压缩数据更慢。选择压缩算法时,务必确保解压速度 > 磁盘读取速度。
三、列式编码
3.1 RLE(Run-Length Encoding)
RLE 对连续相同的值进行游程编码:
# RLE 编码def rle_encode(values): """游程编码""" if not values: return []
encoded = [] current = values[0] count = 1
for v in values[1:]: if v == current: count += 1 else: encoded.append((current, count)) current = v count = 1 encoded.append((current, count))
return encoded
# 示例:性别列values = ['M', 'M', 'M', 'F', 'F', 'M', 'M', 'M', 'M', 'F']encoded = rle_encode(values)# [('M', 3), ('F', 2), ('M', 4), ('F', 1)]
# 压缩比:10 个值 → 4 个对,压缩比 2.5x# 对于低基数字段(性别、状态、国家),RLE 压缩比可达 10–100x| 数据特征 | RLE 压缩比 | 适用场景 |
|---|---|---|
| 排序后低基数 | 10–100x | 状态列、国家列 |
| 排序后中等基数 | 3–10x | 日期列、类别列 |
| 随机分布 | 1–2x | 不适合 |
| 高基数 | 1x | 不适合 |
3.2 字典编码
字典编码将值映射到整数索引:
字典编码与 RLE 组合效果惊人:先字典编码将字符串转为短整数索引,再对索引序列做 RLE。对于排序后的低基数列(如性别、国家),字典 + RLE 的压缩比可达 100x 以上。Parquet 正是采用这种组合策略。
# 字典编码class DictionaryEncoding: def __init__(self): self.dictionary = {} # 值 → 索引 self.values = [] # 索引 → 值 self.encoded = [] # 编码后的索引序列
def encode(self, value): """编码一个值""" if value not in self.dictionary: idx = len(self.values) self.dictionary[value] = idx self.values.append(value) return self.dictionary[value]
def decode(self, idx): """解码一个索引""" return self.values[idx]
# 示例:城市列encoder = DictionaryEncoding()cities = ['北京', '上海', '北京', '广州', '上海', '北京', '深圳', '北京']encoded = [encoder.encode(c) for c in cities]# 字典: {'北京': 0, '上海': 1, '广州': 2, '深圳': 3}# 编码: [0, 1, 0, 2, 1, 0, 3, 0]
# 存储:4 个字典项 + 8 个 2-bit 索引 = ~4 bytes# 原始:8 × 6 bytes = 48 bytes(假设每个城市 6 字节 UTF-8)# 压缩比:12x| 数据特征 | 字典编码压缩比 | 适用场景 |
|---|---|---|
| 低基数(< 1000) | 5–50x | 枚举、状态、城市 |
| 中等基数(1K–1M) | 2–10x | 用户 ID、产品 ID |
| 高基数(> 1M) | 1–2x | 不适合 |
3.3 位打包(Bit Packing)
位打包用最小位数存储每个值:
# 位打包编码def bit_pack_encode(values): """位打包编码""" max_val = max(values) # 计算需要的位数 bits_per_value = max_val.bit_length()
# 将值打包到位数组 packed = 0 offset = 0 for v in values: packed |= (v << offset) offset += bits_per_value
return packed, bits_per_value, len(values)
# 示例:年龄列(0-127,7 bits 足够)ages = [25, 30, 45, 28, 35, 42, 50, 22]packed, bits, count = bit_pack_encode(ages)# 每个值只需 7 bits(而非 int32 的 32 bits)# 压缩比:32/7 ≈ 4.6x3.4 Delta 编码
Delta 编码存储相邻值的差值:
# Delta 编码def delta_encode(values): """Delta 编码""" if not values: return []
encoded = [values[0]] # 第一个值原样存储 for i in range(1, len(values)): encoded.append(values[i] - values[i-1])
return encoded
# 示例:时间戳列(毫秒)timestamps = [1718000000000, 1718000000150, 1718000000320, 1718000000500, 1718000000680]encoded = delta_encode(timestamps)# [1718000000000, 150, 170, 180, 180]
# 原始:5 × 8 bytes = 40 bytes(int64)# 编码:8 + 4 × 2 bytes = 16 bytes(int64 + 4 × int16)# 压缩比:2.5x# 如果再配合位打包,压缩比更高四、Parquet 编码
4.1 Parquet 编码策略
Parquet 根据数据特征自动选择最优编码:
| 编码 | 适用数据类型 | 典型压缩比 | Parquet 版本 |
|---|---|---|---|
| PLAIN | 所有类型 | 1x | V1 |
| RLE | 排序后低基数 | 10–100x | V1 |
| DICTIONARY | 低基数 | 5–50x | V1 |
| DELTA_BINARY_PACK | 排序整数 | 5–20x | V2 |
| DELTA_LENGTH_BYTE | 变长字节 | 2–5x | V2 |
| DELTA_BYTE_ARRAY | 排序字符串 | 3–10x | V2 |
| BYTE_RUN_SPLIT | 重复字节 | 2–5x | V2 |
4.2 Parquet 编码组合
# Parquet 列编码流程def parquet_encode_column(values, data_type): """Parquet 列编码""" # 1. 统计分析 cardinality = len(set(values)) is_sorted = all(values[i] <= values[i+1] for i in range(len(values)-1)) null_count = sum(1 for v in values if v is None)
# 2. 选择编码策略 if cardinality < 1000: # 低基数:字典编码 dict_encoded = dictionary_encode(values) # 字典值再用 RLE 或位打包 if is_sorted: return rle_encode(dict_encoded) else: return bit_pack_encode(dict_encoded)
elif is_sorted and data_type in ('INT', 'TIMESTAMP'): # 排序整数:Delta + 位打包 delta = delta_encode(values) return bit_pack_encode(delta)
else: # 其他:Plain + 块压缩 return plain_encode(values) # + LZ4/ZSTD
# 实际压缩比示例# 性别列(M/F): 字典 + RLE → 100x# 国家列(~200 值): 字典 + 位打包 → 30x# 日期列(排序): Delta + 位打包 → 20x# 金额列(随机): Plain + ZSTD → 3x# 描述列(长文本): Plain + ZSTD → 2x五、InnoDB 压缩
5.1 InnoDB 行格式
| 行格式 | 压缩 | 页大小 | 说明 |
|---|---|---|---|
| REDUNDANT | 无 | 16KB | 旧格式 |
| COMPACT | 无 | 16KB | 默认,紧凑存储 |
| DYNAMIC | 无 | 16KB | 默认(5.7+),溢出页优化 |
| COMPRESSED | zlib | 1K/2K/4K/8KB | 页级压缩 |
| COMPRESSED | LZ4 | 1K/2K/4K/8KB | MySQL 8.0.30+ |
-- 创建压缩表CREATE TABLE compressed_table ( id BIGINT PRIMARY KEY, data VARCHAR(255), payload BLOB) ENGINE=InnoDB ROW_FORMAT=COMPRESSED KEY_BLOCK_SIZE=8; -- 压缩后页大小 8KB
-- 查看压缩效果SELECT table_name, row_format, table_rows, avg_row_length, data_length, index_lengthFROM information_schema.tablesWHERE table_name = 'compressed_table';5.2 InnoDB 压缩的代价
| 代价 | 说明 | 影响 |
|---|---|---|
| CPU 开销 | 每次读取解压、修改重压 | 增加 10–30% CPU |
| 修改惩罚 | 修改压缩页需要解压→修改→重压 | 写入延迟增加 |
| 缓冲池浪费 | 压缩页和非压缩页同时占用内存 | 有效缓存减少 |
| 碎片化 | 压缩后大小不一,空间浪费 | 实际压缩比低于理论值 |
InnoDB 的 COMPRESSED 行格式在写入密集场景下性能下降明显。推荐替代方案:使用 Normal 行格式 + 操作系统/文件系统级压缩(如 Btrfs zstd),或使用 RocksDB 的 Block 级压缩。
六、RocksDB 压缩
6.1 RocksDB 压缩策略
RocksDB 支持不同层级使用不同压缩算法:
# RocksDB 压缩配置# 典型配置:快速压缩 + 底层高压缩比rocksdb_compression_config = { "per_level_compression": { 0: "kNoCompression", # L0: 不压缩(频繁读写) 1: "kLZ4Compression", # L1: LZ4(快速) 2: "kLZ4Compression", # L2: LZ4 3: "kZSTD", # L3+: ZSTD(高压缩比) 4: "kZSTD", 5: "kZSTD", 6: "kZSTD", }, # 底层压缩:最底层的 SSTable 用最高压缩比 "bottommost_compression": "kZSTD", "bottommost_compression_level": 9, # ZSTD level 9
# 压缩缓存:避免重复压缩/解压 "compression_dict": True, # 训练字典}6.2 RocksDB 压缩效果
| 层级 | 压缩算法 | 压缩比 | CPU 开销 | 数据特征 |
|---|---|---|---|---|
| L0 | 无 | 1x | 无 | 频繁读写 |
| L1 | LZ4 | 2–3x | 低 | 中等生命周期 |
| L2 | LZ4 | 2–3x | 低 | 中等生命周期 |
| L3+ | ZSTD | 3–5x | 中 | 冷数据 |
七、压缩性能基准
7.1 TPC-H 基准压缩效果
| 表 | 原始大小 | Snappy | ZSTD-3 | ZSTD-9 | Gzip-6 |
|---|---|---|---|---|---|
| lineitem | 7.2 GB | 2.8 GB | 2.1 GB | 1.9 GB | 1.7 GB |
| orders | 1.6 GB | 0.6 GB | 0.45 GB | 0.4 GB | 0.35 GB |
| customer | 0.23 GB | 0.08 GB | 0.06 GB | 0.05 GB | 0.04 GB |
| 总计 | 9.0 GB | 3.5 GB | 2.6 GB | 2.4 GB | 2.1 GB |
7.2 压缩对查询性能的影响
# 压缩对查询性能的影响(TPC-H Q6 示例)# 场景:扫描 lineitem 表 7.2GB
results = { "无压缩": { "io_read": "7.2 GB", "cpu_decompress": "0s", "total_time": "12.5s", # I/O 瓶颈 }, "Snappy": { "io_read": "2.8 GB", "cpu_decompress": "1.9s", "total_time": "6.6s", # 快 1.9x }, "ZSTD-3": { "io_read": "2.1 GB", "cpu_decompress": "2.1s", "total_time": "5.7s", # 快 2.2x }, "Gzip-6": { "io_read": "1.7 GB", "cpu_decompress": "8.5s", "total_time": "11.3s", # 反而更慢!CPU 瓶颈 },}八、实战:压缩效果观察
8.1 InnoDB 压缩效果
-- 创建压缩和非压缩表对比CREATE TABLE normal_table (id INT PRIMARY KEY, data VARCHAR(100)) ENGINE=InnoDB ROW_FORMAT=DYNAMIC;
CREATE TABLE compressed_table (id INT PRIMARY KEY, data VARCHAR(100)) ENGINE=InnoDB ROW_FORMAT=COMPRESSED KEY_BLOCK_SIZE=4;
-- 插入相同数据后比较SELECT table_name, pg_size_pretty(data_length + index_length) AS total_sizeFROM information_schema.tablesWHERE table_schema = 'testdb' AND table_name LIKE '%_table';8.2 RocksDB 压缩统计
# RocksDB 压缩统计# 通过 db.GetProperty() 获取rocksdb.compression-ratio-at-level0 # L0 压缩比rocksdb.compression-ratio-at-level1 # L1 压缩比rocksdb.compression-ratio-at-level2 # L2 压缩比
# 压缩输入/输出字节数rocksdb.compression-input-bytesrocksdb.compression-output-bytes九、总结
| 主题 | 核心要点 | 关键词 |
|---|---|---|
| 压缩动机 | 用 CPU 换 I/O,在 I/O 瓶颈场景下总是值得 | CPU 换 I/O, 瓶颈判断 |
| 通用压缩 | Snappy/LZ4 快速低压缩比,ZSTD 均衡,Gzip 高压缩比慢速 | Snappy, ZSTD |
| 列式编码 | RLE(低基数)、字典编码(枚举)、位打包(小整数)、Delta(排序值) | RLE, 字典编码 |
| Parquet 编码 | 自动选择最优编码,列存压缩比可达 10–100x | 自适应编码, 高压缩比 |
| InnoDB 压缩 | COMPRESSED 行格式,写入场景性能下降明显 | 行格式, 写入代价 |
| RocksDB 压缩 | 分层压缩策略,L0 不压、L1-2 LZ4、L3+ ZSTD | 分层压缩, LZ4/ZSTD |
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






