第六章:编码与压缩技术
🎯 核心概览
Lance 的性能秘密之一就是智能编码。相同的数据用不同的编码方式,压缩率可能从 0% 到 99%。本章深入讲解各种编码算法和选择策略。
📊 第一部分:编码的四个层次
物理编码 vs 逻辑编码
markdown
用户看到的数据(逻辑):
[100, 2000, 300, 40000, 500]
Lance 的处理:
1. 逻辑编码:选择编码算法(基于数据特征)
2. 物理编码:实际的字节序列(硬盘存储)
3. 压缩:可选的通用压缩(LZ4/Zstd)
4. 存储:最终的字节流
为什么需要多种编码?
markdown
不同数据的最优编码不同:
递增序列 [1,2,3,4,5]:
- Delta 编码最优:只需 1-2 bits/数
重复值 [A,A,A,B,B,C]:
- Dictionary 编码最优:只需 1-2 bits/值
随机值 [1234, 5678, 9012]:
- Bitpacking 最优:按实际范围存储
稀疏数据(大量 NULL):
- Bitmap 编码最优:NULL 用 1 bit 标记
Lance 的智能:**自动选择**最优编码
🔧 第二部分:主要编码方式详解
1. Bitpacking(比特打包)
原理:只用必要的比特数存储值
css
示例:存储 0-100 的数字
方式 A:标准 int32(32 bits)
100 → [00000000 00000000 00000000 01100100]
浪费:99% 空间
方式 B:Bitpacking(7 bits)
100 → [01100100] (去掉前 25 个 0)
100 个这样的数:
标准方式:400 bytes
Bitpacking:87.5 bytes
压缩率:78%
Lance 中的应用:
rust
// 自动计算所需比特数
fn compute_bit_width(values: &[i64]) -> usize {
let max_val = values.iter().max().unwrap();
let min_val = values.iter().min().unwrap();
let range = max_val - min_val;
// 计算所需比特数(向上取整)
(64 - range.leading_zeros()) as usize
}
// 使用 SIMD 优化(AVX2)加速编解码
#[cfg(target_arch = "x86_64")]
fn bitpack_avx2(values: &[i64], bit_width: usize) -> Vec<u8> {
// 一次处理 256 比特(4 个向量值)
// 性能:5GB/s
}
何时使用:
- ✓ 数值范围有限的整数列
- ✓ 多数值接近的数据
- ✗ 非常大的数值(范围 > 2^32)
2. Dictionary 编码(字典编码)
原理:重复的值用索引代替
sql
原始数据:
[user, admin, user, guest, admin, user, admin]
共 8 字节 × 7 = 56 字节(假设指针)
字典编码:
Dictionary: {
"user": 0,
"admin": 1,
"guest": 2,
}
Indices: [0, 1, 0, 2, 1, 0, 1]
存储大小:
- 字典:30 字节
- 索引:7 字节(如果用 1 byte/索引)
- 总计:37 字节
压缩率:34%
实际 Lance:
使用 Bitpacking 对索引进一步编码(如果只有 3 种值,用 2 bits)
总计:5 字节 + 字典 30 字节 = 35 字节
压缩率:37%
重复率与压缩率的关系:
erlang
列数据 1000 000 行
重复率 10%(1000 种不同值):
字典大小:1000 × 平均值长度
索引大小:1000000 × 4 bytes = 4MB
压缩率:40%
重复率 50%(500 种不同值):
压缩率:60%
重复率 99%(100 种不同值):
压缩率:90%
Lance 的策略:
rust
fn choose_encoding(column: &ColumnData) -> Encoding {
let distinct_count = estimate_distinct_count(column);
let cardinality = distinct_count as f64 / column.len() as f64;
if cardinality < 0.05 { // 重复率 > 95%
Encoding::Dictionary
} else if cardinality < 0.3 { // 重复率 > 70%
Encoding::Dictionary
} else {
// 尝试其他编码...
}
}
3. Delta 编码(差分编码)
原理:存储相邻值的差而不是值本身
ini
原始数据(时间序列):
[100, 102, 105, 101, 103, 108]
Delta 编码:
第一个值:100(完整存储)
差值:[+2, +3, -4, +2, +5]
存储空间:
原始:6 × 8 bytes = 48 bytes
Delta:1 × 8 + 5 × 2 bytes = 18 bytes
压缩率:62%
进一步优化(Bitpacking 差值):
差值范围:-4 到 +5 → 需要 4 bits
总计:1 × 8 + 5 × 0.5 bytes = 10.5 bytes
压缩率:78%
适用场景:
- ✓ 时间序列数据
- ✓ 单调递增的值
- ✓ 值变化平缓的列
4. RLE 编码(游程编码)
原理:存储值和重复次数
scss
原始数据(高重复):
[A, A, A, A, A, B, B, B, C, C, D, D, D, D, D]
RLE 编码:
(A, 5), (B, 3), (C, 2), (D, 5)
存储空间:
原始:15 × 1 byte = 15 bytes
RLE:4 × (1 + 4) = 20 bytes
压缩率:-33%(反而变大!)
但对于长序列:
[A] × 1000000 → (A, 1000000)
原始:1MB
RLE:10 bytes
压缩率:99.999%
Lance 的使用:
- 仅用于高度重复的数据
- 在 RLE 段很短时自动禁用
5. Prefix 编码(前缀编码)
原理:只存储与前一个值不同的部分
arduino
原始字符串列:
["apple", "application", "apply", "apricot", "banana"]
Prefix 编码:
"apple" (完整)
"application" → (+4, "ication") // 前 4 字符相同
"apply" → (+3, "ly")
"apricot" → (-3, "ricot") // 回溯后添加
"banana" → (-4, "nana")
存储大小:
原始:5+11+5+7+6 = 34 bytes
Prefix:5+4+2+3+4 = 18 bytes
压缩率:47%
字符串列的最优编码:
- 有序字符串:Prefix 编码 ✓
- 无序但有重复:Dictionary ✓
- 完全随机:Dictionary(如果有重复)或 Raw
🗜️ 第三部分:通用压缩算法
编码之后,还可应用通用压缩:
LZ4 vs Zstd
bash
特征对比:
LZ4 Zstd
编码速度 3GB/s 1GB/s
解码速度 5GB/s 3GB/s
压缩率 40% 50%
内存占用 低 中
选择原则:
- 写入频繁,需要快速编码 → LZ4
- 存储成本重要 → Zstd
Lance 的策略:
rust
pub enum CompressionAlgorithm {
LZ4, // 默认,快速
Zstd, // 高压缩率
None, // 不压缩
}
// 自动选择
fn choose_compression(column_type: &DataType) -> CompressionAlgorithm {
match column_type {
DataType::Utf8 => CompressionAlgorithm::Zstd, // 字符串压缩率高
DataType::Binary => CompressionAlgorithm::LZ4, // BLOB 用速度
_ => CompressionAlgorithm::LZ4, // 默认 LZ4
}
}
📊 第四部分:编码选择的决策树
Lance 如何自动选择最优编码?
markdown
收到列数据
↓
检查数据特征:
├─ 重复率 > 80%?→ Dictionary
├─ 范围小(< 2^16)?→ Bitpacking
├─ 单调递增?→ Delta
├─ 高度重复段?→ RLE
├─ 字符串且有序?→ Prefix
└─ 默认 → Raw (不编码)
↓
应用压缩算法(LZ4/Zstd)
↓
比较原始 vs 编码 + 压缩
↓
选择更小的方案
编码成本分析:
diff
列大小:100MB
方案 A:Raw
- 编码时间:0
- 结果:100MB
- 读取时间:10ms
方案 B:Bitpacking + LZ4
- 编码时间:100ms
- 结果:25MB (75% 压缩)
- 读取时间:3ms(IO 快)+ 2ms(解压)
选择:
写入时间允许 → 方案 B
实时写入需求 → 方案 A
🎯 第五部分:向量的特殊编码
向量量化(Quantization)
向量编码的关键在于量化。无索引的向量用 raw float32 存储太大:
ini
1 百万个 768 维向量:
Raw float32:
768 × 4 bytes × 1M = 3GB
不压缩
IVF_PQ 量化:
768 维 → 8 个 codebook
每个 codebook:256 码字
编码:8 × log2(256)/8 = 8 bytes per vector
总大小:8MB
压缩率:99.7%
搜索性能:
Raw:扫描 3GB,计算 768 维距离
IVF_PQ:只检查 1% 候选,用 8 字节近似距离
性能提升:1000 倍
量化的精度损失
diff
精度与性能的权衡:
Top-10 准确率:
无索引(100%):
- 第 1 名:完全正确
IVF_PQ:
- Top-10:95-99% 准确
IVF_SQ(标量量化):
- Top-10:98-99% 准确
选择:
- 需要完美精度:无索引(或黄金集合重排)
- 推荐系统:IVF_PQ(99% 准确足够)
- 图搜索:任何量化都可
📈 第六部分:实际压缩效果统计
真实数据集的压缩率
erlang
数据集 1:电商产品数据(1000万行)
列类型 原始大小 编码方式 压缩后 压缩率
product_id 40MB Bitpacking 10MB 75%
name 100MB Dictionary 5MB 95%
category 50MB Dictionary 1MB 98%
price 40MB Bitpacking 10MB 75%
description 500MB Zstd 25MB 95%
embedding 30GB IVF_PQ 240MB 99.2%
总计:30.73GB → 251MB
整体压缩率:99.2%
erlang
数据集 2:时间序列数据(5000万条)
timestamp 200MB Delta+LZ4 20MB 90%
sensor_id 200MB Dictionary 2MB 99%
temperature 200MB Delta+LZ4 15MB 92.5%
humidity 200MB Delta+LZ4 15MB 92.5%
总计:800MB → 52MB
整体压缩率:93.5%
💡 最佳实践
编码选择的三原则
| 场景 | 编码方案 | 原因 |
|---|---|---|
| 高基数(>10% 不同值) | Bitpacking | 编码简单快速 |
| 高重复(<5% 不同值) | Dictionary | 极高压缩率 |
| 时间序列 | Delta + LZ4 | 针对性优化 |
| 向量数据 | IVF_PQ | 支持高效搜索 |
| 字符串(有序) | Prefix + Zstd | 大幅节省空间 |
| BLOB(小) | Zstd | 最大压缩 |
编码的性能规律
bash
编码成本 vs 读取速度:
写入时间(按数据大小):
Bitpacking: 1GB/s
Dictionary: 500MB/s
Delta: 2GB/s
RLE: 5GB/s
Raw: 10GB/s(不编码)
读取时间(已编码数据):
Raw: 10GB/s(无解码)
Bitpacking: 5GB/s
Dictionary: 3GB/s(需要查表)
Delta: 2GB/s(需要累加)
权衡:
- 写入频繁 → 选择简单编码
- 读取频繁 → 选择高压缩编码
- 混合场景 → LZ4(折中方案)
📚 总结
Lance 的编码系统通过以下创新实现高效存储:
- 多种编码算法:适应不同数据特征
- 自动选择:基于数据统计自动选择
- 级联压缩:编码 + 通用压缩双重优化
- 向量特化:PQ 量化支持向量搜索
- 性能优化:SIMD 加速编解码
下一章将讲解编码器和解码器的实现细节。