Ch.4: 字节放哪、怎么找 — LSM 追加写优 vs B-Tree 原地读写优; 行存/列存/倒排/向量四套索引各有各的题
存储引擎回答一个问题: 字节放哪、怎么找到它。LSM-Tree像记账本 — 收银时只往最后一页流水账上追加 (顺序写飞快), 打烊后再慢慢誊清合并 (compaction); 查账要翻好几本账 (读放大), 但前面贴了Bloom filter便签"这本肯定没有"就整本跳过。B-Tree像图书馆卡片柜 — 每格固定大小、原地改卡片, 找书顺着目录 4 层必达, 但每次改都要拿笔涂改还可能换格子 (页分裂)。剩下的都是"给谁快": 行存给"取一行", 列存给"算一列", 倒排给"搜词", 向量给"像不像"。
index = {} # key → 文件偏移
index["user:42"] = offset # 追加后记位置
# get: seek(offset) 一次读 — 快!
# 但 WHERE age > 18? hash 无能为力mem = {} # memtable
mem["user:42"] = data # 满了 → flush SSTable
# read(key): memtable → bloom → L0→L2 段
# 同 key 多版本 → 取"最新段"的值# tiered: L0:[1MB,1MB,1MB] → 合并成 3MB 段 # leveled: L1 键范围 [a~m] 与 L2 [a~f] 重叠 → 下推 # 同 key 留最新, 清 tombstone, 回收空间
# m=10 bits/key, k=7 hash → 误报 ~1% # bloom 说"没有" → 100% 没有, 直接跳段 # bloom 说"有" → 可能误报, 真去段里查
put(key, "TOMBSTONE") # 删除 = 写标记 get(key) # 读到墓碑 → 不存在 # compaction 遇到墓碑且无旧值 → 物理清除
# 点查路径: root → inner → leaf = 3~4 页 # 常驻缓存后 = 内存级点查 # 范围查询: 叶子层顺序扫 — 天生友好
# 写事务 = append WAL (fsync) + 改内存页 # 崩溃 → 重放 WAL 恢复到一致点 # 复制: follower 重放同一份 WAL
# LSM: 写 1GB 数据物理写 5~20GB (WA=5~20) # B-Tree: 改一行 = 写 1 页 + WAL (WA≈2) # 空间: LSM 段并存可到 1.5~2×
CREATE INDEX idx_email ON users(email); -- InnoDB 二级索引叶 = 主键值 → 回表拿整行 -- PG 二级索引叶 = ctid 行指针 → 回表拿整行
-- 覆盖: 答案全在索引里, 不碰表 CREATE INDEX idx ON orders(user_id, amount); SELECT user_id, sum(amount) FROM orders GROUP BY user_id; -- Index Only Scan
# 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
# 文档 d1:"数据库系统" d2:"文件系统" "系统" → [d1, d2] # postings list "数据" → [d1] # 查"数据 系统" = 交集 AND → [d1]
q = embed("怎么优化慢查询") # [0.12, -0.9, ...]×768 sim = cosine(q, doc_vec) # HNSW: 从顶层图贪心下降 → 近邻几跳可达 # 召回 95% + 速度 ×1000 = ANN 的交易
埋点事件每秒 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 倍。
余额点查要求延迟稳定可预测 — 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 吞吐高。
写入高峰 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。
每个段一个 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 也够。
联合索引 (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
原则: 复合索引列序 = 过滤区分度优先; 跳过最左列的查询另建索引。
商品列表只要 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。
每笔订单都等 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 — 后者是拿持久性换的。
订单表转列存: 同列相似度高, 压缩与扫描双赢。
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;
列存的本质红利: 读的量与查询涉及的列成正比, 与表宽无关。
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。
用户搜 "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, 与倒排求交集 — 全文/子串的通用解。
# 错: 余额查询放 Cassandra (Dynamo 系 LSM) # 对: 读稳优先 → InnoDB/PG (B-Tree)
# 错: 无限速灌 50 万写/s # 对: stall 指标告警 + 后台 IO 限速
# 错: DELETE 5000 万行就收工 # 对: 删除窗口 + 随后 gc_grace 过期压实
# 错: bloom.contains(k) → 直接返回缓存 miss # 对: bloom 拦截"必无", 其余走正常读
# 错: page_size=64KiB "提升 IO 效率" # 对: 点查密集 4~8KiB, 顺序扫描可大页
# 错: for log: append; fsync() # 对: 攒 10ms 一次 fsync (呼应 IO 页组提交)
# 错: fsync=off / wal_level=minimal 混生产 # 对: WAL 全开, 压力走批量与限速
# 错: 20 个索引, 写入 5×放大 # 对: pg_stat_user_indexes 清 idx_scan=0
# 错: WHERE store_id=? 用 idx(status, store_id) # 对: idx(store_id, status) + store_id 等值在前
WHERE upper(name)= 让 B-Tree 索引失效. 原因: 索引存的是原值. 正解: 函数索引或改写查询。 # 错: WHERE DATE(created)=today # 对: 范围改写 / CREATE INDEX ON f(created)
# 错: INCLUDE 12 列的大索引 # 对: 热点查询逐个评估覆盖收益
# 错: v4 UUID 做 InnoDB 主键 # 对: uuidv7 / 雪花 ID (时间前缀, 顺序插入)
# 错: 订单只写 Redis # 对: Redis 缓存 + 库为 SoR, 或开启 AOF everysec
# 错: 订单交易库选 ClickHouse # 对: TP 用行存, T+0 同步给列存分析
# 错: user_id 列 bitmap 编码 # 对: status/city 低基数列才 bitmap
# 错: 上线即用, 不测召回 # 对: recall@10 > 0.95 才算达标
# 错: OpenAI embedding + L2 距离 # 对: vector_cosine_ops / 归一化后内积
WHERE age > 18 全表扫. 原因: 只记得 hash 快. 正解: 范围条件必须 B-Tree/有序结构。 # 错: hash index 上跑 BETWEEN # 对: range 列建 B-Tree 索引
# 错: 全库 4096 维 float32 # 对: 768 维 + product quantization
# 错: 用 HBase 做报表聚合扫描 # 对: 宽列存 KV 点查, 分析导出到列存