B-Tree与B+Tree深度解析:从磁盘I/O到MySQL索引优化

📅 发布时间:2026/10/19 17:47:24
B-Tree与B+Tree深度解析:从磁盘I/O到MySQL索引优化
搞后端和数据库的朋友应该都有过这种体验一条慢查询让你加班到深夜explain 出来一看全表扫描几百万行一条条扫过去不卡才怪。这时候你下意识想加索引而但凡深入研究过索引原理就绕不开两个名字B-Tree 和 BTree。面试问数据库原理几乎必考工作里优化慢查询也躲不掉甚至你只是简单说一句“给表加了索引”背后的数据结构都是它们。这篇我打算把这两个结构彻底讲透它们是什么、怎么一步步演变出来的、到底差在哪以及 MySQL 的 InnoDB 引擎具体是怎么用它们的。我尽量不用教科书口吻直接按我理解这个问题的逻辑来讲——先搞清楚磁盘约束再顺着约束推导设计最后落到真实的建索引和排障场景里。看完你至少能做到三件事能口算一棵三层 BTree 大概能存多少行数据能解释为什么自增主键比 UUID 主键香面试再被问“B-Tree 和 BTree 区别”时能给出有条理而不是背稿子的回答。1. 为什么索引问题绕不开“多路树”1.1 一切从磁盘 I/O 说起数据库的数据最终落在磁盘上这是理解所有索引结构的原点。磁盘随机读写一个页通常 4KB~16KB的代价机械盘大约要 10ms固态盘好很多但也要零点几毫秒而内存访问是几十纳秒级别两者之间差了好几个数量级。我习惯打了个比方内存里查数据像是在桌上翻资料磁盘随机读像是跑仓库里翻档案每翻一次都要先走到对应货架。所以索引结构的第一个设计目标从来不是“算法复杂度最漂亮”而是“尽量减少随机磁盘访问次数”。一个数据结构的树高是几层最坏情况下点查询就要读几个磁盘页这个数直接决定了查询延迟的上限。红黑树、AVL 树在内存里非常能打几百万数据照样跑得飞快但一到磁盘场景就露馅树节点通常不在一块连续地址上每次从根走到叶子都可能触发一次随机 I/O这正是它们不适合做数据库索引的根本原因。1.2 二叉树的极限扇出只有 2二叉树每个节点最多两个孩子所以 1000 万条记录树的高度大约是 log2(1000万)约 24 层。如果每层对应一个磁盘页、每次随机 I/O 按机械盘 10ms 算一次点查最坏要 240ms就算换高速固态盘、按 0.1ms 算24 次也接近 2.4ms还没算解析、传输和锁开销。更要命的是普通二叉搜索树碰上有序插入会退化成长链表复杂度直接变成 O(n)。红黑树通过旋转和染色能把高度收敛到对数级别但本质扇出还是 2高度依然随数据量增长。内存场景无所谓CPU 纳秒级循址可以接受二十多次指针跳转磁盘不行每一次指针跳转都可能是一次 10ms 的随机访问。结论很直接在磁盘上要控制高度必须让每个节点多“分叉”也就是提高扇出。1.3 多路平衡的思想用宽度换高度如果每个节点不是 2 个孩子而是 100 个甚至 1000 个呢10 亿条记录在扇出为 100 的树里高度只有 log100(10亿) 约 4.5 层如果扇出能做到一千以上三层就能覆盖千万级数据。这就是“多路”的直觉来源用更大的节点换更低的树高。磁盘按页读写一个节点刚好设计成一个页的大小访问一个节点最多一次磁盘 I/O“读页”和“找节点”就在物理上对齐了。B-Tree 和 BTree 正是顺着这个思路设计的节点内部维护有序关键字数组可以在页内二分查找节点之间用指针连接每次深入一层就是读一个新页。理解了这一层后面所有细节都能顺下来。2. B-Tree定义、结构与核心操作2.1 先搞懂严格定义别凭感觉答B-Tree 里面的 B 一般认为是 Balanced也有人说是发明者 Bayer 的名字首字母但一定不是 Binary。这里有个常见误区有人把 B-Tree 读成“B 减树”其实是“B 横杠树”减号只是连接符。一棵阶数为 m 的 B-Tree 满足这几条规则每个节点最多有 m 棵子树也就是最多 m-1 个关键字除根节点以外每个非叶子节点至少有 ⌈m/2⌉ 棵子树也就是至少 ⌈m/2⌉-1 个关键字根节点如果不是叶子节点至少要有 2 棵子树所有叶子节点必须在同一层这是“绝对平衡”的体现节点内部关键字有序子树指针夹在关键字之间。“至少半满”这条约束很关键它保证空间利用率不会低到离谱也给插入分裂、删除合并提供了明确的边界某个节点低于下限就借位或合并超过上限就分裂树始终维持平衡不会慢慢长歪。2.2 查找过程页内二分加逐层下钻B-Tree 查找从根开始把节点页读进内存在有序关键字数组里做二分查找找到第一个大于等于目标值的 key再根据比较结果决定走向哪棵子树。每深入一层就读一个新页所以点查询的磁盘访问次数上限就是树高复杂度 O(log_m N)。这里要强调一个认知真正贵的是“把页读进内存”这一步页内二分是纯内存操作几十纳秒就能完成基本可以忽略。所有优化都围绕“少读页”展开这也是为什么大家在讨论索引结构时总盯着树高说话。树高直接等于最坏 I/O 次数一点水分没有。2.3 插入与节点分裂树是怎么长高的插入过程先走一遍查找路径到达目标叶子节点。如果叶子还有空位直接按序插入就结束了如果叶子满了就要执行分裂把节点从中间一分为二两个新节点各占约一半关键字中间那个关键字上移到父节点。父节点如果也因此满了就继续分裂这个动作可能一路向上传导直到根节点。如果根节点也满了就分裂出两个新节点并新建一个根树整体长高一层。整个过程是自底向上的局部调整不破坏“所有叶子在同一层”的约束。为什么分裂成两个半满节点而不是一个满一个空因为要给后续插入留缓冲让节点空间利用率保持在 50% 到 100% 之间减少频繁重组的概率。2.4 删除与合并借位和收缩的规矩删除比插入麻烦一点。如果要删的关键字在内部节点先找它的前驱或后继在子树的最深层叶子附近上来顶替把删除动作转移到叶子上。叶子删完后如果关键字数跌破下限就出现“下溢”。下溢处理有两种策略先看相邻兄弟节点是否有多余关键字有的话通过父节点做一次旋转借一个过来兄弟节点也穷就把当前节点和兄弟节点合并并顺带从父节点删掉一个关键字。这种借位和合并同样可能向上传导根节点被合并掉时树整体矮一层。你会发现整个 B-Tree 的操作都是局部微调不搞全局重排。这是平衡树在磁盘场景下的共同哲学代价可控、结构稳定这是它在数据库领域长期占据统治地位的核心原因。3. BTree换了三处设计工程上却全面胜出3.1 三处关键差异一张表看清BTree 是 B-Tree 的变体两者长得像但有三处结构差异决定了工程表现的巨大分野对比维度B-TreeBTree数据存放位置内部节点和叶子节点都存数据数据只存在叶子节点内部节点内容key 数据 子树指针只有 key 子树指针是纯索引叶子节点之间没有额外链接用链表串起来点查询路径可能在内部节点提前命中稳定走到叶子才能拿到数据范围查询需要中序遍历频繁回溯父节点叶子链表顺序扫描天然连续看起来 BTree 把数据从内部节点“挪”到叶子多了一层冗余像是做了无用功。但真正的工程取舍恰恰藏在这个“看似浪费”的设计里。3.2 为什么数据放叶子反而更快内部节点不再存整行数据后单个槽位从“8 字节 key 大块数据”变成“8 字节 key 6 字节子指针”一个 16KB 页能装下约 1170 个槽位。如果内部节点也塞 1KB 的行数据一页只能装 16 个 key 左右扇出直接缩水到十几树就得多长两三层。树矮意味着点查询的磁盘 I/O 少。三层和五层的差距在磁盘场景里就是 3 次和 5 次随机读的差距机械盘上差出 20ms这还没考虑多层缓存未命中带来的连锁影响。另一个不太被注意但很实在的好处是“可预期性”。B-Tree 的 key 可能在第 1、2、3 层就命中查询路径不稳定存储引擎没法做稳定优化BTree 所有数据都在叶子每个点查询都走同样深度优化器可以放心预估成本缓存策略也好设计。3.3 叶子链表范围查询就像开挂数据库查询从来不止等值匹配还有大量 BETWEEN、大于、小于、ORDER BY 这种范围需求。BTree 的叶子页之间用链表串起来InnoDB 里是双向链表找到左边界后顺着链表往右扫就行相邻叶子页在物理上尽量连续存储引擎还能做预读顺序扫描效率极高。B-Tree 面对范围查询就很尴尬数据散落在各个内部节点必须做中序遍历反复回溯到父节点甚至祖先节点每次回溯都是一次随机 I/O。数据量一上来这个差距直接压垮查询性能。我见过不少 B-Tree 点查不慢、范围查询慢到怀疑人生的案例核心原因就在这里。BTree 结构示意简化 [ 30, 60 ] / | \ [10,20] [40,50] [70,80] / \ / \ / \ (10)(20) (40)(50) (70)(80) ↓ ↓ ↓ ↓ ↓ ↓ 10 - 20 - 40 - 50 - 70 - 80 叶子链表3.4 扇出与容量估算三层到底能存多少行把上面的数字具体化。假设 InnoDB 默认 16KB 页主键是 BIGINT8 字节内部节点的子指针按 6 字节估算那么一个槽位占 14 字节一个页大约能放 16384 / 14 ≈ 1170 个槽也就是扇出约 1170。假设行平均大小 1KB每个叶子页大约放 16 行。第 1 层根节点 1 个页第 2 层最多 1170 个内部页第 3 层最多 1170 × 1170 ≈ 137 万个叶子页总行数137 万 × 16 ≈ 2190 万行。结论就是三层 BTree 覆盖一两千万行的表四层就能覆盖两百多亿行。日常 OLTP 表的索引深度基本稳定在 3 到 4 层这也是为什么总说“三层就能扛住千万级数据”。一旦发现某个索引查找走了五层甚至六层就要回头检查是不是 key 太长、行太大或设计出了问题。4. InnoDB 里到底怎么用 BTree 的4.1 聚簇索引、辅助索引与回表InnoDB 把主键索引做成了聚簇索引整张表的数据其实就是一棵 BTree叶子节点直接存整行数据。换句话说表就是索引索引就是表。其他列上建的辅助索引叶子节点存的是对应主键值而不是行指针。为什么辅助索引叶子存主键值而不是物理地址因为聚簇索引的叶子页会随着页分裂、合并而移动物理指针会失效主键值是稳定的。代价是多一次查询先用辅助索引找到主键再回聚簇索引树查一遍这就是常说的“回表”。如果 select 的列全部包含在辅助索引里就不用回表这叫覆盖索引是优化辅助索引查询的常用手段。4.2 自增主键为什么是主流UUID 为什么踩坑BTree 的 key 是全局有序的按主键顺序插入时新数据永远追加在最右侧的叶子页。自增主键完美匹配这个特征页分裂频率低空间利用率高写性能非常稳定。UUID 主键是完全随机的插入点散落在整棵树任意位置。每来一条新数据都可能落在任意的叶子页导致大量页分裂、页空洞和碎片索引文件膨胀缓存命中率下降写入放大明显。我实际处理过一个归档表优化案例原来用 UUID 主键写入经常伴随间歇性慢插入改成自增 BIGINT 后同等硬件下写入吞吐几乎翻倍索引文件明显缩小。这不是玄学就是 BTree 插入模式和写入顺序不匹配的必然结果。当然UUID 主键不是完全不能用。分布式场景需要全局唯一 ID、写少读多的冷数据表随机主键的问题并不突出也可以改用有序 UUID 变体来降低随机性。核心原则是选主键前先想清楚业务生成的数据顺序和 BTree 的 key 有序插入能不能对上。4.3 联合索引与最左前缀排序规则决定一切联合索引是把多个列拼成一个复合 key 存进 BTree排序规则是先按第一列排第一列相同再按第二列排以此类推。这条规则直接解释了最左前缀原则。WHERE a 1 AND b 2完全命中索引WHERE a 1命中索引WHERE b 2用不上这个索引的前缀定位功能只能把整棵索引当数据扫一遍很可能还要回表。LIKE abc% 能用索引因为前缀是确定的%abc 用不上因为没有可定位的前缀。MySQL 8.0 引入了跳跃扫描能在某些场景下绕过最左前缀但别依赖它优化器不一定选代价也不低。设计联合索引的实操经验是等值过滤的列放在前面范围过滤的列放在后面因为范围条件之后的列很难继续命中排序。排查时看 explain 的 key_len能直接算出索引实际用到了哪几列这是验证索引设计是否合理的最快手段。5. 常见问题、面试答案与避坑记录5.1 面试高频题B-Tree 和 BTree 区别怎么答这个问题的标准回答框架是“结构差异三条加工程理由两条”。结构差异第一数据存放位置不同B-Tree 内部节点和叶子都存数据BTree 只在叶子存数据第二内部节点内容不同BTree 内部节点是纯索引槽位小、扇出高第三叶子节点之间 BTree 用链表相连支持高效范围扫描。工程理由第一扇出高所以树更矮点查询磁盘 I/O 更少第二叶子链表让范围查询和排序不再需要反复回溯祖先节点。回答时如果能现场口算三层 BTree 存多少行再补一句 InnoDB 聚簇索引的实际用法这道题基本就拿稳了。5.2 面试进阶题为什么 MySQL 用 BTreeMongoDB 用 B-Tree这个对比经常被问但很多人的认知还停在旧版本。早年 MongoDB 的 MMAPv1 存储引擎用的是 B-Tree但现在的 WiredTiger 引擎底层更接近 BTree还提供 LSM 选项。所谓“文档库就该用 B-Tree”更多是历史说法。真正的命题是工作负载匹配如果业务以单条文档点查为主范围扫描少单条数据自包含B-Tree 那种“在内部节点就能命中并拿到数据”的设计有优势少读一层就少一次 I/O如果业务以范围扫描、排序、覆盖查询为主BTree 的纯索引内部节点加叶子链表更占优。所以别死记“谁用哪个树”要会说“取决于查询模式”。5.3 补充视角还有哈希索引和 LSM-Tree一说索引就是 BTree 也不全面。等值查询特别多、完全不需要范围查询的场景哈希索引能做到 O(1) 定位InnoDB 也提供自适应哈希索引作为加速写入极其密集的存储引擎会选 LSM-Tree把随机写转换成顺序写代价是读放大和周期性合并。BTree 在读写均衡、范围查询多的通用场景里最稳但选型永远没有银弹都是取舍。5.4 我踩过的几个索引坑第一个坑是在低区分度列上建索引。性别列或者状态列一共就两三个取值BTree 定位到大量重复 key还是要回表访问大量行走了索引反而可能比全表扫更慢。区分度越低的列越要想清楚索引回报。第二个坑是把大字段直接塞进联合索引。单个槽位变大页内能装的 key 变少扇出下降树长高I/O 变多。索引键越短越好这是从 BTree 结构直接推导出来的结论。第三个坑是习惯性 select *让覆盖索引形同虚设。辅助索引里明明有你要的列结果多写了一堆用不到的字段优化器只能被迫回表。第四个坑是忽略主键类型对写入的影响。自增和 UUID 的差异前面已经算过设计表结构时多花五分钟后面能少加一宿班。5.5 常见误区速查表误区说法实际情况B-Tree 比 BTree 快点查询也许快一点范围查询被 BTree 碾压数据库场景整体 BTree 更优索引建得越多查询越快每个索引都是一棵独立 BTree写入和存储都会被放大索引要克制主键随便选不影响性能聚簇索引决定数据物理分布UUID 主键会让写放大和页碎片问题非常突出覆盖索引包治百病覆盖索引确实省回表但辅助索引本身占用空间、增加写成本要权衡说点个人体会。我最初也是把 B-Tree 和 BTree 当面试背题素材直到有一次排查生产环境慢查询explain 显示已经走了索引但依然卡才意识到关键是会算树高、会看 key_len、理解数据访问模式。数据结构不是背出来的是先理解“磁盘随机访问有多昂贵”再顺着这个约束把设计推导出来。建议你拿一张千万行的测试表把主键类型、行大小、页大小代入公式亲手算一遍三层能存多少行再 explain 几个不同类型的查询对照验证。做完这一步这个知识点就彻底长在你身上了以后不管是面试被追问还是线上遇到慢查询你都能用这套底层逻辑快速找到方向。