DDIA · 存储引擎与索引

Ch.4: 字节放哪、怎么找 — LSM 追加写优 vs B-Tree 原地读写优; 行存/列存/倒排/向量四套索引各有各的题

LSM-Tree: 追加 + 后台合并 (写优) 写请求 WAL 先落日志 崩溃可恢复 memtable (跳表) 满几 MB → 刷成段 flush SSTable 段 L0 按键排序 · 不可变 SSTable 段 L1 / L2 compaction 逐层下推 Bloom: 必无 → 跳段 tombstone 删除标记 读: 新段→旧段 取最新 ✓ 顺序写吞吐高 · 压缩好 · 适合写多读少 ✗ 读要查多段 (读放大) · compaction 抢 IO 抢 CPU B-Tree: 固定页 + 原地覆写 (读优) root page inner page (分支~500) inner page leaf pages (4/8/16 KiB) 原地覆写 · 可 range 扫描 容量直觉: 4 层 × 分支 500 × 4KiB 页 ≈ 250 TB 可索引 每次点查 ≈ 4 次页读 (常驻缓存) ✓ 读便宜且可预测 · range 便宜 · 4 层封顶 ✗ 原地覆写 + 页分裂 → 每次写都要 WAL 兜底 二级索引家族: 从行定位到向量 聚簇索引 (InnoDB) 整行存进主键叶子里; 堆文件则存行指针 覆盖索引 covering 索引里就有答案列, 免回表 — 换写放大 复合索引 (last, first) 像电话簿: 只有最左前缀可用 多维索引 (R 树/H3) 地理/RGB 同时过滤, 单列索引骗不过 倒排索引 (Lucene/GIN) 词项 → postings list; 全文检索核心 向量索引 (IVF / HNSW) 语义近 = 向量近; 近似换速度 选型口诀: 等值点查 hash/BTree · 范围 BTree · 全文倒排 · 语义向量 · 地理 R 树 列式存储: 分析查询只碰需要的列 行存 (OLTP) row1: [全部列连在一起] 读一行全字段 = 1 次页读 但聚合只取 1 列也要扫全行 列存 (OLAP) col A col B col C 每列连续存放, 行序对齐 ✓ 只读查询涉及的列 (IO ÷10) ✓ 同列相似 → 压缩极好 (RLE) ✗ 单行更新/读整行很痛苦 bitmap + RLE: n 个 distinct 值 → n 个位图 → 游程压缩 brand=Nike 的位图: 00100100… → 2 个 0, 1, 2 个 0, 1 … AND/OR 位运算即查询执行 — 列存分析快的另一重原因

两大引擎的取舍

  • • LSM: 追加+合并 → 写吞吐王, 读放大换的
  • • B-Tree: 页+原地覆写 → 读稳可预测, 写靠 WAL 兜底
  • • 选型看负载: 写多读少 LSM / 读多 B-Tree
  • • 放大三兄弟: 写放大 / 读放大 / 空间放大

索引即派生数据

  • • 索引加快读, 代价是写时同步维护
  • • 聚簇/堆文件、覆盖、复合最左前缀
  • • 多维 (R 树/H3)、倒排 (全文)、向量 (HNSW)
  • • 索引不是越多越好 — 写放大 + 空间

行的形状

  • • 行存服务"取一整行", OLTP 主场
  • • 列存服务"扫一列", OLAP 主场 + RLE 压缩
  • • 宽列 (Bigtable) 名字像列存实为行存 KV
  • • 向量: cosine 相似度 + IVF/HNSW 近似

💡 一句话理解

存储引擎回答一个问题: 字节放哪、怎么找到它。LSM-Tree像记账本 — 收银时只往最后一页流水账上追加 (顺序写飞快), 打烊后再慢慢誊清合并 (compaction); 查账要翻好几本账 (读放大), 但前面贴了Bloom filter便签"这本肯定没有"就整本跳过。B-Tree像图书馆卡片柜 — 每格固定大小、原地改卡片, 找书顺着目录 4 层必达, 但每次改都要拿笔涂改还可能换格子 (页分裂)。剩下的都是"给谁快": 行存给"取一行", 列存给"算一列", 倒排给"搜词", 向量给"像不像"。

🧠 必知必会 必考 & 必会

hash index 段
内存 hash 表存 key→文件偏移, 数据追加进日志段; 最简 KV 引擎 (Bitcask 式), 但不支持范围查询。
index = {}                      # key → 文件偏移
index["user:42"] = offset      # 追加后记位置
# get: seek(offset) 一次读 — 快!
# 但 WHERE age > 18? hash 无能为力
SSTable / memtable / LSM
写入先进内存有序结构 (memtable/跳表), 满几 MB 刷成按键排序不可变段 (SSTable); 读时 memtable→新段→旧段 取最新。
mem = {}                        # memtable
mem["user:42"] = data          # 满了 → flush SSTable
# read(key): memtable → bloom → L0→L2 段
# 同 key 多版本 → 取"最新段"的值
compaction 两策略
size-tiered: 小段并大段, 写优但费空间、读可能扫多段; leveled: 逐层按 key range 下推, 读优省空间但合并更频繁。
# tiered: L0:[1MB,1MB,1MB] → 合并成 3MB 段
# leveled: L1 键范围 [a~m] 与 L2 [a~f] 重叠 → 下推
# 同 key 留最新, 清 tombstone, 回收空间
Bloom filter
概率结构只回答"一定不存在": 用于读路径跳过不含该 key 的段。10 bits/key ≈ 1% 误报, 每加 5 bits 误报除以 10。
# m=10 bits/key, k=7 hash → 误报 ~1%
# bloom 说"没有" → 100% 没有, 直接跳段
# bloom 说"有"   → 可能误报, 真去段里查
tombstone
删除 = 写一个墓碑标记: 读到它语义是"不存在"; 物理空间在 compaction 时才回收 — 删 100GB 不是瞬间的事。
put(key, "TOMBSTONE")          # 删除 = 写标记
get(key)                       # 读到墓碑 → 不存在
# compaction 遇到墓碑且无旧值 → 物理清除
B-Tree 结构
固定大小页 (4/8/16 KiB) 是磁盘 I/O 单位, 页号即磁盘指针; 内页子页数叫分支因子 (约数百), 4 层 × 500 × 4KiB ≈ 250TB。
# 点查路径: root → inner → leaf = 3~4 页
# 常驻缓存后 = 内存级点查
# 范围查询: 叶子层顺序扫 — 天生友好
WAL 与 fsync
先顺序写日志并 fsync, 再改页 — 崩溃恢复与复制的基石。fsync 是"持久性开关": 不刷盘, 断电即丢。
# 写事务 = append WAL (fsync) + 改内存页
# 崩溃 → 重放 WAL 恢复到一致点
# 复制: follower 重放同一份 WAL
三种放大
写放大: 1 次逻辑写造成几次物理写 (LSM 高); 读放大: 一次读要碰几个段/页 (LSM 段数, B-Tree 4 层); 空间放大: 实际占用超数据的比例 (LSM 段并存)。
# LSM: 写 1GB 数据物理写 5~20GB (WA=5~20)
# B-Tree: 改一行 = 写 1 页 + WAL (WA≈2)
# 空间: LSM 段并存可到 1.5~2×
聚簇 / 堆 / 二级索引
聚簇: 整行存进主键索引叶子 (InnoDB); 堆文件: 行无序存, 索引存行指针 (PG)。二级索引: 非主键列 → 行定位; 值不唯一, 有回表成本。
CREATE INDEX idx_email ON users(email);
-- InnoDB 二级索引叶 = 主键值 → 回表拿整行
-- PG 二级索引叶 = ctid 行指针   → 回表拿整行
覆盖 / 复合索引
覆盖索引把答案列也放进索引, 免回表; 复合索引 (last, first) 像电话簿 — 只支持最左前缀, 顺序错了就废。
-- 覆盖: 答案全在索引里, 不碰表
CREATE INDEX idx ON orders(user_id, amount);
SELECT user_id, sum(amount) FROM orders
GROUP BY user_id;              -- Index Only Scan
列存与 RLE
按列连续存放、行序对齐: 只读查询涉及的列; 同列取值重复 → bitmap 编码 + 游程压缩, AND/OR 位运算即查询。
# brand 列: [N,N,N,S,S,N,N] → 位图 ×2 → RLE
Nike:  [1,1,1,0,0,1,1]  → 3×1, 2×0, 2×1
# WHERE brand='N' AND qty>10 → 位图 AND
倒排与 trigram
倒排: 词项 → 文档列表 (postings list), 全文检索核心; trigram 把每 3 字符滑窗建倒排, 支持任意子串/正则 (PG pg_trgm)。
# 文档 d1:"数据库系统" d2:"文件系统"
"系统" → [d1, d2]   # postings list
"数据" → [d1]
# 查"数据 系统" = 交集 AND → [d1]
向量嵌入与 IVF/HNSW
文本/图像编码为高维向量, 语义近 = 向量近 (cosine); flat 暴力精确, IVF 质心分区探近邻, HNSW 多层图导航 — 近似换速度。
q = embed("怎么优化慢查询")   # [0.12, -0.9, ...]×768
sim = cosine(q, doc_vec)
# HNSW: 从顶层图贪心下降 → 近邻几跳可达
# 召回 95% + 速度 ×1000 = ANN 的交易

🏭 生产实战 real world

场景 1 · 用户行为流写入: 写多读少上 LSM

埋点事件每秒 50 万条写入、只按设备号点查 — RocksDB/Cassandra 的主场。

# Python: RocksDB(LSM) 写路径示意
db.put("evt:2026-09-26:dev:8842:1937", payload)  # 追加, 顺序写
db.put("evt:2026-09-26:dev:8842:1938", payload)

# 读取按前缀扫 (range): iterator(prefix) 走有序段
for k, v in db.iteritems(prefix="evt:2026-09-26:dev:8842:"):
    emit(v)

# 调参: memtable 256MB + leveled compaction
# 写吞吐: 机械盘上 ≈ 顺序写极限, 随机更新无惩罚

同负载在 B-Tree 引擎上: 随机 key 插入 = 随机页写 + 频繁页分裂, 吞吐差 3~5 倍。

场景 2 · 账户余额查询: 读多改少选 B-Tree

余额点查要求延迟稳定可预测 — B-Tree 4 层封顶的特性正合适。

-- InnoDB(聚簇 B-Tree): 点查 4 次页读, 热数据全在 buffer pool
SELECT balance FROM accounts WHERE id = 10086;
# 执行计划: const, rows=1 — 延迟 P99 稳定 < 1ms

-- LSM 引擎跑同负载的问题:
#   热点 key 反复覆盖 → 段里多版本 → compaction 跟不上
#   读延迟随段数量波动, P99 抖

选择依据不是"谁快", 是延迟形状: B-Tree 尾部稳, LSM 吞吐高。

场景 3 · compaction 风暴: 写放大压垮 P99 的调参

写入高峰 compaction 跟不上, 段堆积, 读延迟翻 10 倍 — 三招救回。

# 症状: flush 队列堆积, stall (写入被卡) 频发
# 看板: compaction_pending > 20, write_amplification > 25

# 1) memtable 加大, 减少 flush 频率
write_buffer_size = 256MB          # 64MB → 256MB
# 2) leveled + 限速, 把合并 IO 挪出高峰
rate_limit = 200MB/s               # 平滑后台 IO
# 3) 写入侧批量合并: 100 条/批, 放大直接 ÷10

结果: WA 从 28 降到 6, 写 stall 消失, P99 恢复 5ms。

场景 4 · Bloom filter 空间账: 误报率换内存

每个段一个 Bloom, 内存换读放大 — 用数字定 bits/key。

def bloom_fp(bits_per_key, k=7):
    m_n = bits_per_key
    return (1 - (1 - 1/m_n)**k) ** k

for bits in (5, 10, 15):
    print(bits, round(bloom_fp(bits)*100, 2), "%")
# 5 → 7.5%   10 → 0.82%   15 → 0.06%
# 10 亿 key × 10 bits ≈ 1.2GB 内存 — 换掉 90% 的无效段读

经验: OLTP 路径 10~15 bits/key; 冷数据归档 5 bits 也够。

场景 5 · 复合索引最左前缀: 慢查询 EXPLAIN 修复

联合索引 (store_id, status) 摆在那, 查询只按 status 过滤 — 索引装睡。

-- 错: 跳过最左列, 全表扫
EXPLAIN SELECT * FROM orders WHERE status = 'paid';
-- type=ALL, rows=1024000   ← 没用上索引

-- 对: 带上最左列, 索引范围扫描
EXPLAIN SELECT * FROM orders
WHERE store_id = 7 AND status = 'paid';
-- type=ref, key=idx_store_status, rows=340

原则: 复合索引列序 = 过滤区分度优先; 跳过最左列的查询另建索引。

场景 6 · 覆盖索引: 报表 SQL 免回表快 8 倍

商品列表只要 id/price/stock — 把这三列装进索引, 查询不碰表。

-- 建覆盖索引: 查询列全部进索引
CREATE INDEX idx_listing ON products(category, price, stock);

EXPLAIN SELECT id, price, stock FROM products
WHERE category = 'keyboard' ORDER BY price;
-- Extra: Using index ← Index Only Scan, 0 次回表

-- 代价自查: 索引列越多, 写放大与空间越大 — 别把整表塞进索引

收益: 随机回表 30 万次 → 0 次, P99 120ms → 15ms。

场景 7 · fsync 风暴: 每事务一次刷盘的代价与组提交

每笔订单都等 fsync, 机械盘一次 ~10ms — 吞吐被"持久性税"锁死。

# PostgreSQL: synchronous_commit 组提交
# 错: 每事务独立 fsync (commit_delay=0, 低并发下最贵)

# 对: 组提交 — 多个事务攒着一起刷一次盘
commit_delay = 1000      # μs, 等 1ms 攒同伴
commit_siblings = 5      # 活跃事务 ≥5 才启用
# 效果: 200 TPS → 2600 TPS, 持久性语义不变

# 红线: synchronous_commit=off 只能用于可丢会话数据

顺序: 先攒批, 再考虑关 fsync — 后者是拿持久性换的。

场景 8 · 列存 + RLE: 一张 10TB 行表压成 1.2TB Parquet

订单表转列存: 同列相似度高, 压缩与扫描双赢。

import pyarrow.parquet as pq
# 行存 10TB → 列存: status 列 99% 是 'paid'
# RLE: 'paid'×999, 'refund'×1 → 编码后几乎为 0

pq.write_table(table, "orders.parquet",
               compression="zstd", use_dictionary=True)

-- 查询只碰 2 列: 10TB 全扫 → 200GB 列段扫描
SELECT status, count(*) FROM orders GROUP BY status;

列存的本质红利: 读的量与查询涉及的列成正比, 与表宽无关。

场景 9 · 语义搜索: pgvector + HNSW 建索引

100 万条文档做"意思相近"检索, flat 暴力扫 300ms, HNSW 5ms 召回 96%。

-- pgvector: 建表 + HNSW 索引
CREATE TABLE docs (id bigint, body text,
                   vec vector(768));
CREATE INDEX ON docs USING hnsw (vec vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

-- 查询: cosine 距离 top 10
SELECT id, body FROM docs
ORDER BY vec <=> $1::vector LIMIT 10;
-- ef_search=64: 召回 96%, 5ms — flat 是 300ms 全扫

调参方向: 召回不够 → 加 ef_search; 建索引太慢 → 调 ef_construction。

场景 10 · 模糊搜索: GIN + pg_trgm 任意子串

用户搜 "klboard" 这种拼错的词 — B-Tree LIKE '%x%' 全表扫, trigram 倒排毫秒出。

-- 三字词滑窗: "mechanical" → mec, ech, cha, han, ...
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_name_trgm ON products
  USING gin (title gin_trgm_ops);

SELECT title FROM products
WHERE title % 'klboard';     % 相似度运算符
-- LIKE '%board%' 也能走 GIN — 而不是全表扫

原理: 查询词也切成 trigram, 与倒排求交集 — 全文/子串的通用解。

⚠️ 编码注意与常见坑 pitfalls

坑 1 · 把 LSM 当读优引擎 — 点查要穿多层段+多版本, 读延迟不稳. 原因: 只看"写快". 正解: 读敏感负载用 B-Tree 或加 Bloom+大块缓存。
# 错: 余额查询放 Cassandra (Dynamo 系 LSM)
# 对: 读稳优先 → InnoDB/PG (B-Tree)
坑 2 · compaction 不设限 — 写入持续超合并能力, 段无限堆积直至磁盘爆. 原因: 只算写入没算合并. 正解: 监控 pending compaction + 写入限速。
# 错: 无限速灌 50 万写/s
# 对: stall 指标告警 + 后台 IO 限速
坑 3 · tombstone 永不清理 — 大批删除后空间不回收, 读还背着墓碑扫. 原因: 等着"自动"合并. 正解: 删后手动触发 major compaction。
# 错: DELETE 5000 万行就收工
# 对: 删除窗口 + 随后 gc_grace 过期压实
坑 4 · 把 Bloom"有"当"真有" — 误报被当命中, 业务读到空. 原因: 不懂概率语义. 正解: Bloom 只用于"必无则跳", 命中必须回源确认。
# 错: bloom.contains(k) → 直接返回缓存 miss
# 对: bloom 拦截"必无", 其余走正常读
坑 5 · 无脑调页大小 — 页太大随机读浪费 IO, 太小树高增加. 原因: 拍脑袋. 正解: 负载定型后基准测试再定 (默认 4~16KiB 通常合理)。
# 错: page_size=64KiB "提升 IO 效率"
# 对: 点查密集 4~8KiB, 顺序扫描可大页
坑 6 · fsync 当免费 — 每条日志一次 fsync, 机械盘把吞吐锁死. 原因: 低估持久性成本. 正解: 组提交/攒批, 高频日志合并刷。
# 错: for log: append; fsync()
# 对: 攒 10ms 一次 fsync (呼应 IO 页组提交)
坑 7 · 关 WAL 换性能 — 崩溃后数据/索引损坏, 复制也没了根基. 原因: 性能焦虑. 正解: WAL 是底线, 优化方向是组提交与盘。
# 错: fsync=off / wal_level=minimal 混生产
# 对: WAL 全开, 压力走批量与限速
坑 8 · 二级索引当免费午餐 — 每加一个索引, 每次 INSERT 多写一棵树. 原因: "以防万一"建索引. 正解: 按 P99 查询建, 每季度清未使用索引。
# 错: 20 个索引, 写入 5×放大
# 对: pg_stat_user_indexes 清 idx_scan=0
坑 9 · 复合索引列序写反 — (status, store_id) 对按 store 查无用. 原因: 不懂最左前缀. 正解: 等值列在前、范围列在后。
# 错: WHERE store_id=? 用 idx(status, store_id)
# 对: idx(store_id, status) + store_id 等值在前
坑 10 · 函数包索引列 — WHERE upper(name)= 让 B-Tree 索引失效. 原因: 索引存的是原值. 正解: 函数索引或改写查询。
# 错: WHERE DATE(created)=today
# 对: 范围改写 / CREATE INDEX ON f(created)
坑 11 · 覆盖索引建太宽 — 把整行塞进索引 = 复制一张表. 原因: 免回表上瘾. 正解: 只覆盖热点查询的 2~3 列。
# 错: INCLUDE 12 列的大索引
# 对: 热点查询逐个评估覆盖收益
坑 12 · 随机 UUID 当聚簇主键 — 插入点随机分布, 页分裂与缓存击穿双杀. 原因: 主键随手生成. 正解: 趋势递增主键 (自增/雪花/uuidv7)。
# 错: v4 UUID 做 InnoDB 主键
# 对: uuidv7 / 雪花 ID (时间前缀, 顺序插入)
坑 13 · 内存库当持久存储 — Redis 当 SoR, 重启/故障数据蒸发. 原因: 只享受快. 正解: 内存库配持久化 (AOF/RDB/快照) 且明确可丢窗口。
# 错: 订单只写 Redis
# 对: Redis 缓存 + 库为 SoR, 或开启 AOF everysec
坑 14 · 列存当 OLTP 用 — 单行 UPDATE 在列存里要改所有列段. 原因: "压缩率高"的诱惑. 正解: 列存只服务扫描分析, 更新走批/Delta。
# 错: 订单交易库选 ClickHouse
# 对: TP 用行存, T+0 同步给列存分析
坑 15 · 高基数列上用 bitmap — 100 万 distinct 值 = 100 万个位图, 爆炸. 原因: 编码不分场合. 正解: 低基数 bitmap, 高基数用字典/排序。
# 错: user_id 列 bitmap 编码
# 对: status/city 低基数列才 bitmap
坑 16 · 向量检索盲信默认参数 — HNSW ef_search 太小, 召回悄悄掉到 60%. 原因: 没量化召回率. 正解: 用标注集测 recall, 再调 ef/m。
# 错: 上线即用, 不测召回
# 对: recall@10 > 0.95 才算达标
坑 17 · 距离度量选错 — 语义向量用 L2 而非 cosine, 排序全歪. 原因: 不看嵌入模型的训练目标. 正解: 模型文档说什么用什么 (多数是 cosine/内积)。
# 错: OpenAI embedding + L2 距离
# 对: vector_cosine_ops / 归一化后内积
坑 18 · hash 索引硬做范围 — WHERE age > 18 全表扫. 原因: 只记得 hash 快. 正解: 范围条件必须 B-Tree/有序结构。
# 错: hash index 上跑 BETWEEN
# 对: range 列建 B-Tree 索引
坑 19 · 嵌入维度无脑拉满 — 4096 维 × 1 亿条 = 1.6TB 裸向量. 原因: 越大越准的迷信. 正解: 小模型降维 (768/512) + 量化 (PQ)。
# 错: 全库 4096 维 float32
# 对: 768 维 + product quantization
坑 20 · wide-column 当列存 — Cassandra 每行仍是行存 KV, "列族"不是列压缩. 原因: 名字误导. 正解: 分析扫描用真列存 (Parquet/ClickHouse)。
# 错: 用 HBase 做报表聚合扫描
# 对: 宽列存 KV 点查, 分析导出到列存