最近把《数据库系统概念》第十章重新翻了翻,说实话,这一章在数据库学习路径里经常被低估。我见过不少同学把 SQL、索引、执行计划背得滚瓜烂熟,但一遇到性能问题就抓瞎:明明建了索引,好像也走了索引,可查询还是慢得离谱;明明表不大,一条普通的更新却能卡住几秒。这些问题追到最后,几乎都回到同一个地方——存储和文件结构。
这一章讲的不是怎么写 SQL,也不是怎么调执行计划,而是数据库把数据放在磁盘上时,"放成什么样、怎么读最快、坏了怎么救"的问题。我这里按自己的理解,把教材里的存储层次、磁盘 IO、文件组织、页与记录、RAID 这几块拆开,结合一点实际排查经验,写一份能直接参考的笔记。看懂这章,很多之前觉得"玄学"的性能问题,其实都能变成可估算、可推导的算术题。
1. 这一章到底在解决什么问题
1.1 一条 SQL 背后藏着的"磁盘舞蹈"
先从一个最基本的场景说起。你执行一条SELECT * FROM orders WHERE order_id = 2024001,数据库做了什么?表面上看是"查出一条记录",但如果把视角放到存储层,它其实经历了一次完整的寻址过程:
- 客户端把 SQL 发给数据库,经过解析、权限校验、优化器生成执行计划。
- 存储引擎根据表名找到对应的数据文件,再根据索引或扫描路径定位到具体的文件偏移。
- 把偏移所在的那一整页数据从磁盘读到内存缓冲区。
- 在页内找到满足条件的记录,返回给上层。
这里面最容易忽略的一点是:数据库和磁盘打交道的最小单位,不是一条记录,而是一个"页"。哪怕你只想查一条 100 字节的记录,数据库也可能要把 8KB 的一整页读进来。页就是数据库在存储层的基本积木。
可以把这个过程想象成在图书馆找书:图书管理系统(索引)告诉你,某本书在第 3 排第 7 列的架子上,但你不能只把那一本书抽出来,而是要把那一整格(页)搬到阅读桌上,再在格子里挑需要的书。如果这一格刚好和周围的书是按编号连续摆放的,工作效率就很高;如果书架上的书是乱序的,你要在各个格子之间来回跑,速度自然就下来了。
1.2 为什么不能全靠缓存解决
有人会问:现代数据库不是有缓冲池吗?命中缓存不就不需要读磁盘了?话是这么说,但现实是:数据库的缓存只对"经常被访问的页"有效。范围查询、排序、全表扫描、索引随机回表、写操作后的刷盘,每一样都会产生真实的磁盘 IO。尤其 OLTP 系统里随机访问多,命中率很难做到 100%,只要出现一次缓存未命中,物理存储的性能就成了那个绕不过去的瓶颈。
所以这一章叫"存储和文件结构",本质上是在回答三个问题:
- 数据放在什么样的介质上,决定了"基础速度"。
- 数据在文件里怎么组织,决定了"访问路径"是否高效。
- 数据坏了怎么办,决定了"可靠性底线"。
这三个问题不搞清楚,后面的索引优化、查询优化都像是建在沙滩上的楼,看着漂亮,根基不稳。
1.3 这一章和后续章节怎么衔接
学完第十章,紧接着就是索引结构(B+ 树)、查询执行、事务恢复。这几章全部默认一个前提:数据以页为单位组织,页在文件里连续或不连续分布。B+ 树为什么每层能存那么多键?因为一个节点正好放在一页里。为什么回表慢?因为要再访问另一个页。事务日志为什么叫 WAL?因为它把"先写日志、再改数据页"变成了顺序写,而顺序写在存储层比随机写快得多。
可以说,存储和文件结构是整个数据库物理设计的入口,后面看到的大量结构设计、优化手段,根子都在这一章。把这一章想明白,后面学习会顺很多。
2. 存储介质层次:先弄懂数据库的"慢"从哪里来
2.1 一张表看清存储层次
教材开头通常会放一张存储层次图,从寄存器、缓存、主存一直排到磁盘、磁带。很多人跳过去,觉得只是科普,其实这张表信息量很大。我通常建议先只记住三层:内存、SSD、机械硬盘。
| 存储层级 | 典型容量 | 读延迟量级 | 易失性 | 成本量级 | 数据库里的角色 |
|---|---|---|---|---|---|
| 内存(DRAM) | 几十 GB | 几十~几百纳秒 | 断电丢失 | 高 | 缓冲池、日志缓冲 |
| SSD | 几百 GB~几 TB | 几十~几百微秒 | 不丢失 | 中 | 主数据存储 |
| 机械硬盘 | 几 TB | 几~十几毫秒 | 不丢失 | 低 | 备份、归档、大容量存储 |
注意看延迟量级:内存是纳秒级,SSD 是微秒级,机械硬盘是毫秒级。每一级之间差了两到三个数量级。数据库做任何优化,本质都是在"让数据尽量靠近更快的层级,或者让慢层级的访问次数尽量变少"。
2.2 机械硬盘的三大耗时:寻道、旋转、传输
机械硬盘的读操作可以拆成三个耗时部分,这个模型特别重要,因为很多性能直觉都从这里来。想象一个老式黑胶唱片机,磁头要找到音轨上的目标位置——但音轨是圆形环绕的,所以分三步:
- 寻道时间:磁头移动到目标磁道上方。现代硬盘平均寻道大概 4~8 毫秒。
- 旋转延迟:盘片转到目标扇区对准磁头。假设一个 7200 转/分钟 的硬盘,每秒转 120 圈,转一圈需要 8.33 毫秒,平均等半圈就是 4.17 毫秒。
- 传输时间:磁头读数据,把内容传出来。以 100MB/s 的传输速率计算,读一个 8KB 的页只需要 0.08 毫秒。
把三者加起来:一次随机读一个页,大约是 4 + 4.2 + 0.1 ≈ 8.3 毫秒。而如果连续读 100 个页,寻道和旋转只需要一次,传输 100 页也不过 8 毫秒左右,总耗时可能只有 12 毫秒。平均到每个页,连 0.12 毫秒都不到。
这个对比太关键了:随机读一个页,大约要 8 毫秒;顺序读一个页,只要 0.1 毫秒上下。差了将近两个数量级。换句话说,顺序读一百页,比随机读一页还要快。这也是为什么几乎所有数据库都特别在意"顺序 IO":预读、区段分配、日志追加写,全都是在尽可能地制造顺序访问。
2.3 数据库为什么想尽办法"往连续里塞"
既然顺序访问这么香,存储层文件组织的首要目标就是尽量把相关的数据放在连续的物理位置。教材里提到的几种做法,底层逻辑都是一样的:
- 顺序文件组织:记录按某个键排序存放,做范围查询时像翻书一样连续读。
- 聚簇索引:数据行本身按索引键的顺序物理排序,这样主键范围查询的页访问量最小。
- 区段/扩展(extent)分配:文件系统一次分配一整块连续空间,避免页散落各处。
- 预读:检测到顺序访问模式时,一次把后面若干页也读进缓冲区,把随机等待变成一次性传输。
用图书馆类比:书架上的书如果是按索书号连续排放,你要借 1 到 100 号的书,直接沿着一排走,顺手就能拿齐;如果这些书东一本西一本,你得在书架间小路来来回回跑几十趟,时间全花在路上了。
2.4 SSD 时代,这些规律还成立吗
很多人觉得机械硬盘过时了,SSD 没有寻道和旋转延迟,随机读写都快,之前的理论是不是没用了?其实不是。SSD 依然存在顺序和随机的差异,只是差距从机械硬盘的百倍缩小到了几倍甚至接近持平。但还有两个更关键的点:
第一,SSD 的写操作有写放大效应。闪存不能原地覆盖,要先擦除整块,再做写入,中间会搬移大量数据。数据库里如果随机小写太多,SSD 反而容易被拖垮。
第二,WAL 日志的顺序写优势依然成立。无论底层是 SATA SSD 还是 NVMe SSD,顺序写总是比随机小写对闪存更友好,带来的延迟也更稳定。
所以,存储层次的分析框架并没有失效,只是各档次的绝对速度变了。理解"随机访问贵、顺序访问便宜"这个直觉,在任何时代都值钱。
3. 文件结构与页面组织:记录怎么被"收纳"
3.1 定长记录与变长记录:偏移量是王道
文件里存的是一条条记录。记录格式首先要区分定长和变长。
定长记录最简单:每条记录长度固定,比如一张表有 5 个列,每列分别是 8、4、8、16、4 字节,加起来正好 40 字节。那么第 N 条记录从文件起始位置的偏移就是N * 40。直接算,不需要任何额外信息,随便定位到任意一条,随机访问性能极好。
变长记录麻烦一些。最常见的例子是VARCHAR字段,长度不是固定的。如果只用分隔符(比如;)来区分字段,一旦字段内容本身包含分隔符,解析就会出错。所以数据库通常采用"偏移表"方案:在记录开头放一个槽区域,记录每个字段的偏移量和长度。读取字段时,先查偏移表,再取数据。
这就像打包行李时,每个箱子上贴一张清单,写着"第一件在哪、第二件在哪、各自多大"。虽说多了点元数据,但换来的好处是字段可以灵活变长,解析还非常快。
3.2 页与槽结构:记录怎么放在页里
页是数据库和磁盘交换的基本单位,教材里经常叫块(block)。一个页内部也有自己的布局,常见设计是:页头 + 槽目录 + 数据区。
页头保存页号、页类型、记录数量、空闲空间指针这些元信息。数据区用来真正存放记录。槽目录可以理解成页内的"索引表",每条记录在数据区里的位置和长度都记录在槽里。定长记录可以直接按顺序排列,用编号定位;变长记录则更依赖槽目录,因为记录可能在页内移动、删除、插入,有了槽目录,查找记录时只需要读槽目录,再跳转到数据区。
页内还有一个细节:数据从页的底部向上增长,槽目录从页的头部向后增长。为什么要这么设计?因为删除和插入会让页内碎片变得不可控,这种"两端生长"的方式可以尽可能利用剩余空间,等空间实在不够时再触发页分裂或整理。
实际开发中,我遇到过一种情况:一条记录更新后变长,页内放不下。数据库通常会把整条记录迁移到另一个页,在原位置留下一个"转发指针"。这个机制本身没问题,但如果大量更新都触发转发,读一条记录可能要多读一整页,查询性能会明显变差。这也是为什么很多数据库在大量UPDATE之后,需要做一遍REORGANIZE或者重建表,本质上是在修复页内、页间的碎片化。
3.3 文件里记录的组织方式怎么选
教材里讲了堆文件、顺序文件、散列文件、聚簇文件这几种,我整理了一张对比表格,方便直接对照:
| 组织方式 | 插入成本 | 查找成本 | 典型场景 |
|---|---|---|---|
| 堆文件(Heap) | 低,直接追加到有空闲空间的页 | 按顺序扫描整文件才最稳 | 一般 OLTP 表,插入频繁、无强排序需求 |
| 顺序文件 | 高,通常要在两份文件之间搬移 | 按排序键查找可用二分,范围查询顺读快 | 需要按主键做大量范围扫描的场景 |
| 散列文件 | 看桶的负载,桶内插入较快 | 等值查询 O(1) 级别,但不支持范围扫描 | 点查为主、等值匹配多的场景,如用户 ID 查询 |
| 聚簇文件 | 需要维护聚簇键顺序,成本和收益取决于负载 | 按聚簇属性访问时页访问极少 | 经常按某属性连表或范围取数的场景 |
堆文件是默认选项,因为它实现简单,插入就是追加写,代价是所有非主键查询都可能是全表扫描。但全表扫描不一定慢——如果表很小,或者需要返回大部分行,顺序扫描比走索引再随机回表还要快,优化器经常会这么选。
顺序文件适合排序键上的范围查询,但插入是硬伤:保持物理有序需要频繁移动后面所有记录,成本极高。实际数据库很少用纯顺序文件组织整个表,更多是用索引来提供逻辑顺序,物理顺序交给聚簇索引或区段分配去近似。
聚簇文件要重点理解一个词:聚簇键。如果一张表的数据行本身按某个字段物理聚簇存放,那么按这个字段做范围查询,一次定位之后基本就是顺序读。MySQL 的 InnoDB 主键索引就是典型的聚簇索引,这也是为什么用自增主键做插入通常比随机 UUID 快得多——随机 UUID 会导致新记录被插入到中间位置,频繁触发页分裂和物理搬迁。
3.4 页碎片与"逻辑连续不等于物理连续"
很多人以为,一张表的数据在文件里是连续挨着的,其实不一定。随着插入、删除、更新不断发生,页之间可能通过链表或指针松散连接,逻辑顺序和物理顺序可能错位。
比如你按主键扫描一段范围,逻辑上这些记录应该在同一段区间里连续存放。如果之前发生过大量随机插入导致页分裂,记录就可能散落在一堆不连续的页里。扫描时表面上是"顺序范围查询",实际底层却是随机读。这个差异在机械硬盘上最致命,在 SSD 上也有影响。
所以评估一个查询快不快,不能只看执行计划,还要看实际页分布。后面的常见问题部分我会专门说怎么排查这类问题。
4. RAID:数据库系统的"防塌方"设计
4.1 为什么数据库下面要垫一层 RAID
先明确一点:RAID 不是数据库特有的东西,但数据库对 RAID 的选型非常敏感。单块硬盘的容量有限,读写速度有限,坏掉一次就全凉。RAID 把多块盘组合起来,在操作系统层面看成一个逻辑盘,同时解决三件事:扩大容量、提升性能、增加容错。
教材里讲 RAID 通常会从条带化(striping)和镜像(mirroring)开始。条带化就是把数据切成一段段,轮流写到不同盘上,让多块盘同时干活,提高吞吐;镜像则是把同一份数据写到两块盘上,写的时候做双份,读的时候可以从任意一块读。这两种基本思想组合出了不同级别。
4.2 各 RAID 级别拆解与对比
| RAID 级别 | 工作原理 | 可用容量 | 容错能力 | 写惩罚(每次写需要几次 IO) | 典型场景 |
|---|---|---|---|---|---|
| RAID 0 | 条带化,无冗余 | 总容量 100% | 任意一块盘坏,全坏 | 1 | 性能测试、可重建的缓存层 |
| RAID 1 | 两盘镜像 | 总容量 50% | 允许坏一块盘 | 2 | 日志盘、操作系统盘 |
| RAID 5 | 条带化 + 分布式奇偶校验 | (n-1)/n | 允许坏一块盘 | 4 | 读多写少的归档和日志分析 |
| RAID 6 | 条带化 + 双校验 | (n-2)/n | 允许坏两块盘 | 6 | 大容量存储,重视数据安全 |
| RAID 10 | 先镜像后条带 | 总容量 50% | 每组镜像一个坏盘内允许坏一块 | 2 | 数据库主数据盘,OLTP 首选 |
表格里的"写惩罚"特别重要,我多解释一下。写惩罚是指:为了完成一次写请求,存储系统实际需要产生的内部 IO 次数。比如 RAID 5 写一个数据块,需要先读旧数据块、读旧校验块,再写新数据块、写新校验块,一共 4 次 IO。RAID 6 则是 6 次。RAID 10 只需要写两块镜像盘,所以是 2 次。
这意味着:同样一块数据库盘,放在 RAID 5 底下,随机写性能会显著低于 RAID 10。OLTP 系统里到处都是小随机写(更新一行、回写脏页),选 RAID 5 等于自己给自己加负担。这也是为什么几乎所有一线 DBA 都会建议:数据库数据盘用 RAID 10,日志盘更要用 RAID 1 或 RAID 10,不要用 RAID 5。
4.3 实际选型的心得:数据盘和日志盘要分开对待
教材里把 RAID 讲得很全面,但真正动手做环境时,有几个经验值得分享:
- 数据库的数据文件盘,我建议 RAID 10。它兼顾性能和容错,虽然容量利用率只有一半,但对于核心数据库来说,可靠性比容量更值钱。
- 事务日志/重做日志盘,优先 RAID 1。日志是顺序写的,镜像不会损失太多性能,但能保住"写日志不能错"的底线。
- 归档、历史备份、数据仓库中的冷数据,可以接受 RAID 6。因为这类场景更多是顺序大读,写惩罚的影响没那么明显。
- 严禁把日志文件和数据文件混在同一 RAID 组里。日志的连续写会被数据文件的随机读写干扰,造成整个数据库的提交延迟抖动。
这里插一句:很多云环境里你选不到物理 RAID,但云盘自身有冗余机制,选型和底层逻辑是相通的。比如高吞吐的云盘适合顺序读场景,高 IOPS 的云盘适合随机读场景,道理和 RAID 选型完全一致。
4.4 坏盘重建的风险
RAID 看起来解决了不少问题,但它不是保险箱。最让人头疼的是"重建风暴":某块盘坏了,RAID 控制器会用剩下的盘做校验重建,重建期间所有盘要持续高负荷读,这时系统整体性能会明显下降,如果再赶上一块盘同时出问题,整个阵列就可能直接崩掉。
用生活化类比:一间图书馆里有一本重要的大书,散页装在多个盒子里。现在一个盒子坏了,管理员要把剩下盒子里的纸全部重新读一遍,把丢失的内容重新拼一份出来。这个过程里,所有盒子的纸都被翻得飞快,万一碰坏另一盒,整本大书就拼不回来了。
所以生产环境里要做几件事:留热备盘,坏了能立刻顶上;重建时限制重建速度,别让它抢占所有 IO;在重建前确认备份完整。教材不一定会写这些,但这几年我见过太多阵列为省成本不给热备盘,最后在重建窗口里二次损坏导致丢数据的事故。
5. 实操笔记:存储层性能问题的定位与排查
5.1 现象一:明明走了索引,为什么还是很慢
这是最经典的问题。很多人看执行计划显示 "Using index" 就觉得稳了,但查询还是慢。原因常常出在"索引访问的随机性"上。
一个 B+ 树索引点查,通常要经历:从根页一路加载到叶子页,假设树高 3,那就是 3 次访问;拿到主键之后再回表,根据主键去聚簇索引里再读 1 到 2 页。也就是说,一行数据可能要访问 4~5 个页,而且这些页在文件里很可能不连续,物理上就是 4~5 次随机读。如果一次返回 1000 行,回表 1000 次,那就要读上千个随机页。
对比全表扫描:如果整张表 2000 个页,顺序读 2000 页,总耗时反而可能比随机读 1000 页快。优化器判断"走索引还是全扫",算的本质上就是这两种方案各自的页访问次数和访问模式。
排查时可以这么想:如果一条 SQL 选择走索引,但表很大,且WHERE条件过滤性不够强,回表次数太多,那就试着改成覆盖索引,或者优化条件让索引选择性更高。最忌拍脑袋,要拿页访问次数去估。
5.2 现象二:插入和更新越来越慢,整表性能下滑
数据库运行几个月之后,如果频繁插入新数据,尤其是随机主键(比如 UUID),很容易触发一个问题:页分裂。
页一旦满了,再插入必须分出一个新页,原有页的数据要搬一半过去,同时父节点索引要更新。这一个操作很可能引发连锁页分裂。页分裂的直接后果有两个:一是写入路径变长,插入延迟飙高;二是原本紧凑的页出现大量碎片,顺序扫描物理连续性变差。
应对思路也有几条:
- 如果主键本身是随机生成的,考虑换成单调递增的主键,或者至少在业务层把 UUID 换成有序版本。
- 对索引设置合理的填充因子,给页内预留一些空间,减少分裂频率。
- 定期维护,重建索引或者做碎片整理。这个操作虽然有点像"理书架",但隔一段时间做一次,收益非常明显。
5.3 现象三:IO 很高,业务却不忙
有时候存储 IO 居高不下,数据库 CPU 也不高,看起来很奇怪。这时候要想到后台任务。统计信息自动采集、索引维护、备份任务、异步刷脏页,都可能在你意想不到的时间点集中爆发。
排查方法比较固定:用系统工具看 IOPS、IO 队列长度、iowait这些指标;再看数据库内部当前有哪些后台任务在跑;把维护窗口错开到业务低峰。还是拿图书馆类比:白天大家都在看书,管理员非要在中午把所有书架重新排一遍,读者自然会觉得整个馆子都卡。
5.4 一把实用的"尺子":把查询换算成页访问
这几年我做性能评估,最顺手的一个方法就是把 SQL 翻译成"大概要访问多少页、是随机还是顺序"。这样可以快速判断慢在哪。
- 全表扫描:页面总数约为表大小除以页大小,大表顺序扫描如果几十 GB,那几百毫秒都正常。
- 索引点查:大约等于索引树高加回表页数,正常应该在个位数页。
- 聚簇索引范围查询:大约等于跨过的页数,行数越多、分布越散,页数越不可控。
- 嵌套循环连接:外层表每匹配一行,可能在内层表做一次索引点查,所以总页访问量等于外行数乘以内层单次访问页数。
这个方法不需要太精准,上下一个数量级内就够用了。判断出"这是随机访问还是顺序访问"之后,再决定要不要加索引、改 SQL、调整存储配置,方向感会强很多。
6. 读完这一章的三个收获
教材看到最后,我在笔记上记了三句话,也算是我现在做数据库性能排查的底层信念。
第一句,物理层决定性能上限。你 SQL 写得再漂亮,如果存储层的访问基本盘是随机小 IO,那再好的优化也只能在上限之内腾挪。反之,理解底层后,很多优化是"顺水推舟"。
第二句,一切都翻译成页访问次数。随机读一页、顺序读一页,这句话的性价比极高。每当有人问我某个查询为什么慢,我会先让他估算一下这条 SQL 到底会触碰多少页。答案出来,问题往往就清楚了一半。
第三句,别把可靠性寄托在单点上。RAID、备份、热备盘,这些听起来像是运维的事,但作为了解存储结构的人,选型时多考虑一分,事故时就少一分慌。
最后分享一个我常用的学习方法:拿一张真实业务表,把一条点查、一条范围查、一条 join 的 SQL 分别估算页访问次数,再对比实际执行耗时,误差往往能控制在可解释的范围内。当你能用存储层的逻辑解释执行计划时,这一章就算真正读透了。