ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

B树与磁盘IO:从数据结构原理到数据库索引调优实战

B树与磁盘IO:从数据结构原理到数据库索引调优实战 如果你维护过线上数据库一定经历过这种诡异时刻一条 SQL 明明走了索引却依然慢到报警。看执行计划索引没失效统计信息也是新的数据量也就几百万行。真正拖慢查询的往往不是 SQL 本身而是索引背后的存储结构——B 树。而 B 树的直系前身正是本文标题里的 B 树。把“B 树、磁盘 IO、数据结构”这三个词放在一起几乎就能概括数据库索引设计最核心的命题。这篇文章我想从一个开发者的视角把 B 树从“为什么存在”到“怎么运作”再到“实际调优”完整讲一遍。无论你是在准备 408 考研、冲刺算法岗面试还是写业务代码时天天被慢查询折磨这篇内容都能给你一条清晰的理解主线。别怕公式B 树的数学并不难难的是把树和磁盘这两个跨层的事物联系起来。我先说一个核心结论B 树存在的唯一理由就是节省磁盘 IO。理解了这句话整棵树的形状、阶数、分裂合并规则全都能顺理成章地推导出来。1. 从磁盘 IO 说起B 树到底在优化什么1.1 一次磁盘 IO 到底有多贵很多人在学校学数据结构时起点是二叉搜索树、红黑树觉得树就应该是二叉树的样子。这种直觉在“数据全放内存”的场景下完全正确但只要把数据规模放大到亿级放到磁盘上这套直觉就会失效。原因是硬件层面的差距实在太大。对比几组延迟数据你就明白了CPU 访问 L1 缓存大约 1ns访问内存大约 100ns而一次机械硬盘的随机读包含寻道、旋转延迟和数据传输大约是 7ms 到 10ms。这是什么概念内存比磁盘快了大概 5 个数量级。哪怕用的是 SSD随机读延迟也还在几十到几百微秒依然比内存慢两三个数量级。更关键的是磁盘读写有自己的“脾气”它偏爱连续读写厌恶随机跳转。机械硬盘把磁头从一条磁道移到另一条磁道本身就要耗费毫秒级时间而操作系统一次读盘默认是按页进行的常见的是 4KB数据库引擎则通常是 4KB 到 16KB。也就是说你为了找一个 8 字节的 key不得不把整个页都读上来。如果你设计的数据结构每次比较都要触发一次随机 IO查一个数据要触发几十次那这系统基本就没法用了。打个比方内存像你办公桌上摊开的文件翻哪份都很快机械硬盘则像一个超大的旧式档案馆管理员取一个档案要跑一趟但一趟能抱回一摞。所以理想的索引结构应该是少跑几趟档案馆但每跑一趟尽量把这一趟的容量用满。1.2 二叉搜索树为什么在磁盘面前“输”了顺着这个思路再看二叉搜索树就明白问题出在哪了。一棵存了 1 亿个 key 的二叉搜索树理想平衡高度大约是 log2 1亿约 27 层。如果每次比较都对应一次磁盘读那你查一个数据要做大约 27 次随机 IO每次 IO 按 7ms 算光寻道时间就接近 200ms。就算操作系统有页缓存、树的中间几层能命中内存最深的叶子层也躲不开几次真实磁盘读。而且二进制树的节点利用率极低一个 4KB 的页可能就放了一个 key 和两个指针大量空间被浪费。你不能让一个页只装一个节点就完事你要的是让一个页装下几百个 key让一次磁盘 IO 解决尽可能多的比较。用一句直白的话总结二叉搜索树是为“内存随机访问”设计的而磁盘世界的输家就是它。我们需要的一种“一页多用”的树让树的每个节点正好对齐一个磁盘页并且每个节点能容纳大量关键字。这就是 B 树登场的原因。2. B 树的结构与规则一棵又矮又胖的平衡多路树2.1 m 阶 B 树的定义五条规则锁死一切B 树的官方定义很多教材版本都有细微差别我这里采用数据结构领域最常见的“m 阶 B 树”定义。所谓 m 阶指的是每个节点最多拥有 m 棵子树。整棵树的规则可以用一张表锁死规则内容1每个节点最多有 m 棵子树也就是最多 m-1 个关键字2根节点如果不是叶子至少要有 2 棵子树3除根节点外所有非叶节点至少有 ceil(m/2) 棵子树也就是至少 ceil(m/2)-1 个关键字4所有叶子节点出现在同一层整棵树绝对平衡5节点内关键字按升序排列一个关键字把子树分成左右两个区间先解释一下为什么非得有这些最低限额。规则 1 规定了“胖”的上限规则 3 规定了“瘦”的下限防止节点退化成一个只有一两个 subtree 的链表规则 4 保证了搜索路径稳定可预测。这四个条件组合在一起保证了无论插入删除如何折腾树的高度始终被压缩在对数级别而且所有叶子在同一层意味着每次查找的 IO 次数有上界。举个例子5 阶 B 树。每个节点最多 4 个关键字最少 2 个关键字根节点可以只有 1 个关键字。网上很多 B 树可视化的图节点上方标的数字就是关键字下方伸出去的每一根线就是一棵子树关键字数量永远比子树数量少一。记住这一点后续的插入和分裂就不容易乱。2.2 为什么 B 树能把树高压得这么低一棵 m 阶 B 树存储 N 个关键字高度大致是 log_m N。对比二叉搜索树的 log2 N如果 m 取 1000也就是每个节点能装下 1000 个关键字那么存放 10 亿条数据也只需要 3 层。这个计算很震撼但前提是“每个节点真的能装下那么多关键字”。而让一个节点能装下大量关键字的硬件基础正是“页”。假设一个数据库页是 16KB一条索引项要存一个 8 字节的 key 加一个 6 字节的页号指针约 14 字节一个页就能装下 16000 / 14 约 1140 个索引项。这意味着每次磁盘读一页就能支持 1000 多路的比较分支。三层树结构就能覆盖大约 130 万1140 × 1140个叶子页一个叶子页哪怕只装十几行记录总数据量也能到千万级别。所以 B 树的搜索路径上磁盘 IO 的次数约等于树高通常是 3 到 4 次。对比二叉树的 27 次随机 IO这个优化是非常直观的。B 树本质上是在用“节点变大、分支变多”来换“树的层数降低”而层数降低的每一层都意味着磁盘寻道少一次。顺带说一句正因为 B 树每个节点大小和磁盘页强相关数据库建索引时通常要求字段长度越短越好。字段短一个页里能放的 key 就多分支因子就大树就更矮。很多人忽略这一点在索引里放一个 128 字节的 VARCHAR等于亲手把树的层数往上推。3. 手把手拆解 B 树三大操作查找、插入与删除3.1 查找一次从根到叶的“磁盘旅行”B 树的查找并不复杂和二叉搜索树的思路一模一样只是从“二选一”变成了“多选一”。从根节点开始在节点内按升序遍历或二分查找当前 key如果命中就直接返回如果没命中根据 key 落在哪个区间进入对应的子树继续。重复这个过程直到找到或者到达叶子节点。伪代码如下def btree_search(node, key): i 0 n len(node.keys) while i n and key node.keys[i]: i 1 if i n and key node.keys[i]: return node, i # 命中 if node.is_leaf: return None # 叶子还没找到说明不存在 return btree_search(node.children[i], key) # 进入对应区间的子树节点内部用顺序查找或二分查找影响都在内存里代价可以忽略。真正昂贵的是每一层递归对应一次磁盘读。所以查找的 IO 次数基本就等于树的层数。如果根节点经常被缓存第一层 IO 可以省掉实际查询通常是 2 到 3 次 IO。我实际调优时最常用的一条经验如果发现某个索引的树深度到了 4 层甚至 5 层优先检查索引字段是不是太长、有没有冗余前缀而不是急着加内存。压缩字段让一个页放下更多 key往往比扩大缓存更有效。3.2 插入与节点分裂中间的关键字往上走插入操作的难点一言以蔽之维护“每个节点关键字数量不超过 m-1”这个上限。你向叶子节点插入一个 key 后如果这个节点的关键字数量超过 m-1就必须分裂。分裂的规则是把当前节点的中间关键字往父节点提左右两半各自成为一个新节点。这里最容易理解错的点是“中间”怎么取。比如 5 阶 B 树最多放 4 个 key插入后满了 5 个a, b, c, d, e。此时把第 3 个 c 上移到父节点左边留 a、b右边留 d、e。两边各 2 个正好不低于下限 2。 如果父节点也满了就继续对父节点执行同样的分裂一路向上。极端情况是根节点分裂此时树的高度加一原来的根变成两个孩子新的根只有一个 key。这也是 B 树唯一长高的方式。插入过程的核心伪代码可以这样理解def btree_insert_key(node, key): if node.is_leaf: insert_id_to_node(node, key) # 节点内有序插入 if len(node.keys) MAX_KEYS: # 溢出 split_node(node) else: child choose_child(node, key) btree_insert_key(child, key) def split_node(node, parentNone): mid_key middle_key(node) right_node node.split_right_half() if parent is None: # 根分裂新建根 new_root Node() new_root.children[0] node new_root.children[1] right_node new_root.keys[0] mid_key return new_root else: parent.insert_key(mid_key) parent.insert_child(right_node) # 注意 child 数组同样要维护顺序 if len(parent.keys) MAX_KEYS: split_node(parent)这里有一个很隐蔽的细节分裂时要同时处理 key 数组和 child 数组。B 树的节点里key 的数量总比 child 数量少一。左半节点要保留原来的前几个 child右半节点要接住被切出去的后几个 child。很多初学者实现 B 树时就是把 key 分开了忘掉 child 也要跟着分结果树的结构直接错乱。另外向父节点插入 key 时child 数组也要在对应位置插入新节点顺序不能乱。否则查找时会走进错误的子树。调试这一类问题我的办法是在每个节点打印完整的 key 数组和 child 指针标识逐层检查每一个区间的前后顺序。3.3 删除借位、合并与递归修复删除是 B 树操作里最复杂、最容易写错的环节。核心难点在于维护“节点关键字数量不低于 ceil(m/2)-1”这个下限。先拆成两种情况。如果要删除的 key 在内部节点那么用它的前驱左子树的最大 key或后继右子树的最小 key替换它然后删除前驱或后继。这个技巧把问题转化为“从叶子节点删除 key”因为在 B 树里前驱和后继最终一定落在叶子上。变成“叶子删除”之后如果删除后节点关键字数量仍不低于下限直接完事。如果低于下限就进入修复流程修复方式有两种按优先级排列第一种是“借”。看左兄弟或右兄弟有没有多余的关键字如果有通过父节点“旋转”比如从右兄弟借一个最小 key先把父节点里夹在两者之间的 key 拉下来放到当前节点尾部再把右兄弟的最小 key 提到父节点。注意如果节点不是叶子孩子指针也要跟着换。这一步细节最多数组操作尤其容易越界。第二种是“合并”。左右兄弟都穷得借不出时把当前节点、兄弟节点、以及父节点夹在两者之间的那个 key三者合并成一个新节点。合并后父节点少了一个 key 和一个 child。父节点如果因此下溢就递归向上做同样的修复。最坏情况递归到根根合并后只剩下一个孩子此时树高减一原来的孩子成为新根。我见过太多人在删除实现里漏了“合并时父节点也会减少一个 child”这一步。父节点 child 数量少一后数组尾部的元素要整体左移不然查找路径就对不上了。我的建议是写完删除逻辑后立刻写一个 vetify 函数遍历全树检查每个节点的 key 数量是否在合理区间、所有叶子是否同层、key 是否升序。每一步操作后调用一次比什么都管用。4. B 树与 B 树数据库为什么几乎都选了 B 树4.1 结构差异数据放哪、叶子连不连B 树和 B 树的名字只差一个“”但数据库索引领域完全是 B 树的天下。MySQL 的 InnoDB、PostgreSQL、Oracle 的索引实现核心结构都是 B 树变体。两者的差别概括起来就两条。第一B 树的每个节点既存 key 也存数据或指向真实数据行的指针非叶子节点命中就能直接返回结果。而 B 树的所有数据都集中在叶子节点非叶子节点只存在索引 key不存数据。第二B 树的叶子节点用链表串接起来在 InnoDB 里通常是双向链表可以从最小 key 开始顺序遍历整棵树的叶子。这两条结构差异把 B 树推向了“数据库索引标准答案”的位置。从逻辑上看B 树查一个存在的 key 可能在任意一层命中查询路径长度不可预测B 树每次查询都必须走到叶子层路径固定。路径固定意味着 IO 次数稳定便于评估性能和设计缓存策略。4.2 IO 视角下的胜负手宽度、稳定性与顺序访问如果往 IO 这个核心指标上仔细分析B 树的优势会进一步被放大。第一更宽。因为内部节点不存数据同样的 16KB 页B 树能容纳的 key 远多于 B 树。B 树的内节点既存 key 又存数据可能一个页只装得下几百个索引项而 B 树内节点能装一千多。分支因子变大树就变矮查询路径上的 IO 次数就更少。这在海量数据场景下是实打实的性能差距。第二更稳定。B 树查找一个 key运气好在根节点命中运气不好要吃到叶子层。这种不确定性会导致不同查询的延迟抖动大。而 B 树每条查询都必须走同样长度的路径延迟方差小更利于数据库查询计划对成本的估算。第三更适合范围查询。这是 B 树完全没法比的一点。B 树的叶子节点通过链表连在一起执行WHERE id BETWEEN 100 AND 200这种范围查询找到起始叶子后直接顺着链表往后扫就行整个过程是顺序 IO磁盘读取效率极高。B 树要做范围查询就得反复从根开始搜索产生大量随机 IO。也别小看排序场景数据库ORDER BY走索引时靠的就是叶子链表的顺序性。顺便说个很多人提到的面试知识InnoDB 的主键索引是聚簇索引叶子节点直接存整行记录而二级索引的叶子节点存的是主键值查到主键后再回表查聚簇索引。这也是为什么二级索引设计得尽量短。这些细节顺着 B 树的设计去理解会清晰很多。4.3 一个不算冷的知识InnoDB 的索引页把计算落到实际数据上。InnoDB 默认页大小是 16KB假设主键是 8 字节的 BIGINT索引项里再加 6 字节的页号指针一共 14 字节。那么一个页大约能存放 16384 / 14 约 1170 个索引项。也就是说一棵 B 树第一层根节点能分出约 1170 个分支第二层同样每个节点分出约 1170 个分支两层内部节点就能组织出约 1170 × 1170 约 137 万个叶子页。如果一个叶子页平均存放 30 行记录这棵三层高根 中间层 叶子的 B 树就能支撑大约 4100 万行数据。哪怕每行记录占空间更大三层覆盖千万到亿级的数据规模也是不难做到的。这也就是为什么很多生产环境里千万级表用索引查询延迟依然很稳定——它们连第四层都没被逼出来。反过来如果你给这样一张表建了一个长度很大的索引字段比如 64 字节的字符串一个页能放的索引项数量就会大幅缩水。页数量不变树高就会增加每多一层就意味着多一次磁盘 IO。所以“索引字段要短”不是洁癖是完全从 IO 次数反推过来的纪律。5. 实操避坑从实现到调优的常见问题5.1 实现层面的边界条件最容易写错的地方如果你打算手写一棵 B 树练手或者在做数据结构课程设计下面几个边界条件是我强烈建议优先测试的。第一个坑是分裂时 child 数组的处理。分裂一个节点时左右两半都要带上各自的孩子指针而且孩子指针数量永远比 key 数量多一。如果你只把 key 分成两半而忘了同步切割 child树会立刻出现“走进不存在的区间”这种诡异 bug。第二个坑是删除合并后父节点的 child 数量维护。合并两个节点后父节点要少一个 key 和少一个 child并且数组尾部整体前移。很多实现只做了 key 的删除忘掉 child 数组导致后续查找时父节点指向了一个已经被合并掉的旧节点。第三个坑是递归向上传播的条件。插入分裂会向上传播删除合并也会向上传播但传播条件不同。插入是“节点满了才分裂”删除是“节点低于下限才合并或借位”。把这两个触发条件写混程序会在某些特定序列上爆栈或陷入死循环。我的测试方法很朴素。先用 m3 和 m4 各写一组用例顺序插入 1 到 1000再顺序删除 1 到 1000每删一个都检查树是否满足全部 B 树性质然后换随机序列重复一遍最后用同一批数据在内存里做暴力查找比对逐 key 验证。只要这三轮测试全过这个 B 树实现基本可以认为没有低级错误。5.2 调优层面的页大小、键设计与写放大如果你不是手写 B 树而是通过数据库使用 B 树索引那么调优的重点完全在“怎么少触发维护操作”和“怎么让树更矮”上。先说键设计。整型主键永远是优选4 字节的 INT 或 8 字节的 BIGINT一页能存很多 key树自然矮。UUID 这种 16 字节随机值不是不行但它是随机无序的插入时会让 B 树频繁发生叶子页分裂带来额外的写放大和页碎片。生产环境里我很推荐保留自增整数主键同时用 UUID 做业务标识而不是让 UUID 直接当主键。这是我见过索引碎片问题最常见的来源之一。再说页大小。数据库页太大单次 IO 成本高内存缓存命中率下降页太小单页容纳 key 少树变高随机 IO 变多。InnoDB 默认 16KB 绝大多数场景够用完全没必要为了跑分调成 64KB。文件系统的页通常是 4KB索引页与文件系统页对齐能减少跨页读的概率这也是 16KB 这类整数倍设计的原因之一。最后说批量操作。需要从一个千万行的大表新建索引时千万不要逐条插入那会产生成千上万次分裂。正确做法是全部取出排序后按照“先建叶子层再逐层往上建父节点”的批量装载方式一次成型。数据库在ALTER TABLE ADD INDEX内部做的基本就是这个事所以生产环境建索引通常不影响可用性。5.3 高频面试与考研考点速查最后整理一下 B 树相关的高频考察点不论你是面后端岗还是准备 408这些都属于必背级别问题核心答案为什么数据库索引普遍用 B 树而不是 B 树B 树非叶子节点只存索引、分支更多树更矮叶子链表适合范围查询查询路径稳定IO 波动小m 阶 B 树每个节点关键字数量范围根节点 1 到 m-1其他非叶节点 ceil(m/2)-1 到 m-1B 树什么时候长高只有根节点分裂时树高加一这是唯一方式B 树删除内部节点怎么处理用前驱或后继替换把删除问题转移到叶子节点B 树为什么适合范围查询叶子节点通过链表串接找到起点后顺序遍历磁盘顺序 IO顺带说一个很多人考试时容易算错的点含 N 个关键字的 m 阶 B 树最大高度是多少。思路是让每层节点数尽量少也就是每个节点都按下限 ceil(m/2) 棵子树算然后凑 N 个关键字能分几层。不要背公式背思路考场上现推比记公式可靠得多。最后聊点我自己的体会。B 树这套设计本质是在“内存快但小、磁盘大但慢”这对矛盾里找平衡。很多人初学时死背那几个规则觉得 ceil(m/2) 不过是考试数字等你真的在线上数据库里排查过索引碎片、调过慢查询就会明白每一个下限和上限都是吞吐量与空间利用率的妥协。每次分裂上移都是为下一次顺序读争取预算。学 B 树最重要的是盯住“IO”这两个字去理解而不是盯住树的形状。理解了 IO你甚至能自己推导出 B 树的大部分规则那才是真的学懂了。
返回列表