跳转至

DDIA Ch.3 存储与检索 — 复习专题

梳理自《Designing Data-Intensive Applications》第 2 版 Ch.3 存储与检索(存储引擎)
定位:历史脉络 → 挑战 → 解法 + 哈希 / B+Tree / LSM + Java MVP;从 DDIA 地图 拆出,避免单文件过长。
配对:Ch.10 批处理 · Ch.11 流处理

← 返回 DDIA 框架 | 专题索引 | ↑ 总脑图 查询路径 · L4 存储

组件深潜ClickHouse OLAP · Flink 状态


6h 第 2 段复习清单(45min)

对应 ddia-framework § 第 2 段(1:30–2:15)。
节奏:先 全景串讲 15min,再按需下潜哈希 / B+Tree / LSM 各一块。

时段 做什么
0–10 min § 全景串讲 + 30 秒版(LSM)
10–20 min B+Tree 图解精讲哈希索引 二选一
20–35 min 另一族 + 选型对照
35–45 min 写下产出;MVP 代码 改天

只读这些块(其余扫标题):

  1. OLTP vs OLAP — 和数仓直接相关
  2. 列存储 — 对照 ClickHouse
  3. LSM / B+Tree — 各读「思想」;B+Tree 先看 历史动机
  4. LSM:15–20 分钟(30 秒版 + 三个放大 + 选型表)
  5. 跳过:全文搜索、图数据库细节

写下来

ClickHouse 在 DDIA 谱系里 ≈ ___________(列存 / OLTP 行存 / …)
  • 完成 Ch.3 跳读

Ch.3 存储与检索

场景 存储思路 本专题组件
OLAP 分析、报表 列存、MergeTree、分区 ClickHouse
批处理中间结果 文件 / 对象存储 / 表 Airflow 落盘、Hive 路径
调度元数据 关系库 Airflow Postgres
实时状态 内存 / RocksDB 状态后端 Flink 状态

索引与查询原理(提纲)

日志结构(LSM)  →  写多读少、顺序写
B 树 / B+ 树     →  读多写少、范围查询
列存 + 稀疏索引  →  分析型聚合(ClickHouse)
倒排索引         →  全文 / 日志检索

数据库只做两件事: 1. 向他插入数据时,它把数据存起来 2. 向他查询数据时,他把数据返回给你

从数据库的视角看,该如何存储数据呢,收到查询请求时,怎样重新找到数据呢

为啥应用开发需要了解数据库内部的存储与检索? 因为: - 你要选一款数据库、引擎作为存储组件 - 为了实现可扩展性的期望,你要知道如何评估负载、针对负载如何调优

数据库使用场景两大类: - 事务型 OLTP - 分析型 OLAP

存储引擎两大家族: - 日志结构 - 面向页(B-tree)


存储引擎全景:历史脉络 → 挑战 → 解法(串讲)

用法:先读这一节建立一条时间线,再按需下潜 哈希B+TreeLSM 细节与 MVP。
疑问对照 框架 QA(Ch.3 相关条目 Q1–Q5)。

一句话主线:磁盘又慢又贵 → 人类不断换 「数据怎么摆、怎么找」,在 读快 / 写快 / 范围查 / 省磁盘 之间做取舍;没有银弹,只有 负载匹配


0. 起点:数据库只做两件事
INSERT / PUT  →  存起来
SELECT / GET  →  找出来

最朴素实现 = 只追加的日志(log)(见 db_set / db_get):

表现
O(1) append
O(n) 扫全日志,取同一 key 最后一条
瓶颈 数据一大,读崩了

第一个挑战:如何在磁盘上 更快找到 一条 key?


1. 哈希索引:用「路标」换读速度(点查为王)
年代/场景 早期 KV、今天 Redis / 缓存 / 内存索引层
挑战 日志读太慢;要 按 key 一跳到位
解法 内存 HashMap<key, 文件偏移> + 磁盘 只追加 value logTiny Redis MVP
得到 点查 O(1) 平均;写仍是 append + 更新索引
新代价 内存吃索引;重启要 扫 log 重建无 key 顺序 → 做不了 BETWEEN / ORDER BY key
典型 Redis(内存)、Riak Bitcask(盘+log+哈希)

一旦业务要 范围、排序、多条件分析,光有哈希不够 → 需要 有序结构


2. B+Tree:通用 OLTP 的答案(~1972 起)
年代/场景 关系库黄金年代至今:MySQL InnoDB、PostgreSQL
挑战 ① 数据量大,不能全表扫;② 要 点查 + 范围扫;③ 要 原地改一行(余额、库存);④ 磁盘 按块读写,不能随便改 1 字节
解法 B+Tree:非叶子当路标,叶子存行 + 叶子链表;一页 = 一个节点页怎么理解);树高 O(log_m n),常只需 3~4 次 I/O复杂度
读写模型 写 = 读路径同步做完:找叶 → 页内改/插 → 满则 分裂B+Tree MVP
得到 点查/插入 O(log n) 页;范围 O(log n + k);事务、锁成熟
新代价 更新 = 随机写页(HDD 寻道痛;SSD 仍怕碎写);极高追加写吞吐 不如后面 LSM

「有序」在此:按 主键/索引键 排序,不是默认事件时间(§ 有序指什么)。


3. Log-structured / LSM:写吞吐时代的答案(~1992 思想,~1996 LSM)
年代/场景 日志、埋点、消息、分布式 KV;Flink RocksDB 状态
挑战 B+Tree 原地随机写页 扛不住 每秒海量追加;HDD 随机写极慢,SSD 仍偏好顺序块写
解法 写路径拆开:① 前台 只追加(memtable → 顺序 flush 成 SSTable);② 后台 compaction 批量合并(30 秒版
读写模型 同 key 多次写 = 多版本;读要 memtable + 多层 SSTable 合并取最新
得到 写入 顺序 I/O、高吞吐、吸收写突发
新代价 读放大、写放大、空间放大;compaction 堵住 → 延迟抖、磁盘涨

「有序」在此:按 sort key 排,为了 归并 + 范围扫 + 稀疏索引;树/文件 本身就是索引(见 QA Q3)。

思想对照(和 B+Tree 别混)

B+Tree LSM
一次 UPDATE 同步改叶子页 追加新版本,旧的后台 merge
读最新值 叶子即当前 可能查多层
索引 树 = 索引 有序文件 + 稀疏块

4. 并行演化:同一时代还有这些「解法分支」
分支 核心挑战 解法 本专题触点
全内存哈希(Redis) 极低延迟、数据能放进内存 哈希 + 可选持久化;工程难点在缓存治理 Redis 生产
列存 + 分区 merge(ClickHouse) 分析扫描 GB/TB、聚合 列存 + 不可变 part + 后台 merge(有 LSM 味道,实现不同 ClickHouse · 例子 4
倒排索引 全文 / 日志检索 词项 → 文档列表 ES / OpenSearch(提纲未展开)

5. 时间线一图(复习用)
timeline
    title 存储引擎演化(简化)
    section 朴素
        追加日志 : 写快读慢 O(n)
    section 点查
        哈希索引 : HashMap 路标 O(1) 无范围
    section OLTP
        B+Tree : 页式树 O(log n) 原地更新 范围查
    section 写吞吐
        LSM/SSTable : 只追加 + compaction 读/写/空间放大
    section 分析/缓存
        列存 MergeTree : 列存 + part merge
        Redis : 内存哈希 缓存语义

6. 选型:用「挑战」反推「解法」(不是背名字)

先问负载,再选引擎(总表):

你的挑战 更倾向
key → value 点查,数据能进内存 Redis / 哈希
点查 + value 在盘、key 量可控 Bitcask 类
读多、范围扫、行级 UPDATE、强事务 B+Tree(InnoDB / PG)
写多读少、海量追加、可接受后台 merge LSM(RocksDB / Cassandra / HBase)
大范围聚合扫描、报表 列存(ClickHouse)

和你栈的映射

组件 存储思路
MySQL / Airflow Postgres B+Tree OLTP
Redis 内存哈希
ClickHouse 列存 + part merge
Flink 状态(RocksDB) LSM
Kafka 日志段 只追加不可变段(思想近 LSM,实现不同)

7. 三档 MVP 对照(把抽象落地)
哈希(Tiny Redis) B+Tree(SimpleBPlusTree) LSM(SimpleLsmTree)
历史位置 路标加速读 OLTP 主路径 写吞吐路径
append log 原地改叶,满则分裂 append → flush SSTable
O(1) 哈希 O(log n) 树 多层合并
范围 ✅ 叶子链 ✅(多层时更贵)
代码 § 哈希 MVP § B+Tree MVP § LSM MVP

8. 读完本节,你应该能口述
  1. 为啥从 append log 走到 哈希 → B+Tree → LSM(每次都是 上一代的瓶颈 逼出来的)。
  2. B+Tree 和 LSM 不是谁取代谁,是 随机写页 + 读简单 vs 顺序写 + 后台整理 + 读复杂
  3. 「页」「有序」「O(log n)」 各自指什么(链到正文,别混事件时间 / Hashtable)。
  4. 你们计费栈里 Postgres / Redis / ClickHouse / Flink RocksDB 各站在哪条分支上。

下潜阅读顺序(建议):本节 → 哈希索引B+TreeLSMQA


数据库核心:数据结构

db_set(){
        echo "$1,$2" >> database
}

db_get(){
        grep "^$1," database | set -e "s/^$1,//" |tail -n 1
}

日志更通用的含义:一个仅支持追加写的记录序列

时间复杂度: - 写O1 - 读On

若要提高读效率,就类似于字典等,添加额外的元数据作为路标 不过代价除了占用了额外的存储空间外,降低了写速度,因为每新增一条数据就要更新路标 以上就是应用人员需要权衡的点。

哈希索引

是的,生产里非常常见,但通常用于 精确 key 查询(point lookup),而不是范围分析。

为啥叫 hash?(复习用)

说法 含义
英文 hash 原意「切碎、搅在一起」→ 比喻把任意长的 key 搅成 固定长度的数
hash 函数 hash(key) → 整数,同一 key 每次结果相同
哈希表 / 哈希索引 用这个整数当 桶下标或路标,直接定位,不用全表扫
中文 散列 强调 key 被 分散 到不同桶里
key "user:10086"
    → hash 函数搅一下 → 例如 7283941
    → 对桶个数取模 → 落在第 17 号桶
    → 在桶里存 (key, value 或 offset)

名字听着抽象,干的事很具体:用「搅出来的数」当地址,换 O(1) 左右的点查。
Java 里 HashMapHash 就是这个意思;业务代码只写 get/put,hash 在 JDK 内部(见下文 Tiny Redis 追问)。

先给结论:

哈希索引擅长:key = ?(等值查询)
哈希索引不擅长:key BETWEEN a AND b(范围查询)、ORDER BY key(有序扫描)

MVP 伪代码(Tiny Redis:追加日志 + 内存哈希索引)

// TinyRedis.java (pseudocode)
//
// 思路:
// 1) value 只追加写入 append-only log
// 2) 内存里维护 key -> offset 的哈希索引
// 3) GET 先查哈希,再按 offset 读日志

class TinyRedis {
    private final RandomAccessFile log;
    private final Map<String, Long> index = new HashMap<>(); // key -> latestOffset

    TinyRedis(String logPath) throws IOException {
        this.log = new RandomAccessFile(logPath, "rw");
        this.log.seek(this.log.length()); // 初始定位到文件尾(append)
        rebuildIndex();                   // 启动时扫日志重建索引
    }

    String set(String key, String value) throws IOException {
        byte[] record = encode(new Record("SET", key, value));
        long offset = log.length();
        log.seek(offset);
        log.write(record);
        log.getFD().sync();               // MVP 简化:每次写后刷盘
        index.put(key, offset);           // 更新哈希索引
        return "OK";
    }

    String get(String key) throws IOException {
        Long offset = index.get(key);
        if (offset == null) return null;
        log.seek(offset);
        Record rec = decode(readOneLine(log));
        return rec.value;                 // 最新值
    }

    String delete(String key) throws IOException {
        // 逻辑删除:写 tombstone,避免随机改写历史日志
        byte[] record = encode(new Record("DEL", key, null));
        long offset = log.length();
        log.seek(offset);
        log.write(record);
        log.getFD().sync();
        index.remove(key);
        return "OK";
    }

    private void rebuildIndex() throws IOException {
        // 重启恢复:线性扫全日志,最后一次 op 覆盖前面的状态
        log.seek(0);
        while (log.getFilePointer() < log.length()) {
            long offset = log.getFilePointer();
            byte[] line = readOneLine(log);
            Record rec = decode(line);
            if ("SET".equals(rec.op)) {
                index.put(rec.key, offset);
            } else if ("DEL".equals(rec.op)) {
                index.remove(rec.key);
            }
        }
        log.seek(log.length()); // 回到文件尾,继续 append
    }

    // --- 伪函数:省略具体序列化/反序列化细节 ---
    private byte[] encode(Record r) { /* ... */ return null; }
    private Record decode(byte[] b) { /* ... */ return null; }
    private byte[] readOneLine(RandomAccessFile f) { /* ... */ return null; }

    static class Record {
        String op;
        String key;
        String value;
        Record(String op, String key, String value) {
            this.op = op;
            this.key = key;
            this.value = value;
        }
    }
}

追问:HashMap 里只看到 Map,hash 在哪?

你写得对:业务代码里只有 index.put(key, offset) / index.get(key)没有手写 hash(key)
hash 在 JDK 的 HashMap 内部,被封装掉了。

你写的:                    JDK 内部(简化):
index.get("user:10086")  →  h = key.hashCode()
                         →  bucket = h & (table.length - 1)
                         →  在 bucket 链表/树里用 equals 找 key
                         →  返回 value(这里是 Long offset)

所以:

你看到的 实际含义
HashMap 这个名字 实现方式就是 哈希表
Map 接口 对外只说「键值映射」,不关心底层是哈希还是树
put / get 底层在做 hash + 寻址,不是从头遍历所有 key

若把 hash 摊开写(教学用,不必自己实现),等价逻辑大概是:

// 显式哈希索引(示意,非生产代码)
int bucket = Math.floorMod(key.hashCode(), buckets.length);
// 在 buckets[bucket] 里找/放 (key, offset)

结论:Tiny Redis 的「哈希索引」= 你用 HashMap 当索引;hash 在类名和 JDK 实现里,不在你每一行业务代码里。

复杂度(平均):

  • SET: O(1)(追加写 + 哈希更新)
  • GET: O(1)(哈希查 offset + 一次磁盘定位)
  • DELETE: O(1)
  • 重启恢复:O(N)(N 为日志记录条数)

这个 MVP 故意没做的生产能力:

  • 崩溃一致性(校验和、WAL fsync 策略)
  • 内存控制(大 key 数下 index 膨胀)
  • compaction(清理旧版本和 tombstone)
  • 并发控制(锁 / 分片锁)
  • 主从复制与高可用

真实生产例子:

  1. Redis(最典型)
  2. 核心键空间就是哈希表(dict)
  3. GET user:123 这类点查接近 O(1)
  4. 业务场景:会话缓存、热点配置、限流计数器

  5. Riak Bitcask(DDIA 经典例子)

  6. 数据追加写入 value log(磁盘)
  7. 内存维护 key -> 文件偏移量 的哈希索引(keydir)
  8. 读时先查哈希,再按偏移直接定位值

  9. 很多 KV 系统/中间件的「内存层」

  10. 即使底层是 LSM/B+Tree,前台的热点索引、对象缓存也常用哈希表
  11. 场景:用户画像缓存、幂等键表、去重键表

Redis 生产挑战(线上为何不简单)

SET/GET 的接口很简单,但线上难点主要在「系统工程」:

挑战 具体问题 常见后果 常见治理手段
内存容量与成本 Redis 主要吃内存,数据增长快 OOM、频繁淘汰、成本飙升 TTL 分层、冷热分离、压缩、key 规范
热点 key / 倾斜 少数 key QPS 过高 单分片打爆、延迟抖动 本地缓存、多副本读、热点拆分
缓存一致性 DB 更新后缓存何时更新 脏读、旧值回潮 延迟双删、订阅变更流、幂等回填
穿透/击穿/雪崩 缓存 miss 放大到 DB 下游数据库被打挂 布隆过滤、互斥重建、随机过期
持久化与重启恢复 RDB/AOF 策略不当 数据丢失窗口大、重启慢 AOF 策略分级、混合持久化、预热
高可用切换 主从切换与脑裂处理 短暂不可用、数据回退 Sentinel/Cluster、超时参数调优
大 Key 与阻塞命令 单 key 过大,KEYS/HGETALL 全量扫 事件循环阻塞、延迟飙升 bigkey 扫描治理、SCAN 替代全量命令
过期与淘汰策略 TTL 不合理或策略不匹配 命中率差、抖动 allkeys-lru/lfu 按业务选择、TTL 随机化
网络与序列化开销 value 太大、跨可用区 RT 增加、带宽贵 value 拆分、批量 pipeline、就近部署
多租户与运维复杂度 权限、限流、监控不足 相互干扰、事故定位慢 资源隔离、慢日志、SLA/告警体系

可以把 Redis 线上问题总结为一句:

功能是 KV,挑战是「缓存系统生命周期管理」:
写入策略、淘汰策略、一致性策略、故障恢复策略。

如果你做的是计费/对账类系统,最容易踩的是这三类:

  1. 一致性错觉:以为缓存和数据库总是同步(实际常有延迟窗口)
  2. 过期风暴:同批 key 同时过期,瞬时打穿下游
  3. 热点 key:单用户/单租户超高并发把一个分片打满

你这两个追问(非常关键):

Q1:压测去重时 key 短 TTL 很常见,为什么还会「过期风暴」?

你说得对:“key 经常过期”本身不是问题
真正的问题是:大量 key 在同一时刻过期 + 这些 key 又刚好是高频访问,导致下游被瞬时击穿。

可以把它理解成两个维度:

维度 健康情况 风暴情况
过期时间分布 分散(抖动) 集中(同秒/同分钟)
miss 后代价 低(本地可重建) 高(打 DB / 外部服务)

只有同时满足「过期集中 + miss 代价高」,才会形成风暴。

压测去重典型坑(你这个场景非常常见):

10万请求在 t=0~10s 打进来
去重 key 全部设置 TTL=60s(整秒)
→ t=60s 左右这批 key 同时过期
→ 下一波请求全部 miss,瞬时打到下游

治理手段(去重场景可直接用)

  1. TTL 加随机抖动ttl = base + rand(0, jitter),避免同秒过期
  2. 分桶过期:按 hash(key)%N 把 key 分散到不同过期窗口
  3. 分层缓存:本地短缓存 + Redis,削峰
  4. miss 互斥重建:同一个 key 只让一个请求回源
  5. 容量保护:下游限流 + 熔断,防止雪崩扩散

一句话:你要避免的是 “同步过期”,不是“过期”本身。


Q2:既然有 Bitcask,为啥还会有 Redis?

因为它们的目标函数不一样,属于不同工程取舍,不是互相替代。

维度 Redis Bitcask(Riak)
核心定位 内存数据结构服务器(超低延迟) 磁盘日志型 KV 存储
延迟 通常更低(内存命中) 通常更高(依赖磁盘/OS cache)
数据模型 丰富结构:String/Hash/List/Set/ZSet/Stream 以 KV 为主,模型较单一
持久化角色 可持久化,但常当缓存/状态层 更偏持久化 KV 存储
典型用途 缓存、计数器、限流、会话、队列 持久 KV、分布式容错存储
运维关注点 内存、过期淘汰、热点、复制切换 compaction、磁盘 IO、恢复时间

更直白一点:

  • 你要的是 亚毫秒级访问 + 丰富原子操作INCRZADD、Lua、Stream)→ 更偏 Redis
  • 你要的是 更便宜的大容量持久 KV,可接受更高延迟 → 更偏 Bitcask 类方案

很多系统会两者并用:

Redis:热点与实时状态层(快)
Bitcask/LSM/KV:持久事实层(稳)

HashMap 索引的局限性(你要记这 8 点):

局限 说明 典型后果
只支持等值查 无序结构 无法高效做范围查询、排序、前缀扫描
内存开销大 指针、桶、装载因子 key 很多时内存压力显著
扩容/rehash 有成本 桶数量变化要搬迁 延迟抖动(有些实现会渐进式 rehash 缓解)
哈希冲突 多 key 落同桶 极端情况下退化,吞吐下降
依赖哈希函数质量 分布不均会倾斜 热桶、热点 key 问题
持久化恢复成本 纯内存索引重启要重建 启动慢(需扫日志重建索引)
删除与更新复杂 需 tombstone/清理策略 长期运行会产生碎片和膨胀
不适合多维条件 只能按单 key 跳转 where a=? and b>? 这类查询要靠别的索引

一句话取舍:

如果业务主要是 key -> value 点查,哈希索引非常强;
一旦需要范围、排序、复合条件分析,就要引入 B+Tree / LSM 有序结构或列存索引。

SSTables 和 LSM-Tree

为啥要了解?(不必先成为 LSM 工程师)

目的 说明
读懂 DDIA Ch.3 和 B+Tree 对照:两种主流「磁盘上怎么存」的思路
解释线上现象 写入突然变慢、磁盘涨、compaction 卡住、读放大
选型对话 别人提 RocksDB/Cassandra/HBase 时知道在说什么
串你现有栈 Flink 状态后端、部分 OLAP/日志型存储底层常见 LSM 思想

不需要:手写 LSM、背 compaction 算法证明。
需要:能画 4 步流程 + 说出适用场景和 3 个代价。


30 秒版(复习背这个)

写:先落内存 memtable(有序)→ 满了刷成磁盘 SSTable 文件(只追加、不可改)
读:可能查 memtable + 多层 SSTable,合并视图找最新值
维护:后台 compaction 把多层小文件合并成大文件,回收删除/过期数据
代价:写放大、读放大、空间放大(换写入吞吐和顺序写)

SSTable 是什么:Sorted String Table,磁盘上一段按排序键(sort key)有序、不可变的数据文件。
LSM-Tree 是什么:用 多层 SSTable + memtable + compaction 组成的日志结构存储树。

「有序」按哪个字段? 见下节(不是默认事件时间 / 插入时间)。


「有序」在 memtable / SSTable 里指什么?

「有序」= 按你声明的排序键(primary key / sort key)的字节序升序排列——不是默认按事件时间,也不是默认按插入顺序。

常见混淆 实际
事件时间 有序? 只有 event_time 在 key 里(如 (user_id, event_time))才算;否则物理顺序 ≠ 事件时间
插入时间 有序? 一般 ;除非 key 设计成含 inserted_at 或自增 id 当 key
谁决定按什么排? 建表 / 建 CF / 引擎配置 时定的 key;RocksDB 的 user key、Cassandra 的 partition+clustering、ClickHouse 的 ORDER BY 各有一套名字
和 Flink event time 那是 流计算语义;落盘到 RocksDB 后,除非你 key 带上时间,否则 存储层不管 event time

排好序是为了什么?(不止「为了范围查询」)

目的 说明
多路归并 flush、compaction 时合并两个 已有序 文件,线性扫一遍即可
范围查询 key >= a AND key < b 在有序段上 顺序读,不必哈希全表
缩小区间再查 文件内 稀疏索引(每隔 N 个 key 记一个 offset)+ 可选 Bloom filter,减少读盘

「都要排序了,还要索引干什么?」

这里「索引」别理解成「另一张哈希表」——在 B+Tree / SSTable 里,有序结构本身就是在做索引

结构 「索引」长什么样
B+Tree 整棵树按 key 有序;非叶子 = 路标叶子 = 数据(或行指针) + 叶子链表做范围扫
SSTable 整个文件 key 有序 + 文件内 稀疏 key→offset 块
哈希索引 只有 key→位置,无顺序 → 擅长点查,不擅长 BETWEEN / ORDER BY key

所以:排序是为了归并、范围扫、二分缩区间;稀疏索引 / Bloom / B+Tree 内部节点是为了 少读盘、快点查。不是二选一,是 一体设计


为啥会有 SSTable / LSM-Tree?(历史动机)

常见误解:「数据天然顺序读写,所以 LSM 更合理,B+Tree 是历史错误。」
实际按文件顺序扫描按 key 更新某一磁盘页 是两层事;B+Tree 与 LSM 都是在 迁就不同负载下的磁盘瓶颈,不是谁更「符合物理规律」。

时间线:先有 B+Tree,后有 LSM
年代 里程碑 当时主流负载
~1972 B-Tree / B+Tree 通用 DB:点查、范围扫、行级 UPDATE
~1992 Log-structured 思想(日志结构文件系统) 写路径改成 只追加
~1996 LSM-Tree 论文 写多读少、海量追加 的 KV / 日志型

所以不是「先选错了结构」,而是 先有 OLTP 式索引需求,几十年后写吞吐才变成主矛盾

B+Tree 当年为什么合理

1970s–1990s 数据库典型问题:

  • WHERE id = ?点查
  • WHERE created_at BETWEEN …范围查询
  • UPDATE balance … WHERE account_id = ?原地改一行

B+Tree:O(log n) 次磁盘读定位叶子页;叶子链表支持 范围扫;节点对齐 OS/磁盘页(4KB/8KB)
读 + 行级更新 + 事务 设计,不是为「每秒百万条只追加、不改旧行」设计。

「顺序」和「随机」各指什么
直觉里的「顺序」 数据库里常发生的
从头到尾读一个文件 UPDATE … WHERE id=12345 — 只动 某一页
日志一行行 append B+Tree:找到 key 所在页 → 读 → 改 → 写回(页在盘上位置固定)
全表扫描 有索引可 不扫全表,但改一行仍是 定位到特定页
  • 顺序 I/O:磁头/控制器沿连续地址读写的吞吐高。
  • 随机 I/O(B+Tree 更新路径):很多 不相邻的页 被分别读改写。

LSM 写入:新值顺序 append 到 memtable / 新 SSTable;旧值留着,后台 compaction 再批量整理。

随机写慢在哪?(HDD vs SSD)

机械硬盘(HDD)
磁头要 seek 到磁道。随机写 = 改很多分散的页 → 磁头来回跳 → 大量时间耗在寻道上(比顺序写慢 一个数量级 很常见)。

SSD
无机械寻道,但闪存 按页写、按块擦;大量碎小、分散的原地改 → 控制器 读-改-擦-写、写放大。
大块顺序写 仍更省、吞吐更高 → 「SSD 上随机写仍比顺序写贵」= 差距缩小了,没消失

后来为什么需要 LSM

当负载变成:

  • 日志、埋点、消息:只追加、很少改旧行
  • 监控指标:写远大于读
  • 分布式 KV:磁盘上要撑住海量写入

工程师的思路:

别在磁盘上到处改旧页了,新数据一律顺序追加
旧数据先留着,后台 批量合并(compaction) 再整理。

SSTable = 每次刷下去的那块「排好序、不会再改」的磁盘文件。
LSM-Tree = 用很多层 SSTable + 内存表,组织成一棵「逻辑上有序、物理上分段」的树。

一句话:银行余额频繁原地改 + 事务 → 仍偏 B+Tree;埋点灌库 → 偏 LSM(代价是读/写/空间放大,见下文)。


他们解决了什么问题?

痛点(没有 LSM 时) LSM 的做法 结果
随机写磁盘太慢 写入先缓冲,顺序 flush 成 SSTable 写入吞吐高
写入流量波动 memtable 吸收突发,后台慢慢 compaction 写路径更平滑
删除/更新不想原地改 写新版本,旧版本标记删除,合并时清理 写路径仍 mostly append
单机磁盘要存大量 KV 多层 SSTable 分层,可 compaction 回收空间 成本低于全内存

没解决、或要付出代价的

  • 读一条 key 可能要查多层 → 读放大
  • 同一条数据多次重写 → 写放大空间放大
  • compaction 堵了 → 写入抖动、磁盘涨

是不是「只能」LSM 来解决?

不是。 没有银弹,只有 负载匹配

方案 更适合 例子
B+Tree(原地/页式更新) 读多、范围查询、行级更新、事务 MySQL InnoDB、PostgreSQL
追加日志 + 哈希索引(Bitcask) 纯 KV 点查、value 可落盘、key 量可控 Riak Bitcask
LSM + SSTable 写多读少、追加型、可接受后台 merge RocksDB、Cassandra、HBase
列存 + 分区 merge 分析型扫描、聚合 ClickHouse MergeTree
全内存哈希(Redis) 极低延迟、数据能放进内存 缓存、计数器

LSM 擅长的是:磁盘上要高写入吞吐,且多数是追加写
银行转账改余额、库存扣减这种 频繁原地更新 + 强事务,通常仍是 B+Tree 更舒适。


优劣势 + 具体例子(对照理解)

例子 1:App 埋点 —— LSM 很合适 ✅

场景:每秒 10 万条事件写入,event_id 为主,按天查询某用户轨迹。

写:不断 append 新事件(几乎不改历史行)
读:按 key / 时间范围查一部分
维度 表现
优势 顺序写磁盘,灌库快;和 Kafka / 日志消费模型契合
劣势 范围查可能扫多层 SSTable;要保证 compaction 跟得上

典型组件:Cassandra 写路径、Flink RocksDB 状态(checkpoint 时大量写)。

例子 2:账户余额 —— B+Tree 往往更合适 ✅

场景:同一账户行被高频 UPDATE balance,要求强一致、低延迟读最新余额。

写:反复改同一行(随机写页)
读:总是读「当前余额」这一条
维度 表现
若硬上 LSM 同一 key 多版本堆叠,compaction 前读要合并,事务语义更绕
B+Tree 优势 页内原地更新,范围扫、锁、事务成熟(InnoDB)

结论:不是 LSM 不能做,而是 问题形状更像 B+Tree 主场

场景keyBy(user_id) 后维护大量聚合状态,checkpoint 时要持久化到磁盘。

写:checkpoint 周期性地批量刷盘(突发 + 量大)
读:恢复时按 key 加载状态

为何用 RocksDB(LSM):顺序写友好,比随机更新整棵 B+Tree 更扛得住 checkpoint 写入峰值。
代价:状态过大时 compaction / 磁盘 IO 成为瓶颈,需要调 RocksDB 参数、SSD、增量 checkpoint。

例子 4:计费日批灌 ClickHouse —— 有 LSM 味道,但不是经典 LSM ⚠️

场景:每天上亿行明细 INSERT 进 ODS,再 merge 成 part。

写:批量追加 part(不可变段)
维护:后台 merge part(类似 compaction)

和 LSM 的相似:不可变段 + 后台合并
不同:列存、按列压缩、查询是分析扫描,不是 KV 点查。
排障时:merge 慢、part 过多 → 和「compaction 堵了」同一类直觉。

例子 5:纯缓存会话 —— Neither,Redis 更合适 ✅

场景session:token TTL 30 分钟,纯点查,丢了可重建。

内存哈希 即可,不必 LSM,也不必 B+Tree 落盘模型。


优劣势总表(复习一张表)

优势 劣势
写入 顺序写、高吞吐、吸收突发 写放大(compaction 重写)
读取 点查可接受(尤其带 Bloom filter) 读放大(多层文件);范围查成本高于 B+Tree
空间 可 compaction 回收 空间放大(多版本、删除标记暂留)
运维 适合日志型、WAL 型思维 compaction 卡住、磁盘膨胀、延迟抖动
事务 可实现,但不如 B+Tree 成熟统一 实现复杂(多组件协调)

哪些场景会用到这块知识?

场景 和 LSM 的关系 你可能在哪遇到
高吞吐写入 顺序 append,比随机改页快 日志采集、埋点 bulk load、CDC 灌库
KV / 宽列存储 Cassandra、HBase、RocksDB 典型 LSM 基础设施选型、Flink 状态(RocksDB)
时序 / 事件流落盘 按时间分区 + 不可变段 Kafka 日志段思想类似(概念相近,实现不同)
数仓明细追加 MergeTree 名字带 Tree,但是 列存 + 合并,不是经典 LSM;仍需懂「不可变 part + 后台 merge」 ClickHouse parts、merge 变慢
压测 / 运维排障 compaction 堆积 → 写延迟抖、磁盘满 「昨晚写入变差」类事故

和你栈的对照(别混)

组件 更接近哪种思路
Redis 内存哈希,不是 LSM 主路径
Bitcask 追加日志 + 内存哈希索引,不是 多层 SSTable
ClickHouse MergeTree 列存 + 分区 + 后台 part 合并(有 LSM 味道,别当同一实现)
Flink + RocksDB 典型 LSM(状态落盘)

需要画大块时间吗?

深度 建议时间 学什么
够用(推荐你现在) 30–45 分钟 上面 30 秒版 + 三个放大 + 一张「何时选 LSM vs B+Tree」
面试 / 架构讨论 +1 小时 size-tiered vs leveled compaction 区别(名词即可)
引擎开发 多天起 自己实现或改 RocksDB 参数调优手册

今天 DDIA 6h 节奏里:Ch.3 跳读 LSM 15–20 分钟足够,把时间留给 Ch.10/11(批/流)更划算。

何时选 LSM vs B+Tree(一句话)

选 LSM 倾向 选 B+Tree 倾向
写很多、读少量 key、可接受后台 merge 读多、范围查询、更新单条记录
顺序写磁盘、吞吐优先 事务行级更新、索引范围扫

三个「放大」——线上为什么会卡(必记)

放大 含义 体感
写放大 同一条逻辑数据多次刷盘、多次 compaction 重写 写入 QPS 不高但磁盘 IO 很高
读放大 读一次要查 memtable + 多层 SSTable 读延迟不稳定
空间放大 旧版本、删除标记在 compaction 前占空间 磁盘占用大于逻辑数据量

待你自己填 1 行(复习用)

我工作里和 LSM 最相关的组件是:________(例如 Flink RocksDB / ClickHouse parts / 无)

MVP 示例代码(Java):先 SSTable,再 LSM-Tree

教学用伪代码:能跑通思路,不是生产引擎。
对应 DDIA:不可变有序段(SSTable)+ 内存表 + 多层合并(LSM)

示例 1:SimpleSSTable(单个不可变有序文件)
// SimpleSSTable.java (pseudocode)
//
// 文件布局(按键字典序写入,打开时建内存索引 key -> fileOffset):
// [entry][entry]...
// entry = keyLen(int) | key | valueLen(int) | value

class SimpleSSTable {
    private final RandomAccessFile file;
    private final Map<String, Long> sparseIndex = new HashMap<>(); // key -> offset

    // 从有序数据构建并落盘(构建后不可再改)
    static SimpleSSTable build(Path path, SortedMap<String, String> sorted) throws IOException {
        SimpleSSTable table = new SimpleSSTable(path, "rw");
        for (var e : sorted.entrySet()) {
            long offset = table.file.length();
            table.sparseIndex.put(e.getKey(), offset);
            table.writeEntry(e.getKey(), e.getValue());
        }
        table.file.getFD().sync();
        return table;
    }

    // 打开已有 SSTable
    static SimpleSSTable open(Path path) throws IOException {
        SimpleSSTable table = new SimpleSSTable(path, "r");
        table.rebuildSparseIndex();
        return table;
    }

    String get(String key) throws IOException {
        Long offset = sparseIndex.get(key);
        if (offset == null) return null;
        return readEntryAt(offset).value;
    }

    Iterable<String> keys() {
        return sparseIndex.keySet();
    }

    private void rebuildSparseIndex() throws IOException {
        sparseIndex.clear();
        file.seek(0);
        while (file.getFilePointer() < file.length()) {
            long offset = file.getFilePointer();
            Entry e = readEntryAt(offset);
            sparseIndex.put(e.key, offset);
        }
    }

    private void writeEntry(String key, String value) throws IOException {
        byte[] k = key.getBytes(StandardCharsets.UTF_8);
        byte[] v = value.getBytes(StandardCharsets.UTF_8);
        file.writeInt(k.length);
        file.write(k);
        file.writeInt(v.length);
        file.write(v);
    }

    private Entry readEntryAt(long offset) throws IOException {
        file.seek(offset);
        int kLen = file.readInt();
        byte[] k = file.readNBytes(kLen);
        int vLen = file.readInt();
        byte[] v = file.readNBytes(vLen);
        return new Entry(new String(k, StandardCharsets.UTF_8),
                         new String(v, StandardCharsets.UTF_8));
    }

    static class Entry {
        String key, value;
        Entry(String key, String value) { this.key = key; this.value = value; }
    }
}

SSTable 要点

  • 构建时 一次性排序写入,之后 只读
  • 稀疏索引只存 key -> 文件偏移,value 仍在磁盘
  • 适合 顺序写 + 按 key 点查;范围查询可扫 keys()(MVP 未优化)

示例 2:SimpleLsmTree(memtable + 多层 SSTable + 简易 compaction)
// SimpleLsmTree.java (pseudocode)
//
// 写路径:put -> memtable(TreeMap 保序)
//        memtable 满 -> flush 成新的 SSTable,放到 levels[0]
// 读路径:memtable -> level0 -> level1 ...(新数据优先)
// 维护:level0 文件数超过阈值 -> compact 合并成更大 SSTable

class SimpleLsmTree {
    private final Path dir;
    private TreeMap<String, String> memtable = new TreeMap<>();
    private final List<List<SimpleSSTable>> levels = new ArrayList<>();
    private int memtableThreshold = 4;   // MVP:4 条就 flush
    private int l0FileThreshold = 2;     // MVP:L0 两个文件就 compact

    SimpleLsmTree(Path dir) throws IOException {
        this.dir = dir;
        Files.createDirectories(dir);
        levels.add(new ArrayList<>()); // L0
        levels.add(new ArrayList<>()); // L1
    }

    void put(String key, String value) throws IOException {
        memtable.put(key, value);
        if (memtable.size() >= memtableThreshold) {
            flushMemtable();
        }
    }

    String get(String key) throws IOException {
        // 1) 先查 memtable(最新)
        if (memtable.containsKey(key)) {
            return memtable.get(key);
        }
        // 2) 再查各层 SSTable(L0 最新文件在前)
        for (List<SimpleSSTable> level : levels) {
            for (int i = level.size() - 1; i >= 0; i--) {
                String v = level.get(i).get(key);
                if (v != null) return v;
            }
        }
        return null;
    }

    void delete(String key) throws IOException {
        // tombstone:MVP 用特殊标记表示删除
        put(key, "__TOMBSTONE__");
    }

    private void flushMemtable() throws IOException {
        if (memtable.isEmpty()) return;
        Path sstPath = dir.resolve("l0-" + System.nanoTime() + ".sst");
        SimpleSSTable sst = SimpleSSTable.build(sstPath, memtable);
        levels.get(0).add(sst);
        memtable = new TreeMap<>();

        if (levels.get(0).size() >= l0FileThreshold) {
            compactLevel0();
        }
    }

    private void compactLevel0() throws IOException {
        // 合并 L0 所有 SSTable -> 一个新的 L1 SSTable
        TreeMap<String, String> merged = new TreeMap<>();
        for (SimpleSSTable sst : levels.get(0)) {
            for (String k : sst.keys()) {
                merged.put(k, sst.get(k)); // 后写覆盖先写(MVP 简化)
            }
        }
        // 清理 tombstone
        merged.entrySet().removeIf(e -> "__TOMBSTONE__".equals(e.getValue()));

        Path out = dir.resolve("l1-" + System.nanoTime() + ".sst");
        SimpleSSTable compacted = SimpleSSTable.build(out, merged);

        // 删除 L0 旧文件,L1 追加新文件
        for (SimpleSSTable sst : levels.get(0)) {
            Files.deleteIfExists(/* sst.path */ Paths.get("placeholder"));
        }
        levels.get(0).clear();
        levels.get(1).add(compacted);
    }
}

LSM-Tree 要点

组件 作用
memtable 吸收最新写入(内存有序表)
SSTable 磁盘不可变段
多层 level L0 小文件多,compaction 后沉到 L1 大文件
从新到旧查,先命中最新版本
compaction 合并多文件、去 tombstone、回收空间

和 Tiny Redis / 真实 RocksDB 的对照
Tiny Redis(哈希索引) Simple B+Tree Simple SSTable Simple LSM
索引 HashMap 点查 树即索引(按 key 有序) 有序稀疏索引 memtable + 多层 SSTable
追加 log + 改索引 原地改叶子页(满则分裂) 批量 build 一次 先 memtable 再 flush
范围查询 ✅(叶子链表) ✅(可扫 key)
典型代价 内存、重建索引 随机写页、分裂 文件不可变 读/写/空间放大

LSM MVP 故意没做:WAL、Bloom filter、并发锁、增量 compaction 策略、崩溃恢复细粒度一致性。


B+Tree / B-Tree 存储引擎(与 LSM 对照)

为啥要了解?

目的 说明
读懂 DDIA Ch.3 SSTable / LSM 并列的另一半:页式、原地更新
日常 OLTP MySQL InnoDB、PostgreSQL 默认路径
选型 读多、范围扫、行级 UPDATE、强事务 → 常仍是 B+Tree
对照 LSM 搞清「写路径一次做完」vs「写与整理拆开」

不需要:手算 B+Tree 分裂公式。
需要:能画 根→叶 查找 + 说出 和 LSM 各擅长什么


30 秒版(复习背这个)

结构:多叉平衡树;数据(或行指针)主要在叶子;叶子按 key 串成链表
读:从根沿 key 比较 O(log n) 次落到叶子页 → 读行
写:找到叶子页 → 页内改/插 → 满则分裂(可能随机写多个页)
范围:沿叶子链表顺序扫 key 区间
代价:随机 I/O、页分裂;优势:读最新值简单、事务/锁成熟

B+Tree 是什么(和 B-Tree 的差别记一句即可;图解推导见下节

  • B-Tree:非叶子节点也可能存数据。设计者 Bayer & McCreight(1972)
  • B+Tree(数据库更常见):只有叶子存数据/行指针;非叶子只做 路标叶子链表 串起来方便范围扫。→ B-Tree 图解精讲
  • 页(page):磁盘与内存之间 读写的最小常用单位;B+Tree 里 一个树节点通常 = 一页。详见下节。
B-Tree 图解精讲:叶子节点、推导、谁设计的

读完本节应能:画出一棵 3 层 B+Tree、说出 叶子 vs 内部节点、解释 为啥数据库几乎都用 B+Tree 而不是 B-Tree


0. 谁设计的?什么时候?
名称 人物 / 时间 备注
B-Tree Rudolf BayerEdward McCreight1972 论文 Organization and Maintenance of Large Ordered Indexes;在 Boeing 科研实验室为大型索引设计
B 的含义 常说是 BayerBalanced;Bayer 本人提过与 Boeing 有关 不必纠结字母,记住 1972、为磁盘大块读写而生 即可
B+Tree B-Tree 的 变体;约 1970 年代中后期 在数据库实现中普及(Knuth TAOCP 等有系统叙述) MySQL InnoDB、PostgreSQL、SQLite 的聚簇/索引结构都是 B+Tree(不是经典 B-Tree)
和 DDIA 的关系 Kleppmann 讲存储引擎时以 B+Tree vs LSM 对照 不要求背历史,但要知:这是 50+ 年的工程选择,不是某一家数据库拍脑袋

1. 先建立直觉:三种「树」别混
flowchart TB
  subgraph bin["二叉搜索树 BST"]
    b1["每个节点 1 个 key + 左右子树"]
    b2["树高 h ≈ log₂(n)"]
    b3["问题:节点小 → 磁盘上要读很多层"]
  end
  subgraph bt["B-Tree(经典)"]
    t1["每个节点一页,塞很多 key"]
    t2["内部节点和叶子都能存「记录」"]
    t3["树高 h ≈ log_m(n),m 很大"]
  end
  subgraph bpt["B+Tree(数据库主流)"]
    p1["内部节点:只存 key 当路标"]
    p2["叶子:存全部数据 + 叶子链表"]
    p3["范围查询沿叶子扫"]
  end
  bin -->|"磁盘时代"| bt
  bt -->|"范围扫 + 更大扇出"| bpt
概念 一句话
节点(node) 树上的一个「格子」;在数据库里 通常 = 一页(16KB 等)
内部节点(internal / non-leaf) 不在最底层;只有 路标 key + 子指针,用来 往下找
叶子节点(leaf) 最底层真正存数据(行本身,或 (key → 行位置) 指针)
根(root) 最顶上那个节点;整棵树的入口
扇出 m(fanout) 一个节点里大约能指向 多少个儿子;m 越大,树越矮
树高 h(height) 从根到叶子的层数;点查大约读 h 次页

叶子为什么叫「叶」?
和植物一样:养分(你的 行数据)在 末梢;树干树枝(内部节点)只负责 指路,不负责「结果长在树枝中间」——在 B+Tree 里更是 强制 如此。


2. 推导:为啥不继续用 BST / 哈希?

挑战链(和 §2 B+Tree 历史脉络 一致)

数据从 1 万行 → 10 亿行
  → 不能全表扫
  → 要能按 key 排序、BETWEEN(哈希做不到)
  → 磁盘一次读 4KB/16KB 一块,不能「只读 8 字节」还划算
  → 要让一次 I/O 里带尽可能多的 key(多叉)
  → 还要树平衡,最坏情况可控(B-Tree 平衡)
  → 范围查询还要顺 key 扫(B+Tree 叶子链表)

数值推导(树高为什么通常是 3~4)

设:

  • n = 索引里 key 的个数(例如 10⁹ 行)
  • m = 每个节点平均扇出(一页 16KB,路标 key 很小,m 常取几百

叶子层要能放下 n 个 key,需要层数 h 满足:

m^h ≥ n   ⇒   h ≥ log_m(n)
n m = 100 m = 500
10⁶ h ≥ 3 h ≥ 2
10⁹ h ≥ 5 h ≥ 3~4
10¹² h ≥ 6 h ≥ 4~5

结论: 不是魔法,是 「一层把 key 空间切成 m 份,叠 h 层」;m 一大,h 就矮,点查只要 h 次磁盘读


3. 结构图:B-Tree vs B+Tree(同一组 key)

假设 key = {5, 12, 20, 25, 30, 40},极简 2 层示意(真实节点一页能装远多于 6 个 key)。

经典 B-Tree(数据可能在内部节点)

flowchart TB
  root["根节点<br/>key: 25"]
  i1["内部<br/>key: 12"]
  i2["内部<br/>key: 30"]
  L1["叶子 5"]
  L2["叶子 12, 20"]
  L3["叶子 25"]
  L4["叶子 30, 40"]
  root -->|"< 25"| i1
  root -->|">= 25"| i2
  i1 --> L1
  i1 --> L2
  i2 --> L3
  i2 --> L4

注意:有的教材画 内部节点也带记录(例如 key=12 的行就挂在内部),实现杂,数据库很少用纯 B-Tree

B+Tree(DDIA / InnoDB 心智模型)

flowchart TB
  root2["根(只路标)<br/>keys: [25]"]
  int1["内部<br/>keys: [12]"]
  int2["内部<br/>keys: [30]"]
  leaf1["叶子: 5"]
  leaf2["叶子: 12, 20"]
  leaf3["叶子: 25"]
  leaf4["叶子: 30, 40"]
  root2 --> int1
  root2 --> int2
  int1 --> leaf1
  int1 --> leaf2
  int2 --> leaf3
  int2 --> leaf4
  leaf1 -.->|next 链表| leaf2
  leaf2 -.-> leaf3
  leaf3 -.-> leaf4
对比 B-Tree B+Tree
数据在哪 内部 + 叶子都可能 只在叶子
内部节点 key + 数据 + 指针 只有 key + 指针(路标)
内部节点的 key 可能「代表」子树范围 常是 分隔符(「往左 < k,往右 ≥ k」)
范围查询 要中序遍历树 沿叶子链表 顺序扫
扇出 内部节点胖 → m 偏小 内部节点瘦 → 同一页能放更多指针 → 更矮

「叶子链表」解决啥?
找到 key ≥ 12 的叶子后,不必再「爬树找下一个 key」,直接 leaf → leaf.next 扫——对应 SQL WHERE id BETWEEN 12 AND 40


4. 点查走一遍:WHERE id = 25

以 B+Tree 为例(InnoDB 聚簇索引同理):

sequenceDiagram
  participant Q as 查询
  participant R as 根页
  participant I as 内部页
  participant L as 叶子页
  participant D as 行数据
  Q->>R: 读根(1 次 I/O)
  Note over R: 25 在 [12, 30) 之间?<br/>比较 key 选子指针
  Q->>I: 读内部页(第 2 次 I/O)
  Q->>L: 读叶子页(第 3 次 I/O)
  L->>D: 页内二分/扫描找到 id=25 的行

页内发生什么(每层 O(1) 次比较,因页大小固定)

根:    [ · | 12 | · | 25 | · | 30 | · ]  → 选「25 所在区间」的指针
内部:  同上,再缩小区间
叶子:  [ (12,row) (20,row) (25,row) ... ]  → 命中 row

I/O 次数 ≈ 树高 h,不是 O(n) 扫全表。


5. 插入与分裂:为啥会「长个子」?

步骤(插入 key=22)

  1. 同点查 落到应插入的 叶子页(O(h) 读)。
  2. 叶子里 有空位 → 插入,结束(可能 1 次写脏页)。
  3. 叶子 满了分裂(split)
flowchart LR
  subgraph before["分裂前:叶满"]
    P["叶子页<br/>12,20,22,25,30"]
  end
  subgraph after["分裂后"]
    P1["左叶<br/>12,20,22"]
    P2["右叶<br/>25,30"]
    PAR["父节点插入分隔 key 25"]
  end
  before --> after
  1. 分隔 key(通常是 右叶最小 key 或中间 key,实现细节因引擎而异)插入父节点
  2. 父也满 → 继续向上分裂;若根分裂 → 树高 +1(多一层)。

为啥这么设计?

设计 原因
多叉、一页一节点 对齐磁盘块;一次 I/O 带一堆 key,压低 h
平衡(所有叶同深) 最坏查找仍是 O(log n),不会退化成链表
分裂而不是无限拉长一页 页大小固定(16KB),buffer pool 才能管理;太大则 I/O 浪费
B+Tree 数据只在叶 内部节点更瘦 → 更大 m → 更矮 h;范围扫走链表

代价(和 LSM 对照)
分裂 + 原地改页 → 随机写 多个页;高并发更新时页锁竞争——这是后来 LSM 用追加换整理 的动机,不是 B+Tree 设计失败。


6. 一页里长什么样?(和 页(page) 衔接)
┌────────────────────────────────────── 16KB 页 ──────────────────────────────────────┐
│ 页头:校验和、LSN、前后指针…                                                          │
│ 索引项目录(稀疏/稠密)                                                                │
│ [ key₁ | 子页号₁ ] [ key₂ | 子页号₂ ] …     ← 内部节点:路标 + 指针                  │
│ 或                                                                                   │
│ [ key₁ | row₁ ] [ key₂ | row₂ ] …           ← 叶子:key + 行(聚簇)或 key + 主键指针   │
│ 叶子还有:next_page_id → 下一叶子(范围扫)                                            │
└──────────────────────────────────────────────────────────────────────────────────────┘

聚簇索引(InnoDB):叶子 = 整行二级索引 叶子 = (索引列, 主键),再 回表 到聚簇叶找行。


7. 自测(能否讲给别人听)
  1. 叶子节点存什么? → B+Tree:真实行或指针;内部节点:不存用户行
  2. 为啥 h=3 就够上亿行? → m 大,m^h ≥ n。
  3. B+Tree 比 B-Tree 多啥?叶子链表 + 数据只在叶
  4. 谁设计的?Bayer & McCreight,1972;数据库主流用 B+Tree 变体
  5. 和 LSM 根本分歧? → B+Tree 同步改旧页;LSM 追加新文件再合并(见 优劣势总表)。

页(page)怎么理解?

一句话:页 = 数据库按 固定大小的一块(常见 4KB / 8KB / 16KB)从磁盘搬进内存、或写回磁盘;B+Tree 的节点就长在这一块里

类比 页在干什么
图书馆 不是一次搬一整书架,而是按 「一本合订册」 借还——册子大小固定,方便编号、搬运
快递 标准箱 装货,车一次运一箱;箱大小和车、叉车对齐
你 MVP 里的 Page 逻辑上的「一块节点」;真数据库还会把这块 序列化到磁盘某个偏移,并放进 buffer pool(页缓存)

和三个概念别混

概念 是什么
页(DB page) 存储引擎定的块,如 InnoDB 16KB 一页
操作系统 页(4KB) 内存管理单位;DB 页往往是 OS 页的 整数倍
B+Tree 节点 逻辑结构;实现上 通常 1 节点 = 1 页,所以文档里常混说「读一页」「读一个节点」

为啥非要「页」,不能一行一行读?

磁盘 / SSD 对 整块读写 最划算;一次 I/O 读 16KB,里面可以塞 很多 key 路标或很多行,摊薄寻道/命令开销。
点查 WHERE id=100:往往 读 3~4 页(根→内部→叶子),不是读「整表」。

读 / 写时发生什么

读:磁盘第 N 页 → 拷进内存 buffer pool → 在页内二分/扫描找 key
写:找到页 → 在内存页里改 → 标脏 → 稍后刷回磁盘同一页号(或分裂成两页)

和 LSM 的「页」

B+Tree LSM
主要单位 (原地改某一页) SSTable 文件(顺序 append 新块,旧块不改)
更新一行 常改 该行所在那一页 新记录 到新 memtable/SSTable

「有序」在 B+Tree 里指什么?

SSTable 的有序 同一概念:按 索引键(主键 / 聚簇键) 排序。
PRIMARY KEY (user_id) → 树按 user_id 有序;WHERE user_id BETWEEN … 走叶子链表。
二级索引是 另一棵(或一棵的)B+Tree,叶子存 主键指针,再回表。


读写路径 + 时间复杂度

点查 WHERE pk = ?

根页 → 比较 key,选子指针 → … → 叶子页 → 页内找到行
磁盘读次数 ≈ 树高 h(通常 3~4 层)

更新 UPDATE … WHERE pk = ?

同上找到叶子页 → 在页内原地改(或插入新行、标记旧行)
页满 → 分裂:新页、父节点加指针 → 可能向上传播分裂
对磁盘:常是 改固定几页的随机写(不像 LSM 只 append 新文件)

范围 WHERE pk BETWEEN a AND b

定位到 a 所在叶子 → 沿叶子链表扫到 b
读写都是 O(log n) 吗?log 的底是谁?

结论(先背)

操作 相对 key 总数 n 相对 磁盘 I/O(页) 说明
点查 O(log n) O(h) 次,h = 树高 每层下潜一页
插入 / 更新(含分裂) O(log n) O(h) 最坏(分裂一路到根) 先找叶子 O(h),再最多分裂 O(h)
范围扫 O(log n + k) O(h) + 扫 k 个叶子项 k = 命中行数,不是纯 log n

这里的 n = 索引里 有多少个 key(行/记录数),不是页数。
log n 在教材里常写成 O(log_m n)m = 扇出(branching factor),一页里能放大约 m 个子指针或 O(m) 个 key。

为啥是 log n?(推导树高 h)

B+Tree 每层把 key 空间 大约切成 m 份(m 由页大小决定,InnoDB 一页 16KB 时常有 几百 个路标):

第 0 层(根)     :1 页,覆盖整棵索引
第 1 层           :最多 m 页
第 2 层           :最多 m² 页
…
第 h 层(叶子)   :最多 m^h 页,存 n 个 key

要能放下 n 个 key,需要:

m^h ≥ n   →   h ≥ log_m(n)

所以 树高 h = O(log_m n)。大 O 里底数 m 当常数时,就写成 O(log n)

数值直觉(体会为啥生产里 h 很小):

n(行数) m≈500 时 h ≈ log₅₀₀(n)
10⁶ ≈ 2
10⁹ ≈ 3~4
10¹² ≈ 4~5

读一条 key 为啥 O(log n)?

  1. 从根开始,最多 h 层
  2. 每层:在 一页内 用 key 比较选 一个 子指针(页内二分,页大小固定 → 页内 O(log m) = O(1) 常数);
  3. 落到叶子,页内再找到该行。

磁盘 I/O 次数 ≈ h = O(log n)(每层读 1 页)。
CPU 比较次数 ≈ h × log m,m 固定时也是 O(log n)

写(插入/更新)为啥也是 O(log n)?

  1. 查找叶子:同读,O(h) = O(log n) 页;
  2. 页内插入或覆盖:页大小固定 → O(1)
  3. 页满分裂:新页 + 把一个 分隔 key 插进父节点;父也满则继续向上,最多到根

一条路径上 最多分裂 h 次 → 再 O(h) 页写。
合计:O(h) = O(log n) 次页访问(最坏;均摊分析下插入仍是 O(log n) 量级)。

不是 O(log n) 的情况(别背错)

场景 复杂度
WHERE pk BETWEEN a AND b 命中 k 行 O(log n + k),沿叶子链表扫 k 个
全表扫描(不用索引) O(n)
哈希索引点查 O(1) 平均(但没有 key 顺序)

和 MVP SimpleBPlusTree 的关系

代码里 findLeafwhile 下降一层,层数 = h;ORDER 很小时 h 相对大,但 渐近仍是 O(log n)。生产用大页、大扇出,常数项更小(h=3~4)。


解决了什么问题?

痛点 B+Tree 的做法 结果
海量数据仍要点查 树高 O(log n),每层一页 几次 I/O 定位一行
范围查询 叶子链表按 key 顺序 BETWEEN、ORDER BY 主键高效
行级更新、事务 页级锁 / MVCC + 原地或版本链 InnoDB / PG 成熟生态
读「当前最新值」 叶子上一行即最新(无多层版本合并) 读路径直观

没解决、或要付出代价的

  • 高频更新 → 随机写页、页分裂(HDD 时代尤其痛;SSD 仍不如顺序写)
  • 写入突发 → 同步刷脏页可能抖动(靠 buffer pool、组提交缓解)
  • 极海量 只追加写 → 不如 LSM 顺序 flush 扛写吞吐

优劣势总表(与 LSM 对照)

B+Tree LSM
原地改页,随机 I/O 多 顺序 append + 后台 compaction
读最新 叶子即当前值,路径短 可能查 memtable + 多层,读放大
范围查 叶子链表,成熟 可行,多层时成本更高
事务 非常成熟 可实现,组件更多
写吞吐(追加型) 一般
空间 原地更新,无多版本堆积(MVCC 除外) 空间放大、compaction 前多版本

何时选谁(一句话) → 见 § 何时选 LSM vs B+Tree;账户余额见 例子 2


思想对照:写路径是否拆开

B+Tree LSM(Log-structured)
一次 UPDATE 同步:定位页 → 改页 →(可能分裂) 前台:append 新版本;后台:compaction 合并
读模型 树 + 页,O(log n) 有序 memtable + 多层 SSTable,可能读放大
索引与有序 树即索引 有序文件 + 稀疏索引块

和本文件其它结构放一起

Tiny Redis / 哈希 B+Tree LSM
有序 ✅ 按主键 ✅ 按 sort key
点查 O(1) 平均 O(log n) O(log n)~多层
范围 ✅(多层时更贵)
典型 缓存 InnoDB、PostgreSQL RocksDB、Cassandra

MVP 示例代码(Java):SimpleBPlusTree

教学用伪代码:内存里的 页 + B+Tree,对应 DDIA:非叶子路标、叶子存值、叶子链表范围扫、页满分裂
SimpleLsmTree 对照:写路径在 put 里同步定位并改页,没有后台 compaction。

示例:SimpleBPlusTree(页式、原地插入、叶子链表)
// SimpleBPlusTree.java (pseudocode)
//
// 简化:阶数 ORDER=4(每页最多 3 个 key);仅 String key/value;全程内存
// 叶子:keys[i] -> values[i],next 指下一叶子
// 内部:keys[i] 为分隔;children[i] 为子指针(children 比 keys 多 1)

class SimpleBPlusTree {
    private static final int ORDER = 4;
    private static final int MAX_KEYS = ORDER - 1; // 3

    private Page root = newLeaf();

    String get(String key) {
        Page leaf = findLeaf(key);
        int i = indexInPage(leaf, key);
        return i >= 0 ? leaf.values.get(i) : null;
    }

    void put(String key, String value) {
        if (root.isLeaf && root.keys.size() < MAX_KEYS) {
            insertInLeaf(root, key, value);
            return;
        }
        if (needsSplit(root)) {
            Page newRoot = newInternal();
            newRoot.children.add(splitAndReturnLeft(root, null, key, value));
            root = newRoot;
        } else {
            insertRecursive(root, key, value);
        }
    }

    // 范围扫 [from, to](闭区间,按 key 字典序)
    List<String> range(String from, String to) {
        List<String> out = new ArrayList<>();
        Page leaf = findLeaf(from);
        while (leaf != null) {
            for (int i = 0; i < leaf.keys.size(); i++) {
                String k = leaf.keys.get(i);
                if (k.compareTo(from) < 0) continue;
                if (k.compareTo(to) > 0) return out;
                out.add(leaf.values.get(i));
            }
            leaf = leaf.next;
        }
        return out;
    }

    private Page findLeaf(String key) {
        Page p = root;
        while (!p.isLeaf) {
            int c = childIndex(p, key);
            p = p.children.get(c);
        }
        return p;
    }

    private void insertRecursive(Page node, String key, String value) {
        if (node.isLeaf) {
            if (!needsSplit(node)) {
                insertInLeaf(node, key, value);
                return;
            }
            // 叶子满 → 分裂(父可能继续分裂,MVP 用简化递归)
            splitAndReturnLeft(node, null, key, value);
            return;
        }
        int c = childIndex(node, key);
        Page child = node.children.get(c);
        if (needsSplit(child)) {
            Page newChild = splitAndReturnLeft(child, node, key, value);
            node.children.set(c, newChild);
            if (needsSplit(node)) {
                splitAndReturnLeft(node, null, null, null);
            }
        } else {
            insertRecursive(child, key, value);
        }
    }

    // 页满时分裂:左半保留原页,右半新页;把中间 key 推到父(B+Tree:内部只存分隔 key)
    private Page splitAndReturnLeft(Page page, Page parent, String key, String value) {
        if (page.isLeaf && key != null) {
            insertInLeaf(page, key, value); // 先插入再分裂(MVP)
        }
        int mid = page.keys.size() / 2;
        String promote = page.keys.get(mid);

        Page right = page.isLeaf ? newLeaf() : newInternal();
        right.keys.addAll(page.keys.subList(mid + (page.isLeaf ? 0 : 1), page.keys.size()));
        if (page.isLeaf) {
            right.values.addAll(page.values.subList(mid + 1, page.values.size()));
            right.next = page.next;
            page.next = right;
            page.keys.subList(mid, page.keys.size()).clear();
            page.values.subList(mid, page.values.size()).clear();
        } else {
            right.children.addAll(page.children.subList(mid + 1, page.children.size()));
            page.keys.subList(mid, page.keys.size()).clear();
            page.children.subList(mid + 1, page.children.size()).clear();
        }

        if (parent == null) {
            Page newRoot = newInternal();
            newRoot.keys.add(promote);
            newRoot.children.add(page);
            newRoot.children.add(right);
            root = newRoot;
            return page;
        }
        int pos = indexInPage(parent, promote);
        if (pos < 0) pos = -pos - 1;
        parent.keys.add(pos, promote);
        parent.children.add(pos + 1, right);
        return page;
    }

    private void insertInLeaf(Page leaf, String key, String value) {
        int i = indexInPage(leaf, key);
        if (i >= 0) {
            leaf.values.set(i, value); // 原地更新(B+Tree 典型 UPDATE)
        } else {
            i = -i - 1;
            leaf.keys.add(i, key);
            leaf.values.add(i, value);
        }
    }

    private boolean needsSplit(Page p) {
        return p.keys.size() > MAX_KEYS;
    }

    private int childIndex(Page internal, String key) {
        int i = 0;
        while (i < internal.keys.size() && key.compareTo(internal.keys.get(i)) >= 0) {
            i++;
        }
        return i;
    }

    private int indexInPage(Page p, String key) {
        for (int i = 0; i < p.keys.size(); i++) {
            int cmp = key.compareTo(p.keys.get(i));
            if (cmp == 0) return i;
            if (cmp < 0) return -i - 1;
        }
        return -p.keys.size() - 1;
    }

    private Page newLeaf() {
        Page p = new Page();
        p.isLeaf = true;
        return p;
    }

    private Page newInternal() {
        Page p = new Page();
        p.isLeaf = false;
        return p;
    }

    static class Page {
        boolean isLeaf;
        List<String> keys = new ArrayList<>();
        List<String> values = new ArrayList<>();   // leaf
        List<Page> children = new ArrayList<>(); // internal
        Page next; // leaf 链表,供 range()
    }
}

B+Tree MVP 要点(对照 LSM)

步骤 代码里对应 和 LSM 的差别
点查 findLeaf 从根下降 LSM 查 memtable + 多层文件
更新 insertInLeaf 同 key 覆盖 value LSM append 新版本,旧值后 compaction
范围 叶子 next 链表 range() LSM 需扫有序 SSTable(可能多层)
页满 splitAndReturnLeft 分裂、提升 key LSM flush 新文件,不原地分裂
磁盘 MVP 未模拟 Page 落盘;生产 = 一页一次 I/O LSM = 顺序写 SSTable 文件

B+Tree MVP 故意没做:磁盘页缓存(buffer pool)、WAL、并发锁、MVCC、二级索引、父节点分裂的完整递归(上面 splitAndReturnLeft 为教学简化)。


待沉淀

  • ClickHouse MergeTree 与 DDIA「存储引擎」章节的对应关系
  • 分区键、排序键、主键在查询路径上的作用(单独立项)