你在为一个交易/账本系统选底层存储引擎:订单表写入峰值 10 万 QPS,点查(按订单号)5 万 QPS,单表数据量增长到 10 TB+,宕机后必须零数据丢失恢复(RPO=0),且大量并发读写不能互相阻塞。摆在面前的问题不是「用 MySQL 还是 Postgres」这种表层选择,而是:这些数据库内部的存储引擎,凭什么保证一次 UPDATE 既快又不丢、还能被别的事务同时读到一致快照?
要回答这个,必须打开数据库这个黑盒,看清四件事:数据在盘上怎么组织(B-tree vs LSM)、崩溃了怎么不丢(WAL)、并发读写怎么不打架(MVCC)、一条 SQL 怎么被翻译成最优的盘上访问(优化器)。这也是设计存储密集型系统、以及面试架构师岗位的分水岭。
graph TD
SQL["SQL 查询"] --> PARSE["Parser + Planner
基于代价的优化器"]
PARSE --> EXEC["Executor
Access Method 接口"]
EXEC --> AM{"存储引擎"}
AM -->|读多| BT["B+Tree
InnoDB / Postgres"]
AM -->|写多| LSM["LSM-Tree
RocksDB / Cassandra"]
BT --> BP["Buffer Pool
页缓存 · 脏页"]
LSM --> MEM["MemTable
内存有序表"]
WRITE["写路径"] -.先写.-> WAL[("WAL / redo log
顺序追加")]
BP -->|checkpoint 刷脏页| DISK[("数据文件
heap / SSTable")]
MEM -->|flush + compaction| DISK
WAL -.崩溃恢复 redo/undo.-> BP
classDef q fill:#1a2530,stroke:#64c8ff,color:#e8eef5
classDef eng fill:#1a1a30,stroke:#ffb450,color:#e8eef5
classDef dur fill:#2a1530,stroke:#ff7ab6,color:#e8eef5
class SQL,PARSE,EXEC q
class BT,LSM,BP,MEM eng
class WAL,DISK dur
核心不变量:任何数据落地前,先写 WAL(顺序 IO);数据结构本身决定读/写/空间放大三者的取舍
原理:磁盘(尤其机械盘和 SSD)的顺序写远快于随机写。B+Tree 把数据原地(in-place)组织成有序的页,查找 O(log n) 且每次点查只读一个叶子页——读友好;但更新一行要把对应页读进来、改、再写回,是随机写,且一页只改几字节也要整页刷盘(写放大)。LSM-Tree 反过来:写只追加到内存 MemTable,满了就顺序 flush 成不可变的 SSTable,后台再 compaction 合并——把随机写变成顺序写,写吞吐极高;代价是一个 key 可能散落在多层 SSTable,点查要查多层(读放大),靠 Bloom Filter 缓解。
# LSM 点查:从新到旧逐层找,Bloom Filter 先挡掉不存在的层
def lsm_get(key):
if v := memtable.get(key): # ① 内存最新
return None if v is TOMBSTONE else v
for sst in sstables_newest_to_oldest(): # ② L0→Ln 逐层
if not sst.bloom.might_contain(key): # 假阳性率 ~1%
continue # 绝大多数层被跳过
if v := sst.get(key): # 命中则返回(删除是墓碑)
return None if v is TOMBSTONE else v
return None
# 写:只 append,删除也是「写一个墓碑」,真正回收在 compaction
原理:数据页在内存 buffer pool 里被改成「脏页」,若此时宕机、脏页还没落盘,数据就丢了。WAL(Write-Ahead Logging)的铁律:任何对数据页的修改,其 redo 日志必须先顺序写入磁盘并 fsync,才允许脏页刷盘。这样崩溃后重放 WAL 就能重建内存中丢失的修改。顺序写 WAL 也顺便把「多个随机写」摊销成「一次顺序写 + 异步刷脏页」,反而提速。工业标准是 ARIES 算法:三阶段恢复——Analysis(找出崩溃时的脏页和活跃事务)、Redo(重放到崩溃瞬间的状态,含未提交事务)、Undo(回滚未提交事务),配合 LSN、checkpoint、CLR 保证幂等可重入。
innodb_flush_log_at_trx_commit=1):✅ RPO=0、真持久;❌ 每次 commit 一次磁盘同步,延迟高、吞吐受限。# 提交时的 WAL 顺序(简化)
def commit(txn):
lsn = wal.append(txn.redo_records) # ① 顺序追加 redo(还含 undo 信息)
wal.fsync_up_to(lsn) # ② 强制落盘 —— 这一步返回后才算「已提交」
mark_committed(txn) # ③ 现在才回复客户端 OK
# 脏页稍后由 checkpoint 异步刷;崩溃则靠 redo 重放补回
pg_wal 目录即日志;物理复制、PITR 全靠它。原理:如果读写都靠锁互斥,一个长事务的读会把写全堵死。MVCC(多版本并发控制)让每次更新不覆盖旧行而是产生一个新版本,每行带创建/删除的事务时间戳(xmin/xmax)。事务开始时拿一个快照,读取时只看「对我可见」的版本——于是读永远不加锁、看到一致快照,写也不用等读。这就是 REPEATABLE READ / Snapshot Isolation 的实现基础。代价是旧版本会堆积,必须回收:Postgres 靠 VACUUM 清理死元组,InnoDB 靠 purge 线程清理 undo。
# 可见性判断(Postgres 风格,简化)
def visible(tuple, snapshot):
# 创建该版本的事务已提交,且在我的快照之前
if not committed_before(tuple.xmin, snapshot):
return False
# 该版本未被删除,或删除它的事务对我不可见
if tuple.xmax and committed_before(tuple.xmax, snapshot):
return False
return True
# 读只扫版本链挑「可见」的那个,全程不加锁
原理:同一条 SELECT ... WHERE a=? AND b>? 可以有多种执行方式:全表扫、走 a 的索引再过滤、走复合索引 (a,b) 直接定位。基于代价的优化器(CBO)用统计信息(直方图、distinct 值数、行数)估算每种方案要读多少页、结果集多大,选代价最小的。索引本身是 B+Tree:聚簇索引叶子存整行,二级索引叶子存主键(需回表),覆盖索引让查询列全在索引里、免回表。选择性(selectivity)是关键——低选择性列(如性别)走索引反而更慢。
-- 用 EXPLAIN 看优化器到底怎么走
EXPLAIN ANALYZE
SELECT order_id, amount FROM orders
WHERE user_id = 42 AND created_at > '2026-01-01';
-- 期望: Index Scan using idx_user_created (user_id, created_at)
-- 若见 Seq Scan + Filter → 统计过期或选择性误判, 先 ANALYZE orders;
-- 复合索引 (user_id, created_at): user_id 等值在前, 范围列在后
EXPLAIN (ANALYZE, BUFFERS),直方图统计存 pg_statistic。WHERE date(t)=...)都会让索引用不上,是最高频线上慢查询。innodb_flush_log_at_trx_commit=2 或 synchronous_commit=off 会在崩溃时丢已回复 OK 的事务。账本/支付绝不能碰,这是 RPO≠0。EXPLAIN 验证而不是猜。面试高频追问:①「为什么顺序写比随机写快,SSD 上还成立吗?」②「WAL 里既有 redo 又有 undo,各自作用?ARIES 三阶段?」③「MVCC 下两个事务同时更新同一行会怎样(写写冲突/first-committer-wins)?」④「聚簇索引 vs 二级索引,回表是什么,覆盖索引怎么避免?」⑤「Snapshot Isolation 能防住哪些异常,防不住什么(write skew)?」
表面看 SSD 随机 IOPS 很高,但优势没消失,原因在写放大与 SSD 内部机制:
结论:SSD 缩小了差距,但没抹平;真正要比的是端到端写放大和延迟毛刺,而非单纯 IOPS。
这是 MVCC + VACUUM 的经典连锁:
修法:拆分/限时长事务(idle_in_transaction_session_timeout)、把分析查询放只读副本、监控 pg_stat_activity 里的 xact_start 和最老快照年龄、必要时 VACUUM FULL 或 pg_repack 回收空间。这也是「OLTP 和 OLAP 要分库」的底层理由之一。
经典例子——医院值班:约束是「任何时刻至少 1 名医生值班」。当前 Alice、Bob 都在值班。两人同时点「我请假」:
这就是 write skew:两事务基于同一份读快照做决策,各改各的,合起来违反了跨行不变量。
修法:①Serializable Snapshot Isolation(SSI)——Postgres 的 SERIALIZABLE 会检测读写依赖环并中止一个事务。②显式加锁物化冲突——SELECT ... FOR UPDATE 锁住相关行,或对「值班人数」这个聚合加一把锁/一行计数器,把隐式冲突变成显式写写冲突。
两种设计真实存在,取舍相反:
本质是「间接层放哪」的取舍:主键间接层换来更新时索引稳定(写省),物理地址换来读时少一跳(读省)——又一次 read/write 放大的对立。
不行,这正是 ARIES「先 Redo 全部、再 Undo 未提交」设计的精妙之处,核心是幂等与状态一致:
page.LSN < record.LSN 的才重放;undo 写 CLR(补偿日志记录)记录回滚进度。于是恢复可以反复重启而不出错。「跳过未提交的」听起来省事,但你根本无法知道磁盘上哪些未提交改动已经落盘——不重放到已知状态,就没有安全的回滚基线。