ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从零搭建数据库存储内核:页、索引、日志与崩溃恢复全解析

从零搭建数据库存储内核:页、索引、日志与崩溃恢复全解析 1. 为什么要亲手搭一套“数据库底层”拿“数据库底层搭建”当标题听起来像是 DBA 或存储引擎开发者的专属话题但其实它离业务开发并不远。我见过不少同事写增删改查溜得很维护的表结构也清楚可一旦遇到“为什么这个查询走了索引还是慢”“为什么更新同一行会把整个系统卡住”“为什么数据库文件目录里那么多 .ibd、.frm、redo_log 文件”这类问题就完全没有头绪。根子在于大多数人只从 SQL 层面看数据库而没有从存储引擎视角看数据库。所谓数据库底层就是数据从内存到磁盘、从行到页、从日志到索引的那一整条物理链路。1.1 “底层”这个词到底指什么把数据库拆开看它其实就五层网络协议层负责收 SQL、解析器负责把 SQL 变成语法树、优化器负责决定怎么查最快、执行器负责调用存储引擎、存储引擎真正管数据和索引。大家平时说的“数据库底层”通常指的是最后这一层也就是存储引擎及其依赖的文件格式、缓冲池、事务日志、锁和 MVCC 机制。一句话概括底层是“数据怎么存、怎么找、怎么保持一致”的一套工程方案。搞清楚这套方案数据库的很多行为就不再是黑盒。比如 MySQL 默认的 InnoDB 会把你建的表拆成数据页和管理页每页通常 16KBPostgreSQL 的数据页默认 8KBSQLite 更狠一个数据库就是一个单文件内部照样分页管理。这些差异直接影响你能塞多少行、一条记录最长多少、随机读和顺序写的表现如何。1.2 一条 SQL 从进入到返回内部发生了什么以一条极其普通的查询为例SELECT name FROM users WHERE id 42。执行过程大致是客户端把 SQL 发给服务端解析器校验语法优化器看到id上有主键索引决定走索引查找执行器把这个计划交给存储引擎存储引擎先查缓冲池里有没有包含id42所在行的数据页有就直接返回没有就去磁盘读那一页顺手把页放进缓冲池再提取记录返回给客户端。这里面有个很关键的反直觉点存储引擎的最小 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。所以查询条件是a1或a1 AND b2可以用索引直接查b2就不能用因为树的第一层比较键根本没有 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::vectorField 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::vectorint64_t keys; std::vectoruint32_t child_pages; // 内部节点指向子节点页 std::vectoruint32_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 为什么重要也理解了“日志先行”这四个字的分量。后来再看那些数据库工具比如备份工具、同步工具、图形化管理工具心里都有数了——它们无非是在帮你操作文件、页、索引、日志这些底层元素。希望看完这篇的你也能建立这样的视角。如果你也想动手从零搭一个迷你数据库我的建议是先别追求功能多能把“插入一条记录并保证断电不丢”跑通就已经赢过大多数只会背概念的人了。
返回列表