专业书籍精读 · Database Internals · 第 1 章
Database Internals · Ch 1 · Alex Petrov · 2019
你在购物 App 里点一下搜索,一秒出结果;财务同事跑一张全年报表,要等半分钟。两件事都叫「查数据库」,可背后那台机器长得完全不一样。《Database Internals》(数据库内幕)第 1 章不教你怎么用数据库,而是把外壳拆掉,让你第一次看清里面有哪些零件、各管什么。
把数据库想成一家大型仓储超市。你递进去一张购物清单:门口的接待员收下单子;有个店员看懂清单、顺便规划一条最省腿的取货路线;另一个店员照着路线真的跑去拿货。而最底下、也最要紧的一层,是货架本身怎么摆。这一层叫「存储引擎」,也是这本书接下来几百页要死磕的东西。
怪事来了:MySQL、Redis、ClickHouse 都自称数据库,但你把跑报表的活交给前者、把下单的活交给后者,快慢能差出上百倍。差别不在牌子,在货怎么摆。
一种摆法是按顾客的篮子打包:一个订单的所有信息挨在一起,看某一单一伸手就全拿到——适合下单场景。另一种是按品类上架:所有订单的「金额」堆一处、「日期」堆另一处;要算全年总金额,只去「金额」那堆扫一遍,别的货架碰都不用碰——适合报表,而且同一堆里的东西长得像,压缩起来还格外省地方。
还有一层是放哪儿:全摊在柜台上(内存)伸手就够着,但柜台很贵、一停电就全没;还是老实堆进仓库(硬盘),便宜、断电也还在,代价是每次都得跑一趟。
这一章最值钱的,是作者给了三个「旋钮」——拧出不同组合,就是市面上各种存储方式。
一、要不要先攒着。来一笔就往仓库跑一趟,还是先在便签上记几十笔、攒够了一次搬进去?攒着写起来省力得多,但那张便签万一丢了就麻烦,得另想办法保底。
二、改东西的方式。翻回原来那页把旧数字擦掉重写,还是在本子最后另起一行、旧那行作废?前者省地方但每次得先翻页找位置;后者写得飞快,代价是本子越来越厚、得定期整理。
三、要不要排好序。像通讯录那样按字母排好,找「张」开头的一整段能连着翻;还是随手记、靠目录去找。排好序方便一次拿一整段,但每来一条新的都得插进正确位置。
所以这一章真正想让你改掉的,是「哪个数据库最好」这个问法。该问的是:这活是一次看几条、还是一次扫上亿条?读多还是写多?数据热不热、装不装得进柜台?作者还提醒了一句挺扎心的话:别拿别人的跑分选数据库——跑分用的活跟你的多半不是一回事。
诚实的代价是:这三个旋钮没有哪个组合全能——你把写变快,通常就得在读、在占地方、或者在事后整理的开销上还回去。
数据库不是黑箱,它是四层:接单 → 规划路线 → 跑去拿 → 东西怎么摆。决定它擅长什么的是最底下那层。而摆法只有三个旋钮:写之前攒不攒、改的时候擦掉重写还是另起一行、要不要按顺序排好。选数据库,其实就是在选这三个旋钮的组合。
想看真正的解剖图、具体量级和落地案例? → 切到精读版
你以为选数据库是在选品牌,其实是在选三个旋钮。本章先把「数据库」这个黑箱词拆成四层——传输、查询处理、执行、存储引擎,再给出三把分类尺(内存 / 磁盘、行存 / 列存、数据文件 / 索引文件),最后收束成贯穿全书的三个变量:缓冲(buffering)、可变性(mutability)、顺序(ordering)。后面六百多页,都是在这个坐标系里填格子。
8 KB,InnoDB 16 KB)。作者 Alex Petrov 是 Apache Cassandra 的贡献者。本章是全书 Part I「存储引擎」的开篇,同时是整本书的总纲。阅读序列上它正好接在 DDIA 之下一层:DDIA 第 3 章告诉你「LSM-tree 和 B-tree 各是什么性格」,本书从这一章起一路钻到页布局、分裂合并、恢复协议的实现细节。下启 Ch2–Ch7(B-Tree 基础、文件格式、B-Tree 实现、事务与恢复、B-Tree 变体、日志结构存储)与 Part II 分布式篇。
「数据库」是个被用滥了的黑箱词:它同时指 PostgreSQL、Redis、ClickHouse、Cassandra——这四个东西的内部构造差异,比汽车和摩托艇的差异还大。不把这个词拆开,选型就只能靠跑分和市场话术。
代价是具体的。一个日均 2000 万 订单、峰值 5k QPS、每次只读写一两条记录的交易系统,若选了列式存储,单条更新会退化成对几十个列文件各改一次;反过来,一张 200 列、日增 50 亿 行、永远只聚合其中三五列的日志表,若放进行存数据库,每次扫描都要把用不上的 197 列一并从磁盘搬进内存。更麻烦的是这类错误在压测阶段常常藏得住——数据量小时缓存全兜住了,等涨到 TB 级、命中率掉下来,才在生产上原形毕露。
这一章就是给你一套解剖坐标。Petrov 开篇很诚实地承认:数据库设计并没有一张统一蓝图——但绝大多数系统仍能被同一张分层图套住,而它们真正的分歧,最后都能归到三个变量上。
图里最值得停一下的是查询优化器:它依据统计信息估算各条路线的代价,把选中的方案表达成一棵操作依赖树(执行计划)。量级感很关键——一条两表连接,「先过滤再连接」和「先连接再过滤」的扫描量能差好几个数量级,优化器省下的往往不是 10%,是 100 倍。执行引擎分本地与远程,后者是分布式里跨节点取数再拼回结果的那部分,也是本书 Part II 的伏笔。
存储引擎那五个零件里,最容易混的是前两个:事务管理器管逻辑一致(转账两边要么都成要么都不成),锁管理器管物理别写坏(字节别被两只手同时改乱)——一个管业务语义,一个管并发安全。访问方法就是存储结构本身(堆文件、B-Tree、LSM-Tree),数据以什么形状躺在盘上由它决定,也是接下来六章的全部内容。面试里那道经典的「一条 SQL 是怎么执行的」,照这四层复述就是标准答案。
先建立量级感,这是整个存储引擎学科存在的理由:内存随机访问约 100 纳秒;NVMe SSD 随机读约 100 微秒(慢约一千倍);机械盘一次寻道约 10 毫秒(慢约十万倍)。正是这三个数量级的落差,逼出了页、缓冲池、顺序写这一整套设计。
磁盘型(disk-based)数据主要在磁盘、内存只做缓存。因为磁盘只能按页读写,你得操心一堆内存里不存在的问题——记录怎么序列化成字节、一页里怎么摆、指针怎么表达成文件偏移量、怎么让「常一起被读」的数据落在同一页。B-Tree 的全部复杂性都是被这件事逼出来的。而内存型(in-memory)可以直接用指针型结构(链表、跳表),不必为「对磁盘友好」扭曲结构,实现反而更简单。
最常见的误解是「内存数据库不持久」。不对:内存型照样靠 WAL + 周期快照落盘——写先追加进磁盘日志,再定期把内存整体存一份,崩溃后加载快照、重放其后的日志。至于内存装不下:磁盘型的答案是缓冲池(内存放页的副本,正本在磁盘),反缓存(anti-caching)则反过来——以内存为正本,满了就把最久没用的记录踢到磁盘。区别只在「谁是正本」这一句。
量级感:热数据 200 GB 以内、要求 p99 亚毫秒的会话 / 计数 / 排行榜,内存型很划算;而一个 20 TB、访问高度倾斜(5% 数据吃掉 90% 请求)的业务主库,全塞内存是花钱买不需要的东西——磁盘型配一个够大的缓冲池才是答案。
行存:一条记录的所有字段在磁盘上挨着,取整条记录一次 I/O 就够——这正是 OLTP 想要的。列存:同一列的所有值挨着、各列往往各自成文件。一条 SELECT date, sum(amount) … GROUP BY date 打在一张 200 列的宽表上只需要读 2 列,理论 I/O 是行存的 1%;更妙的是同一列的数据高度同质——同一个国家码、同一天的日期大量重复,压缩率天然高一个档次,磁盘省下的同时,搬进内存的字节也一起省了。
代价同样具体:列存把一行拆散到多个文件,插入或更新单条记录会变成对多个文件各改一次;而且要靠某种隐式的位置编号(每列的第 i 个值属于第 i 行)才能把一行拼回来。所以列存几乎都被设计成批量写、极少更新。
Petrov 在这里专门纠正一个高频误解:BigTable / HBase / Cassandra 这类宽列存储(wide column store)不是列式数据库。它们把数据组织成多维映射(行键 → 列族 → 列 → 时间戳版本),而列族内部仍按行连续存放。名字里有 column,性格却更接近行存——把 Cassandra 说成「列式数据库」,是面试里非常经典的扣分点。
数据库把东西存进两类文件:数据文件装记录本身,索引文件装「怎么快速定位到记录」。数据文件有三种组织方式:堆组织(heap-organized)——记录按写入顺序随便堆、不维护任何顺序,写最快但找必须靠索引;索引组织(index-organized table, IOT)——数据直接存在索引结构里,按键有序,主键查一次到位、范围扫描连续;哈希组织(hash-organized)——按键的哈希落桶。与之配套的是聚簇(clustered)与非聚簇(nonclustered):聚簇指记录的物理顺序跟着索引键的顺序走,非聚簇则数据另存一处、顺序与键无关。
然后是本章最耐嚼的一个权衡:二级索引里该存什么?
PostgreSQL 走 A,MySQL InnoDB 走 B。这不是学术分歧——它直接决定了一张表挂十几个索引时写放大有多严重,下一节 Uber 的实证就是它的账单。
本章最后收束成一句非常好用的话:任何存储结构都可以用三个变量描述。
① 缓冲(buffering)——写入前要不要先在内存里攒一批。攒的价值是把大量小随机写合并成少量顺序写:机械盘上顺序写比随机写快约两个数量级,SSD 上则显著降低写放大、也更护寿命。代价是攒着的那部分一断电就没,所以必须配 WAL 兜底。LSM-Tree 的内存表(memtable)是最典型的例子。
② 可变性(mutability)——原地更新(in-place)还是只追加(append-only)。原地改省空间、读路径短,代价是随机写多、要处理页内碎片和并发改同一页;只追加写得极快、天然对顺序 I/O 友好,代价是空间放大加上后台合并(compaction)的持续开销——同一条记录的多个历史版本得靠合并才能清掉。Copy-on-Write B-Tree(如 LMDB)就是「B-Tree 的形状 + 不可变的写法」这种组合。
③ 顺序(ordering)——记录在磁盘上是否按键有序。有序换来范围扫描(WHERE date BETWEEN …)能连续读,代价是每次写都要维持顺序;无序(堆文件 + 哈希索引)写最快,但只擅长按键点查。
这三个变量是全书的坐标系:后面每讲一种结构,先问「它这三个旋钮拧在哪」,再去听实现细节——比死记结构名字有用得多。
本章收尾有一节相当反常识:比较数据库比想象中难得多,而且大多数公开比较都不可信。理由有三层——跑分用的负载多半不是你的负载;同一个系统换配置、换硬件、换数据分布,结论能整个反过来;厂商写在文档里的一致性 / 持久性保证,未必真成立。Petrov 给的做法是:拿你自己的真实负载去跑(YCSB、TPC-C 只是起点不是终点);翻源码、issue tracker 和邮件列表,看「已知问题」长什么样;对分布式保证的宣称,去看第三方独立验证——他点名推荐了 Jepsen。
表 1 · 内存型 vs 磁盘型:正本放哪儿
| 磁盘型 disk-based | 内存型 in-memory | |
|---|---|---|
| 正本在 | 磁盘;内存里是缓冲池(页的副本) | 内存;磁盘只用于恢复(反缓存时才把冷记录踢下去) |
| 访问延迟 | SSD 随机读 ~100 µs,机械盘寻道 ~10 ms | ~100 ns,快约 10³–10⁵ 倍 |
| 结构复杂度 | 高——要管页布局、序列化、文件偏移量 | 低——可直接用指针型结构(链表、跳表) |
| 持久性 | 天然落盘 + WAL | 照样持久:WAL + 周期快照,崩溃后重放 |
| 成本天花板 | TB ~ PB 级都便宜 | 受内存容量与单价硬约束 |
| 该选它当 | 数据 TB 级、访问倾斜(如 5% 数据吃 90% 请求) | 热数据 <200 GB、要 p99 亚毫秒:会话、计数、排行榜 |
表 2 · 行存 vs 列存 vs 宽列:三个常被混为一谈的东西
| 行存 row-oriented | 列存 column-oriented | 宽列 wide column | |
|---|---|---|---|
| 磁盘布局 | 同一行的字段连续 | 同一列的值连续,各成文件 | 行键 → 列族;列族内仍按行连续 |
| 擅长 | 点查、单条读写(OLTP) | 大范围聚合、只碰少数列(OLAP) | 海量行的按键读写 + 稀疏字段 |
| 典型 I/O | 读一行 = 一次 I/O | 200 列表只读 2 列 ≈ 1% 的数据量 | 读一行 ≈ 一次(每列族一次) |
| 压缩 | 一般——同行字段类型杂 | 好——同列同质、重复度高 | 一般 |
| 单条更新 | 便宜 | 贵——要改多个列文件 | 便宜 |
| 代表 | PostgreSQL、MySQL | ClickHouse、Vertica | BigTable、HBase、Cassandra |
| 最常见误解 | — | — | 把它当成列式数据库(错) |
表 3 · 数据文件的组织方式与索引引用方式
| 堆组织 + 直接引用 | 索引组织(聚簇)+ 主键间接 | |
|---|---|---|
| 记录存在哪 | 独立的堆文件,按写入顺序随便堆 | 就在主键索引树里,按主键有序 |
| 二级索引存什么 | 记录的物理位置 | 记录的主键 |
| 读一条(走二级索引) | 1 跳,更快 | 2 跳,多一次主键索引查找 |
| 更新一个字段 | 记录换页 → 所有二级索引都要改 | 只改真被碰到的那个索引 |
| 主键范围扫描 | 索引有序但数据无序 → 回表可能随机 I/O | 数据本身有序 → 连续读 |
| 代表 | PostgreSQL | MySQL InnoDB |
| 痛在哪 | 索引一多,写放大陡增 | 二级索引读路径长;主键变长则每个二级索引都变胖 |
表 4 · 三个旋钮拧出的常见结构
| 结构 | 缓冲 | 可变性 | 顺序 | 换来什么 / 付出什么 |
|---|---|---|---|---|
| B-Tree | 少 | 原地改 | 有序 | 读稳、空间省;写是随机 I/O,高写入下吃力 |
| Copy-on-Write B-Tree | 少 | 不可变 | 有序 | 读不加锁、崩溃恢复简单;每次改写要复制路径上的页 |
| LSM-Tree | 重 | 只追加 | 有序 | 写吞吐极高、顺序 I/O;读要查多层、后台合并持续吃 CPU 与带宽 |
| 堆文件 + 哈希索引 | 无 | 原地改 | 无序 | 写最省事、点查极快;做不了范围扫描 |
表 5 · 反过来用:给定场景,该往哪拧
| 场景 | 关键负载特征 | 该拧成什么 |
|---|---|---|
| 交易主库 | 5k QPS,每次读写 1–2 条,读写比 ~10:1,索引多 | 行存 + 磁盘型 + 有序;写重就考虑主键间接(少写放大) |
| 写入洪峰型日志 / 埋点 | 每秒几十万条追加,几乎不更新 | 重缓冲 + 只追加(LSM 系),别用原地改的 B-Tree |
| 报表 / 看板 | 单表 200 列、日增 50 亿 行,永远只聚合 3–5 列 | 列存;接受「批量写、几乎不更新」的约束 |
| 会话 / 计数 / 排行榜 | 热数据 <200 GB,要求 p99 亚毫秒 | 内存型;持久性靠 WAL + 快照,别以为它不持久 |
| 纯按键读写、无范围查询 | 只有 get(key) / put(key) | 无序(堆 + 哈希)最省;上了有序结构是白付写入成本 |
这一章的价值不在「知识点」,在于它给了一套能拆开任何数据库的坐标。看 RocksDB,先答:重缓冲、只追加、有序;看 LMDB:少缓冲、不可变、有序;看 Redis:内存正本、WAL + 快照兜底。面试里被问「为什么 Cassandra 写这么快、读却要当心」,答案不是背结论,而是把三个旋钮拧出来给面试官看——写快是因为重缓冲 + 只追加,读要当心是因为一条记录可能散在多层里、得靠合并才收敛。而下面这几条,是真实公司公开为这些权衡开出的账单。
REPEATABLE READ 会出现丢失更新等异常,既不满足 Adya 定义的可重复读、也不满足 ANSI SQL 那个含糊的定义。一个文档写着「可重复读」的隔离级别,实测下来名不副实——这就是「别轻信厂商文档、要看第三方独立验证」最硬的例子。Jepsen「MySQL 8.0.34」, 2023 ↗CLUSTER 命令只是一次性按索引顺序重排表,之后新写入照样乱序,并不维护持续聚簇的结构。① 本章是全书地图——把「数据库」这个黑箱拆成四层,再收束成三个变量。
② 四层解剖:传输 → 查询处理(解析 + 优化器出执行计划)→ 执行引擎(本地 / 远程)→ 存储引擎。存储引擎五件套:事务管理器(逻辑一致)、锁管理器(物理别写坏)、访问方法(存储结构本身)、缓冲管理器(页缓存)、恢复管理器(WAL)。
③ 内存 vs 磁盘是 100 ns 对 100 µs 对 10 ms——磁盘的页语义逼出了 B-Tree 的全部复杂性。内存型照样持久(WAL + 快照);反缓存与缓冲池的区别只在「谁是正本」。
④ 行存取整行一次到位;列存只碰用到的列(200 列表读 2 列 ≈ 1% 数据量)、压缩率更高,代价是单条更新贵。
⑤ 宽列 ≠ 列存:BigTable / HBase / Cassandra 的列族内部仍按行连续,性格是行存——最高频的一个误解。
⑥ 二级索引存物理位置还是存主键,决定了读快一跳还是写少放大——PostgreSQL 与 InnoDB 分踞两端,Uber 2016 年的迁移就是这道题的账单。
⑦ 全书坐标系是三个变量:缓冲(随机写变顺序写,须配 WAL)、可变性(原地改省空间 vs 只追加写得快但要合并)、顺序(有序换范围扫描,无序换写入速度)。
⑧ 拧法即结构:B-Tree = 少缓冲 + 原地改 + 有序;LSM-Tree = 重缓冲 + 只追加 + 有序;CoW B-Tree = 少缓冲 + 不可变 + 有序;堆 + 哈希 = 无缓冲 + 原地改 + 无序。
⑨ 作者的忠告:别拿公开跑分选数据库——用自己的负载测,翻源码与 issue,对分布式保证看第三方独立验证(Jepsen)。
⑩ 分寸:数据库设计没有统一蓝图;四层图在存算分离的云原生架构里已被拉开,但三变量的分类学依旧成立。