第8章:LSM Tree核心原理
导言:为什么需要LSM Tree
传统数据库使用B+Tree来存储数据:
scss
B+Tree
├── 读性能:非常快(O(log N))
├── 写性能:中等(涉及分裂和重平衡)
└── 问题:大量随机写入导致磁盘IO抖动
Paimon的选择:LSM Tree(Log-Structured Merge Tree)
markdown
LSM Tree
├── 写性能:非常快(顺序写入)
├── 读性能:中等(需要多文件合并)
└── 优点:
├── 顺序写入,充分利用磁盘带宽
├── 合并在后台异步进行
└── 写入不阻塞读取
类比:
- B+Tree:像一个整理很好的图书馆(读书很快,但整理花时间)
- LSM Tree:像一个订阅箱(收包裹很快,每天晚上批量整理)
第一部分:LSM Tree的基本结构
1.1 分层设计
LSM Tree将数据分为多个有序层级:
css
Level 0(最新):写入点
├── [File_1] 100MB
├── [File_2] 100MB
├── [File_3] 100MB
└── [File_4] 100MB
总大小:400MB
Level 1:
├── [File_5] 400MB
└── [File_6] 400MB
总大小:800MB
Level 2:
└── [File_7] 1.6GB
总大小:1.6GB
Level 3:
└── [File_8] 3.2GB
总大小:3.2GB
关键特性:
- 逐层增大 - 上层小,下层大
- 逐层合并 - Level i的大小 = Level i-1 × SizeRatio
- 每层有序 - 同层内文件的key范围不重叠
1.2 SortedRun的概念
什么是SortedRun?
SortedRun是一个有序的文件集合,内部数据按主键排序:
vbnet
SortedRun_0(Level 0):
├── File_1: key [1-100]
├── File_2: key [101-200]
├── File_3: key [201-300]
└── File_4: key [301-400]
特点:文件之间可能有重叠(File_1和File_2的key可能有交集)
SortedRun_1(Level 1):
├── File_5: key [1-500]
└── File_6: key [501-1000]
特点:文件之间无重叠(key范围不相交)
为什么Level 0可能有重叠?
因为Level 0是写入点,新数据不断flush到Level 0,无法保证排序一致:
vbnet
时间顺序:
Flush 1 → File_1 (key 1-100)
Flush 2 → File_2 (key 50-150) ← key 50-100 与File_1重叠!
Flush 3 → File_3 (key 90-200) ← 再次重叠!
1.3 Levels结构
在Paimon中,Levels对象管理整个LSM树:
java
public class Levels {
private List<DataFileMeta>[] level; // level[0..N]分别对应Level 0到N
// 查询指定key的版本
public List<DataFileMeta> getFiles(InternalRow key) {
List<DataFileMeta> result = new ArrayList<>();
// 从Level 0开始,依次查找包含该key的文件
for (int i = 0; i < level.length; i++) {
for (DataFileMeta file : level[i]) {
if (file.containsKey(key)) {
result.add(file);
}
}
}
return result; // 可能包含多个版本
}
}
第二部分:LSM Tree的写入流程
2.1 写入到Memtable
第一步:写入到内存表(Memtable)
ini
应用程序:write(KeyValue(key=1, value="Alice"))
↓
WriteBuffer(内存中的红黑树)
├── key=1 → value="Alice"
├── key=2 → value="Bob"
└── key=3 → value="Charlie"
大小:256MB
Memtable的特点:
scss
红黑树存储:
└── O(log N)查询
└── O(log N)插入
└── O(N)全扫描
内存占用:
├── 256MB写缓冲
├── ~1M条记录(假设256字节/条)
└── 查询时直接在内存中进行
2.2 Flush到Immutable Memtable
Memtable满时,转换为不可变内存表(Immutable Memtable):
scss
Memtable (可写)
256MB满
↓
Flush Thread触发
↓
Memtable → ImmutableMemtable (只读)
↓
新建新的Memtable继续写入
↓
同时,后台线程开始写ImmutableMemtable到磁盘
两个缓冲区:
bash
┌─────────────────────────────────┐
│ Memtable #1 │ ← 应用继续写入
│ (写) 占用100MB │
├─────────────────────────────────┤
│ Memtable #2 │ ← 后台正在flush
│ (ImmutableMemtable) 满256MB │
└─────────────────────────────────┘
应用永不阻塞!
2.3 Flush到Level 0
ImmutableMemtable被后台线程写入磁盘,生成Level 0文件:
ini
ImmutableMemtable
├── key=1 → value="Alice"
├── key=2 → value="Bob"
├── key=3 → value="Charlie"
└── ... (1M条记录,256MB)
↓
序列化 & 压缩
↓
生成 DataFile_0.parquet
├── Size: 32MB (256MB ÷ 8倍压缩率)
├── Rows: 1M
├── Stats: min_key=1, max_key=1000000
└── 已排序
↓
添加到 Level 0
Level 0的特点:
css
Level 0:
├── [File_1] [File_2] [File_3] [File_4]
└── 文件之间可能有key重叠
为什么不立即合并到Level 1?
← 写入优先!Level 0→Level 1的合并是异步的
何时触发合并?
← 当Level 0的文件数 > compaction-trigger(如4个)时
第三部分:Compaction(压缩)的机制
3.1 何时触发Compaction
Paimon使用UniversalCompaction策略,触发条件:
ini
条件1:文件数触发
┌─────────────────────────────────┐
│ Level 0: [F1] [F2] [F3] [F4] │
│ 文件数 = 4 ≥ 触发条件(4) │
│ → 立即触发 Level 0→Level 1 │
└─────────────────────────────────┘
条件2:大小比例触发
┌─────────────────────────────────┐
│ Level 0: 400MB │
│ Level 1: 700MB │
│ 比例: 700/400 = 1.75 < 2 │
│ → 应该合并到Level 1(目标800MB) │
│ → 立即触发 │
└─────────────────────────────────┘
条件3:全量Compaction触发
┌─────────────────────────────────┐
│ 检测点:每1小时 │
│ 条件:多个Level都接近阈值 │
│ → 触发从Level 0到底层的连锁反应 │
└─────────────────────────────────┘
3.2 合并过程详解
Level 0→Level 1 的Compaction:
ini
输入:
├── Level 0: [F1(100MB)][F2(100MB)][F3(100MB)][F4(100MB)]
│ └─ key范围:1-1000, 500-1500, 1000-2000, 1500-2500
│ (文件之间有重叠)
│
└── Level 1: [F5(400MB)] [F6(400MB)]
└─ key范围:1-2000, 2001-4000
(文件之间无重叠)
处理流程:
Step 1: 选择Level 0的所有文件 + Level 1的相关文件
├── Level 0: 全部4个文件(400MB)
└── Level 1: 仅包含 1-2500 key的文件(F5)(400MB)
└─ 总计:800MB
Step 2: 多路归并(Merge)
├── 因为输入都已排序,可以使用高效的多路归并
├── 时间复杂度:O(N log K),N=记录数,K=输入流数
└─ 实际:O(N)线性扫描
Step 3: 去重(Deduplication)
同一key可能在多个输入文件中:
├── F1: key=1, seq=1, value="Alice"
├── F2: key=1, seq=3, value="Alice_v2" ← 更新
├── F3: key=1, seq=2, value="Alice_v1"
└─ 合并后:只保留seq=3的版本
Step 4: 聚合(可选,根据merge-engine)
如果配置了聚合函数:
├── 对多个版本的value进行聚合
└─ 例如:SUM、COUNT等
Step 5: 编码 & 压缩
├── 新的已排序数据 → Parquet格式
├── 压缩(snappy)→ 8:1
└─ 输出大小:800MB ÷ 8 = 100MB
输出:
├── Level 0: 清空(4个输入文件删除)
└── Level 1: [F5(400MB)] [F7(100MB)] [F6(400MB)]
└─ F7是新生成的合并文件
多路归并示意图:
makefile
输入文件(已排序):
F1: 1, 2, 3, 4, 5
F2: 2, 4, 6, 8
F3: 1, 3, 5, 7, 9
多路归并:
┌──────────────────────────────────┐
│ 1(F1) 1(F3) │ ← 1的两个版本,选最新
│ 2(F1) 2(F2) │ ← 2的两个版本,选最新
│ 3(F1) 3(F3) │ ← 3的两个版本,选最新
│ 4(F1) 4(F2) │ ← 4的两个版本,选最新
│ 5(F1) 5(F3) │ ← 5的两个版本,选最新
│ 6(F2) │ ← 6仅在F2中
│ 7(F3) │ ← 7仅在F3中
│ 8(F2) │ ← 8仅在F2中
│ 9(F3) │ ← 9仅在F3中
└──────────────────────────────────┘
输出:1, 2, 3, 4, 5, 6, 7, 8, 9(单个版本,已排序)
3.3 连锁Compaction
当Level 0→Level 1完成后,Level 1可能变大,触发Level 1→Level 2的合并:
vbnet
初始:
L0: 400MB (4个文件) → 触发合并
L1: 700MB (2个文件)
L2: 1.6GB (1个文件)
L3: 3.2GB (1个文件)
Step 1: L0→L1 Compaction
L0: 清空
L1: 700MB + 100MB(合并结果) = 800MB
└─ 仍是2个文件(F5, F6)或1个大文件
Step 2: L1 size > 阈值,触发L1→L2 Compaction
L1: 800MB → 100MB(压缩后)
L2: 1.6GB + 100MB = 1.7GB
└─ 现在是2个文件,可能触发L2→L3
Step 3: L2→L3 Compaction
L2: 清空
L3: 3.2GB + 200MB = 3.4GB
最终:
L0: 空
L1: 空
L2: 空
L3: 3.4GB (1个大文件)
第四部分:LSM Tree的读取
4.1 点查(Point Query)
查询 key=1 的值:
yaml
查询请求:Get(key=1)
↓
Step 1: 查Memtable(最新数据)
├── 查询WriteBuffer(O(log N))
└─ 是否找到?
├─ YES → 直接返回
└─ NO → 继续
↓
Step 2: 查Level 0
├── 遍历所有Level 0文件
├── 对每个文件进行二分查找(O(log M))
└─ 是否找到?
├─ YES(可能多个版本)→ 继续检查更低level(可能有更新)
└─ NO → 继续
↓
Step 3: 查Level 1-N
├── 对每个level,使用二分查找找到包含key的文件
├── 文件数少,查询快
└─ 是否找到?
├─ YES → 返回
└─ NO → 查完所有level,返回不存在
成本分析:
bash
最坏情况(key不存在):
├── Memtable: O(log M) M=memtable大小
├── Level 0: O(K log N) K=Level 0文件数, N=平均文件大小
├── Level 1-N: O(log N) 对每个level
└─ 总计:O(K log N) = O(4 × log 100M) ≈ 130次操作
最好情况(key在Memtable):
├── Memtable: O(log M) ≈ 20次操作
└─ 总计:20次操作(几微秒)
优化:Lookup表(将Level 0的bloom filter缓存)
└─ 使用bloom filter快速判断key是否存在
→ 避免不必要的文件打开
→ 实际延迟:<100ms
4.2 范围扫描(Range Scan)
扫描 key 在 [100, 200] 的所有记录:
vbnet
扫描请求:Range(100, 200)
↓
Step 1: Memtable
├── 扫描WriteBuffer中 100≤key≤200 的记录
└─ 收集所有结果
↓
Step 2: Level 0-N
├── 对每个level,找出包含[100,200]范围的文件
├── 对文件进行范围扫描
└─ 多个level的结果需要合并去重
合并问题:
ini
来自不同level的相同key:
Memtable: key=150 → version_3 (最新)
Level 0: key=150 → version_2
Level 1: key=150 → version_1
结果合并时:
├── 按(key, version)排序
├── 同key只保留最新版本
└─ 最终:key=150 → version_3
4.3 Lookup表优化
什么是Lookup表?
Lookup表是Level 0的内存索引,加速点查:
ini
Level 0有多个文件:
├── F1: key [1-100]
├── F2: key [50-150]
├── F3: key [90-200]
Lookup表:
├── key=50: {F1, F2}
├── key=75: {F1, F2}
├── key=90: {F1, F2, F3}
├── key=100: {F1, F2, F3}
└── ...
点查 key=150:
├── 查Lookup表:{F2, F3}
├── 仅打开F2和F3,无需扫描F1
└─ 性能提升:最多50%
第五部分:LSM参数详解
5.1 核心参数
| 参数 | 默认值 | 说明 |
|---|---|---|
num-levels |
4 | LSM树的层数 |
sorted-run-size-ratio |
2 | 相邻层级的大小比例 |
num-sorted-run-compaction-trigger |
4 | 触发compaction的文件数 |
max-size-amplification-percent |
100 | 允许的LSM树膨胀百分比 |
target-file-size |
128MB | 每个DataFile的目标大小 |
5.2 参数组合推荐
场景1:实时性优先(频繁压缩)
yaml
num-levels: 5 # 层数更多,便于逐层提升
sorted-run-size-ratio: 2 # 严格控制
num-sorted-run-compaction-trigger: 2 # 每2个文件就压缩
max-size-amplification-percent: 50 # 不允许膨胀
结果:
├── L0: 100MB (2文件) → 立即压缩
├── L1: 200MB
├── L2: 400MB
├── L3: 800MB
├── L4: 1.6GB
└─ 总大小:3.1GB(紧凑)
场景2:高吞吐优先(宽松压缩)
yaml
num-levels: 4 # 层数少,压缩频率低
sorted-run-size-ratio: 4 # 宽松比例
num-sorted-run-compaction-trigger: 8 # 8个文件才压缩
max-size-amplification-percent: 200 # 允许膨胀
结果:
├── L0: 400MB (8文件) → 压缩成100MB
├── L1: 400MB
├── L2: 1.6GB
├── L3: 6.4GB
└─ 总大小:8.5GB(膨胀,但写吞吐高)
场景3:平衡方案(推荐)
yaml
num-levels: 4
sorted-run-size-ratio: 2
num-sorted-run-compaction-trigger: 4
max-size-amplification-percent: 100
结果:
├── L0: 200MB (4文件) → 压缩成50MB
├── L1: 400MB
├── L2: 800MB
├── L3: 1.6GB
└─ 总大小:3GB(适中)
5.3 参数的影响
arduino
增加 sorted-run-size-ratio(从2→4):
├─ 优点:压缩频率降低,写吞吐提升
├─ 缺点:LSM树变大,读性能下降
└─ 影响:吞吐提升30%,读延迟增加20%
增加 num-levels(从4→6):
├─ 优点:分层更细致,每层大小管理更好
├─ 缺点:压缩链更长,某个layer阻塞影响整个系统
└─ 影响:一般无显著变化
减少 num-sorted-run-compaction-trigger(从4→2):
├─ 优点:LSM树更紧凑,读性能更好
├─ 缺点:压缩频率高,CPU占用增加
└─ 影响:读延迟改善30%,CPU占用翻倍
第六部分:实战案例
6.1 案例1:高吞吐日志系统
需求:1000MB/s写入吞吐
配置:
yaml
num-levels: 3 # 层数少,压缩频率低
sorted-run-size-ratio: 4
num-sorted-run-compaction-trigger: 8 # 宽松条件
max-size-amplification-percent: 200
write-buffer-size: 1GB # 大buffer
target-file-size: 1GB # 大文件
write-buffer-spillable: true
write-buffer-spill-disk-size: 8GB
LSM结构:
yaml
Level 0: 800MB (8文件)
Level 1: 3.2GB
Level 2: 12.8GB
总容量: 16.8GB(充足存放1小时数据)
性能指标:
bash
写入吞吐:1000MB/s
Compaction CPU:30%(后台单线程)
读延迟:<500ms(允许更长)
6.2 案例2:低延迟在线查询
需求:点查<100ms,频繁读取
配置:
yaml
num-levels: 5 # 层数多,分散数据
sorted-run-size-ratio: 2 # 严格控制
num-sorted-run-compaction-trigger: 2 # 频繁压缩
max-size-amplification-percent: 50
lookup-enabled: true # 启用lookup表加速
target-file-size: 128MB # 小文件便于快速定位
LSM结构:
yaml
Level 0: 100MB (2文件) + Lookup表
Level 1: 200MB
Level 2: 400MB
Level 3: 800MB
Level 4: 1.6GB
总容量: 3.1GB(紧凑)
性能指标:
erlang
点查延迟:<50ms(Lookup表优化)
范围扫描:<200ms
Compaction CPU:50%(后台频繁压缩)
第七部分:Lookup优化详解
7.1 为什么需要Lookup
没有Lookup的点查:
ini
查询key=1500
↓
Level 0: [F1(key 1-100)][F2(key 50-150)][F3(key 90-200)][F4(key 150-300)]
↓
key=1500 在哪个文件?
├─ F1?NO [F1 key 1-100]
├─ F2?NO [F2 key 50-150]
├─ F3?NO [F3 key 90-200]
├─ F4?YES [F4 key 150-300] ← 但需要逐个检查!
└─ 实际:打开了4个文件才找到
有Lookup的点查:
yaml
Lookup表:
├── 1500在F4的范围内
└─ → 直接打开F4
结果:仅打开1个文件
性能提升:4倍!
7.2 Lookup的实现
java
public class LookupLevels {
// 为Level 0的每个文件建立Lookup索引
private Map<InternalRow, DataFileMeta> keyToFile;
public List<DataFileMeta> lookup(InternalRow key) {
// 快速查找包含该key的文件
return keyToFile.get(key); // O(1)
}
}
7.3 Lookup的成本与权衡
vbnet
Lookup表占用内存:
├─ 100万条key,每条100字节
└─ 总计:100MB(对于Level 0的1GB数据不算大)
优缺点:
├─ 优:点查性能提升4-10倍
├─ 缺:内存占用100MB,需要维护
└─ 推荐:启用(对大多数场景)
禁用Lookup:
├─ 场景:内存严重不足、纯扫描工作负载
└─ 命令:lookup-enabled: false
第八部分:常见问题与调优
Q1: 为什么Compaction会导致读延迟增加?
yaml
正常情况:
L0: [F1][F2][F3]
L1: [F4]
查询key:
├─ L0: 3个文件中查找
└─ L1: 1个文件中查找
Compaction期间:
L0: 正在merge → 可能文件不可读
L1: 正在接收新数据 → 可能被锁定
L2: 可能等待写入 → 多路竞争
结果:读延迟增加10-20%
Q2: 如何诊断LSM树过大?
arduino
症状:
├─ 磁盘占用大幅增加
├─ 压缩频率高(CPU 80%+)
├─ 读性能逐渐下降
原因:
└─ sorted-run-size-ratio太大
或num-sorted-run-compaction-trigger太大
解决:
└─ 降低参数,增加压缩频率
Q3: Compaction何时会阻塞写入?
markdown
正常情况:
写入 → Level 0(无阻塞)
Compaction → Level 1/2/3(后台异步)
阻塞情况:
Level 0堆积 > 阈值(如200MB)
↓
写入进程必须等待Compaction清理Level 0
↓
应用延迟突增(从<1ms变为>100ms)
预防:
├─ 增大Level 0阈值
├─ 增加Compaction线程数
└─ 降低写入速度
总结
LSM Tree vs B+Tree
| 特性 | LSM Tree | B+Tree |
|---|---|---|
| 写吞吐 | 非常高(1000MB/s+) | 中等(100MB/s) |
| 点查延迟 | 中等(50-500ms) | 非常低(1-5ms) |
| 范围扫描 | 高效 | 高效 |
| 磁盘占用 | 稍大(膨胀) | 紧凑 |
| 适用场景 | 写入密集 | 读取密集 |
参数调优总结
yaml
高吞吐:
├─ ratio: 4, trigger: 8
└─ 吞吐提升:30%
低延迟:
├─ ratio: 2, trigger: 2, lookup: true
└─ 延迟改善:50%
平衡:
├─ ratio: 2, trigger: 4
└─ 推荐用于大多数场景
下一章告:第9章将讲解Compaction压缩机制,包括UniversalCompaction、CompactRewriter、异步压缩等