1. 为什么要亲手搭一套“数据库底层”
拿“数据库底层搭建”当标题,听起来像是 DBA 或存储引擎开发者的专属话题,但其实它离业务开发并不远。我见过不少同事,写增删改查溜得很,维护的表结构也清楚,可一旦遇到“为什么这个查询走了索引还是慢”“为什么更新同一行会把整个系统卡住”“为什么数据库文件目录里那么多 .ibd、.frm、redo_log 文件”这类问题,就完全没有头绪。根子在于,大多数人只从 SQL 层面看数据库,而没有从存储引擎视角看数据库。所谓数据库底层,就是数据从内存到磁盘、从行到页、从日志到索引的那一整条物理链路。
1.1 “底层”这个词到底指什么
把数据库拆开看,它其实就五层:网络协议层负责收 SQL、解析器负责把 SQL 变成语法树、优化器负责决定怎么查最快、执行器负责调用存储引擎、存储引擎真正管数据和索引。大家平时说的“数据库底层”,通常指的是最后这一层,也就是存储引擎及其依赖的文件格式、缓冲池、事务日志、锁和 MVCC 机制。
一句话概括:底层是“数据怎么存、怎么找、怎么保持一致”的一套工程方案。搞清楚这套方案,数据库的很多行为就不再是黑盒。比如 MySQL 默认的 InnoDB 会把你建的表拆成数据页和管理页,每页通常 16KB;PostgreSQL 的数据页默认 8KB;SQLite 更狠,一个数据库就是一个单文件,内部照样分页管理。这些差异直接影响你能塞多少行、一条记录最长多少、随机读和顺序写的表现如何。
1.2 一条 SQL 从进入到返回,内部发生了什么
以一条极其普通的查询为例:SELECT name FROM users WHERE id = 42。执行过程大致是:客户端把 SQL 发给服务端,解析器校验语法,优化器看到id上有主键索引,决定走索引查找;执行器把这个计划交给存储引擎,存储引擎先查缓冲池里有没有包含id=42所在行的数据页,有就直接返回,没有就去磁盘读那一页,顺手把页放进缓冲池,再提取记录返回给客户端。
这里面有个很关键的反直觉点:存储引擎的最小 IO 单位是页,不是行。哪怕你只查一条记录,数据库也会把整页数据读到内存。所以“按主键查 100 行”和“按主键查 100 条分散在不同页的数据”,成本可能差很多。页是 16KB,如果一张表所有行都在同一页,那 100 次点查可能只需要读一次磁盘;如果行分散在 100 个不同页,就是 100 次磁盘随机读。这就是为什么设计表结构时,把经常一起查的字段放同一张表、避免过度宽表化,会有实实在在的物理收益。
1.3 自己搭一个迷你内核能学到什么
很多人觉得“我又不写数据库,学底层干嘛”,但我在实际工作里的体会是,理解底层能直接帮你解决生产事故。死锁、锁等待、慢 SQL、磁盘爆满、数据文件损坏,这些问题如果不理解底层,就只能靠重启、靠猜、靠百度经验;理解了底层之后,你看问题的角度会从“怎么处理症状”变成“怎么定位病因”。
更实际的是,这个领域有不少“最小可复现”的练习路径:你可以用几百行 C++ 或 Python 实现一个单文件数据库,支持建表、插入、按主键查询,还能做到断电后不丢数据。做完这一套,再看 MySQL 的 redo log、undo log、doublewrite buffer,你会觉得熟悉得像老朋友。这也是我写这篇文章的目的:带你从零搭一个可运行的存储内核,把存储、索引、日志、崩溃恢复一次讲透。
2. 存储层:文件、页面与行
2.1 为什么数据库不直接读文件,而是先划成“页”
文件系统读写的最小单位是扇区,通常是 512 字节或 4KB,但数据库不愿意一次只读写几条记录,因为系统调用开销不小,而且机械硬盘的随机 IO 比顺序 IO 慢几个数量级。把数据按固定大小切成页,每次读写都以页为单位,可以显著减少 IO 次数。页内部再维护空闲空间列表,记录按顺序塞进去,页满了就开新页。
多数数据库的数据页会分成三块:页头、记录区、页尾。页头存页号、页类型、空闲空间起始偏移、记录数量;页尾存校验和,用来检测页是否被写坏。记录区并不是死板地从前往后排列,因为删除和插入会留下碎片,通常用槽位数组管理:记录从页尾往前放,槽位从页头往后排,每个槽位指向一条记录的偏移量。这样删除一条记录时,只需要把槽位移除,不用搬动其他记录。
2.2 行存储与列存储:不同场景的选择
底层文件布局上,数据库分成两大类:行存储和列存储。行存储把一条记录的所有字段连续放一起,适合 OLTP 场景,比如订单查询、用户登录、购物车操作,这类操作经常需要读写一行里的多个字段。列存储把同一列的数据连续放一起,压缩率极高,适合 OLAP 场景,比如做报表、做聚合分析,只关心某些列的求和、平均、最大值。
举个例子:一张订单表有 20 个字段,业务要统计“昨天总金额”。行存储要把每行 20 个字段都读出来,再扔掉 19 个;列存储只需要读“金额”那一列的连续存储区域,IO 量能差出十几倍。这也是为什么现在很多数据库在往“行列混合存储”方向走,像 TiDB、PolarDB 都在探索把行存和列存放同一个引擎里,按查询特征自动选择。
2.3 一个页面里是怎么放多条记录的
从实现角度看,页内记录管理要考虑变长字段。像VARCHAR(255)、TEXT、BLOB这种字段,长度不固定,不能像定长字段那样用固定偏移量定位。通常的做法是:记录头里保存字段数量和一个变长字段长度数组;解析一条记录时,先读固定长度部分,再根据长度数组跳过变长字段。更长的字段如果超出页容量,数据库会把数据挪到单独的溢出页,原页只保留一个指针和长度信息。
我自己实现迷你存储内核时,最开始图省事,把所有字段定长化,用结构体直接memcpy到页面缓冲。这样确实简单,但撑不过 10 分钟就会碰到两个问题:字段长度不够用,以及删改后页内碎片越来越多。后来改成“页头 + 槽位数组 + 记录从尾部倒放”的布局,写起来多花半小时,却解决了绝大多数碎片问题。数据库的页内管理,本质就是内存分配器的那套思路,只不过它操作的是一个固定大小的字节数组。
3. 索引层:从 B+ 树到查询加速
3.1 B+ 树为什么是默认选择
索引的底层结构,数据库界基本被 B+ 树统治。哈希索引适合等值查询,但没法做范围查询;普通二叉搜索树在数据量大了之后高度太高,最坏情况下退化成链表;B+ 树是多路平衡树,每个节点能存很多个键,树高度通常只有 3 到 4 层,查询一个键最多只需要做几次磁盘 IO。
B+ 树和 B 树最大的区别是:B 树每个节点既存键也存数据,B+ 树只有叶子节点存数据,内部节点只存键和指向子节点的指针。这个设计带来两个明显好处:内部节点可以装更多键,树更矮;叶子节点之间用链表串起来,做范围查询时直接顺序遍历叶子,不用反复回上层。MySQL InnoDB 的主键索引就是典型的聚簇 B+ 树,叶子节点直接存整行数据。
3.2 聚簇索引和非聚簇索引的差别
很多人在面试时背过“聚簇索引的叶子节点存整行,非聚簇索引的叶子节点存主键值”,但未必理解这对查询性能的影响。聚簇索引按主键顺序物理排序,主键范围查询非常快;非聚簇索引(也叫二级索引)查出来的只是主键值,还要再回聚簇索引查一次完整行,这个动作叫“回表”。
回表不是免费的。如果二级索引的 select 列表里只有索引字段本身,查询可以在索引页直接返回结果,不需要回表,这叫“覆盖索引”。设计索引时,尽量把常用查询字段都放进索引,能省一次随机读。举个例子:SELECT name FROM users WHERE status = 1,如果(status, name)建了联合索引,这条语句能直接从索引覆盖,但SELECT phone FROM users WHERE status = 1就会回表查主键索引。
3.3 索引失效背后的底层原因
“索引失效”这个词很玄,但底层逻辑其实是优化器认为“走索引不如全表扫描”。最典型的是对索引列做函数运算,比如WHERE YEAR(create_time) = 2025。因为索引里存的是原始时间值,不是年份值,无法按 B+ 树查找;优化器只能放弃索引,把全表每一行都取出来算一遍。同理,对索引列做隐式类型转换,也会让 B+ 树上的等值匹配失效。
还有一个经常被人忽略的细节:联合索引的最左前缀原则,本质上是 B+ 树节点里键的比较顺序决定的。联合索引的键是(a, b, c),B+ 树先按 a 比较,再按 b,最后按 c。所以查询条件是a=1或a=1 AND b=2可以用索引,直接查b=2就不能用,因为树的第一层比较键根本没有 b 的参与机会。
4. 事务层:日志、锁与多版本并发控制
4.1 WAL:先写日志,再动数据文件
事务的原子性和持久性靠的是什么?靠预写日志,英文叫 Write-Ahead Logging,约定俗成叫 WAL。核心规则很简单:修改数据之前,先把“我要改哪一页、改成什么”写进日志文件,刷到磁盘,之后才允许修改内存里的数据页,再异步把脏页刷回磁盘。
这个顺序看起来多此一举,实际意义巨大。如果没有 WAL,数据库正在写数据页时突然断电,内存里的数据页可能只写了一半,磁盘上的页可能是新老数据混合的脏状态。有了 WAL,启动恢复时只需要读取日志文件,凡是日志里记录过但没来得及落盘的操作,重新执行一遍就行。MySQL 的 redo log、PostgreSQL 的 WAL、SQLite 的 journal 文件,本质都是这套思想的工程实现。
4.2 死锁形成的四个条件与排查路径
死锁是并发事务最让人头疼的问题,绕不开四个必要条件:互斥、持有并等待、不可剥夺、循环等待。两个事务分别持有 A 行的锁,又都去等 B 行的锁,谁也不让谁,就死锁了。打破其中一个条件就能解锁,数据库的通用做法是死锁检测和锁等待超时。
我踩过一个典型坑:业务在同一个事务里先更新订单表,再更新用户表;另一个事务顺序相反,先用户表后订单表。两个事务并发一多,死锁率直线上升。解决办法不是把锁超时调大,而是统一所有事务的加锁顺序,先用户表再订单表。这类问题在底层实现里很好理解,因为锁本质上是在索引记录上加的,加锁顺序不一致,就必然存在循环等待的可能。
4.3 Read View 如何决定哪条数据可见
MVCC,全称多版本并发控制,是解决读写冲突的利器。实现上,数据库会在每行隐藏几个字段:事务 ID、回滚指针。一行数据被修改时,旧版本通过回滚指针串成一个版本链,每个版本都记录生成它的事务 ID。读操作根据当前事务的快照判断哪个版本可见:如果版本的事务 ID 小于当前 Read View 的最小活跃事务 ID,说明这个版本已经提交且在我开始之前,可见;如果大于当前最大事务 ID,说明版本在我开始之后才产生,不可见。
这个机制让普通查询不需要加锁也能读到一致的数据,写操作只会在真正更新同一行时通过锁串行化。理解 MVCC 后,你会明白为什么“快照读”和“当前读”的行为不一样:快照读通过版本链直接选一个可见版本,当前读一定要拿最新版本然后加锁。很多死锁和锁等待问题,都是因为逻辑里混用了两种读模式。
5. 动手实现:一个最小可用的数据库存储内核
5.1 定义字段、表和页面
纸上谈兵再久,不如动手写一次。下面我很简化地实现一个单文件数据库,目标是支持建表、插入、按主键查询、崩溃恢复。先定义基本数据结构:
enum class FieldType { INT, STRING }; struct Field { std::string name; FieldType type; uint32_t length; // 字符串最大长度 }; struct Schema { std::string table_name; std::vector<Field> fields; uint32_t primary_key_index; // 第几个字段是主键 }; // 数据页头:16 字节 struct PageHeader { uint32_t page_id; uint16_t free_offset; // 空闲区起始偏移 uint16_t free_length; // 空闲区大小 uint16_t record_count; // 记录数 uint16_t flags; // 页面是否已满 };文件开头放一个元数据页,记录表结构;之后每个数据页大小固定 4096 字节。主键按顺序排列,方便做二分查找;如果主键是变长字符串,就在页内额外维护一个排序的槽位数组,槽位保存(key, offset, length)。
5.2 写入一条记录,并在崩溃后恢复
写入流程遵循 WAL:先把操作追加到日志文件,再改内存中的数据页,最后把脏页异步刷入数据文件。一个最简日志记录长这样:
struct LogRecord { uint64_t lsn; // 日志序列号,单调递增 uint32_t page_id; // 受影响的数据页 uint16_t offset; // 写入位置 uint16_t length; // 写入长度 char payload[4096]; // 页面数据副本 };写入时先把整页内容复制进payload,追加到log.log文件并fflush,确保日志真正落盘;然后才修改内存中的 page 缓存。恢复时从日志文件末尾往前扫描,对每一条记录判断:这个页面的最新修改是否已经落盘,如果没落盘,重新执行一次 payload。这种恢复方式叫“回放日志”,不需要完整的事务 undo,也能保证持久性,因为每个写入操作都是幂等的。
5.3 在内存中建立 B+ 树索引并用文件落盘
B+ 树的实现可以只覆盖插入和等值查找。叶子节点存键值和记录定位;内部节点存键和子节点页脸。所有节点都放在固定大小的节点缓冲区中,节点之间用页号引用,天然支持落盘。
struct BPlusNode { bool is_leaf; std::vector<int64_t> keys; std::vector<uint32_t> child_pages; // 内部节点指向子节点页 std::vector<uint32_t> record_offsets; // 叶子节点指向记录位置 uint32_t next_leaf; // 叶子节点链表 };插入时从根节点逐层往下找到叶子,插入键值;如果叶子满了就分裂成两个节点,把中间键上提到父节点。删除时反向合并或重分配。整个过程不复杂,但必须时刻保持所有叶子在同一层,这也是 B+ 树平衡性的核心。
5.4 手工验证一致性:检查点与启动恢复
实现完成后,我用三种方式验证:
- 写入 100 条记录,正常关闭,重新打开,检查数据完整。
- 写入过程中用
kill -9杀掉进程,重新打开,用日志回放恢复,确认数据不丢。 - 同时开多个写入进程,验证互斥锁逻辑是否正确。
检查点也很重要,它把内存中已经落盘的日志清理掉,避免日志无限膨胀。最简单做法是:定期把所有脏页刷到数据文件,然后记录一个检查点位置;恢复时只需要从检查点之后的日志开始回放。
6. 真实数据库里的底层坑位与排查经验
6.1 表空间损坏:别慌,先看日志和备份策略
数据文件损坏常见的诱因包括:磁盘空间满导致写入中断、主机突然断电、文件系统异常、磁盘硬件故障。发生损坏后,第一时间不要反复重启数据库,先把数据目录完整备份出来,然后看错误日志,确定是哪个表空间、哪个页出了问题。很多数据库提供了工具做离线检查,比如 MySQL 的innodb_force_recovery,但这只是保数据的应急手段,不是常规解法。
我的经验是:底层设计上最该重视的是“恢复能力”而不是“永不损坏”。每天全量备份、每 5 分钟增量备份、binlog 保留足够长的时间,这三样做到位,绝大多数损坏场景都能恢复到分钟级。反向思考一下,WAL 日志在恢复时扮演的角色,和你备份体系里增量日志的角色几乎一样,理解了前者,后者自然也就想通了。
6.2 死锁与锁等待的“在线”排查
排查死锁,先查事务列表和锁信息,再结合业务日志定位。死锁发生后,数据库会回滚其中一个事务,业务日志里会出现“Deadlock found when trying to get lock”之类的报错。此时要做的不是改超时重试次数,而是找出两个事务的加锁顺序差在哪里。
我总结过一套快速排查流程:先利用information_schema.innodb_trx看当前事务在等什么锁,再利用performance_schema查锁等待链路,最后结合慢查询日志定位具体 SQL。真正常见的死锁不是并发太高导致,而是两个事务访问表的顺序颠倒,或者某条 SQL 同时命中了两条二级索引,产生了额外的锁范围。
6.3 从底层原理反推三组常用调优参数
理解底层之后,调优参数就变得有据可循,这里总结三组我真实项目中调整过的高频参数:
| 参数 | 作用 | 从底层视角看 |
|---|---|---|
| 缓冲池大小 | 缓存数据页的总量 | 池越大,磁盘随机读越少;但不能无脑加大,会因为内存换页和 gc 放大反而变慢 |
| 日志刷盘策略 | 控制日志落盘时机 | 每次提交都刷盘,崩溃恢复最稳但性能差;允许组提交,性能好但要接受极小概率丢事务 |
| 脏页刷盘速率 | 控制脏页写回磁盘的速度 | 刷太快,写放大严重;刷太慢,崩溃恢复时间长,两头要平衡 |
调优没有一个万能值,不同业务拼的是“能不能理解参数背后的瓶颈”。有一次我遇到一个数据库周期性卡顿的问题,看硬件监控一切正常,最后定位到是脏页刷盘线程和业务高峰撞在一起,导致 IO 峰值叠加。解决办法是把脏页刷盘峰值调峰,把部分刷盘任务放到低谷时段,这是纯粹的底层调度问题,跟 SQL 优化完全不沾边。
我在实际项目中,真正把底层知识“吃透”是在一次数据恢复演练中:数据库文件被误删了半个表空间,靠完整备份加上归档日志,把所有事务逐条回放,最终恢复到故障前 15 秒。那一刻才真正理解 WAL 为什么重要,也理解了“日志先行”这四个字的分量。后来再看那些数据库工具,比如备份工具、同步工具、图形化管理工具,心里都有数了——它们无非是在帮你操作文件、页、索引、日志这些底层元素。希望看完这篇的你,也能建立这样的视角。如果你也想动手从零搭一个迷你数据库,我的建议是先别追求功能多,能把“插入一条记录并保证断电不丢”跑通,就已经赢过大多数只会背概念的人了。