LSM Tree vs B+ Tree:数据库存储引擎核心原理与性能对比实战

深入解析 LSM Tree 和 B+ Tree 两种核心数据结构的工作原理、读写性能差异,以及如何根据业务场景选择合适的数据库存储引擎。附 Python 代码实现。

数据库 2026-05-31 12 分钟

每次你执行一条 SELECT * FROM users WHERE id = 123 时,背后都有一个数据结构在默默工作——它决定了这条查询是 0.1ms 还是 100ms 返回。MySQL InnoDB 选择了 B+ Tree,而 RocksDB、LevelDB、Cassandra 却选择了 LSM Tree(Log-Structured Merge Tree)。这两种数据结构的设计哲学截然相反:一个优化读取,一个优化写入。理解它们的原理,是选对数据库、写好 SQL、做好性能调优的基础。

🌲 一、B+ Tree:关系型数据库的读取利器

B+ Tree 是几乎所有关系型数据库(MySQL InnoDB、PostgreSQL、Oracle、SQL Server)的默认索引结构。它的核心设计目标是:最小化磁盘 I/O 次数

1.1 B+ Tree 的结构与特性

B+ Tree 是一棵多路平衡搜索树,每个节点可以有多个子节点(通常几百个)。它的关键特性:

  • 所有数据都存在叶子节点,内部节点只存键值和指针
  • 叶子节点通过链表串联,支持高效的范围查询
  • 树高极低:一棵存储 1 亿条记录的 B+ Tree,树高通常只有 3-4 层
  • 每次查询只需 3-4 次磁盘 I/O

📌 记住: B+ Tree 的树高 ≈ log_m(N),其中 m 是每个节点的子节点数(通常几百),N 是记录数。即使 N 达到十亿级别,树高也仅为 4-5 层。

以 InnoDB 默认的 16KB 页大小为例,假设每个指针 8 字节、每个键 8 字节:

参数
页大小 16KB
每个指针+键 16 字节
每个内部节点可容纳 ~1000 个子节点
2 层可索引记录数 ~1000 × 1000 = 100 万
3 层可索引记录数 ~10 亿

这意味着:10 亿条记录,只需 3 次磁盘 I/O 就能定位到目标数据

1.2 代码实现:一个简化版 B+ Tree

以下是一个支持插入和查找的简化版 B+ Tree 实现(阶数 = 4):

# B+ Tree 简化实现(阶数=4,支持插入和查找)
class BPlusTreeNode:
    def __init__(self, is_leaf=False):
        self.keys = []
        self.children = []
        self.is_leaf = is_leaf
        self.next = None  # 叶子节点链表指针

class BPlusTree:
    def __init__(self, order=4):
        self.root = BPlusTreeNode(is_leaf=True)
        self.order = order

    def search(self, key):
        """查找操作 - 时间复杂度 O(log_m N)"""
        node = self.root
        while not node.is_leaf:
            i = 0
            while i < len(node.keys) and key >= node.keys[i]:
                i += 1
            node = node.children[i]
        # 在叶子节点中查找
        for i, k in enumerate(node.keys):
            if k == key:
                return True
        return False

    def insert(self, key):
        """插入操作 - 自底向上分裂"""
        root = self.root
        if len(root.keys) == self.order - 1:
            new_root = BPlusTreeNode()
            new_root.children.append(self.root)
            self._split_child(new_root, 0)
            self.root = new_root
        self._insert_non_full(self.root, key)

    def _insert_non_full(self, node, key):
        if node.is_leaf:
            # 在叶子节点中找到插入位置
            i = 0
            while i < len(node.keys) and key > node.keys[i]:
                i += 1
            node.keys.insert(i, key)
        else:
            # 找到合适的子节点
            i = len(node.keys) - 1
            while i >= 0 and key < node.keys[i]:
                i -= 1
            i += 1
            if len(node.children[i].keys) == self.order - 1:
                self._split_child(node, i)
                if key > node.keys[i]:
                    i += 1
            self._insert_non_full(node.children[i], key)

    def _split_child(self, parent, index):
        """节点分裂 - B+ Tree 的核心操作"""
        node = parent.children[index]
        mid = len(node.keys) // 2
        new_node = BPlusTreeNode(is_leaf=node.is_leaf)
        # 将右半部分移到新节点
        new_node.keys = node.keys[mid:]
        node.keys = node.keys[:mid]
        if not node.is_leaf:
            new_node.children = node.children[mid + 1:]
            node.children = node.children[:mid + 1]
        else:
            new_node.next = node.next
            node.next = new_node
        parent.keys.insert(index, new_node.keys[0])
        parent.children.insert(index + 1, new_node)

# 使用示例
tree = BPlusTree(order=4)
for key in [10, 20, 5, 6, 12, 30, 7, 17]:
    tree.insert(key)
print(tree.search(12))  # True
print(tree.search(99))  # False

1.3 为什么 MySQL InnoDB 选择 B+ Tree

关系型数据库的典型工作负载是读多写少(读写比通常 10:1 甚至 100:1)。B+ Tree 的优势恰好匹配:

  • 点查(Point Query):3-4 次 I/O,极快
  • 范围查询(Range Query):叶子节点链表,顺序扫描
  • 排序查询:叶子节点天然有序
  • 写入性能:每次插入可能触发页分裂,随机 I/O 较多

⚠️ 警告: B+ Tree 的写入瓶颈在于随机 I/O。每次插入需要先找到叶子节点位置(随机读),再写入(随机写)。在 HDD 上,随机写性能比顺序写低 100 倍以上。

📝 二、LSM Tree:写入性能的极致追求

LSM Tree 的设计哲学完全相反:把随机写转换为顺序写。它是 LevelDB、RocksDB、Cassandra、HBase、TiKV 等存储引擎的核心。

2.1 LSM Tree 的写入路径

LSM Tree 的写入过程分为三步:

  1. 写入 WAL(Write-Ahead Log):保证数据持久性
  2. 写入 MemTable(内存表):一个有序的内存数据结构(通常是跳表)
  3. MemTable 满后刷盘为 SSTable:顺序写入磁盘
写入路径:Client → WAL → MemTable(内存) → SSTable(磁盘)
读取路径:Client → MemTable → L0 SSTable → L1 SSTable → ... → Ln SSTable

关键结论: LSM Tree 的写入全程都是顺序 I/O——WAL 是追加写,SSTable 是一次性顺序写入。这就是它写入性能碾压 B+ Tree 的根本原因。

2.2 代码实现:一个简化版 LSM Tree

# LSM Tree 简化实现(支持写入、查找、flush)
import bisect
import json
import os

class MemTable:
    """内存表 - 使用有序列表模拟"""
    def __init__(self, max_size=1000):
        self.data = {}  # 使用 dict 保持插入顺序
        self.max_size = max_size

    def put(self, key, value):
        self.data[key] = value

    def get(self, key):
        return self.data.get(key, None)

    def is_full(self):
        return len(self.data) >= self.max_size

    def flush(self):
        """将 MemTable 数据刷盘为 SSTable"""
        sorted_items = sorted(self.data.items())
        self.data = {}
        return sorted_items

class SSTable:
    """排序字符串表 - 不可变的有序文件"""
    def __init__(self, filepath, level=0):
        self.filepath = filepath
        self.level = level
        self.index = []  # 稀疏索引
        self.data = []
        self._load()

    def _load(self):
        if os.path.exists(self.filepath):
            with open(self.filepath, 'r') as f:
                self.data = json.load(f)
            # 构建稀疏索引(每 10 条记录一个索引点)
            self.index = [(item[0], i) for i, item in enumerate(self.data[::10])]

    def save(self, sorted_items):
        self.data = sorted_items
        with open(self.filepath, 'w') as f:
            json.dump(self.data, f)
        self.index = [(item[0], i) for i, item in enumerate(self.data[::10])]

    def get(self, key):
        """使用稀疏索引 + 二分查找"""
        # 1. 在稀疏索引中定位
        pos = 0
        for i, (k, _) in enumerate(self.index):
            if key >= k:
                pos = i * 10
            else:
                break
        # 2. 在数据块中二分查找
        end = min(pos + 10, len(self.data))
        for i in range(pos, end):
            if self.data[i][0] == key:
                return self.data[i][1]
        return None

class SimpleLSMTree:
    """简化版 LSM Tree"""
    def __init__(self, data_dir='/tmp/lsm_data', memtable_size=5):
        self.data_dir = data_dir
        os.makedirs(data_dir, exist_ok=True)
        self.wal = []  # 预写日志
        self.memtable = MemTable(max_size=memtable_size)
        self.sstables = []  # SSTable 列表(从新到旧)
        self.sstable_count = 0

    def put(self, key, value):
        """写入操作 - 全程顺序 I/O"""
        # 1. 写 WAL
        self.wal.append((key, value))
        # 2. 写 MemTable
        self.memtable.put(key, value)
        # 3. MemTable 满了就 flush
        if self.memtable.is_full():
            self._flush()

    def get(self, key):
        """读取操作 - 从新到旧逐层查找"""
        # 1. 查 MemTable
        result = self.memtable.get(key)
        if result is not None:
            return result
        # 2. 查 SSTable(从新到旧)
        for sst in self.sstables:
            result = sst.get(key)
            if result is not None:
                return result
        return None

    def _flush(self):
        """将 MemTable 刷盘为 SSTable"""
        sorted_items = self.memtable.flush()
        filepath = os.path.join(self.data_dir, f'sst_{self.sstable_count}.json')
        sst = SSTable(filepath)
        sst.save(sorted_items)
        self.sstables.insert(0, sst)  # 新的放前面
        self.sstable_count += 1
        self.wal = []  # 清空 WAL

# 使用示例
lsm = SimpleLSMTree(data_dir='/tmp/lsm_demo', memtable_size=3)
for i in range(10):
    lsm.put(f'key_{i}', f'value_{i}')
print(lsm.get('key_5'))   # value_5
print(lsm.get('key_99'))  # None

2.3 Compaction 策略与写放大问题

LSM Tree 的核心挑战是 Compaction(合并压缩):随着 SSTable 数量增多,读取性能会下降(需要查多个 SSTable)。Compaction 将多个 SSTable 合并为一个,消除重复 key、删除已标记删除的数据。

常见的两种 Compaction 策略:

策略 描述 写放大 读放大 代表数据库
Size-Tiered 同大小的 SSTable 合并 低(10-30x) Cassandra, HBase
Leveled 分层组织,每层大小递增 高(10-30x) LevelDB, RocksDB

⚠️ 警告: 写放大(Write Amplification)是 LSM Tree 的最大痛点。一次 1KB 的用户写入,实际可能触发 10-30KB 的磁盘写入(因为 Compaction 过程中的反复读写)。在 SSD 上,这会显著缩短磁盘寿命。

2.4 LSM Tree 的读取优化:Bloom Filter 与缓存

LSM Tree 的读取路径比 B+ Tree 长得多——需要依次查找 MemTable、L0 SSTable、L1 SSTable……直到找到数据。如果每一层都做磁盘查找,读取性能会非常差。实际工程中,有三种关键优化手段:

  • Bloom Filter:每个 SSTable 附带一个 Bloom Filter,可以快速判断"这个 key 一定不在这个 SSTable 中",避免 99% 的无效磁盘 I/O
  • Block Cache:将热点数据块缓存在内存中,减少磁盘读取次数
  • 稀疏索引(Sparse Index):SSTable 内部使用稀疏索引定位数据块,再在块内做二分查找

这三种优化组合使用后,LSM Tree 的点查性能可以接近 B+ Tree 的水平,尤其是在读取热点数据时。

⚖️ 三、性能对比与选型实战

3.1 读写性能全面对比

指标 B+ Tree (InnoDB) LSM Tree (RocksDB) 推荐
单条写入 随机 I/O,较慢 顺序 I/O,极快 ✅ LSM Tree
批量写入 中等 顺序 I/O,极快 ✅ LSM Tree
点查(Point Query) 3-4 次 I/O,极快 需查多层 SSTable,较慢 ✅ B+ Tree
范围查询 叶子链表,快 需合并多层,中等 ✅ B+ Tree
空间效率 中等(页内碎片) 高(压缩友好) ✅ LSM Tree
写放大 低(1-2x) 高(10-30x) ✅ B+ Tree
读放大 低(1x) 中等(需查多层) ✅ B+ Tree
并发写入 行锁,有竞争 MemTable 无锁,高吞吐 ✅ LSM Tree

3.2 真实场景选型分析

场景一:电商订单系统(MySQL InnoDB)

  • 读写比 50:1,典型的读多写少
  • 需要复杂的 JOIN、排序、分页
  • 事务要求严格(ACID)
  • 数据量 5000 万 - 5 亿条

→ ✅ 选择 B+ Tree(MySQL InnoDB / PostgreSQL)

场景二:用户行为日志(RocksDB)

  • 写入量巨大(每秒 10 万+条)
  • 查询模式简单(按 key 查找)
  • 数据量可达 TB 级别
  • 可容忍读取稍慢

→ ✅ 选择 LSM Tree(RocksDB / LevelDB)

场景三:实时消息系统(TiKV)

  • 写入频繁,读取也频繁
  • 需要分布式支持
  • 要求低延迟

→ ✅ 选择 LSM Tree(TiKV 基于 RocksDB)+ 缓存层

3.3 选型决策树

# 数据库存储引擎选型决策脚本
def choose_storage_engine(
    read_write_ratio,      # 读写比(读:写)
    data_size_gb,          # 数据量(GB)
    query_complexity,      # 查询复杂度:simple / complex
    write_throughput_qps,  # 写入 QPS
    need_transaction,      # 是否需要事务
    storage_type           # 存储类型:ssd / hdd
):
    """根据业务特征选择存储引擎"""
    score_bplus = 0
    score_lsm = 0

    # 读写比分析
    if read_write_ratio > 10:
        score_bplus += 3  # 读多写少 → B+ Tree
    elif read_write_ratio < 1:
        score_lsm += 3    # 写多读少 → LSM Tree
    else:
        score_bplus += 1
        score_lsm += 1

    # 查询复杂度
    if query_complexity == 'complex':
        score_bplus += 3  # 复杂查询 → B+ Tree
    else:
        score_lsm += 1

    # 写入吞吐量
    if write_throughput_qps > 10000:
        score_lsm += 3    # 高写入 → LSM Tree
    else:
        score_bplus += 1

    # 事务需求
    if need_transaction:
        score_bplus += 2  # 事务 → B+ Tree

    # 存储类型
    if storage_type == 'hdd':
        score_lsm += 2    # HDD 上顺序写优势更大

    if score_bplus > score_lsm:
        return 'B+ Tree (MySQL InnoDB / PostgreSQL)'
    else:
        return 'LSM Tree (RocksDB / LevelDB / TiKV)'

# 测试
result = choose_storage_engine(
    read_write_ratio=50, data_size_gb=10,
    query_complexity='complex', write_throughput_qps=1000,
    need_transaction=True, storage_type='ssd'
)
print(f'推荐引擎: {result}')
# 输出: 推荐引擎: B+ Tree (MySQL InnoDB / PostgreSQL)

💡 提示: 很多现代数据库同时使用两种引擎。例如 TiDB 使用 TiKV(LSM Tree)作为存储引擎,但对外提供 SQL 接口和事务支持。选择存储引擎不是非此即彼,而是理解各自的 trade-off。

🔧 四、进阶:混合引擎与新趋势

4.1 RocksDB 的 Bloom Filter 优化

LSM Tree 最大的弱点是读取性能——需要逐层查找 SSTable。RocksDB 通过 Bloom Filter 将无效查找从 O(n) 降到接近 O(1):

# Bloom Filter 原理演示(LSM Tree 读取优化)
import hashlib

class SimpleBloomFilter:
    """简化版 Bloom Filter - 用于 LSM Tree 读取优化"""
    def __init__(self, size=1024):
        self.size = size
        self.bits = [0] * size

    def _hashes(self, key, num_hashes=3):
        """生成多个哈希值"""
        results = []
        for i in range(num_hashes):
            h = hashlib.md5(f'{key}_{i}'.encode()).hexdigest()
            results.append(int(h, 16) % self.size)
        return results

    def add(self, key):
        for pos in self._hashes(key):
            self.bits[pos] = 1

    def might_contain(self, key):
        """可能存在 → 可能存在(有假阳性)
           不存在 → 一定不存在(无假阴性)"""
        return all(self.bits[pos] for pos in self._hashes(key))

# 模拟 LSM Tree 读取优化
bloom = SimpleBloomFilter()
for i in range(1000):
    bloom.add(f'key_{i}')

# 不存在的 key,Bloom Filter 直接返回 False,避免磁盘查找
print(bloom.might_contain('key_500'))     # True → 需要查磁盘
print(bloom.might_contain('key_9999'))    # False → 跳过,无需查磁盘

📌 记住: 在 RocksDB 中,每个 SSTable 都有独立的 Bloom Filter。查找一个不存在的 key 时,Bloom Filter 可以避免 99% 以上的无效磁盘 I/O,这是 LSM Tree 在实际生产中读取性能不差的关键原因。

4.2 PebblesDB 和 WiscKey:新一代 LSM 优化

学术界和工业界一直在优化 LSM Tree:

  • PebblesDB(SOSP 2017):引入 Fragmented LSM Tree,减少 Compaction 时的写放大
  • WiscKey(FAST 2016):将 key 和 value 分离存储,减少 Compaction 的数据量
  • RocksDB BlobDB:WiscKey 思想的工程实现,大 value 存在 blob 文件中

这些优化让 LSM Tree 的写放大从 10-30x 降低到 3-5x,进一步缩小了与 B+ Tree 在读取性能上的差距。

⚠️ 五、常见误区与避坑指南

在实际工程中,很多开发者对 B+ Tree 和 LSM Tree 存在误解,以下是几个典型的坑:

误区一:LSM Tree 写入一定比 B+ Tree 快

LSM Tree 的写入优势在于顺序 I/O,但在以下场景中,B+ Tree 反而更快:

  • 单条随机写入 + 立即读取:LSM Tree 需要合并多层才能读到最新数据
  • 数据量小、全部在内存中:此时磁盘 I/O 不是瓶颈,B+ Tree 的简单结构反而更高效
  • 需要强事务保证:LSM Tree 的事务实现更复杂,开销更大

💡 提示: 写入性能不能只看吞吐量,还要看延迟分布。LSM Tree 的 P99 延迟可能因为 Compaction 突然飙升到几百毫秒,而 B+ Tree 的延迟通常更稳定。

误区二:B+ Tree 不适合大数据量

B+ Tree 的树高随数据量增长极慢(对数级别),即使数据量达到十亿级别,树高也只有 4-5 层。B+ Tree 的真正瓶颈不是数据量,而是:

  • 写入并发:高并发写入时,行锁竞争激烈
  • 随机写入放大:每次写入都需要找到正确的叶子节点位置
  • 页分裂开销:节点满时需要分裂,涉及多次磁盘写入

误区三:Compaction 可以忽略

很多开发者在使用 RocksDB 时忽略了 Compaction 的影响:

  • ⚠️ Compaction 会占用大量磁盘 I/O 和 CPU,在高峰期可能影响正常请求的延迟
  • ⚠️ Compaction 策略选择不当会导致空间放大:Size-Tiered 策略的空间放大可达 2x
  • ⚠️ 建议在业务低峰期触发 Compaction,或者限制 Compaction 的 I/O 带宽

📊 六、总结与建议

场景 推荐引擎 代表产品
OLTP 事务系统 B+ Tree MySQL, PostgreSQL
日志/时序数据写入 LSM Tree RocksDB, InfluxDB
缓存/会话存储 LSM Tree LevelDB, RocksDB
搜索引擎 LSM Tree Elasticsearch (Lucene)
分布式 KV 存储 LSM Tree TiKV, FoundationDB
数据仓库/OLAP 列式存储 ClickHouse, DuckDB

关键结论: 没有"更好"的存储引擎,只有"更合适"的。B+ Tree 优化读取,LSM Tree 优化写入。理解这个 trade-off,你就能在面对任何数据库选型时做出正确的判断。当你的应用写入量大、查询模式简单时,考虑 LSM Tree;当你的应用读取频繁、需要复杂查询和事务时,选择 B+ Tree。

最后,推荐几个深入学习的资源:

  • 🔧 RocksDB 官方文档https://rocksdb.org — 工业级 LSM Tree 实现
  • 🔧 InnoDB 架构详解:MySQL 官方文档 — B+ Tree 的工程巅峰
  • 🔧 CMU 15-445 数据库课程:Andy Pavlo 的课程,深入讲解存储引擎原理
  • 🔧 《Designing Data-Intensive Applications》:Martin Kleppmann 的经典著作,第 3 章详细对比了 B+ Tree 和 LSM Tree

📚 相关文章