mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1974 字
6 分钟
压缩与编码
2025-05-24

直觉告诉我们:压缩数据要消耗 CPU,查询会变慢。但现实恰恰相反——在 I/O 受限的场景下,压缩让查询更快。1GB 的未压缩数据从磁盘读取需要 10 秒;压缩到 300MB 后,加上解压的 CPU 开销,总耗时可能只要 4 秒。用 CPU 换 I/O,在磁盘是瓶颈时是一笔划算的交易。

压缩不只是缩小文件体积。RLE、字典编码、Delta 编码各有擅长的数据模式;Snappy、ZSTD、LZ4 在压缩率与速度之间各有取舍。选对编码和算法,查询性能可以提升数倍。

一、压缩的动机#

1.1 为什么压缩#

graph TB subgraph 无压缩 DATA1["1GB 数据"] --> IO1["1GB I/O<br/>磁盘瓶颈"] end subgraph 有压缩 DATA2["1GB 数据"] --> COMP["压缩<br/>CPU 开销"] --> STORE["0.3GB 存储"] STORE --> READ["0.3GB I/O"] --> DECOMP["解压<br/>CPU 开销"] --> RESULT["1GB 数据"] end style IO1 fill:#ffcdd2,stroke:#c62828 style COMP fill:#fff9c4,stroke:#f9a825 style READ fill:#c8e6c9,stroke:#2e7d32
场景I/O 延迟CPU 延迟压缩是否值得
HDD 随机读10 ms0.01 ms非常值得
SATA SSD0.1 ms0.01 ms值得
NVMe SSD0.01 ms0.01 ms取决于压缩比
内存0.001 ms0.01 ms不值得
Note

压缩的核心权衡: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/Snappy2–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 time
import snappy
import lz4.frame
import zstandard as zstd
data = b'a' * 100_000_000 # 100MB 测试数据
# Snappy
start = time.time()
compressed = snappy.compress(data)
snappy_time = time.time() - start
snappy_ratio = len(data) / len(compressed)
# LZ4
start = time.time()
compressed = lz4.frame.compress(data)
lz4_time = time.time() - start
lz4_ratio = len(data) / len(compressed)
# ZSTD
cctx = zstd.ZstdCompressor(level=3)
start = time.time()
compressed = cctx.compress(data)
zstd_time = time.time() - start
zstd_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")
flowchart TD DATA["待压缩数据"] --> SPEED{"延迟敏感?"} SPEED -->|"是:实时写入<br/>RocksDB/Kafka"| FAST["快速压缩族"] FAST --> LZ4["LZ4<br/>~700 MB/s<br/>压缩比 ~2.5x"] FAST --> SNAPPY["Snappy<br/>~500 MB/s<br/>压缩比 ~2x"] SPEED -->|"否:归档/冷数据"| RATIO["高压缩比族"] RATIO --> ZSTD9["ZSTD-9<br/>~50 MB/s<br/>压缩比 ~4x"] RATIO --> GZIP["Gzip-9<br/>~20 MB/s<br/>压缩比 ~6x"] SPEED -->|"中等:温数据"| BALANCE["均衡族"] BALANCE --> ZSTD3["ZSTD-3<br/>~200 MB/s<br/>压缩比 ~3.5x"] LZ4 --> CHECK1{"解压速度 ><br/>磁盘带宽?"} SNAPPY --> CHECK1 CHECK1 -->|"是"| OK["压缩有效"] CHECK1 -->|"否"| NO["换更快算法<br/>或放弃压缩"] ZSTD9 --> OK2["适合冷数据<br/>读取频率低"] GZIP --> CHECK2{"解压 200MB/s ><br/>磁盘 5GB/s?"} CHECK2 -->|"否"| NO2["Gzip 解压<br/>反而更慢"] CHECK2 -->|"是"| OK2 style FAST fill:#c8e6c9,stroke:#2e7d32 style RATIO fill:#fff9c4,stroke:#f9a825 style BALANCE fill:#e3f2fd,stroke:#1565c0 style OK fill:#c8e6c9,stroke:#2e7d32 style NO fill:#ffcdd2,stroke:#c62828 style NO2 fill:#ffcdd2,stroke:#c62828
Warning

选择压缩算法时最容易犯的错误:只看压缩比,忽略解压速度。在 NVMe SSD 上,Gzip-6 的解压速度(~200 MB/s)远低于磁盘带宽(~5 GB/s),压缩后反而让查询更慢。务必确保解压速度 > 磁盘顺序读取速度

Caution

在写入密集的 OLTP 场景中使用高压缩比算法(如 ZSTD-9、Gzip),会导致写入延迟飙升和 CPU 资源争抢,严重影响在线请求的尾部延迟。热数据应始终使用 LZ4/Snappy,冷数据才用高压缩比算法。

2.2 数据库中的压缩选择#

数据库默认压缩底层压缩说明
InnoDBzlib(页压缩)COMPRESSED 行格式
RocksDBLZ4ZSTD(底层)快速压缩 + 底层高压缩比
ParquetSnappyZSTD/Gzip可配置
KafkaLZ4Snappy/ZSTD生产者压缩
MongoDBSnappyZSTDWiredTiger 引擎

2.3 压缩比与速度权衡#

选择压缩算法时,压缩比和速度往往不可兼得。以下是在 TPC-H lineitem 表(7.2GB,混合数据类型)上的实测对比:

算法压缩后大小压缩比压缩耗时解压耗时压缩+解压总耗时综合评价
Snappy2.8 GB2.6x1.4s0.5s1.9s最快,适合热数据
LZ42.9 GB2.5x1.0s0.4s1.4s极速解压,适合传输
ZSTD-32.1 GB3.4x3.6s0.7s4.3s均衡,适合温数据
ZSTD-91.9 GB3.8x14.4s0.7s15.1s高压缩比,适合冷数据
Gzip-61.7 GB4.2x14.4s3.6s18.0s最高压缩比,仅归档
Note

在 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 字典编码#

字典编码将值映射到整数索引:

flowchart LR subgraph 原始数据["原始列数据"] V1["北京"] --> V2["上海"] --> V3["北京"] --> V4["广州"] V4 --> V5["上海"] --> V6["北京"] --> V7["深圳"] --> V8["北京"] end subgraph 字典["字典表"] D0["0 → 北京"] D1["1 → 上海"] D2["2 → 广州"] D3["3 → 深圳"] end subgraph 编码结果["编码后索引"] E1["0"] --> E2["1"] --> E3["0"] --> E4["2"] E4 --> E5["1"] --> E6["0"] --> E7["3"] --> E8["0"] end 原始数据 -->|"构建字典"| 字典 原始数据 -->|"查表替换"| 编码结果 style 字典 fill:#fff9c4,stroke:#f9a825 style 编码结果 fill:#c8e6c9,stroke:#2e7d32
Tip

字典编码与 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.6x

3.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 根据数据特征自动选择最优编码:

graph TB subgraph Parquet编码["Parquet 编码选择策略"] DATA["列数据"] --> CARD{"基数检查"} CARD -->|"低基数<br/>(< 1000)"| DICT["字典编码<br/>+ 位打包"] CARD -->|"中等基数"| BITPACK["位打包<br/>+ RLE"] CARD -->|"高基数<br/>(排序后)"| DELTA["Delta 编码<br/>+ 位打包"] CARD -->|"随机分布"| PLAIN["Plain 编码<br/>(无压缩)"] end style DICT fill:#c8e6c9,stroke:#2e7d32 style BITPACK fill:#e3f2fd,stroke:#1565c0 style DELTA fill:#fff9c4,stroke:#f9a825 style PLAIN fill:#ffe0b2,stroke:#e65100
编码适用数据类型典型压缩比Parquet 版本
PLAIN所有类型1xV1
RLE排序后低基数10–100xV1
DICTIONARY低基数5–50xV1
DELTA_BINARY_PACK排序整数5–20xV2
DELTA_LENGTH_BYTE变长字节2–5xV2
DELTA_BYTE_ARRAY排序字符串3–10xV2
BYTE_RUN_SPLIT重复字节2–5xV2

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 行格式#

行格式压缩页大小说明
REDUNDANT16KB旧格式
COMPACT16KB默认,紧凑存储
DYNAMIC16KB默认(5.7+),溢出页优化
COMPRESSEDzlib1K/2K/4K/8KB页级压缩
COMPRESSEDLZ41K/2K/4K/8KBMySQL 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_length
FROM information_schema.tables
WHERE table_name = 'compressed_table';

5.2 InnoDB 压缩的代价#

代价说明影响
CPU 开销每次读取解压、修改重压增加 10–30% CPU
修改惩罚修改压缩页需要解压→修改→重压写入延迟增加
缓冲池浪费压缩页和非压缩页同时占用内存有效缓存减少
碎片化压缩后大小不一,空间浪费实际压缩比低于理论值
Important

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 开销数据特征
L01x频繁读写
L1LZ42–3x中等生命周期
L2LZ42–3x中等生命周期
L3+ZSTD3–5x冷数据

七、压缩性能基准#

7.1 TPC-H 基准压缩效果#

原始大小SnappyZSTD-3ZSTD-9Gzip-6
lineitem7.2 GB2.8 GB2.1 GB1.9 GB1.7 GB
orders1.6 GB0.6 GB0.45 GB0.4 GB0.35 GB
customer0.23 GB0.08 GB0.06 GB0.05 GB0.04 GB
总计9.0 GB3.5 GB2.6 GB2.4 GB2.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_size
FROM information_schema.tables
WHERE 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-bytes
rocksdb.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

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

压缩与编码
https://blog.souloss.com/posts/storage/storage-compression-and-encoding/
作者
Souloss
发布于
2025-05-24
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时