流式数据湖Paimon探秘之旅 (八) LSM Tree核心原理

第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

关键特性

  1. 逐层增大 - 上层小,下层大
  2. 逐层合并 - Level i的大小 = Level i-1 × SizeRatio
  3. 每层有序 - 同层内文件的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、异步压缩等

相关推荐
Light6042 分钟前
智慧办公新纪元:领码SPARK融合平台如何重塑企业OA核心价值
大数据·spark·oa系统·apaas·智能办公·领码spark·流程再造
智能化咨询1 小时前
(66页PPT)高校智慧校园解决方案(附下载方式)
大数据·数据库·人工智能
忆湫淮1 小时前
ENVI 5.6 利用现场标准校准板计算地表反射率具体步骤
大数据·人工智能·算法
lpfasd1231 小时前
现有版权在未来的价值:AI 泛滥时代的人类内容黄金
大数据·人工智能
庄小焱1 小时前
大数据存储域——图数据库系统
大数据·知识图谱·图数据库·大数据存储域·金融反欺诈系统
jiayong231 小时前
Elasticsearch Java 开发完全指南
java·大数据·elasticsearch
语落心生1 小时前
流式数据湖Paimon探秘之旅 (七) 读取流程全解析
大数据
语落心生1 小时前
流式数据湖Paimon探秘之旅 (二) 存储模型与文件组织
大数据
n***78681 小时前
PostgreSQL 中进行数据导入和导出
大数据·数据库·postgresql