ARTICLE DETAIL

资讯详情

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

手写数据库内核:从B+树到MVCC,彻底搞懂底层原理

手写数据库内核:从B+树到MVCC,彻底搞懂底层原理 数据库底层搭建这件事很多人一听就觉得是内核团队、DBA体系里才值得碰的东西普通后端开发根本没机会也不会去碰。但我自己花了差不多一年时间从零手写了一个 mini 关系型数据库内核——存储引擎、B树索引、WAL 预写日志、事务与 MVCC、一套能跑通增删改查的 SQL 解析执行链——过程中把 MySQL InnoDB、PostgreSQL、SQLite 的源码拿过来翻来覆去地对照。回头看线上的慢查询、死锁、崩溃恢复、连接池打满这类问题以前很多靠猜的地方现在基本都能直接落到底层机制上去解释。这篇文章不打算写教科书式的体系架构而是按我实际动手搭建底层时的顺序把数据页和缓冲池怎么设计、B树索引为什么能扛住亿级数据、WAL 和 MVCC 怎么配合保证 ACID、SQL 是怎么一步步变成磁盘上的行全部拆开讲同时穿插我踩过的坑和验证过的结论。适合三类人被慢查询和死锁折磨的后端开发、需要做数据库选型或数据同步方案的技术负责人、以及想靠一个数据库课程设计或者个人项目把原理跑通的学生。1. 数据库底层搭建为什么值得亲手走一遍核心问题拆解1.1 一条SQL从客户端到磁盘要走多少路先别急着看存储引擎把一条 SQL 的完整旅程画在脑子里后面每一节都是往这条链路里填细节。客户端发起请求网络层会把这条语句交给连接管理模块做鉴权和会话初始化然后进入解析器词法分析把“select、from、where”这些 token 拆出来语法分析按文法生成抽象语法树接着是绑定器去 cataloq 元数据里查表名、列名、类型把 SQL 里的名字变成内部对象再往后是优化器根据统计信息生成执行计划决定走哪个索引、怎么 join执行器拿到计划后逐级调用最终落到存储引擎——真正干活的缓冲池、索引、事务模块最后才到文件系统和磁盘。我拿点外卖做类比客户端是顾客解析器是前台下单记录口味优化器是后厨排菜顺序存储引擎是仓库和冷柜。这个分层不是套壳好看而是每层都能独立演进、替换、调优这也是为什么市面上有那么多数据库底层骨架却都大差不差。这个链路里最容易被忽视的是慢查询往往不只在“有没有走索引”这一环出问题。我见过不少案例SQL 本身很快但瓶颈在连接层握手太多、在锁等待、在缓冲池命中率、甚至在解析阶段因为嵌套子查询过于复杂而膨胀。你在任何数据库上执行一遍 explain看到的是优化器和执行器视角但真正生产环境里一条 SQL 变慢可能是这条链上任何一段出了问题。搞底层搭建其实就是把这条链的每一段都亲手打通排查问题的时候脑子里才有完整的坐标系。1.2 底层设计的三个第一性原则整个数据库底层的设计说穿了是在跟物理世界博弈三个原则贯穿所有模块。第一个原则内存和磁盘的速度差是万恶之源。内存访问是纳秒级磁盘随机访问是毫秒级差了六七个数量级。所以数据库绝对不干“每次读写都直接去碰磁盘”这种蠢事而是按页为单位把数据搬进内存靠缓冲池把热数据留住。第二个原则随机 IO 贵得离谱顺序 IO 相对便宜。一块机械盘随机 IOPS 可能就是一百多顺序读写却能跑几百兆每秒。WAL 预写日志、B树叶子链表、列存连续摆放本质上都是把随机访问转化成顺序访问。第三个原则并发正确性不能靠运气。多事务同时跑要么靠锁串行化要么靠 MVCC 让读写互不阻塞机器一断电必须靠日志重放保证数据不丢不重。我用一个银行柜台来类比只有一个出纳所有存取款排队最安全但最慢开多个窗口就要防插队、防记错账这就是锁和事务日志的由来打烊后要对账盘点这就是崩溃恢复。这三个原则听起来简单但后面每一个模块设计最终都能回溯到这三句话上。你写代码时一旦发现某个方案同时违背了“减少磁盘 IO”“顺序化”“并发安全”里的两条基本就是走错路了。2. 存储引擎手写指南数据页、缓冲池与 B树这样落地2.1 数据页数据库为什么不直接读文件最早我写存储层的时候脑子里有个天真的想法直接用一个结构体数组记录行每次增删改查都往文件里随机读写。结果用数据一测慢到没法看。问题就出在随机 IO 上更新一行得定位、读取、修改、再写回每次都落在磁盘上不同位置性能完全不可控。所以真实数据库把文件划分成固定大小的逻辑块叫数据页。InnoDB 默认 16KBPostgreSQL 默认 8KBSQLite 的页大小可以从 512B 配到 64KB。页是读写和缓存的基本单位页号到文件偏移量的换算很简单偏移量 页号 × 页大小。页的内部结构也很有意思。一般分三块页头、槽数组slot array、实际行数据区。页头记录页号、页类型、校验和、最近修改的日志序列号LSN、空闲空间上下界槽数组从页尾向前增长每条记录一个槽指向行数据的偏移。行数据从页头之后向后增长两边向中间靠拢空闲空间就是两者之间的距离。用槽数组而不是链表的理由是数组定长、能用二分查找、可以顺便做物理重排消除碎片。我第一版实现时的页头类似这样struct PageHeader { uint32_t page_id; // 页号 uint16_t page_type; // 页类型数据页、索引页、溢出页 uint16_t slot_count; // 槽数量 uint16_t free_space_low; // 空闲区间起始偏移 uint16_t free_space_high; // 空闲区间结束偏移 uint64_t lsn; // 最后一次修改的日志序列号 uint32_t checksum; // 页校验和崩溃恢复时检查完整性 };有了页之后一条 SQL 的增删改查就不再是“直接操作文件里的某一行”而是“先把对应页加载进内存在页内完成操作把页标记为脏页”。哪些页该留在内存、哪些该被换出就是缓冲池的职责。2.2 缓冲池内存不够时怎么优雅淘汰缓冲池本质上就是一个页缓存用一个哈希表按页号快速定位缓冲帧配一个 free list 管理空闲帧一个 flush list 管理脏页。读请求先查池子里有没有没有就淘汰一个旧页换入写请求在内存页上改完只标记脏页不一定立刻刷盘。这套机制并不复杂真正的复杂度在淘汰策略。最简单的 LRU 在全表扫描面前会瞬间崩掉扫描十亿行的表等于把整个表的数据依次塞进缓冲池真正该留热门的页全被顶出去了之后业务查询全部命不中缓存性能直接跳水。InnoDB 的解法是把 LRU 分成两段新生代young和老生代old新读入的页先进老生代只有被再次访问才晋升新生代默认老生代约占 37%。全表扫过的页因为只访问了一次最后都被留在老生代淘汰热门页反而保住了。如果你是自己实现不一定要抄 InnoDB用 clock 算法二次机会法也能达到类似效果代码量小很多。脏页回写的时机也要把握好缓冲帧被淘汰、后台定期 checkpoint、系统空闲时异步刷。有一点必须想明白脏页可以先在内存里留着因为 WAL 日志已经先落盘了数据页晚点刷不会丢数据——这正是“日志先行”换来的自由。参数上我踩过不少坑InnoDB 的 innodb_buffer_pool_size 一般按物理内存的 60% 到 75% 配但不要贪到 90%操作系统 page cache 也要留口粮否则查询时文件读取和日志写入反而变慢。2.3 B树索引层高、扇出与分裂合并为什么关系型数据库默认都选 B树索引而不是哈希索引哈希只适合等值匹配where id 5这种遇到范围查询、前缀查询、排序就废了。B树的叶子节点用双向链表串起来天然支持范围扫描和排序节点内部有序前缀匹配也很快。热词里经常有人搜“mysql 数据库常用命令”“数据库 sql”实践下来大家最常验证的就是“这条 SQL 到底走没走索引”本质上就是在跟 B树打交道。B树能顶住亿级数据靠的是高扇出。拿 16KB 页来算假设键 8 字节、指针 8 字节一页大概能放上千个条目两千万行的表层高也就三层左右。根页常驻内存点查基本两三次磁盘 IO 就到叶子。这就是为什么 B树比二叉搜索树更适合磁盘场景二叉树在磁盘上会退化成几十次随机 IOB树却把 IO 次数压到了个位数。真正的难点在分裂与合并。叶子页满了要分裂成两半向上层插入新键删除后页太饿要做平衡。自实现时你会发现分裂期间并发搜索很容易出现“幽灵键”“丢键”这类 bug因为父层指向子层的指针和子层边界的变化不是原子的。我的建议是第一版先加一个全库范围的写锁把正确性跑通、把所有测试用例过掉再考虑用 B-link 树这类并发结构去提升。索引还有几个概念必须分清主键索引是聚集索引叶子直接挂行数据二级索引叶子挂主键值查完还要回表覆盖索引则让查询只在二级索引上完成连回表都省了。所以写查询尽量只 select 需要的列这不是洁癖是真的影响 IO。3. 事务、锁与MVCC底层实现从WAL到死锁检测一次说清3.1 WAL日志崩溃后凭什么数据不丢很多人在业务层对事务的理解就是 begin、commit、rollback但对底层来说事务的骨架是日志。把一次 update 的物理过程展开在内存页上修改数据生成一条 redo 日志追加到日志缓冲区commit 时把日志 fsync 到磁盘然后才向客户端返回成功。注意数据页这时候可能还在内存里过很久才刷盘。这就是 Write-Ahead Logging日志先行的意思。为什么这么绕因为数据页是随机 16KB 的写入而日志是几十字节到几 KB 的顺序追加。为了不让每次 commit 都付一次随机 IO 的代价数据库选择先把小体积、顺序写、只追加的日志落盘数据页的刷新则可以攒一批、挑空闲时段、按 checkpoint 周期慢慢做。崩溃恢复的时候从最近 checkpoint 的 LSN 开始重放 redo把已提交事务的修改找回来再拿 undo 日志回滚掉未提交事务的修改。这样持久性靠 redo原子性靠 undo。我自己实现的简化版做了个取舍事务开始时记一个 txn_id每次修改生成{txn_id, page_id, offset, old_val, new_val}这样的条目commit 时整体追加写入日志文件并 fsync恢复时扫描日志重放。这个方案性能当然远不如生产级数据库但逻辑足够简单崩溃一致性的坑基本不会踩到。这里我必须强调一个经验很多“数据丢了”的事故根因都是日志落盘顺序被绕过——比如把 fsync 关了或者让数据页抢先于日志刷出。自己搭底层时千万别为了性能走这种捷径日志先行这条铁律不能破。3.2 隔离级别的底层真相锁与MVCC怎么配合隔离级别这块光背概念没用得知道每一档在底层到底靠什么实现。我整理了一张表隔离级别底层核心解决什么问题代价读未提交读不加锁什么都不防会脏读并发最高语义最差读已提交每条语句构建新快照或加短读锁防脏读同一事务两次读结果可能不同可重复读事务第一次读构建快照复用写写仍靠行锁防不可重复读InnoDB 里还能防幻读快照依赖 undo 链长事务影响清理串行化读加锁 gap 锁/谓词锁完全串行并发度最低最容易误解的是可重复读不是靠锁实现的MySQL InnoDB 默认的 RR 级别靠的是 MVCC 快照。MVCC 的核心设计是每行隐藏三列事务 IDtrx_id、回滚指针roll_pointer、删除标记。更新一行时旧版本会被写进 undo 链行头保留新事务 ID回滚指针指向旧版本。读请求根据当前事务的 ReadView——即活跃事务 ID 集合、最小 ID、最大 ID——来判断某个版本是否可见如果行上的 trx_id 还在活跃事务集合里说明这行刚被修改还没提交当前事务不能看新版本只能沿着 undo 链找旧版本。这个机制换来的是读写互不阻塞事务 A 读某行时构造 ReadView事务 B 随便改这一行A 后续再读还是看到旧版本这就是快照读。但要注意写写冲突依然靠行锁MVCC 只解决读写并发。所以生产环境会出现一种“数据没同步”的假象一个长事务一直复用最初的快照而别的会话早就提交了新数据业务层就误以为同步失败。如果业务要求每次读到最新要么把隔离级别调到读已提交要么显式用select ... for update走当前读这些都是底层机制直接决定的玩法。3.3 死锁成因、检测与日常排查死锁这个话题我一开始觉得离自己很远直到第一次在自己的 mini 数据库里跑出全部线程挂死的场景才真正理解它的成因。死锁四要素是互斥、占有且等待、不可剥夺、循环等待数据库里最常见的就是两个事务以相反顺序锁多行或多表。举个例子事务 T1 先更新订单表第 10 行再更新用户表第 5 行事务 T2 先更新用户表第 5 行再更新订单表第 10 行。只要两个事务交错执行必然互锁T1 拿着订单行等用户行T2 拿着用户行等订单行谁也走不动。数据库怎么处理死锁InnoDB 默认开启死锁检测靠等待图wait-for graph找环发现环就挑一个事务回滚释放它的锁让另一个继续。如果检测本身压力大或者被关闭还有 innodb_lock_wait_timeout 做兜底超时就报错。日常排查里最实用的技巧是开两个命令行窗口手动执行脚本复现互锁场景然后用show engine innodb status看输出里的LATEST DETECTED DEADLOCK段落里面会明确写着两个事务持有哪些锁、在等哪一行。预防死锁的套路很固定业务线程统一按同一顺序获取行比如都先按用户 ID 排序再更新事务尽量短别在事务里做外部接口调用大批量更新拆成小批量SQL 尽量走索引因为走不到索引的全表扫描会把 gap 锁范围扩大到吓人的程度死锁和锁冲突概率飙升。4. SQL执行引擎怎么从文本跑到结果解析、优化与向量化4.1 从词法分析到执行计划优化器到底在算账存储引擎和事务讲完了接下来是 SQL 层。一条 SQL 文本进入数据库后先做词法分析把关键字、标识符、数字、字符串切成 token再做语法分析按文法把这些 token 组装成抽象语法树AST。手写一个完整的 SQL 解析器是巨坑建议直接用 ANTLR 或 yacc 生成 parser第一版只支持单表点查、范围查、简单 JOIN 和最基本的 INSERT/UPDATE/DELETE 就够用了。AST 生成之后是绑定去 cataloq 元数据里查表名、列名、权限做类型检查和隐式类型转换。这层的功能不复杂但碎活儿特别多大小写、别名、默认值、兼容性转换稍不留意就会出诡异的 bug。优化器是关键。规则优化RBO做的是谓词下推、投影下推、常量折叠、连接重排、子查询改写成 JOIN 这类确定性的优化代价优化CBO则根据表行数、索引基数、直方图等统计信息估算每种访问路径的代价选最小代价的执行计划。“为什么加了索引却不用”这类问题的答案大多在这里索引选择性太低时比如一个分布均匀的二值字段用索引回表的代价可能比全表扫描还贵统计信息过期没 analyze也会让优化器算错账。所以遇到“索引没用”的疑问先看执行计划别一上来就强制索引。执行计划怎么快速看懂MySQL 的 explain 输出里type 列按访问代价从优到劣大致是system → const → eq_ref → ref → range → index → ALLref 列看走了哪几个索引列extra 里出现Using filesort或Using temporary说明排序或去重没吃到索引红利要重点优化。这张表可以当速查手册explain 字段含义关注点type访问类型如果到 ALL基本是全表扫描key实际使用的索引NULL 说明没走索引rows预估扫描行数和实际偏差过大说明统计信息过期Extra附加信息filesort/temporary 是常见隐忧4.2 执行模型、列存向量化与时序/向量数据库的底层差异执行器的经典模型是迭代器模型也叫火山模型每个算子暴露一个next()接口上层算子从下层算子拉一行或一批数据逐级往上。好处是管道化中间结果不用全部物化到内存坏处是每处理一行都要经过多层虚函数调用性能损耗不小。自实现时先从火山模型入手逻辑最直白。再往上一层是向量化执行。一次处理 1024 行而不是一行一行地调用配合列式内存布局和 SIMD 指令可以大幅摊薄虚函数调用和分支预测失败的开销。这也是列存数据库在 OLAP 场景表现好的原因同一字段连续存放压缩率高聚合扫描时内存带宽利用率也高。时序数据库 TDengine 的底层把“设备/表”模型和数据按时间排序结合起来每个时间片的字段连续存放天然适合向量化聚合。我做过采集端接入用 C 绑定写入的时候必须走taos_stmt_prepare做预处理参数绑定而不是每次拼接 SQL 字符串。实测同一批写入点预处理绑定比拼接 SQL 的 CPU 占用低抖动也小而且避免了字符串拼接带来的注入风险。顺带把两个容易混淆的底子说清楚。SQLite 是单文件数据库整个库就是一个.db文件页为基本单位表、索引都是 B树文件头里写页大小、编码、版本信息WAL 模式下还会多一个.db-wal文件适合嵌入式、桌面和本地工具场景。向量数据库则完全不是关系型这套它用 HNSW、IVF 这类近似最近邻索引支撑高维向量召回不做事务也不做精确 SQL你拿它存订单是选错了工具拿 MySQL 做语义检索也会非常吃力。至于多模态数据库思路是在同一套存储上同时处理结构化表、文档、图和向量存储格式、索引结构、查询执行都要重新设计底层的取舍逻辑比单模型更复杂。理解这些差异之后你会发现数据库选型其实是在选“底层结构与你查询模式是否匹配”。5. 底层搭建中那些坑连接池、DDL与备份恢复实录5.1 连接池底层链路外的第一道雷存储引擎和 SQL 层的底层原理搞懂了我还想专门讲讲连接池因为线上事故里连接池问题比索引问题更隐蔽。每次新建数据库连接都不是免费的TCP 握手、可选 TLS 协商、鉴权、会话初始化高并发下反复建连光握手开销就能吃掉大量 CPU。所以连接池是标配。但池大小不是越大越好连接数超过某个阈值线程上下文切换和操作系统调度开销反而会让延迟升高。我常用的估算方法是 Little 定律并发连接数约等于 QPS 乘以平均查询耗时。比如 QPS 2000、平均每条查询 20 毫秒那么只要池子里有 40 个连接左右就够用配到 500 反而是在浪费资源。真正的姿势是用压测数据校准而不是拍脑袋填一个 max_connections。连接池最常见的故障有两类。一类是池里的空闲连接被数据库 wait_timeout 掐断应用却不知道下一次请求时报 “Lost connection” 或者“连接已被对端关闭”。解决办法是配置连接池的保活机制比如 HikariCP 的 maxLifetime 和 connectionTimeout、Druid 的 testWhileIdle定期探活或重连。另一类是连接泄漏长事务一直不提交连接被占着不归还池子被占满后续请求全部排队最终上游超时雪崩。排查时先看show processlist重点找那些 Sleep 状态但事务开启时间很长的连接。处理方式不是光改配置而是从代码层面对事务边界做认真审查保证拿到连接必须 try/finally 归还。5.2 DDL、备份恢复与几个经典报错排查底层原理用到运维和故障恢复里最能体现价值。先说 ALTER TABLE 的代价MySQL 8.0 支持 INSTANT、INPLACE、COPY 三种算法COPY 算法必须重建整张表并加锁大表直接做 DDL 会拖垮业务。生产环境里大表变更一般用 pt-online-schema-change 或 gh-ost 这类工具本质是建临时表、增量同步数据、最后切表原理还是绕不开底层的数据页拷贝和日志同步。备份与同步的一致性也是个经典坑。mysqldump 导出如果不加--single-transaction或者在主从实例上同时导出逻辑备份可能来自不同时间点恢复后数据是对不齐的。这就要理解物理备份和逻辑备份的差异XtraBackup 这类物理备份直接按页复制同时记录 redo 日志恢复时靠日志回放保证一致。做数据库同步方案时一定要确认 binlog 位点或 GTID 与备份快照对齐否则主从数据会出现缝隙。几个具体报错也值得记下来。SQL Server 在完整恢复模式下做大容量导入会报“该数据库不可以执行非日志模式的大容量复制”因为完整恢复模式要求所有大容量操作也写日志临时把恢复模式切到 BULK_LOGGED导完立即做一次完整备份再切回 FULL同时确认当前用户有 dbo 权限。SQL Server 数据库变成“存疑”SUSPECT状态常见于异常断电、日志损坏或磁盘空间不足处理思路是先用dbcc checkdb定位损坏再进入单用户模式尝试还原或重建日志实在不行从最近完整备份恢复。MySQL 启用了 innodb_file_per_table 后每个表对应一个 .ibd 文件这类底层文件一旦误删或损坏修复成本很高所以必须有物理备份并且定期演练恢复。密码有效期的问题也经常在月底突然爆雷Oracle 的 PASSWORD EXPIRE 策略或 MySQL 的默认密码策略到期后应用连接全部失败业务账号应提前关闭限期过期或安排改密窗口别等到线上报警再处理。这一节其实想说明一个观点数据库的“怪问题”大多能归到存储、日志、连接、权限这几个底层环节把热词里的那些散碎问题——连接池、死锁、存疑、大容量复制、密码有效期——串起来看因果链都非常清晰。底层搭建的意义不在于造一个生产级数据库而在于让你建立这套因果直觉。5.3 写给想动手搭底层的人我最后想说的几件事最后分享几条实操心得都是我踩过坑之后总结出来的。第一第一步先做一个能跑但简陋的版本单线程、无并发、日志直接 fsync、索引先用页内数组加链表。先把增删改查和崩溃恢复跑通再逐步替换成缓冲池、B树和 MVCC。我自己的第一版把大量时间花在 B树上后来发现先验证事务逻辑更重要顺序反了容易两头崩。第二并发正确性的 bug 极其难查第一版务必在“所有写路径加同一个全局锁”的条件下通过一致性测试再考虑加细粒度锁。上来就搞行锁和分裂并发你会分不清问题出在索引还是事务。第三给每个页加 checksum、给每条日志加序号崩溃恢复时你就能快速定位问题出在数据页还是日志顺序否则重放失败时只能全库从头扫日志调试效率极低。第四调试崩溃恢复可以用脚本把“写入、模拟崩溃、重放、校验结果”这个循环跑几千遍比手动造故障可靠得多。最后一点别总想着自己造一个生产级数据库目的应该是把原理亲手验证一遍。生产环境老老实实选成熟产品但在选型时你能看懂它底层的取舍这已经超过大多数人了。
返回列表