mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1205 字
4 分钟
读路径
2025-10-05

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 读路径对比#

步骤InnoDBRocksDB
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 的假阳性率由位数组大小、元素数量和哈希函数个数共同决定。理论公式为:

p(1ekn/m)kp \approx \left(1 - e^{-kn/m}\right)^k

其中 mm 为位数组大小,nn 为已插入元素数量,kk 为哈希函数个数。最优哈希函数个数 k=(m/n)ln2k = (m/n) \ln 2

bits/key最优 k假阳性率每 100 万 key 内存适用场景
64~5.6%~0.72 MB写密集、内存紧张
86~2.3%~0.96 MB均衡场景
107~0.82%~1.20 MBRocksDB 默认
1611~0.05%~1.92 MB读密集、低延迟
2014~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)
替换策略LRULRU 变体(改进型)
并发控制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 FilterSSTable减少 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 Filter
bloom_bits_per_key = 10 # 10 bits/key
# 预取大小
readahead_size = 0 # 关闭预读(点查场景)
# 索引缓存
cache_index_and_filter_blocks = true # 缓存索引和 Bloom
# Pin 索引和 Bloom
pin_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_ratio
FROM (
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.5

8.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 开销读写取舍

支持与分享

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

读路径
https://blog.souloss.com/posts/storage/storage-read-path/
作者
Souloss
发布于
2025-10-05
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时