数据库的「身体构造」:从磁盘到内存的存储之旅
你有没有过这样的疑惑:为什么MySQL里存了1亿条用户订单数据,查询的时候还能在毫秒级返回结果,但如果你把同一个文件用记事本打开,光加载就得等半分钟?为什么你删掉的数据库数据不会立刻从磁盘上消失,甚至关机重启后还能找回来?这些问题的答案,都藏在数据库最底层的「身体构造」里——从磁盘的磁道到内存的缓冲池,从页结构到存储介质的物理特性,这套藏在代码之下的硬件交互逻辑,才是数据库所有 fancy 特性的地基。
一、存储介质:数据库性能的「物理天花板」
数据库的所有操作最终都要落到存储介质上,而不同介质的物理特性,直接决定了数据库的设计逻辑。我们可以把主流存储介质按访问速度从快到慢排:DRAM(内存)> NVMe SSD > SATA SSD > HDD(机械硬盘),它们的性能差异能达到百万倍,这种巨大的鸿沟,是数据库所有优化逻辑的起点。
1.1 机械硬盘(HDD):被时代淘汰的「慢先生」
HDD是过去30年数据库最主要的持久化存储介质,它的物理结构其实和黑胶唱片很像:内部有多个高速旋转的铝制盘片,盘片上涂着磁性材料,磁头悬浮在盘片上方,通过改变磁性材料的极性来存储0和1。 HDD的读写耗时由三部分组成:寻道时间(磁头移动到目标磁道的时间)、旋转延迟(盘片旋转到目标扇区的时间)、传输时间(读取/写入数据的时间)。对于一块7200转的企业级HDD来说,这三部分耗时分别是:$t_{seek}\approx8ms$,$t_{rotation}=\frac{60s}{7200rpm}\times\frac{1}{2}\approx4.17ms$,$t_{transfer}=\frac{4KB}{200MB/s}\approx0.02ms$,总耗时接近12ms。 也就是说,你从HDD里读1KB数据和读1MB数据,耗时几乎一样——因为99%的时间都花在寻道和旋转延迟上,传输时间完全可以忽略。这也是为什么HDD时代数据库极度痴迷「顺序IO」:顺序IO只需要一次寻道和旋转延迟,就能连续读取大量数据,带宽能到200MB/s,而随机IO的带宽只有不到1MB/s,相差200倍。你平时用的数据库索引、预读机制、日志顺序写,本质上都是在迁就HDD的这个特性。 <!-- 纸书注:此处可配HDD物理结构示意图,标注磁道、扇区、磁头位置 --> <!-- 电子版注:此处可插入HDD寻道动画演示,直观展示磁头移动过程 --> <!-- Markdown标签:HDD, 物理结构, 寻道时间, 旋转延迟 -->
1.2 固态硬盘(SSD):没有机械部件的「快选手」
SSD用NAND Flash颗粒存储数据,没有机械运动部件,所以随机读写性能比HDD高了2个数量级。但SSD的存储原理带来了新的特性:它的最小存储单元是「页」,大小通常是4KB,而擦除的最小单元是「块」,大小通常是128KB到2MB。你不能直接修改一个页的内容,必须先擦除整个块,再把新的数据写进去——如果新数据只有4KB,就需要把块里剩下的124KB旧数据先搬出来,擦除块之后再一起写进去,这个就是「写入放大」。 写入放大直接导致SSD的随机写性能远低于顺序写:比如你写4KB的数据,实际可能要搬移128KB的旧数据,写入放大倍数是32倍。这也是为什么SSD时代数据库依然强调顺序写,但随机读的权重被大幅提升:现在NVMe SSD的随机读延迟已经降到10μs,比HDD快了1000倍,随机读和顺序读的性能差只有10倍左右,远低于HDD的200倍。 除了性能,SSD还有两个关键特性影响数据库设计:一是擦写寿命,每个块最多只能擦写1万到10万次,所以数据库不能对同一个块反复写;二是断电不丢数据,不需要像DRAM那样做冗余备份。现在主流数据库的默认页大小都是4KB、8KB、16KB,本质上是和SSD的页结构对齐,减少写入放大。 <!-- Markdown标签:SSD, NAND Flash, 写入放大, 页结构, 块结构 -->
1.3 内存(DRAM):速度最快的「临时缓存」
DRAM是现在速度最快的存储介质,访问延迟只有50-100ns,比NVMe SSD快100倍,比HDD快100万倍。它的原理是用电容存储电荷,有电是1,没电是0,所以需要定期刷新,而且断电后电荷会消失,数据直接丢失。 DRAM的成本是HDD的100倍,1TB服务器内存的价格能买20TB的HDD,所以DRAM永远不可能作为持久化存储,只能作为缓存使用。数据库里的缓冲池,本质上就是把热数据放在DRAM里,避免每次查询都读慢速的磁盘。 现在还有一种介于DRAM和SSD之间的介质:持久化内存(PMEM,比如Intel傲腾),访问延迟是200-500ns,比DRAM慢5倍,但比SSD快10倍,而且断电不丢数据,支持字节寻址(不需要像SSD那样按页读写)。现在TiDB、OceanBase等NewSQL数据库已经开始用PMEM做缓冲池和日志存储,把缓冲池的容量从内存的几十GB提升到几百GB,同时不用怕断电丢数据,这是数据库存储的最新前沿方向。
我们可以把主流存储介质的特性整理成下表,方便对比: | 存储介质 | 随机读延迟 | 顺序读带宽 | 写特性 | 每GB成本 | 持久性 | |---------|-----------|-----------|--------|---------|--------| | HDD(7200转) | 5-10ms | 100-200MB/s | 随机写极慢,顺序写快 | 0.05元 | 断电持久 | | SATA SSD | 50-100μs | 500MB/s | 有写入放大,随机写比顺序写慢10倍 | 0.3元 | 断电持久 | | NVMe SSD | 10-50μs | 3-7GB/s | 写入放大低,随机写接近顺序写 | 0.5元 | 断电持久 | | DDR5 DRAM | 50-100ns | 10-20GB/s | 无写入放大,读写都快 | 5元 | 断电丢失 | | PMEM(傲腾) | 200-500ns | 5-10GB/s | 字节寻址,无写入放大 | 2元 | 断电持久 | <!-- Markdown标签:存储介质对比, 性能参数, 成本, 持久性 -->
1.4 存储介质演进对数据库设计的影响
不同时代的存储介质,直接决定了数据库的核心设计逻辑:
- **HDD时代**:数据库的核心优化目标是减少随机IO,所以诞生了B+树索引(尽量减少树的高度,减少随机读次数)、预读机制(一次读多个连续的页,利用顺序读的高带宽)、WAL日志顺序写(避免随机写数据页)。
- **SSD时代**:随机IO性能大幅提升,LSM树(写优化型索引结构)开始普及,因为它的写是顺序的,能充分发挥SSD的顺序写优势,同时避免B+树的随机写问题。现在很多云原生数据库比如RocksDB、TiDB的底层存储都用了LSM树的变种。
- **PMEM时代**:数据库不再需要把数据页放在DRAM里,也不用做WAL日志落盘,直接把索引和数据存在PMEM上,性能接近内存数据库,同时成本只有DRAM的1/3,持久性还和SSD一样,这是未来5年数据库存储的核心演进方向。
二、磁盘页结构:数据库存储的「最小单元」
数据库不会直接按字节读写磁盘,而是把磁盘空间划分为固定大小的「页」,作为读写的最小单元。这个设计本质上是平衡了IO效率和空间利用率:如果页太小,一次IO读的数据太少,寻道时间的占比太高;如果页太大,读一条数据要读整个页,浪费IO带宽。 主流数据库的页大小通常是4KB、8KB、16KB,刚好和操作系统的文件系统块(通常也是4KB)对齐,这样读写的时候不会出现跨块的问题,效率最高。比如InnoDB的默认页大小是16KB,PostgreSQL是8KB,SQLite是4KB,这些都是根据各自的场景和适配的文件系统选的最优解。
2.1 页的内部结构
一个典型的数据库页可以分为5个部分,我们以InnoDB的16KB页为例: 1. 页头(Page Header):占38字节,存储页的元数据,比如页号、页类型(数据页/索引页/日志页等)、 checksum(校验和)、LSN(日志序列号)、上一个页和下一个页的指针(用于页链表)。 2. 页目录(Page Directory):占几百字节,存储页内数据的索引,比如InnoDB的数据页里,页目录存储了每行的主键和对应的偏移量,这样查询的时候不用全页扫描,直接通过页目录找到目标行的位置,时间复杂度是O(1)。 3. 数据行区域:存储实际的数据行,InnoDB的行格式有Compact、Dynamic、Compressed等,一行数据的大小从几十字节到几KB不等,16KB的页通常能存几十到几百行数据。 4. 空闲空间:页中间未使用的空间,用于插入新的数据行,当空闲空间用完的时候,会触发页分裂或者合并。 5. 页尾(Page Trailer):占8字节,存储页的 checksum和LSN,和页头的对应字段做校验,确保页的完整性。 <!-- 纸书注:此处可配InnoDB页结构示意图,标注各部分的偏移量和大小 --> <!-- Markdown标签:页结构, InnoDB, 页头, 页目录, 数据行, 页尾 -->
这里有两个关键的设计细节,是数据库保证数据一致性的核心:
- **校验和(Checksum)**:页写入磁盘的时候,会计算整个页的CRC32校验和,存储在页头和页尾。读取页的时候,会重新计算校验和,和存储的对比,如果不一致,说明页在写入过程中断电损坏了,数据库会从日志里恢复这个页的内容。
- **日志序列号(LSN)**:LSN是单调递增的数字,每个日志记录都有唯一的LSN,每个页也会记录最后一个修改它的LSN。崩溃恢复的时候,数据库只需要把LSN小于checkpoint(检查点)的脏页刷到磁盘,然后恢复checkpoint之后的日志,就能保证数据一致性,不需要全量扫描所有页,大幅缩短恢复时间。
2.2 页的链表结构
单个页的容量太小,不可能把所有数据都存在一个页里,所以数据库会把页通过链表串联起来,形成更大的存储结构。InnoDB里有三种核心的页链表: 1. 页的物理链表:每个数据页的页头都有上一个页和下一个页的指针,所有数据页按物理顺序串联成一个双向链表,这样扫描全表的时候可以顺序读页,利用磁盘的顺序读高带宽。 2. B+树的索引页链表:B+树的非叶子节点是索引页,叶子节点是数据页,所有叶子节点通过双向链表串联,这样范围查询的时候,只需要找到范围的起点,然后顺着链表读后面的页就行,不用每次都从根节点查索引。 3. 缓冲池的页链表:缓冲池里的页会通过LRU链表、Free链表、Flush链表管理,后面我们会详细讲。
三、缓冲池:数据库的「内存缓存」
如果每次查询都读磁盘,哪怕是最快的NVMe SSD,也只能扛住几百QPS的请求,完全满足不了现在互联网系统每秒几万甚至几十万的请求量。缓冲池(Buffer Pool)就是解决这个问题的核心组件:它是数据库在内存中划分的一块区域,用来缓存磁盘上的数据页,把随机磁盘IO转换成内存访问,性能直接提升几个数量级。
3.1 缓冲池的基本原理
InnoDB的缓冲池默认大小是128MB,生产环境通常设置为物理内存的50%-70%。缓冲池按页的大小(默认16KB)划分为多个缓存页,每个缓存页对应一个磁盘页,同时还有一个描述信息块,存储这个页的元数据:比如对应的磁盘页号、是否被修改(脏页)、访问时间、在链表中的位置等。 当我们执行一条SQL的时候,数据库的流程是这样的: 1. 计算需要访问的页号,先在缓冲池的哈希表里查找这个页是否存在。 2. 如果存在(缓存命中,Cache Hit),直接把缓存页的内容返回给执行引擎,不需要读磁盘。生产环境的缓冲池命中率通常能到99%以上,意味着100次请求只有1次需要读磁盘。 3. 如果不存在(缓存未命中,Cache Miss),就从Free链表里找一个空闲的缓存页,把磁盘上对应的页读进来,放到LRU链表里,再返回给执行引擎。如果没有空闲缓存页,就按照LRU淘汰策略淘汰一个最久未使用的页,如果这个页是脏页,就先刷到磁盘再淘汰。 <!-- 电子版注:此处可插入缓冲池工作原理动画,演示缓存命中、缓存未命中、页淘汰的过程 --> <!-- Markdown标签:缓冲池, 缓存命中, 缓存未命中, LRU淘汰 -->
3.2 缓冲池的链表设计
InnoDB的缓冲池用三种链表来管理缓存页,解决不同场景的问题: 1. LRU链表(最近最少使用链表):用来管理所有的缓存页,按访问时间排序,最近访问的放在链表头部,最久未访问的放在尾部,淘汰的时候直接删尾部的页就行。但普通的LRU有个致命问题:如果做全表扫描,会把所有热数据都挤到链表尾部淘汰掉,导致后续的查询缓存命中率暴跌。所以InnoDB对LRU做了改进,把链表分成两部分:头部是「Young区」(占5/8),存放热数据,尾部是「Old区」(占3/8),全表扫描的页只会放到Old区,不会影响Young区的热数据。只有Old区的页被访问两次之后,才会移动到Young区,这样就避免了全表扫描打穿缓冲池的问题。 2. Free链表:存放所有空闲的缓存页,缓存未命中需要读页的时候,直接从Free链表拿空闲页,不需要淘汰。 3. Flush链表:存放所有脏页(被修改过但还没刷到磁盘的页),后台刷盘线程会定期把Flush链表里的脏页刷到磁盘,保证数据不丢。 <!-- Markdown标签:LRU链表, Free链表, Flush链表, 脏页, 缓冲池优化 -->
3.3 缓冲池的刷盘机制
脏页是缓冲池里最关键的资源:如果脏页太多,一旦数据库断电,就会丢失大量未刷盘的数据;如果脏页太少,又会导致频繁的随机刷盘,性能下降。InnoDB用两种机制平衡这个问题: 1. 后台线程定期刷盘:后台有一个刷盘线程,每秒都会把Flush链表里的脏页刷到磁盘,保证脏页的数量不会太多。 2. Checkpoint机制:Checkpoint是一个检查点,它的LSN是当前所有脏页里最小的LSN。每次刷盘的时候,只需要刷LSN小于Checkpoint的脏页,然后更新Checkpoint的LSN。崩溃恢复的时候,只需要恢复Checkpoint之后的日志就行,不需要全量恢复所有脏页,大幅缩短恢复时间。 这里有个常见的DBA误区:很多人以为脏页越多性能越好,其实不是。如果脏页太多,后台刷盘线程来不及刷,会导致SQL执行的时候触发「刷脏等待」,查询延迟突然飙升,甚至出现卡顿。所以生产环境要监控脏页比例,通常保持在10%以内比较安全。
四、数据库整体架构:从SQL到磁盘的完整旅程
讲完了底层的存储介质和页结构,我们可以把数据库的整体架构串起来了。你执行的一条SQL,从客户端发起到返回结果,要经过6个核心模块,每个模块都对应底层的存储逻辑: ```mermaid graph TD A[客户端请求] --> B[连接器] B --> C[解析器] C --> D[优化器] D --> E[执行引擎] E --> F[存储引擎] F --> G[缓冲池] G --> H[磁盘存储] E --> I[事务管理器] I --> J[锁管理器] I --> K[日志模块] K --> H ``` <!-- Markdown标签:数据库架构, 执行引擎, 存储引擎, 事务管理器, 锁管理器, 日志模块 -->
每个模块的作用和底层的关联: 1. 连接器:负责和客户端建立连接,做身份认证、权限校验,和存储逻辑无关,但连接的建立会占用内存资源,
连接的建立会占用内存资源,每个MySQL连接默认会分配256KB的读缓冲区和256KB的写缓冲区,再加上栈空间和线程自身开销,单个连接大约占用1MB内存。如果业务用了长连接(比如Java应用的连接池默认 keep-alive 8小时),1万个连接就会占用10GB内存,很多中小公司的数据库OOM,本质就是长连接太多撑爆了内存。现在云原生数据库比如PolarDB、TiDB都默认用短连接+代理层连接池,把连接数控制在几百以内,既减少了内存占用,又避免了长连接的权限失效问题。
1.5 解析器:把人类语言翻译成机器能懂的「语法树」
连接器校验完权限,就会把SQL交给解析器。解析器的工作是把你写的自然语言SQL翻译成结构化的抽象语法树(AST),同时做语义校验——比如你写的表名不存在、字段名拼写错误、类型不匹配(比如把字符串插到int字段里),都是解析器阶段报错的。 我们可以用一条简单的JOIN SQL举例,看解析器怎么工作: ```sql SELECT u.id, u.name, o.amount FROM user u JOIN `order` o ON u.id = o.user_id WHERE u.age > 18 AND o.amount > 100 ORDER BY o.amount DESC LIMIT 10; ``` 解析器会先做词法分析,把SQL拆成一个个Token:`SELECT`、`u.id`、`FROM`、`user`、`JOIN`、`order`、`ON`、`u.id`、`o.user_id`、`WHERE`、`u.age`、`>`、`18`……然后做语法分析,把这些Token按SQL语法规则组装成AST,大概长这样: ```mermaid graph TD A[SELECT节点] --> B[投影列: u.id, u.name, o.amount] A --> C[FROM节点] C --> D[JOIN节点] D --> E[左表: user u] D --> F[右表: order o] D --> G[关联条件: u.id = o.user_id] A --> H[WHERE节点] H --> I[过滤条件: u.age>18 AND o.amount>100] A --> J[ORDER BY节点] J --> K[排序字段: o.amount DESC] A --> L[LIMIT节点] L --> M[限制10行] ``` <!-- Markdown标签:解析器, 抽象语法树, 词法分析, 语法分析 --> 这里有个容易踩的坑:SQL的关键字比如`order`是保留字,如果你真的有个表叫`order`,必须加反引号`` `order` ``,否则解析器会把它当成ORDER BY关键字,直接报语法错误——这个错误就是解析器阶段抛出的,和后面的优化、执行都没关系。
1.6 优化器:数据库的「大脑」,决定怎么执行最快
解析器生成的AST只是描述了「要做什么」,但没说「怎么做」。优化器的工作就是给AST生成最优的执行计划,决定用哪个索引、