
先说一个我自己的感受只要带过一段后端业务就不可避免会跟数据库索引打交道。面试聊性能优化要提索引线上接口慢了要查索引甚至有时候一条SQL把生产库拖垮最后的复盘结论也绕不开“索引没建好”。偏偏绝大多数人学索引背下来的是“B树”“主键索引”“回表”“覆盖索引”这些名词真被问到“为什么”的时候就只剩一句“因为索引是树啊树查询快”。这个回答不能说错但离“理解”还有距离。这篇文章我想抛开概念堆砌从“数据在磁盘上到底怎么被读取”这个最朴素的角度切入把“索引为什么快”这件事彻底讲透。适合刚接触数据库的新人也适合写过不少SQL但始终没系统理清索引原理的开发者。咱们不背八股就聊原理和实操。1. 查询慢的本质全表扫描为什么这么拖后腿想弄清楚索引为什么快第一步得先弄明白——没有索引的时候查询到底慢在哪。1.1 磁盘IO才是真正的瓶颈很多人以为SQL查询慢是因为CPU算得慢其实完全不是。CPU处理一条简单比较指令是纳秒级别的而一次磁盘IO的时间是毫秒级别的中间差了六个数量级。所谓“慢查询”绝大部分时间都耗在“把数据从磁盘搬到内存”这个过程上而不是“比较数据”上。举个例子你有一张一千万行的用户表每行大约1KB那整张表就是10GB。如果没有任何索引执行一条SELECT * FROM users WHERE user_name 老王数据库只能从第一页开始把每一行的数据都读进内存然后挨个比较。就算InnoDB每次读16KB的页也需要读整整65万个页。按机械盘平均10ms一次的寻道时间算光读一遍就要一个多小时SSD能快一些但几十秒甚至几分钟也是跑不掉的。这才是“慢”的本质查询的时间复杂度是O(n)数据量翻倍扫描时间就翻倍完全没法扛。1.2 顺序读和随机读的差距比你想的大同样是读磁盘读法和读法之间差距也很大。如果数据在磁盘上是挨着放的数据库可以做顺序预读一次IO就能把一大片数据带回来如果数据分散在几十个不同的地方那每一次跳转都是一次新的寻道和等待。全表扫描之所以还没被彻底淘汰就是因为它在一个方面有优势——顺序读。从头到尾把整张表扫完IO的访问模式非常规律所以大数据量下依然会有“全表扫描反而比走坏索引快”的怪现象这个后面再说。而索引要解决的就是打破“必须从头看到尾”的线性查找让你能跳过无关数据直接定位到目标附近把O(n)变成O(log n)甚至更少。这里就引出核心问题了数据库拿什么结构来做这个“跳跃定位”2. 索引为什么能帮上忙从无序到有序从线性到跳跃索引的思路其实跟查字典是一模一样的。2.1 字典查字的思路就是索引的雏形没有索引的查询相当于一本三千页的字典没有目录也没有拼音检字表你想找一个“龘”字只能从第一页开始翻。而有了索引就好比你按拼音查到它的页码直接翻过去就行。数据库索引做的事情本质上也是一样的额外开辟一块空间维护一份“有序的键值→位置”的映射。注意“有序”这两个字这是整套方案的命门。还是拿用户表举例如果我在user_name字段上建了索引数据库并不会把所有用户数据重排一遍而是另外维护一份只包含“用户名字键和对应行所在的位置值”的小型结构并且按用户名的字典序排好。查询user_name 老王的时候直接在这份结构里用二分查找三下五除二就定位到“老王”这一项再顺着位置指针去读完整记录。存储量上也很划算索引只存键值和指针不需要把整行数据冗余一份一般也就占原表的10%~20%。2.2 但顺序表有个致命缺陷插入和删除太贵可是把索引按顺序放在一个数组里查询是快了插入怎么办你往用户表里插一个新用户它的用户名可能是“啊”开头按字典序得插到最前面那索引里从第一个元素到最后全部都要往后挪一格。一次插入动辄移动几十万条索引项这代价谁也扛不住。所以数据库不能用一个简简单单的有序数组当索引存储结构它需要一种既能保持有序、又能灵活插入删除的数据结构。这就是B树出场的原因。3. B树到底干了一件什么事聊到B树很多人脑子里就是“多路平衡查找树”这个概念但概念背得再熟不知道它解决什么问题也没用。3.1 降低树的高度就是减少磁盘访问次数树结构的查询效率取决于树的层数也就是查一次需要从上往下走多少个节点。二叉树在理想情况下查找一个元素的次数是log2(n)级别的一千万条数据log2(10000000)约等于24也就是最多比较24次。24次听起来不多但问题是树节点是分散存储在磁盘上的每从一个节点走到下一个节点就是一次随机磁盘IO。24次随机IO按每次10ms算就是240ms虽然比全表扫描快得多但还是很慢。B树的聪明之处在于它让“一个节点存很多个元素”把二叉树变成了几百上千叉树。InnoDB里一个节点默认就是一个16KB的页页内存的是多个键值对。假设每条索引项存8字节那一个页能放大约2000个键值对。这样一算一千万条数据需要多少层第一层1个页放2000个键第二层2000个页放400万个键第三层就足够放下80亿条索引项了。也就是说哪怕表里有几千万上亿行走索引查询从上到下也不过是3到4次磁盘IO。这跟全表扫描动辄几十万次IO相比完全不是一个量级。提示这就是B树“矮胖子”结构的精髓——节点越宽树越矮磁盘IO越少。数据库的IO开销是按“次数”算的只要命中率足够高几十万次IO和三四次IO的性能差异是天上地下。3.2 为什么不是哈希索引也不是红黑树看到这里有人会问用哈希表不行吗哈希查找不也是O(1)吗哈希索引确实快但它有两大硬伤。第一哈希表里的数据是无序的只支持等值查询一旦你的SQL里有WHERE age 20这类范围查询哈希索引直接就废了。第二哈希冲突多的时候性能不稳定极端情况下退化成链表反而比B树差。所以做缓存可以用哈希做数据库主索引几乎不用哈希主流关系型数据库的索引实现最终都选了B树。红黑树呢它作为内存里的平衡二叉树很好用Java的TreeMap底层就是它。但红黑树的每个节点只存一个键一千万条索引项就要一千万个节点树高仍然有二十多层带来二十多次随机磁盘IO根本吃不消。3.3 叶子节点串成链表范围查询的隐藏杀招B树还有一个容易被忽略、但实战中极其重要的特性所有真实数据都保存在叶子节点且叶子节点之间用双向链表串了起来。这意味着查一个范围区间WHERE user_name BETWEEN a AND c数据库只需要从B树里找到起点“a”然后顺着叶子节点的链表挨个往后扫就行了不需要再回到树的上一层也不需要频繁回磁盘随机读。这种有序链表让范围查询的效率几乎等同于顺序读。反观B-树它的数据分散在每一层节点上做范围查询要不断回上层节点效率就差多了。这也是B树能成为数据库主流选择的又一个原因。3.4 主键索引、二级索引与聚簇的关系在InnoDB里主键索引和普通索引还有一个重要区别这块特别容易把人绕晕。主键索引是“聚簇索引”它的叶子节点直接存储了完整的一整行数据而建立在其他字段上的索引是“二级索引”叶子节点存储的是索引列的值主键值。不要小看这个区别它解释了一个常见的现象为什么如果你不给表定义主键InnoDB会偷偷给你生成一个隐藏的6字节整型主键。因为InnoDB的聚簇索引必须存在它就是这张表数据本身存放的方式。这张表整体就是一个以主键为键的B树叶子节点就是所有真实行数据。这也解释了为什么用主键查询通常是最快的从B树定位到叶子节点直接就能拿到一整行的数据不需要再做任何额外的读操作。4. 查询走过的真实路径回表、覆盖索引与最左前缀从原理落回实操我觉得这里才是大多数人真正欠缺的部分。知道B树是基础但一条SQL执行的时候走了哪些路径很多人描述不清楚。你得能说出来一次普通查询数据库到底干了哪几件事。4.1 一次查询经历的完整过程拿SELECT * FROM users WHERE nickname 风清扬举例假设nickname上有二级索引执行过程大致是这样的从索引B树的根节点出发二分比较定位到叶子节点在叶子索引项中找到匹配的nickname 风清扬的项取出对应的主键值拿着主键值再回到主键索引的B树里找一次从主键索引的叶子节点取到完整行数据返回结果。第3步就是所谓的“回表”。注意这里一共进行了约6次磁盘IO两次B树的3层遍历而被查找的数据其实只有几KB大小。索引明明已经把位置定位得很准了但为了拿齐全行数据你还得再折腾一次。4.2 覆盖索引为什么能少一次IO如果SQL写的是SELECT nickname FROM users WHERE nickname 风清扬情况就不一样了。因为二级索引的叶子节点本来就已经包含了nickname字段的完整值查询条件、返回字段都在这一棵索引树上根本不需要回表。在数据库术语里这叫作“覆盖索引”一个索引覆盖了查询需要的所有字段查询过程中完全不需要回主表。这不是小优化。在高频查询场景下把每次查询省掉的这次回表累计起来性能收益非常可观。我见过一个真实案例某报表数据量约五百万行查询条件用了十来列几个字段来回组合结果查询平均要1.2秒。后来把核心查询里要返回的几个字段一起做成了联合索引直接命中覆盖索引平均查询时间降到80毫秒量级直接掉了一个半。4.3 联合索引的最左前缀是一个“排序”问题很多人在联合索引上吃亏是因为把联合索引当成了多个独立索引的叠加。实际不是。CREATE INDEX idx_user_name_age ON users(user_name, age)这个索引的存储规则是先按user_name排序user_name相同时再按age排序。整个索引的整体有序性是建立在前缀列上的这才是最左前缀命中的底层逻辑。走索引的查询WHERE user_name a、WHERE user_name a AND age 20、WHERE age 20 AND user_name a优化器会调整顺序。走不了的查询WHERE age 20。因为索引整体先按user_name排序age只有在user_name相同时才有序跳过最左列直接用age查等于在一份“按姓氏排序的通讯录”里找“所有名叫张三的人”毫无头绪只能全表扫。这个原则理解透之后建立联合索引就应该遵循一个基本思路把等值查询的字段放最前面范围查询的字段放后面这样最左前缀能发挥最大价值。5. 索引失效的坑和慢查询排查实录原理归原理实战中的“索引失效”才是大家吐槽最多的地方。明明建了索引执行计划一出来还是全表扫描气得拍桌子。这里我把日常踩得最深的几个坑集中盘一遍然后以一次慢查询排查作为完整案例收尾。5.1 最常见的六类索引失效场景对索引列使用了函数或计算。比如WHERE YEAR(create_time) 2024数据库没法直接对函数结果做二分定位索引直接失效。正确做法是改写成范围查询WHERE create_time 2024-01-01 AND create_time 2025-01-01。隐式类型转换。字段是字符串条件里却传了数字WHERE phone 13800138000MySQL会对字段做隐式CAST索引照样失效。传参类型必须跟字段类型一致这个最容易被忽略。前导模糊查询。LIKE %老王无效因为B树按前缀有序从头开始就不知道往哪找但LIKE 老王%没问题这是前缀匹配索引能用。联合索引不满足最左前缀。上面刚讲过跳过最左列直接查后面的列索引就是废的。OR连接时没有全走索引。WHERE user_name a OR age 20如果只有user_name上有索引而age上没有优化器会放弃索引改成全表扫。OR要慎重必须保证每一侧条件都能走索引。优化器估算“走索引还不如全表扫”。这个最反直觉但确实存在。如果表里某列90%的值都是同一个值这时候你查那个值优化器算了一下用索引回表的随机IO加起来比顺序全表扫描还贵它就会放弃索引。索引不是越多越好有些时候不建反而更好。5.2 用慢查询日志定位问题少走弯路排查慢查询我习惯先开慢查询日志把它作为第一现场。-- 查看慢查询日志状态 SHOW VARIABLES LIKE slow_query_log; SHOW VARIABLES LIKE long_query_time; -- 开启慢查询日志并设置阈值 SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 1; -- 超过1秒的SQL记录日志打开后就去找那些超过阈值的SQL然后拿到具体SQL复制出来用EXPLAIN看执行计划EXPLAIN SELECT * FROM orders WHERE user_id 12345 ORDER BY create_time DESC LIMIT 20\G重点看这几列type出现ALL就是全表扫描出现ref或range说明在走索引eq_ref和const是最理想的状态。key实际用到的索引名为NULL就是没用上。rows估算的行数跟表总行数对比基本能判断是不是全表扫。Extra出现Using filesort要注意排序没用索引出现Using temporary要警惕临时表而Extra里如果出现Using index则说明是覆盖索引。注意只盯着rows看不够还要结合key和Extra一起判断。有时候rows很小但Extra里有Using filesort数据量一大照样慢。5.3 一个真实案例订单查询从2秒到50毫秒前两年排查过一个线上订单接口高峰期接口平均响应2秒多压测数据只能到40并发的水平业务方快被投诉淹没。我拿到慢查询日志以后定位到一条典型SQLSELECT id, order_no, user_id, status, total_amount FROM orders WHERE status 1 AND user_id 12345 AND create_time 2024-06-01 ORDER BY create_time DESC LIMIT 20;EXPLAIN看了一眼typeALLrows800万全表扫描。这个表当时只有一个主键索引用户查询条件里三个字段全都没索引。800万行逐行比较不快才怪。原本我的第一直觉是建一个(user_id, status, create_time)的联合索引用user_id做等值筛选create_time做排序。但分析业务后发现这个接口的查询场景特别固定永远查的是指定用户的指定状态订单同时create_time的范围会变排序永远倒序。所以最终方案是ALTER TABLE orders ADD INDEX idx_user_status_time (user_id, status, create_time);索引建完以后再跑同样的SQLEXPLAIN变成了typeref走了联合索引排序也直接用索引完成不再出现Using filesort。这个查询从2秒降到50毫秒以内接口整体吞吐直接翻了四五倍。这是索引优化最常见的实战范式分析出哪几个字段是等值筛选、哪个字段是范围条件、哪一列负责排序然后根据最左前缀原则按顺序建联合索引。一条SQL命中一个有效的联合索引往往比给每个字段单独建索引管用得多也避免了一张表上索引过多导致的写入放大。6. 最后分享一点个人的体会索引的原理讲到最后其实就是“结构决定性能”这六个字。数据库把查询优化成几百毫秒之内完成靠的不是CPU算得有多快而是靠B树这种精巧的结构把随机访问变成少数几次跳跃再把范围读取变成顺序扫描。我在实际工作中见过太多人把索引当万能药——表一慢就疯狂加索引结果写入被拖垮存储膨胀了好几倍查询却还是慢。真正负责的做法是先通过慢查询日志和EXPLAIN确认问题在哪再根据业务查询模式花五到十分钟设计联合索引用最少的索引去覆盖最多的高频查询路径。少就是多精准比对齐全更重要。希望看完这篇你能对索引背后的“为什么”有一个立体一点的认识下次再遇到慢SQL能先把原理在脑子里过一遍而不是盲目加索引碰运气。