"查询慢?加个索引。"几乎每个后端都听过这句话,也照做过。但索引为什么能让查询快、为什么数据库底层偏偏选了 B+ 树这个数据结构、为什么有时候加了索引还是慢、什么时候不该加索引 —— 这些问题能讲清楚的人就不多了。这篇从"没有索引会怎样"出发,一步步推到 B+ 树,再落到实战里索引为什么会失效、该怎么用对。看完之后,你对索引的理解会从"背规则"变成"能推导"。
没有索引时,数据库怎么找数据
设想一张 1000 万行的用户表,你要执行 where phone = 某个值。没有索引,数据库只能做全表扫描 —— 从第一行开始,一行行读、一行行比对,直到找到为止。
全表扫描:从第一行一直读到最后一行 [行1][行2][行3] ... [行 9,999,999][行 10,000,000] ▲ ▲ 从这里一行行比对 ──────────────────► 一直读到这里 1000 万行,找一个值,平均要读 500 万行 —— 数据一大就是灾难
平均要读 500 万行才能命中。数据量小的时候你感觉不出来,数据一大就是灾难 —— 一个本该毫秒级返回的查询,变成几秒甚至几十秒。
索引的作用,本质上就是:用一种"有组织的结构",让数据库不用一行行翻,而是能"直奔目标"。就像一本书 —— 没有目录,你要找某个章节得一页页翻;有了目录,你瞄一眼目录就知道翻到第几页。数据库索引,就是数据的"目录"。
但"目录"该用什么数据结构来组织?这才是真正有意思的问题。下面我们顺着"程序员的直觉"一步步往下推,看它怎么一步步逼近 B+ 树。
朴素的想法:哈希?二叉树?
说到"快速查找",程序员的第一反应往往是哈希表(查找 O(1))或者二叉搜索树。但它们都不适合做数据库索引,原因很具体:
为什么不用哈希表?为什么不用普通二叉树?
哈希表:等值查询 O(1) 很快,但
✗ 不支持范围查询(age > 18 它就抓瞎了)
✗ 不支持排序、不支持"最左前缀"
二叉搜索树 / 红黑树:有序,支持范围查询,但
✗ 树太"高" —— 1000 万数据,树高约 24 层
✗ 每往下一层 ≈ 一次磁盘 IO,24 次 IO 太多了
关键约束:数据在【磁盘】上,磁盘 IO 比内存慢几万倍
→ 真正的目标是"尽可能减少磁盘 IO 次数"
哈希表的问题:它只擅长"等值查询"(where id = 5),但数据库里大量的查询是范围查询(where age > 18)、排序(order by)、前缀匹配 —— 哈希表对这些一概无能为力,因为哈希之后数据是无序的。
普通二叉搜索树 / 红黑树的问题更隐蔽:它们是有序的,范围查询没问题,但它们"太高了"。1000 万数据,一棵平衡二叉树的高度大约是 24 层。
这里就要点出整篇最关键、也最容易被忽略的约束了:数据库的数据存在磁盘上,不是内存里。磁盘 IO 比内存访问慢好几万倍。而树每往下走一层,就可能意味着一次磁盘 IO。一棵 24 层的树,查一个数据最坏要 24 次磁盘 IO —— 这在数据库的世界里是不可接受的奢侈。
所以,数据库索引这个数据结构的设计目标,从一开始就不是"比较次数最少",而是"磁盘 IO 次数最少"。想明白这一点,你就知道接下来该往哪个方向走了 —— 想办法把树压矮。
为什么是 B 树:为磁盘而生
既然问题是"树太高、IO 太多",那解法就很自然:把树压矮。怎么压矮?让每个节点不止 2 个分叉,而是几百上千个分叉。节点越"胖",同样的数据量,树就越"矮"。
B 树的思路:把树压"矮"、压"胖"
二叉树(高): B 树(矮胖):
o o o o o o ...(一个节点上百个 key)
/ /│ │ │ │
o o o o o o o o ...
/| | 每个节点 = 一个数据页(约 16KB),
...(24 层) 一次 IO 整个读进来,三四层就够装千万级数据
这就是 B 树的核心思想,而且它压得非常"刻意"—— 它让每个节点的大小,正好对应磁盘上的一个数据页(通常是 16KB)。这样一次磁盘 IO,就把一整个节点读进了内存,这个节点里有上百个键、上百个分叉。
这是一个非常精妙的"对齐":磁盘 IO 的最小单位是"页",B 树的节点也是"页"。一次 IO 不浪费,读进来的整页数据全是有用的索引信息。节点越胖,一次 IO 带回来的"路标"越多,往下定位的效率就越高,树自然就矮。
所以 B 树不是某个聪明人凭空设计的"漂亮结构",它是被磁盘的物理特性"逼"出来的 —— 它是为磁盘量身定做的数据结构。理解了这个出身,你就理解了它的一切设计动机。
B+ 树:B 树的进化
B 树已经很好了,但数据库实际用的是它的改进版 —— B+ 树。B+ 树在 B 树基础上做了两个关键改动:
B+ 树:在 B 树基础上再改两点
┌──────── 非叶子节点 ────────┐
│ 只存"索引键 + 指向下层的指针"│ 改动1:数据全部只放叶子层,
│ 不存实际数据 │ 非叶子层纯当"目录"
└────────────┬───────────────┘
▼
┌──────── 叶子节点 ──────────┐
│ 存"完整数据(或主键)",而且│ 改动2:所有叶子节点
│ 所有叶子用双向链表串起来 ←──►│ 用双向链表串成一条有序链
└────────────────────────────┘
改动一:数据只放在叶子节点,非叶子节点只当"目录"。在 B 树里,每一层的节点都既存索引键、又存数据;而 B+ 树的非叶子节点只存索引键和指针,不存实际数据。这意味着:同样大小的一个非叶子节点(一个数据页),能塞下的"键 + 指针"更多了,分叉数更多,树就更矮。所有的实际数据,全部沉到了最底层的叶子节点。
改动二:所有叶子节点用双向链表串起来。整个叶子层,从左到右是一条有序的链表。
这两个改动看着不大,带来的好处却是决定性的 —— 它们正是数据库选 B+ 树而不是 B 树的根本原因。下面具体说。
B+ 树到底快在哪
第一,查询的 IO 次数极少、而且非常稳定。因为数据全在叶子层,任何一次查询都是"从根节点走到叶子节点",路径长度恒等于树的高度。而 B+ 树非常矮 —— 矮到什么程度?我们算一笔账:
一棵 3 层 B+ 树能装多少数据(粗略估算):
假设一个数据页 16KB:
非叶子节点:一个键约 8B + 指针约 6B ≈ 14B
16KB / 14B ≈ 约 1170 个分叉
叶子节点: 一行数据假设约 1KB → 16KB / 1KB ≈ 16 行
第 1 层(根): 1 个节点 → 1170 个指针
第 2 层: 1170 个节点 → 1170 × 1170 指针
第 3 层(叶子): 1170 × 1170 个节点 × 16 行 ≈ 2000 万行以上
→ 千万级的表,查一行只需 3 次磁盘 IO
结论很震撼:一棵 3 层的 B+ 树,就能装下两千万行以上的数据;查任意一行,只需要 3 次磁盘 IO。而且因为所有数据都在叶子层,每次查询走的路径长度都一样 —— 不会出现"有的查询快、有的查询慢"的抖动,性能非常稳定可预测。再考虑到根节点常驻内存,实际的磁盘 IO 往往只有 1-2 次。这就是为什么一个十亿行的大表,有索引的查询依然能毫秒返回。
第二,范围查询和排序"起飞"。这正是"叶子层串成链表"的功劳。执行 where age between 18 and 30,B+ 树只要先定位到 age = 18 所在的那个叶子节点,然后顺着双向链表一路往后扫就行了 —— 完全不用再回到树的上层去一个个重新查。order by、分页 limit 也是同理:数据在叶子层本来就是有序排列的,根本不需要额外的排序操作。
对比一下 B 树:B 树的数据散落在各层节点上,做范围查询要在树里反复上上下下地横跳,效率差得多,也做不到 B+ 树这种"顺着链表扫"的优雅。"数据全在叶子层 + 叶子层串成有序链表",这就是 B+ 树相对 B 树的全部精华。
聚簇索引 vs 二级索引
理解了 B+ 树的结构,再看 MySQL(InnoDB 引擎)里两个绕不开的概念就简单了。
聚簇索引(主键索引):它的 B+ 树,叶子节点里直接存放着整行的数据。换句话说,一张表的数据,物理上就是按照主键,组织成了这么一棵 B+ 树来存储的。所以一张表只能有一个聚簇索引 —— 因为数据本身只能按一种方式物理排列。这也是为什么"主键最好用自增整数":自增意味着新数据总是往 B+ 树的最右边追加,不会引起树中间的频繁分裂和数据挪动。
二级索引(普通索引):你给某个非主键列建的索引。它的 B+ 树,叶子节点里不存整行数据,只存"这个索引列的值 + 对应行的主键值"。
为什么二级索引的叶子只存主键、不存整行?为了节省空间、也为了避免数据冗余 —— 整行数据已经在聚簇索引里存了一份,二级索引再存一份就太浪费了。但这个设计,引出了下一节那个高频面试点和性能坑。
回表与覆盖索引
因为二级索引的叶子节点只存了"索引列 + 主键",所以当你用二级索引查询、但又需要拿这一行里其他列的数据时,会发生一件事:数据库得先在二级索引的 B+ 树里查到主键,再拿着这个主键,去聚簇索引的 B+ 树里查一遍,才能拿到完整的行数据。这个"查两棵树"的过程,叫做回表。
回表是有成本的 —— 等于一次查询走了两趟 B+ 树。如果一个查询要回表很多次(比如范围查询命中了大量行,每行都要回表),性能会明显下降。
那能不能避免回表?能 —— 如果你这个查询需要的所有列,正好都包含在二级索引里(索引列本身 + 主键),那数据库在二级索引的 B+ 树里就能拿到全部所需数据,根本不用回表。这种情况叫覆盖索引(covering index)。
这就解释了一个常被提及、但很多人不知道原因的优化建议:"不要无脑 select *,只查你真正需要的列"。因为如果你只 select 那几个恰好被索引覆盖的列,查询就能命中覆盖索引、避免回表;一旦 select *,几乎必然要回表去捞那些不在索引里的列。理解了回表,这条优化建议对你就不再是"教条",而是"能推导出来的结论"。
实战:为什么加了索引还是慢 —— 索引失效
知道了 B+ 树的原理,就能理解索引为什么会"失效"。最经典的就是联合索引的最左前缀原则。
联合索引 (a, b, c) 的"最左前缀"规则:
能用上索引: where a=1
where a=1 and b=2
where a=1 and b=2 and c=3
where a=1 and c=3 (a 用上,c 用不上)
用不上索引: where b=2 (跳过了最左的 a)
where b=2 and c=3
where c=3
原因:B+ 树先按 a 排,a 相同再按 b 排,b 相同再按 c 排。
跳过 a 直接找 b,数据在树里根本不是有序的,没法二分
道理就藏在 B+ 树的结构里:联合索引 (a, b, c) 在 B+ 树里,是先按 a 排序,a 相同的再按 b 排,b 相同的再按 c 排。如果你的查询跳过 a、直接拿 b 去找,那么 b 这一列在整棵树里根本不是有序的(它只在"a 相同"的小范围内有序)—— 数据库没法在一个无序的列上做高效的二分查找,索引自然就用不上了。
其他常见的索引失效场景,本质上都是同一回事 —— 你做了某件事,破坏了"索引列的有序性",导致数据库没法在 B+ 树上做有序查找:
- 对索引列做运算或套函数:
where YEAR(create_time) = 2026—— 索引里存的是create_time的原值,不是YEAR()之后的值,数据库没法用。要改成范围条件where create_time >= ... and create_time < ...。 - 隐式类型转换:
phone列是字符串类型,你写where phone = 13800000000(传了个数字),数据库被迫把整列做一次类型转换,转换就相当于"套了函数",索引失效。 - 前导模糊匹配:
like '%abc'—— 索引是按"前缀"组织和排序的,你不给前缀、上来就是通配符,它没法定位起点。而like 'abc%'(前缀确定)则可以用上索引。 - 列的区分度太低:比如"性别"列只有两三种值,即使建了索引,数据库的优化器算一下,觉得"走索引还不如直接全表扫",会主动放弃索引。
- 用 OR 连接了非索引列:
where 索引列=x or 非索引列=y—— 因为有一边是非索引列,整体可能退化成全表扫。
索引不是越多越好
讲了这么多索引的好处,要泼一盆冷水:索引是有代价的,绝不是越多越好。很多人一遇到慢查询就加索引,最后一张表上挂了十几个索引,反而拖累了整体性能。
索引的代价主要在三方面:
第一,拖慢写入。每个索引都是一棵独立的 B+ 树。你每 insert、update、delete 一行数据,数据库不仅要改聚簇索引,还要把所有相关的二级索引 B+ 树都维护一遍 —— 该插入的插入、该调整的调整。索引越多,写操作越慢。对于写多读少的表,滥加索引是负优化。
第二,占用存储空间。每个索引都要实实在在地占磁盘空间。一张大表上挂十几个索引,索引占的空间甚至可能超过数据本身。
第三,增加优化器的负担。查询时,数据库的优化器要从众多索引里挑一个最优的来用。索引太多,优化器有时反而会"挑花眼"、选错索引,导致查询变慢。
所以正确的心态是:索引是"用空间和写入性能,换查询性能"的交易。每加一个索引,你都应该想清楚:它服务的是哪些高频查询?这笔交易划算吗?
怎么判断该不该加索引、加在哪
给几条实操层面的判断原则:
- 给高频查询的 where、join、order by 涉及的列加 —— 这些是索引真正能发挥作用的地方。一个一年才跑一次的统计查询,慢就慢点,不值得为它常年维护一个索引。
- 优先建联合索引,而不是给每个列单独建 —— 一个设计良好的联合索引,配合最左前缀,能覆盖多种查询组合,比一堆单列索引更高效、维护成本也更低。
- 区分度高的列才值得建 —— 像性别、状态这种只有几个值的列,单独建索引意义不大。
- 考虑覆盖索引 —— 如果某个高频查询能通过精心设计的联合索引做到"覆盖、免回表",收益会很大。
- 定期审查、删掉没用的索引 —— 业务在变,曾经有用的索引可能现在已经没查询用到了,它还在白白拖累写入。
用 EXPLAIN 看懂你的查询
所有关于索引的分析,最终都要落到一个工具上 —— EXPLAIN。在你的 SQL 前面加上 EXPLAIN,数据库会告诉你它打算怎么执行这条查询:
- 有没有用上索引、用的哪个:看
key这一列。如果是NULL,说明没用索引、在全表扫。 - 大概要扫多少行:看
rows。这个数字越接近全表行数,越说明查询有问题。 - 访问类型:看
type。从好到坏大致是const>ref>range>index>ALL—— 看到ALL(全表扫描)就要警惕了。 - 有没有用上覆盖索引:看
Extra里有没有Using index(用到了覆盖索引,好);如果有Using filesort或Using temporary,说明排序/分组没走索引,可以优化。
遇到任何慢查询,第一反应应该是 EXPLAIN 一下,而不是凭感觉乱加索引。EXPLAIN 会告诉你"问题到底出在哪",而前面讲的所有原理,是帮你"看懂 EXPLAIN 说的是什么意思、该怎么改"。
FAQ
B+ 树和 B 树到底什么关系?B+ 树是 B 树的一个变种。核心差异就两点:B+ 树的数据只在叶子节点(B 树的数据散布在各层),B+ 树的叶子节点串成了链表(B 树没有)。正是这两点让 B+ 树更适合做数据库索引。
为什么不用红黑树?红黑树不是也很高效吗?红黑树在内存里确实很高效。但它是二叉的、很"高",对于存在磁盘上的数据库索引,层数多 = IO 次数多,这是致命的。红黑树和 B+ 树面对的是不同的硬件环境 —— 红黑树为内存优化,B+ 树为磁盘优化。
给字符串列加索引,和给整数列加,有区别吗?原理一样,但字符串通常更长,意味着一个数据页能装的键更少、树可能更高、占空间更多。所以对长字符串,有时会用"前缀索引"(只索引前 N 个字符)来折中。
主键为什么推荐用自增 ID,而不是 UUID?因为聚簇索引的数据是按主键物理排序的。自增 ID 让新数据总是追加到 B+ 树最右边,代价小;而 UUID 是随机的,新数据会插到 B+ 树中间的各个位置,引起频繁的页分裂和数据挪动,写入性能差、还会让索引变得"碎"。
面试高频追问汇总
B+ 树和索引是后端面试的常驻考点,把前面的原理串起来,这几个高频追问你应该能答得很顺:
"为什么 InnoDB 一个数据页是 16KB?" —— 这是一个在"操作系统读取效率"和"树的高度"之间权衡出来的值。页太小,一次 IO 带回来的索引信息少、树会变高;页太大,一次 IO 的成本又上去了、且会读进很多用不到的数据。16KB 是工程上比较均衡的选择。理解了"页是 IO 的单位、页大小影响树高"这个因果,你就能聊这个问题,而不是死记数字。
"为什么建议主键用自增整数?" —— 因为聚簇索引的数据是按主键物理排序的。自增 ID 让新数据永远是往 B+ 树最右边"追加",几乎不引起页分裂;而像 UUID 这种随机值,新数据会插到树中间的各个位置,频繁触发页分裂和数据挪动,写入性能差、索引还会变"碎"。整数也比字符串更短、比较更快、占空间更小。
"联合索引 (a,b,c),where a=1 and c=3 能用上索引吗?" —— 能用上一部分:a 能走索引定位,但因为跳过了 b,c 这一段用不上索引(只能在 a=1 的结果里逐行过滤 c)。这就是最左前缀原则的细节考法 —— 不是"全用上"或"全用不上"的二选一,而是"用到哪一截"的问题。
"count(*) 为什么有时候走二级索引而不是聚簇索引?" —— 因为二级索引的 B+ 树更"小"(叶子节点只存索引列 + 主键,不存整行)。要数行数,扫一棵更小的树,IO 更少。优化器会挑最小的那棵可用的树去扫。这个问题能体现你是不是真理解"聚簇索引叶子存整行、二级索引叶子只存主键"。
"加了索引,update 会变快还是变慢?" —— 要分情况。update 的 where 条件如果能命中索引,"找到要改的那行"会变快;但"改完之后维护索引"会变慢(每个相关索引的 B+ 树都要更新)。所以是"定位变快、维护变慢",整体快慢看具体场景。这个问题能筛掉那些"以为索引只有好处"的人。
写在最后
"加索引"这件事,会的人很多,但能讲清楚"为什么"的人不多。而恰恰是这个"为什么",决定了你能不能在真实场景里把索引用对。把这条逻辑链记住:
- 因为数据在磁盘上、要减少磁盘 IO,所以选了又矮又胖、节点对齐数据页的 B+ 树;
- 因为数据全在叶子层、且串成有序链表,所以单点查询稳定、范围查询和排序极快;
- 因为二级索引叶子只存主键,所以有回表,所以有覆盖索引这个优化;
- 因为 B+ 树是按索引列顺序组织的,所以一旦破坏有序性(跳过最左前缀、列上运算、隐式转换、前导模糊),索引就失效;
- 因为每个索引都是一棵要维护的 B+ 树,所以索引不是越多越好,它是一笔交易。
把这条链子彻底吃透,你对索引的理解就不再是零散的"规则记忆",而是一个能自洽推导的体系。以后遇到任何没见过的慢查询,你都能自己分析出它为什么慢、该怎么救。
—— 别看了 · 2026