1205 字
4 分钟
读路径
LSM 树写入快——一条 Put 请求追加到内存就返回。但读取时,同一个 key 可能散落在 MemTable、Immutable MemTable、L0 的多个 SSTable、L1、L2……每一层都要查。B+ 树读一条数据只需 3–4 次 I/O,LSM 树可能需要几十次。写入越快,读取越慢——这就是 LSM 树的读放大问题。
Bloom Filter 如何用少量内存过滤掉大部分无效查找?Block Cache 如何让热点数据免于磁盘访问?这一章拆解读路径——从请求到数据的完整链路,看存储引擎如何与读放大搏斗。
一、读路径全景
1.1 通用读路径
graph TB
subgraph 读路径["存储引擎读路径"]
REQ["读取请求<br/>Get(key)"] --> CACHE["检查缓存<br/>Block Cache / Buffer Pool"]
CACHE -->|"命中"| HIT["返回数据"]
CACHE -->|"未命中"| FILTER["Bloom Filter<br/>(LSM 树)"]
FILTER -->|"不存在"| MISS["返回 Not Found"]
FILTER -->|"可能存在"| INDEX["查找索引<br/>B+ 树 / SSTable 索引"]
INDEX --> DATA["读取数据块<br/>磁盘 I/O"]
DATA --> DECODE["解码数据"]
DECODE --> CACHE_PUT["放入缓存"]
CACHE_PUT --> RETURN["返回数据"]
end
style REQ fill:#e3f2fd,stroke:#1565c0
style HIT fill:#c8e6c9,stroke:#2e7d32
style MISS fill:#ffcdd2,stroke:#c62828
style DATA fill:#fff9c4,stroke:#f9a825
1.2 InnoDB vs RocksDB 读路径对比
| 步骤 | InnoDB | RocksDB |
|---|---|---|
| 1. 缓存查找 | Buffer Pool(页级) | Block Cache(块级) |
| 2. 索引查找 | B+ 树遍历 | 多层级 SSTable 查找 |
| 3. 过滤 | 无 | Bloom Filter |
| 4. 磁盘读取 | 随机 I/O(1–3 次) | 顺序 I/O(SSTable 内) |
| 5. 数据解码 | 行格式解码 | Block 解码 + 版本合并 |
二、B+ 树读取路径
2.1 InnoDB 点查路径
// InnoDB B+ 树点查流程byte *btree_search(dict_index_t *index, dtuple_t *key) { // 1. 从根页面开始 page_id_t page_id = index->root_page;
// 2. 逐层向下查找 while (true) { // 2a. 在 Buffer Pool 中查找页面 page_t *page = buf_pool_get(page_id);
if (page == NULL) { // 2b. Buffer Pool 未命中,从磁盘读取 page = buf_read_page(page_id); }
// 2c. 在页面内二分查找 int slot = page_binary_search(page, key);
if (page->is_leaf) { // 2d. 叶子页面:返回数据 return page_get_record(page, slot); }
// 2e. 内部页面:获取子节点页面 ID page_id = page_get_child(page, slot); }}2.2 InnoDB 范围扫描路径
graph TB
subgraph 范围扫描["B+ 树范围扫描"]
START["定位起始 Key"] --> LEAF1["叶子页面 1"]
LEAF1 -->|"next 指针"| LEAF2["叶子页面 2"]
LEAF2 -->|"next 指针"| LEAF3["叶子页面 3"]
LEAF3 -->|"next 指针"| LEAF4["..."]
LEAF4 -->|"到达终止 Key"| END["扫描结束"]
end
style START fill:#e3f2fd,stroke:#1565c0
style END fill:#c8e6c9,stroke:#2e7d32
// B+ 树范围扫描void btree_range_scan(dict_index_t *index, dtuple_t *start_key, dtuple_t *end_key, scan_callback_t callback) { // 1. 定位起始 Key page_t *page = btree_find_leaf(index, start_key); int slot = page_find_slot(page, start_key);
// 2. 沿叶子链表扫描 while (page != NULL) { for (int i = slot; i < page->n_records; i++) { record_t *rec = page->records[i];
// 检查是否超出范围 if (compare(rec->key, end_key) > 0) { return; // 扫描结束 }
// 回调处理 callback(rec); }
// 移到下一个叶子页面 page = buf_pool_get(page->next_page); slot = 0; }}三、LSM 树读取路径
3.1 RocksDB 点查路径
// RocksDB 点查流程Status rocksdb_get(DB *db, const Slice &key, string *value) { // 1. 查找 MemTable if (db->memtable->Get(key, value)) { return Status::OK(); // 命中 }
// 2. 查找 Immutable MemTable if (db->immutable && db->immutable->Get(key, value)) { return Status::OK(); // 命中 }
// 3. 查找 SSTable(从 L0 到 Ln) for (int level = 0; level < db->num_levels; level++) { // 3a. 检查 Bloom Filter if (!bloom_filter_may_contain(db->levels[level], key)) { continue; // 跳过此层级 }
// 3b. 在索引中查找目标 Data Block BlockHandle handle; if (!index_find(db->levels[level], key, &handle)) { continue; // Key 不在此层级范围内 }
// 3c. 在 Block Cache 中查找 Block *block = block_cache_lookup(handle); if (block == NULL) { // 3d. 从磁盘读取 Data Block block = read_data_block(handle); block_cache_insert(handle, block); }
// 3e. 在 Data Block 中查找 Key if (block_get(block, key, value)) { return Status::OK(); // 命中 } }
return Status::NotFound("Key not found");}3.2 LSM 树读放大分析
# LSM 树读放大分析def lsm_read_amplification(n_levels=7, l0_files=4): """分析 LSM 树的读放大""" # 最坏情况:检查所有层级 # L0: 检查 l0_files 个 SSTable # L1+: 每层检查 1 个 SSTable(键范围不重叠)
# 无 Bloom Filter worst_case = l0_files + (n_levels - 1) print(f"最坏情况(无 Bloom Filter): {worst_case} 次 SSTable 查找")
# 有 Bloom Filter(假阳性率 1%) # L0: 每个文件都需检查(键范围重叠) # L1+: Bloom Filter 过滤 99% 的无效查找 bloom_fp_rate = 0.01 l1_plus_checks = (n_levels - 1) * bloom_fp_rate typical_case = l0_files + l1_plus_checks print(f"典型情况(有 Bloom Filter): {typical_case:.2f} 次 SSTable 查找")
# 每次 SSTable 查找需要: # 1. 读取索引块(通常在缓存中) # 2. 读取数据块(可能需要磁盘 I/O) # 所以典型情况约 1–2 次磁盘 I/O
lsm_read_amplification()# 最坏情况(无 Bloom Filter): 10 次 SSTable 查找# 典型情况(有 Bloom Filter): 4.06 次 SSTable 查找四、Bloom Filter 优化
4.1 分区布隆过滤器
RocksDB 使用分区布隆过滤器(Partitioned Bloom Filter),每个 SSTable 有独立的 Bloom Filter:
graph TB
subgraph 分区Bloom["分区布隆过滤器"]
SST1["SSTable 1"] --> BF1["Bloom Filter 1<br/>100KB"]
SST2["SSTable 2"] --> BF2["Bloom Filter 2<br/>100KB"]
SST3["SSTable 3"] --> BF3["Bloom Filter 3<br/>100KB"]
end
subgraph 读取流程
KEY["查找 Key=X"] --> BF1_CHECK["检查 BF1"]
BF1_CHECK -->|"可能存在"| READ1["读取 SST1"]
BF1_CHECK -->|"不存在"| BF2_CHECK["检查 BF2"]
BF2_CHECK -->|"可能存在"| READ2["读取 SST2"]
BF2_CHECK -->|"不存在"| BF3_CHECK["检查 BF3"]
BF3_CHECK -->|"不存在"| NOT_FOUND["Key 不存在"]
end
style BF1 fill:#c8e6c9,stroke:#2e7d32
style BF2 fill:#fff9c4,stroke:#f9a825
style BF3 fill:#e3f2fd,stroke:#1565c0
4.2 Bloom Filter 配置
# RocksDB Bloom Filter 配置bloom_configs = { "读密集": { "bits_per_key": 16, # 假阳性率 ~0.05% "block_based": True, # 分区 Bloom "cache_bloom": True, # 缓存 Bloom 到 Block Cache }, "均衡": { "bits_per_key": 10, # 假阳性率 ~0.82% "block_based": True, "cache_bloom": True, }, "写密集": { "bits_per_key": 6, # 假阳性率 ~5% "block_based": False, # 全局 Bloom "cache_bloom": False, },}
for name, config in bloom_configs.items(): print(f"{name:8s}: bits/key={config['bits_per_key']}, " f"block_based={config['block_based']}")4.3 Bloom Filter 假阳性率分析
Bloom Filter 的假阳性率由位数组大小、元素数量和哈希函数个数共同决定。理论公式为:
其中 为位数组大小, 为已插入元素数量, 为哈希函数个数。最优哈希函数个数 。
| bits/key | 最优 k | 假阳性率 | 每 100 万 key 内存 | 适用场景 |
|---|---|---|---|---|
| 6 | 4 | ~5.6% | ~0.72 MB | 写密集、内存紧张 |
| 8 | 6 | ~2.3% | ~0.96 MB | 均衡场景 |
| 10 | 7 | ~0.82% | ~1.20 MB | RocksDB 默认 |
| 16 | 11 | ~0.05% | ~1.92 MB | 读密集、低延迟 |
| 20 | 14 | ~0.01% | ~2.39 MB | 极低假阳性需求 |
Note
假阳性率每降低一个数量级,内存开销约翻倍。RocksDB 默认 10 bits/key 在大多数场景下已足够——假阳性率 0.82% 意味着每 1000 次查找仅约 8 次误判,而节省的无效 I/O 远超这 8 次额外查找的开销。
五、Block Cache
5.1 Block Cache 架构
graph TB
subgraph BlockCache["Block Cache 架构"]
REQ["读取请求"] --> LRU["LRU Cache<br/>Sharded 分片"]
LRU -->|"命中"| RETURN["返回数据"]
LRU -->|"未命中"| DISK["磁盘读取"]
DISK --> INSERT["插入 Cache"]
INSERT --> RETURN
end
subgraph Sharding["Sharded LRU"]
S0["Shard 0<br/>Key hash % N = 0"]
S1["Shard 1<br/>Key hash % N = 1"]
S2["Shard 2<br/>Key hash % N = 2"]
SN["Shard N-1"]
end
style LRU fill:#c8e6c9,stroke:#2e7d32
style Sharding fill:#e3f2fd,stroke:#1565c0
5.2 Block Cache vs Buffer Pool
| 维度 | Block Cache(RocksDB) | Buffer Pool(InnoDB) |
|---|---|---|
| 缓存粒度 | Block(4KB) | Page(16KB) |
| 替换策略 | LRU | LRU 变体(改进型) |
| 并发控制 | Sharded + 自旋锁 | 自适应互斥锁 |
| 预读 | 可配置 | 自适应预读 |
| 缓存内容 | 数据块 + 索引块 + Bloom | 数据页 + 索引页 + 自适应哈希 |
5.3 Block Cache 调优
# Block Cache 大小建议def recommend_block_cache(total_memory_gb, workload_type): """推荐 Block Cache 大小""" if workload_type == "read_intensive": cache_ratio = 0.5 # 50% 内存给 Cache elif workload_type == "balanced": cache_ratio = 0.3 # 30% else: # write_intensive cache_ratio = 0.15 # 15%
cache_size = total_memory_gb * cache_ratio print(f"总内存: {total_memory_gb} GB") print(f"工作负载: {workload_type}") print(f"推荐 Block Cache: {cache_size:.1f} GB ({cache_ratio*100:.0f}%)") return cache_size
recommend_block_cache(64, "read_intensive")# 总内存: 64 GB# 工作负载: read_intensive# 推荐 Block Cache: 32.0 GB (50%)六、预取策略
6.1 预取类型
| 预取类型 | 触发条件 | 预取量 | 适用场景 |
|---|---|---|---|
| 顺序预取 | 检测到顺序访问模式 | 128KB–1MB | 全表扫描 |
| 随机预取 | 随机访问时预读相邻块 | 16–64KB | 索引扫描 |
| InnoDB 预读 | 自适应算法 | 可变 | 混合场景 |
| RocksDB 预取 | BlockBasedTable::Prefetch | 可配置 | 范围扫描 |
6.2 InnoDB 自适应预读
// InnoDB 自适应预读算法(简化)void buf_read_ahead(page_id_t page_id) { // 1. 检测访问模式 int recent_accesses = count_recent_accesses(page_id, WINDOW_SIZE);
if (is_sequential_access(page_id)) { // 顺序访问:预读后续页面 int prefetch_pages = min(READ_AHEAD_AREA, remaining_pages_in_extent(page_id)); for (int i = 1; i <= prefetch_pages; i++) { buf_read_page_async(page_id + i); } } else if (recent_accesses > RANDOM_READ_AHEAD_THRESHOLD) { // 随机访问但频率高:预读同一 Extent 的页面 for (int i = 0; i < PAGES_PER_EXTENT; i++) { page_id_t pid = extent_first_page(page_id) + i; if (!buf_pool_contains(pid)) { buf_read_page_async(pid); } } }}Note
预取是双刃剑:正确的预取可以减少延迟,但错误的预取会浪费 I/O 带宽和缓存空间。InnoDB 的自适应预读在 OLTP 场景中经常被关闭(innodb_read_ahead_threshold=0),因为随机访问模式不适合预读。
七、读路径性能优化
7.1 读取优化清单
| 优化 | 层次 | 效果 | 代价 |
|---|---|---|---|
| Bloom Filter | SSTable | 减少 90%+ 无效 I/O | 额外内存 |
| Block Cache | 缓存 | 热点数据零延迟 | 内存占用 |
| 索引缓存 | 索引 | 避免索引块 I/O | 内存占用 |
| 预取 | I/O | 减少等待时间 | 可能浪费带宽 |
| 覆盖索引 | 查询 | 避免回表 | 额外索引空间 |
| 列裁剪 | 查询 | 减少读取数据量 | — |
7.2 InnoDB 读取调优
-- InnoDB 读取相关配置-- Buffer Pool 大小(最重要的参数)SET GLOBAL innodb_buffer_pool_size = 42949672960; -- 40GB
-- 预读配置SET GLOBAL innodb_read_ahead_threshold = 56; -- 默认值-- 设为 0 可关闭预读(OLTP 场景可能有益)
-- 自适应哈希索引SET GLOBAL innodb_adaptive_hash_index = 1; -- 开启
-- 随机预读SET GLOBAL innodb_random_read_ahead = 0; -- 通常关闭7.3 RocksDB 读取调优
# RocksDB 读取相关配置# Block Cache 大小block_cache_size = 34359738368 # 32GB
# Bloom Filterbloom_bits_per_key = 10 # 10 bits/key
# 预取大小readahead_size = 0 # 关闭预读(点查场景)
# 索引缓存cache_index_and_filter_blocks = true # 缓存索引和 Bloom
# Pin 索引和 Bloompin_l0_filter_and_index_blocks_in_cache = true # L0 的索引常驻内存八、实战:读路径观察
8.1 InnoDB 读路径观察
-- Buffer Pool 命中率SELECT (1 - Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests) * 100 AS buffer_pool_hit_ratioFROM ( SELECT variable_value AS Innodb_buffer_pool_reads FROM performance_schema.global_status WHERE variable_name = 'Innodb_buffer_pool_reads') r,( SELECT variable_value AS Innodb_buffer_pool_read_requests FROM performance_schema.global_status WHERE variable_name = 'Innodb_buffer_pool_read_requests') rr;
-- 目标:Buffer Pool 命中率 > 99%8.2 RocksDB 读路径观察
# RocksDB 统计信息# Block Cache 命中率# block.cache.hit / (block.cache.hit + block.cache.miss)
# Bloom Filter 效率# bloom.filter.useful / bloom.filter.checked
# 典型输出:# Block Cache hit ratio: 95.2%# Bloom Filter useful: 87.3%# Get micros avg: 12.58.3 读取延迟分析
# 使用 perf 分析读取延迟perf record -e 'block:block_rq_issue,block:block_rq_complete' \ -p $(pidof mysqld) sleep 10
# 使用 bcc 分析读取延迟/usr/share/bcc/tools/biolatency -p $(pidof mysqld) 1 5
# 使用 MySQL 慢查询日志# 开启慢查询日志SET GLOBAL slow_query_log = ON;SET GLOBAL long_query_time = 0.01; -- 10ms九、总结
| 主题 | 核心要点 | 关键词 |
|---|---|---|
| B+ 树读取 | 逐层遍历到叶子页面,范围扫描沿叶子链表顺序读取 | 层次遍历, 叶子链表 |
| LSM 树读取 | 从 MemTable 到 L0 到 Ln 逐层查找,需要版本合并 | 多层查找, 版本合并 |
| Bloom Filter | 用 10 bits/key 的空间消除 90%+ 的无效 I/O | 误判率, 空间效率 |
| Block Cache | 热点数据缓存在内存,命中率 > 95% 是目标 | 缓存命中, 热点数据 |
| 预取 | 顺序预读减少延迟,但 OLTP 场景通常关闭 | 顺序预读, 场景适配 |
| 调优 | 读密集场景加大 Cache + 强 Bloom,写密集场景减少 Cache 开销 | 读写取舍 |
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时
相关文章 智能推荐
1
写路径
存储 深入存储引擎的写路径——从写入请求到数据持久化的完整流程——WAL 写入、MemTable 更新、Immutable 切换、Flush 刷盘、Group Commit 与写优化,对比 InnoDB 与 RocksDB 的写路径差异。
2
系列导读
存储 存储系统深入系列导读——从磁盘物理结构到分布式存储系统,19 章覆盖存储层级、文件系统、块层、B 树与 LSM 树、读写路径、WAL 与崩溃恢复、缓冲池、压缩编码、MVCC、列式存储、RAID 与纠删码、分布式文件系统、对象存储、云原生存储,最终手写迷你 LSM 存储引擎。
3
LSM 树深入
存储 深入 LSM 树——MemTable 与 SSTable 的组织、Leveled/Tiered/FIFO Compaction 策略、写放大分析、布隆过滤器与分区索引,理解写优化存储结构的设计精髓。
4
B 树深入
存储 深入 B+ 树的页面组织、分裂与合并算法、Latch 并发控制、前缀压缩与批量加载,理解读优化存储结构的设计精髓。
5
存储引擎对比
存储 深入存储引擎对比——InnoDB vs RocksDB vs TiKV、RUM 猜想、读写放大分析、不同引擎的设计取舍,理解如何选择存储引擎。






