B+ 树用大白话讲一次:MySQL 索引为什么快
一条 select 跑了十秒,加个索引,毫秒级返回。这中间发生了什么?很多人背过「索引是 B+ 树」,但账没算过,于是面试讲不清,工作里也不敢动。把账算一遍就懂了,慢的单位从来不是「比较了多少次」,是「读了多少个磁盘页」。
慢的单位是页
磁盘和内存差着几个数量级,CPU 比一次大小快到可以忽略,从磁盘读一页数据慢到必须计较。MySQL 的 InnoDB 把数据存在页里,一页 16KB。全表扫描就是把表里的页从头读到尾,十万行的表,每页装十来行,几千上万个页,慢在每个页都是一次 IO。
所以优化的目标从头就是少读几页。记住一句话:索引省的是 IO,不是比较次数。
B+ 树的算术
索引为什么快,靠的是树矮。树每高一层,查找就多一次 IO,把树压矮,就是把 IO 压少。B+ 树的办法是让每个节点正好装满一页,一页里塞上千个「键加指针」,这叫扇出。
算一笔账。一页 16KB,一个键值加指针十来个字节,一页能装一千多个。两层的树能索引百万级的行,三层能到十亿级。千万行的表,B+ 树只有三层,查找读三个页就到,全表扫描要读几十万个页。差距是几万倍,这还只是 IO 次数的账。
B+ 树的叶子节点排成链表,这一点是专门为范围查询留的。where age between 20 and 30,顺着叶子链表滑一段就行,这就是范围查询快的原因。
为什么不是二叉树和哈希
二叉树的问题是瘦。每个节点只有两个叉,百万行的树高二十层,查一次读二十个页,比 B+ 树的三页差一个数量级。树高的每一页都是 IO,这就是不用二叉树的全部理由。
哈希的问题是偏科。等值查询确实快,一次定位,但范围查询、排序、前缀匹配全都帮不上忙,age 大于 20 这种条件哈希索引直接失灵。业务里的查询条件千奇百怪,B+ 树的「都能凑合」比哈希的「一门极快」实用。
跳表呢?它把有序链表加了几层索引,范围查询也快,Redis 的有序集合就用它。但跳表的节点是分散在内存里的,每个节点还要额外存好几层指针,一页 16KB 装不下几个,扇出远不如 B+ 树。同样是千万行,B+ 树三层读完,跳表要读的页多得多。磁盘场景下,把节点紧凑地塞进一页、让树变矮,才是王道。
联合索引、回表和覆盖索引
联合索引 (a, b, c) 的排序逻辑就是电话簿。先按姓排,同姓按名排,同名再按年龄排。找「姓张的」可以快速翻到那一段(最左前缀),找「名叫三的」就得整本翻,因为名的顺序只在同姓内部有效。所以 where 条件里 a 必须出现,跳过 a 直接用 b 或 c,索引就用不上——最左前缀原则说的就是这件事。
回表是索引查到主键之后再回数据页拿其他列。select * 逼着 MySQL 每行都回一次表,索引省下的 IO 又还回去了——查一万行就多一万次回表,等于把 B+ 树省下的几万页 IO 又赔进去。只查索引里就有的列,不用回表,这叫覆盖索引,explain 的 Extra 里会写 Using index。select 你真正需要的列,是免费的性能。
亲手算账
下面用一段 Python 把三种查找方式的 IO 账单算出来,直接运行python io_bill.py,数字可以按公式手算核对。
# IO 账单计算器:三种查找方式各读多少个磁盘页# InnoDB 页 16KB,键加指针约 14B,行约 1KB 时每页约 16 行(示意值)PAGE_SIZE=16*1024KEY_AND_POINTER=8+6# 主键大致 8B + 页指针 6BFANOUT=PAGE_SIZE//KEY_AND_POINTER# 一页能装多少个键deffull_scan(rows:int,rows_per_page:int=16)->int:"""全表扫描:每一页都要读。"""returnrows//rows_per_pagedefbinary_tree_height(rows:int)->int:"""二叉树:每层一次 IO,层高约 log2(行数)。"""returnmax(1,rows.bit_length())defbplus_tree_height(rows:int)->int:"""B+ 树:每层一次 IO,层高由扇出撑开。"""height,capacity=1,FANOUTwhilecapacity<rows:height+=1capacity*=FANOUTreturnheightprint(f"扇出 FANOUT ={FANOUT},三层可索引约{FANOUT**3:,}行\n")forrowsin(10_000,1_000_000,10_000_000):print(f"{rows:>10,}行 | 全表{full_scan(rows):>8,}页"f" | 二叉树{binary_tree_height(rows):>2}层"f" | B+ 树{bplus_tree_height(rows)}层")print("\n结论:索引把读页次数从万级压到个位数,这就是快的全部来源。")跑完看这行账单,千万行的表,全表扫描几十万页,B+ 树三层,这就是「加索引变快」的全部秘密。原则就一条:慢 SQL 先看读了多少页。
动手验证
原理落地到排查,就看 explain 的三个字段。key 是不是用上了你以为的索引,rows 是它估计要扫的行数,Extra 里 Using index 是覆盖索引,Using filesort 和 Using temporary 是排序和临时表开销,见到就要留神。排查顺序固定下来,先 explain,看 key,看 rows,看 Extra,再决定是改索引还是改 SQL。比如一条慢 SQL 的 rows 显示扫了 50 万行,key 却是空的,那多半是 where 条件没吃到索引,优先考虑加索引而不是调 SQL。
适用边界
这一篇管单机的索引原理。分库分表后的全局索引、查询优化器选错索引的调参、以及页大小和行格式的细节,是更大的题目,官方文档为准。EXPLAIN 的输出字段随 MySQL 版本会变,看到不认识的就查对应版本的文档。原理的账算过一遍,面试讲得清,慢 SQL 也知道从哪下手,这一篇的目的就达到了。