想象你是城南图书馆的管理员小艾。馆里几万本书整整齐齐码在四排书架上,每排书架只能放几十本,再多就摆不下。读者来借书时,小艾从不一排排翻找:她先查门口检索台的总目录卡,再跟着楼层的引导牌缩小范围,最后直奔目标书架。如果读者说“我要 B 区到 M 区的所有书”,她就沿着书架之间的通道一路扫过去,不用再回总目录。这套“书架 + 引导牌 + 通道”的组合拳,正是数据库索引的底层结构——B+树。这篇文章将带你走进这棵树的内部:节点里究竟存什么、为什么查得这么快、主流数据库如何落地它、以及你建表建索引时该参考哪些经验。
读懂 B+树的节点结构与索引原理,你就能在搭建特征存储、设计训练数据管道时规避慢查询与回表陷阱,让 AI 系统的数据底座跑得更稳更快。
可视化演示
展示B+树的内部节点扇出、叶子节点链表结构与范围查询过程,揭示数据库索引的高效搜索原理。
本节配有交互动效(进入视口后自动加载,约 3KB)。
B+树索引结构 内部节点:仅存键值与子节点指针 叶子链表:顺序连接支持范围查询 高扇出:降低树深度减少磁盘IO Depth = log_f(N), IO = O(log_f N) B+树通过高扇出扁平结构实现高效索引
节点家族三成员
图书馆的“找书系统”分三级:入口的总目录卡、每层的引导牌、还有真正放书的书架。小艾有一个严格规定——引导牌上绝不堆书,书架上绝不兼职指路。各司其职,整个找书流程才不会乱。
B+树的节点也分三类:根节点、内部节点(统称非叶子节点)和叶子节点。根节点是树的唯一入口,内部节点是层层分流的“引导牌”,而所有真正的数据记录,只存放在叶子节点中。这与普通 B 树“每个节点都可以存数据”的朴素设计截然不同,是 B+树性能优势的起点。
一个内部节点里放着若干“键”和若干“指针”:键是分隔值,指针指向覆盖对应键范围的子节点。规律很清晰——n 个键把一维区间切成 n+1 段,所以指针数总是比键数多 1。查询从根出发,每层根据目标值与键的比较选一条指针下降,直到叶子。
1. N_key(键数):节点中用于分隔子区间而存放的键的个数,相当于引导牌上的书号边界。
2. N_ptr(指针数):指向下一层子节点的指针个数,相当于引导牌上的“箭头”数量。
3. +1 的来源:n 个键把范围切成 n+1 段,每段必须有一个指针,所以指针数恒为键数加一。
小艾很清楚:引导牌上写 3 个书号区间,就得画 4 个箭头;书架永远不会出现在引导牌那一层。这份“分工洁癖”正是 B+树维持高效的根本。
内部节点与扇出
引导牌尺寸固定,一行写一个“范围 → 箭头”。小艾发现,决定找书快慢的关键变量是:一张引导牌上能写多少行。写得多,读者被疏散到更多子区域,平均要多走的路就少——这个“一块牌能带几条路”的能力,在 B+树里叫扇出(fanout)。
在 B+树中,内部节点的容量由所在页(page)大小决定,每一行配置是一个“键 + 指针”对。于是扇出约等于页大小除以单个键与指针的字节数总和。键越短、指针越紧凑,扇出越大,树就越“矮胖”。
假设页大小 P,平均键长 K,指针长 T,则扇出:
InnoDB 默认 16KB 页,配上 8 字节的 bigint 主键和 6~8 字节的指针,扇出轻松上千。这意味着三层树就能覆盖百万级节点,四层就能支撑上亿行数据的索引。
1. P_page(页大小):节点可用的存储空间,也就是一次磁盘 I/O 能读入的数据量。
2. K_key + T_ptr(键指针组合):内部节点中一行“键 + 指针”占用的平均字节数。
3. 相除取整:总字节数除以单行字节数得到可容纳行数,向下取整保证不越界。
于是小艾规定:引导牌的标签一律用最简短的编号(“B~M“而不是”文学类 B 区到文学类 M 区”),一行塞下的信息越多,读者少跑两层楼。这不是经验玄学,而是有公式可算的工程权衡。
叶子链表与查询
读者想借“B 到 M 区所有书”。小艾的聪明办法是:书架之间修一条前后贯通的通道,找到起点书架后,顺着通道一本本往后取,全程不用回总目录。在 B+树里,这条通道就是叶子节点之间的双向链表。
叶子节点按键升序存放完整记录,且每个叶子都有 prev / next 指针,串成一条有序的“叶子链表”。这个设计的直接收益是范围查询:先沿树找到左端点所在的叶子(需要 h 次 I/O),再沿链表顺序扫描即可,完全不必逐层回溯。
设树高为 h,每个叶子页平均容纳 L 条记录,范围查询要取 r 条记录,则 I/O 次数约为:
1. h(树高定位):从根到左端点叶子的路径长度,是范围查询的固定开销。
2. r / L(扫描叶页数):结果数量除以每页记录数,就是需要顺序读取的叶子页数。
3. 向上取整:即使某页只读一条记录也要一次 I/O,所以按页数向上取整。
小艾总说:“书架间的通道比天花板上的航拍图重要。” 点查靠树找对书架,批量取书靠通道顺藤摸瓜——这正是数据库 WHERE key BETWEEN ... 能秒回的原因。
盘页对齐设计
图书馆的搬运小车是定制的:一次刚好能装一格书。如果书架格子比小车大,一趟要跑两趟;比小车小,装不满浪费。所以小艾把每一格都按小车容量设计,不多不少。
磁盘读写以“页”为最小单位,数据库一次 I/O 至少读入一整页。B+树的每个节点大小刻意与磁盘页对齐:一个节点恰好占一页(InnoDB 默认 16KB、PostgreSQL 默认 8KB)。这样一次 I/O 就能读入一个完整节点,不浪费任何字节。
点查询沿树下行 h 层,每层访问一个节点就是一次页 I/O,因此:
其中 T_io 是单次磁盘 I/O 的耗时(毫秒级)。树高 3~4 层的 B+树,点查询固定只要 3~4 次 I/O,与数据总量几乎无关——这正是索引能“扛住亿级数据”的底气。
1. h(层数):点查询从根到叶子,每层只访问一个节点。
2. T_io(单次 I/O 耗时):读一个节点对应一次磁盘页 I/O,是查询的主要成本。
3. 相乘得总开销:总耗时约等于层数乘以单次 I/O 时间,所以减少树高就是缩短延迟。
小艾把书架、小车、通道都按同一个“格”来设计——对齐的代价是前期测量,收益是每次搬书都一步到位。数据库里页大小与节点大小的对齐,同理。
分裂合并平衡
某天“推理小说”书架满了,新书没处放。小艾的做法是:把书架从中间拆成两个,中间那本书“升级”到引导牌上当新路标,两边各放一半。过几天隔壁书架太空了,她又把两架合并。这样书架永远不溢出,也永远不浪费。
B+树插入时先定位叶子,若节点已满(键数达到上限 m),则分裂为两个节点,并把中间键提升到父节点继续充当分界。若父节点也因此变满,则继续向上分裂,直到根——根分裂时树整体增高一层。删除时若节点过空,则向兄弟借键或合并,必要时向下级联调整。
对满节点插入第 m+1 个键时,中间位置满足:
左侧保留前 j 个键,第 j 个键作为“中间键”提升到父节点,剩余键交给右侧新节点。由于数据只存在叶子且提升键随时上移,B+树始终满足“所有叶子在同一层”,即绝对平衡。
1. m+1(临时候数):满节点插入新键后,节点里暂时有 m+1 个键。
2. 取半下整(左侧键数):算出左侧节点应保留的键数 j,保证分裂后两边大致均衡。
3. 中间键提升(新分界):第 j 个键上移到父节点,作为左右两个新区域的边界。
书架的拆与并看似只是体力活,但小艾每次都要同步更新引导牌——对应数据库在分裂合并时对父节点的级联修改。平衡不是天生的,而是“拆了必须改路标”这套纪律换来的。
数据库实现差异
城北图书馆 A 馆规定:书必须按索书号排架,找到书架就是找到书,这叫“书跟号走”。城南借书处 B 馆则把书随意放,但每本都贴一张“位置纸条”:第几排第几格。两家都能快速找书,但内部分工完全不同。
MySQL InnoDB 与 PostgreSQL 正是这两派。InnoDB 使用聚簇索引:主键索引的叶子节点直接存放整行数据,表数据“跟着主键走”;二级索引的叶子则存主键值,查询要先走二级索引,再回聚簇索引拿整行。PostgreSQL 采用堆表:行数据放在堆(heap)中,索引叶子存放的是行的物理位置 TID(页号 + 偏移量),所有索引(含主键)都是非聚簇的。
在 InnoDB 中,二级索引等值查询通常要访问两棵 B+树:
h_sec 是二级索引树高,h_clu 是聚簇索引树高。此外两者页大小不同(16KB 与 8KB),导致扇出和树高存在差异。理解这些差异,才能解释“为什么同样的 SQL 在两个库里表现不同”。
1. h_sec(二级索引树高):先用二级索引这棵 B+树把目标主键找出来。
2. h_clu(聚簇索引树高):再拿主键回聚簇索引这棵 B+树读取完整行。
3. 相加(回表总开销):两棵树各走一遍,所以二级索引回表查询是最坏情况。
小艾去 A 馆参观后感叹:A 馆“号到书到”少一趟路,但改索书号等于重排满馆书架,成本高;自己用“位置纸条”灵活,却总要多看一眼纸。没有绝对好坏,只有围绕主键的取舍。
索引策略与优化
小艾总结出了一套选书架铁律:书号越短,引导牌能写的行越多(扇出大),树就越矮;书号递增,新书永远加在最后一格,书架难得拆。这四条经验,换成数据库术语就是主键选择与索引设计策略。
树高与扇出、记录数的关系是指数级的:
扇出 m 越大,树高增长越慢。假设扇出 1000,树高 3 即可覆盖百万级节点;若键变成又长又宽的 varchar(200),扇出骤降,树变高,每次查询多一层就多一次 I/O。
由此得出实用策略:其一,主键选小且有序(自增 bigint 最优),避免随机 UUID 引发大量页分裂与碎片;其二,按真实查询模式建索引,但不是越多越好——每多一个索引就多一棵 B+树,写入要同步维护,产生写放大;其三,用覆盖索引(联合索引包含所需全部列)让查询在索引页内拿齐数据,免回表。
1. N(记录数):要索引的数据总量,对应馆藏图书总册数。
2. m(扇出):每个内部节点平均指向的子节点个数,对应引导牌的容纳行数。
3. 以 m 为底的对数再上整:树高等于记录数按扇出取对数的上整,扇出翻倍树高减一。
小艾把四条铁律贴在工作台:号短、号涨、路牌字小稀疏、通道少而精。数据库里每一次建索引、选主键,其实都在回答同一个问题:你愿意为“读得快”付出多少“写得慢”的代价?
AI 应用场景
场景1:数据库慢查询诊断与索引重建
阶段:数据存储与特征管道
解决的问题:训练数据量增大后,特征表关联查询频繁超时。传统做法是“哪里慢就无脑加索引”,反而拖垮写入性能。理解 B+树节点与扇出后,能按查询模式(等值 or 范围)精准选择列与键类型,并用覆盖索引免回表。
具体案例:某推荐团队的特征表 user_feature(user_id BIGINT, day DATE, feat_xxx ...) 经常按 (user_id, day) 范围取 90 天特征。工程师将主键改为自增 bigint,并建立 (user_id, day) 联合覆盖索引,查询从全表扫描的 2.3 秒降到 23 毫秒。
场景2:特征存储与训练数据批量加载
阶段:训练数据准备
解决的问题:模型训练需要按时间范围批量拉取特征序列。基于对叶子链表范围扫描的理解,可以设计“时间有序追加”的主键与分区,让批量读取走顺序 I/O,而不是随机散读。
具体案例:在线广告 CTR 模型每天从特征库拉取用户 30 天行为序列。特征库采用 (date, user_id) 时间有序主键,读取时先定位起点叶子,再沿叶子链表顺序扫描,吞吐量提升数倍,训练数据准备耗时从小时级降到分钟级。
场景3:AI 应用在线服务的表结构设计
阶段:在线服务数据层
解决的问题:AI 应用上线后,用户行为日志表用 UUID 主键导致页分裂严重、写入变慢。基于 B+树分裂机制,改为趋势递增 id 后,新记录永远追加在右端叶子,写入退化为顺序 I/O。
具体案例:一个 LLM 对话服务把 session_log 表主键从 UUID 换成趋势递增 bigint 后,写入 P99 延迟从 45 毫秒降到 8 毫秒,索引碎片率下降 60%。
Python 演示
import bisect class Node: # B+树节点: 叶子存数据, 内部节点只存键和子指针 def __init__(self, leaf=False): self.leaf = leaf self.keys = [] # 键, 升序排列 self.childs = [] # 内部节点的子指针 self.vals = [] if leaf else None # 叶子节点的数据 self.next_leaf = None # 叶子链表后继 class BPlusTree: # 简化版 B+树: 支持 insert / search / range_query def __init__(self, order=4): self.order = order # 节点最大键数 self.root = Node(leaf=True) def _find_leaf(self, key): n = self.root while not n.leaf: i = bisect.bisect_right(n.keys, key) n = n.childs[i] return n def _height(self, n): if n.leaf: return 1 return 1 + self._height(n.childs[0]) def search(self, key): n = self._find_leaf(key) i = bisect.bisect_left(n.keys, key) if i < len(n.keys) and n.keys[i] == key: return n.vals[i] return None def range_query(self, lo, hi): n = self._find_leaf(lo) res = [] while n is not None: for k, v in zip(n.keys, n.vals): if k > hi: return res if k >= lo: res.append(v) n = n.next_leaf return res def insert(self, key, val): split = self._insert(self.root, key, val) if split is not None: left, mid_key, right = split root = Node() root.keys = [mid_key] root.childs = [left, right] self.root = root def _insert(self, n, key, val): if n.leaf: i = bisect.bisect_left(n.keys, key) n.keys.insert(i, key) n.vals.insert(i, val) if len(n.keys) <= self.order: return None mid = len(n.keys) // 2 right = Node(leaf=True) right.keys = n.keys[mid:] right.vals = n.vals[mid:] right.next_leaf = n.next_leaf n.next_leaf = right n.keys = n.keys[:mid] n.vals = n.vals[:mid] return (n, right.keys[0], right) i = bisect.bisect_right(n.keys, key) split = self._insert(n.childs[i], key, val) if split is None: return None left, mid_key, right = split j = bisect.bisect_left(n.keys, mid_key) n.keys.insert(j, mid_key) n.childs.insert(j + 1, right) if len(n.keys) <= self.order: return None mid = len(n.keys) // 2 up = n.keys[mid] right_node = Node() right_node.keys = n.keys[mid + 1:] right_node.childs = n.childs[mid + 1:] n.keys = n.keys[:mid] n.childs = n.childs[:mid + 1] return (n, up, right_node) if __name__ == '__main__': tree = BPlusTree(order=4) lib = {5: 'Little Prince', 12: 'Algorithm', 25: 'Three Body', 32: 'Deep Learning', 40: 'Statistics', 58: 'LLM Ops'} for k, v in lib.items(): tree.insert(k, v) print('search 25 ->', tree.search(25)) print('range [12, 40] ->', tree.range_query(12, 40)) print('height ->', tree._height(tree.root))
n = self.root; while not n.leaf从根出发,沿内部节点的“引导牌”逐层下降,直到进入叶子。i = bisect.bisect_right(n.keys, key)在内部节点键数组中二分定位,找到第一个大于 key 的位置作为下降子树下标。n.keys.insert(i, key); n.vals.insert(i, val)向叶子有序插入键值对,保持叶子内升序。return (n, right.keys[0], right)叶子分裂后返回“右节点最小键”,由上层把它提升为父节点的新分界。n.childs.insert(j + 1, right)父节点在正确位置挂上新子节点,保证键与指针的排列依然有序。while n is not None: ... n = n.next_leaf范围查询定位起点后沿叶子链表顺序扫描,收集落在区间内的所有记录。
常见误区
误区B+树不就是一棵树吗?内部节点存点数据还能省空间,反正都是存索引。
正解B+树的核心设计恰恰是“数据只存叶子”。内部节点只存键和指针,空间全部留给“路标”。这样同一个页能塞更多键 → 扇出更大 → 树更矮;同时数据只在一个地方,范围查询沿叶子链表走即可,不必在不同层级间横跳。若把数据塞进内部节点,扇出骤降、树变高、I/O 变多,得不偿失。
为何常见:很多人先接触 B 树或二叉搜索树,习惯了“每个节点都存数据”的模型,没意识到 B+树是“指路与藏书分离”的专门设计。
误区主键用 int 还是 varchar(128) 不都能区分记录吗?存得下就行,性能差别不大。
正解键值直接决定扇出:16KB 页、8 字节键时扇出上千;换成 200 字节的字符串键,扇出只剩下几十。扇出小 → 树高增加一层 → 每次查询多一次磁盘 I/O。这正是数据库规范总强调“主键要短小精悍”的原因。
为何常见:新手看不到“树高”这个隐藏变量,只关注存储容量,忽略了单条键对内部节点空间的挤占。
误区查询慢就加索引,反正磁盘便宜,把常用列全建上索引准没错。
正解每个索引都是一棵独立的 B+树。表数据增删改时,所有索引都要同步维护:页分裂、页合并、随机写……索引越多,写放大约严重。索引是“用写入性能换读取性能”的杠杆,应按真实查询模式挑选,能合并成联合索引的就合并。
为何常见:在小数据量、低写入压力的演示环境里,多建索引的代价看不出来,一上生产流量就原形毕露。
误区`WHERE time BETWEEN ...` 肯定很慢,因为它要把结果一条条回主表拿数据。
正解在 InnoDB 聚簇索引上,范围查询定位起点后沿叶子链表顺序扫描,相邻记录就在相邻页甚至同一页里,是高效顺序 I/O。真正慢的是二级索引上的大范围查询:每条命中都要回聚簇索引再取一次行,退化为随机 I/O。所以“慢”精确定义是“二级索引 + 大范围 + 回表”,而非范围查询本身。
为何常见:把“查询慢”简单归因于 SQL 用了范围,忽略了索引结构(聚簇 vs 非聚簇、覆盖与否)才是决定性因素。
面试题
Q1. B+树中真正存储完整数据(行记录或指向行的指针)的节点是?
- A.根节点
- B.内部节点
- C.叶子节点← 正确答案
- D.所有节点
答案:C。像图书馆只有书架藏书一样,B+树所有数据只放在叶子节点。根节点是“总目录”,内部节点是“引导牌”,它们只存键和指针,不存数据。
Q2. 一个内部节点存了 n 个键,它通常有多少个子指针?
- A.n 个
- B.n-1 个
- C.n+1 个← 正确答案
- D.2n 个
答案:C。n 个键把一维区间切成 n+1 段,每段需要一个子指针。相当于引导牌上 n 个书号区间对应 n+1 条走廊,所以指针数恒为键数加一。
Q3. B+树范围查询能高效顺序扫描的根本原因是?
- A.根节点缓存
- B.叶子节点间的双向链表← 正确答案
- C.内部节点二分查找
- D.页对齐设计
答案:B。范围查询定位左端点后,沿叶子双向链表顺序扫描即可,不用反复回上层。双向链表就是“书架间通道”,这才是 B+树范围查询快的根源。
Q4. 在 InnoDB 中,主键索引(聚簇索引)的叶子节点存放的是?
- A.指向堆表的 TID
- B.主键值
- C.整行数据← 正确答案
- D.哈希值
答案:C。InnoDB 聚簇索引的叶子直接存整行数据,相当于“书跟着索书号摆放”。A 是 PostgreSQL 堆表的做法,B 是 InnoDB 二级索引叶子的做法,D 与 B+树无关。
Q5. 其他条件不变,主键从 int 换成 varchar(128) 后,索引树最可能发生什么?
- A.扇出变大
- B.扇出变小,树变高← 正确答案
- C.树高不变
- D.叶子链表消失
答案:B。键变大 → 每行“键 + 指针”更宽 → 一页能放的行数(扇出)变小 → 同样的数据树变得更高 → 每次查询多几次磁盘 I/O。这正是主键要短小的原因。
Q6. 下列哪些做法会降低 B+树内部节点的扇出?
- A.把页大小从 16KB 调成 8KB← 正确答案
- B.用长字符串做主键← 正确答案
- C.增加叶子链表
- D.增加记录条数
答案:AB。扇出约等于“页大小 /(键 + 指针)”。页变小或键变宽,都会让每页能容纳的键指针组合变少。叶子链表和记录条数不参与内部节点容量计算,不影响扇出。
Q7. 假设扇出 m = 1000,树高 3 层(根 + 内部 + 叶子)的 B+树最多能有多少个叶子节点?
- A.10^3
- B.10^6← 正确答案
- C.10^9
- D.无法确定
答案:B。根节点有 1000 个子指针指向内部节点,每个内部节点又有 1000 个子指针指向叶子,所以叶子数最多为 1000 × 1000 = 10^6。每片叶子还能容纳多条记录,实际记录数远大于叶子页数。
Q8. 关于 B+树满节点插入触发分裂,下列说法正确的有?
- A.分裂后两个节点键数大致各半← 正确答案
- B.中间键会提升到父节点← 正确答案
- C.分裂只发生在叶子节点
- D.分裂可能逐层向上传播到根,导致树增高← 正确答案
答案:ABD。叶子满时插入会分裂为左右两个节点,中间键上提父节点;若父节点也因此变满则继续向上分裂,直到根,根分裂时树整体加高一层。内部节点同样会分裂,故 C 错误。
Q9. 覆盖索引(联合索引包含查询所需全部列)能带来哪些好处?
- A.免去回表取行← 正确答案
- B.减少磁盘 I/O← 正确答案
- C.让树高必为 2
- D.消除所有写放大
答案:AB。覆盖索引让查询在索引 B+树内就能拿齐所需列,不必回聚簇索引取整行,从而减少随机 I/O。但它不能改变树高,也不能消除新增索引带来的写放大,故 C、D 错误。
Q10. 对 1 亿行的 InnoDB 表做二级索引等值查询,假设二级索引与聚簇索引树高均为 4,最坏需要多少次页 I/O?
- A.4 次
- B.8 次← 正确答案
- C.16 次
- D.2 次
答案:B。二级索引等值查询先沿二级索引树找主键,消耗 h_sec = 4 次 I/O,再回聚簇索引取整行,消耗 h_clu = 4 次 I/O,共 8 次。若直接拿主键查询只需 4 次——这正是“回表”的代价。