索引这玩意儿,算是数据库里最容易被“背出来”又最容易被“问倒”的知识点。面试前能背得动聚簇索引、二级索引、最左前缀、覆盖索引,可真到线上遇到一条慢SQL,很多人却说不出它到底为什么没走索引。今天我把索引的底子拆开讲,用最直白的方式说清楚“索引到底怎么工作的”,不背八股文,只讲能用在工单和调优现场的逻辑。
先给热搜词做个体检:你搜“索引”时,出来的不一定是同一个东西。m3u8索引是视频分片列表,Windows索引器是系统文件的检索目录,obsidian的多级索引是笔记关联结构,Elasticsearch里的索引是一堆文档的集合,这些和本文要讲的数据库索引根本不是一回事。数据库索引是一个数据结构,目的只有一个:从大量数据里快速定位到想要的那一行。这篇文章适合被数据库面试折磨的开发者,也适合写SQL总慢的运维和数据分析师,我会从本质讲到实操,最后再分享几个踩坑经验。
1. 索引是什么:把“翻书找内容”变成“翻目录”
1.1 索引的本质就是一张目录
表的数据在磁盘上是一行一行存的,没有索引时,查询只能老老实实从头扫到尾,这叫全表扫描。如果表里只有几百行,扫就扫了,毫秒级完成;可到了千万行级别,全表扫描可能要读几千个数据页,慢SQL就这么来了。
索引的本质,就是给表建一张“目录”。书的目录记录“章节名 -> 页码”,索引记录“索引键值 -> 记录位置”。你查数据时,数据库先翻目录,定位到对应位置,再直接取数据,不用每一页都翻。这听起来很简单,但很多讲索引的文章偏偏把它说得像天书,非要从红黑树讲起,其实没必要。
这里有个坑需要提前说清楚:在InnoDB引擎里,二级索引的叶子节点存的并不是记录的物理地址,而是主键值。也就是说,通过二级索引找到的只是一个“书签”,最后还要拿着这个书签去主键索引里再查一次,才能拿到完整行数据,这就是后面要说的“回表”。很多人一开始理解索引时被这个绕晕,我建议先把索引简单理解为“键值到位置的映射”,回表的细节放到后面讲聚簇索引时再说,思路会顺很多。
1.2 索引为什么快:二分查找和B+树层高
目录之所以快,是因为不需要顺序翻页,而是按某个规则快速定位。最朴素的做法是有序数组加二分查找——每次比较都能排除一半数据,查找100万条记录只需要大约20次比较。但数据库面临的问题是数据会不断增删改,维护一个始终有序的数组代价太高,插入一条数据可能要让后面所有数据都挪位置。
所以数据库用了B+树。B+树是一种多路平衡查找树,每个节点可以存储多个键值,节点大小通常和一个磁盘页对齐,这样一次磁盘IO就能读入整个节点。为什么B+树层高那么低?可以做个粗略估算:假设一个16KB的数据页能存储约1000个键值对,两层B+树能覆盖约100万条记录,三层能覆盖约10亿条记录。也就是说,查询十亿级别的数据,走索引只需要3次左右的磁盘IO,而全表扫描可能需要读上百万个页。这就是索引快的最根本原因。
很多人面试被问“为什么用B+树不用二叉树”,答案其实不在树本身,而在磁盘IO。二叉树层高太高,查询一个节点可能要跳很多次磁盘;B+树把树压扁了,一次读页能带走更多信息,IO次数自然少。记住这个逻辑,比死记“B+树矮胖”要有用得多。
1.3 别忘了代价:索引不是白来的
索引提升查询速度,是靠牺牲写入性能换来的。每建一个索引,数据库在写入数据时就要额外维护一棵B+树:插入要找到位置,删除要处理节点变化,更新可能涉及索引键值调整。索引越多,写入链路越长。
我遇到过一张表被加了七八个索引,业务方说“每个查询都要快”,结果批量导入数据时速度掉了好几倍。这不是数据库不行,而是写入时每行都要往每棵索引树里插一遍。所以在生产环境,建索引一定要克制,先观察慢SQL,再针对性建,不能拍脑袋给每列都来一个索引。关于“建几个合适”这个问题,没有标准答案,后面第6章我会细说。
2. 索引的存储形态:B+树、哈希与多级索引
2.1 B+树:大多数数据库的默认答案
InnoDB的索引默认就是B+树。B+树的特点是:叶子节点存储数据,非叶子节点只存索引键;所有叶子节点通过链表串联起来。这个“叶子节点用链表串起来”的设计,就是热搜词里“多级索引链表”的出处,也是B+树比B树更适合数据库的关键原因之一。
叶子节点串联解决的是范围查询。比如查“2024年1月到3月”的订单,走索引找到1月第一条记录后,就可以顺着叶子节点的链表一路往后扫到3月,不需要回到树根重新搜索。如果没有这个链表,范围查询就得反复在树里跳,效率会大打折扣。
B+树的另一个特点是节点分裂和平衡。当节点满时,会分裂成两个;删除数据导致节点太空时,可能发生合并。这些操作都是为了保持树的平衡,确保查询路径长度稳定。你不需要手写B+树,但理解这点有助于明白:为什么频繁增删改的表,索引会产生碎片,查询性能会慢慢下降。碎片问题在后面的实操章节我会讲怎么处理。
2.2 哈希索引:等值查询的“特快专列”
哈希索引走的是另一条路:对索引键做哈希计算,得到哈希值后直接定位到数据位置。哈希索引做等值查询极快,时间复杂度是O(1),比B+树的O(log n)还快。但它有个致命缺点:完全无法支持范围查询和排序。你想查“大于某个值”的数据,哈希索引只能说“我做不到”。
MySQL的MEMORY引擎默认索引类型就是哈希索引,适合临时表、缓存表这类场景。InnoDB内部也有自适应哈希索引(AHI),但它是数据库自动管理的,根据查询模式自动为热点页面建哈希索引,不需要也不能手动创建。很多文章讲“InnoDB只有B+树”,严格来说不完全准确,但手动建索引时你确实只能建B+树。
实际工作中,哈希索引的存在感不强,但面试官爱问。回答时抓住两个核心:等值查询快、范围查询废,就够了。
2.3 主键索引和二级索引:回表到底回什么
InnoDB的表本身就是按主键索引组织的,这种索引叫聚簇索引。聚簇索引的叶子节点直接存整行数据,所以通过主键查询,一次就能拿到完整记录,效率最高。
二级索引(非聚簇索引)的叶子节点存的是主键值。比如你在user_id字段上建了索引,查询时先走二级索引找到匹配的主键值,再拿这个主键去聚簇索引里查整行数据,这个过程就是“回表”。回表意味着多一次磁盘IO,所以查询慢的时候,如果能避免回表就能快很多。
怎么避免回表?用覆盖索引。如果查询需要的列都包含在二级索引的叶子节点里,那就不需要回表了。比如索引是(user_id, name),查询SELECT name FROM table WHERE user_id = 1,name已经在索引里,直接返回即可,省掉一次回表。记住这个思想,后面讲联合索引时还会用到。
2.4 顺带说一句:ES、MongoDB与MySQL的索引不是一回事
既然热搜词里出现了Elasticsearch和MongoDB,我多说两句概念区分。Elasticsearch里的“索引”其实是文档集合,类似关系型数据库里的“库”或“表”,它的分词、倒排索引是另一套体系。MongoDB的索引底层是B树,不是B+树,范围查询逻辑和MySQL有一定差异。Windows索引器、m3u8索引就离得更远了,它们是文件检索和视频分片协议的概念。搞清楚这些,至少能避免在技术讨论时把不同领域的“索引”混为一谈。
3. 联合索引:最左前缀是怎么推导出来的
3.1 联合索引的排列规则
联合索引就是把多个字段放进同一棵B+树里,常见写法是CREATE INDEX idx_user_status ON users(user_id, status)。很多人在这个点上死记“最左前缀”,但没想过为什么。
联合索引的排序规则是先按第一个字段排序,第一个字段相同再按第二个字段排序,依此类推。比如(user_id, status)这个联合索引,先按user_id排,同一个user_id下再按status排。相当于建立了一个按“user_id + status”复合排序的目录。
这个规则意味着一个联合索引能覆盖多种查询场景。索引(a, b, c)实际上能支持几种查询:单独查a、查a和b、查a和b和c。为什么?因为索引先按a排好了,所以只用a来查询时,可以快速定位;再加上b,还是可以利用索引的有序性继续缩小范围。但单独查b,或者单独查c,就完全没有顺序可用,索引自然发挥不了作用。
3.2 最左前缀原则的本质
最左前缀的本质,就是“索引的有序性是按列从左到右建立的”。用字典类比很好理解:字典先按拼音首字母排,首字母相同再看第二个字母。你能快速找到所有“a”开头的词,也能找到“ab”开头的词,但如果你翻开字典想直接找“所有第二个字母是b的词”,就会发现根本无从下手,因为第二个字母只有在首字母确定后才有序。
同理,联合索引(a, b, c)中,跳过a直接查b = ?,数据库只能把整棵树的节点全扫一遍才能得到结果,这就是索引失效。范围查询也会中断后续列的使用,比如WHERE a > 100 AND b = 5,a的范围条件用上了索引,但b无法继续走索引精确匹配,因为a锁定的是一个范围,b在这个范围内不一定有序。
理解了这个逻辑,就不需要背“最左前缀原则”这几个字了。面试时你把字典类比说一遍,再解释为什么跳列会失效,面试官基本就认可你懂原理了。
3.3 覆盖索引和索引下推
联合索引除了支持多列查询,还有一个隐藏优势:更容易实现覆盖索引。如果一个联合索引包含了查询需要的所有列,数据库直接从索引里取数,完全不用回表。对于高频查询,优先设计这样的索引,收益非常明显。
还有一个多列索引的优化叫索引下推(ICP,Index Condition Pushdown)。MySQL 5.6以后默认开启。它做的优化是:在联合索引匹配时,如果索引用到第二列以后的条件,尽量在存储引擎层先用索引里的数据过滤一遍,减少回表次数。
举个例子,索引(a, b),查询WHERE a = 'x' AND b LIKE 'abc%'。没有ICP时,先通过a定位到一批记录,全部回表,再在服务层过滤b;有ICP时,在索引层就把不满足b LIKE的记录过滤掉,只对剩下的记录回表。理解了这个原理,你就知道为什么联合索引设计得好,查询性能能有质的提升。
4. 索引失效场景:别背结论,要看原因
4.1 五类典型失效场景
网上流传的“索引失效场景”很多说法不够准确,有些甚至互相矛盾,因为数据库优化器在不同版本、不同数据分布下会有不同行为。我筛选出五个比较经典、基本不会出错的场景,并解释它们为什么失效:
- 对索引列做函数运算或表达式计算。比如WHERE YEAR(create_time) = 2024,create_time的索引会被YEAR函数破坏,索引树里找不到“2024”这个排序后的键值。应该写成范围条件:WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01'。
- 隐式类型转换。下面单独细说。
- LIKE以通配符开头。WHERE name LIKE '%张',索引是按完整列值建立顺序的,前缀未知就没办法快速定位。
- OR条件中包含非索引列。MySQL很可能选择全表扫描,因为它要把OR两边都查出来再合并,如果有一边没有索引,全表扫反而更简单。
- 违反联合索引最左前缀。前面已经解释过原因。
你发现没有,这些情况的共同点都是“破坏了索引的有序性”。记住这一点,遇到新场景时自己就能判断,不用背清单。
4.2 隐式类型转换,最容易踩的坑
隐式类型转换是我在实际工单里遇到最多的问题。比如user_id字段是varchar类型,业务代码里传了一个数字,SQL写成WHERE user_id = 10086。MySQL比较时会把字符串列转成数字,这意味着列上发生了一次隐式转换,索引直接失效。但如果反过来,写成WHERE user_id = '10086',字符串和字符串比较,索引就能正常走。
排查方法很简单:查看表结构的字段类型,再对比SQL里的传参类型。最坑的是应用框架自动绑定参数时,经常把值转成非字符串类型,导致线上SQL偶然走不上索引。我的习惯是,所有关联字段和WHERE条件字段,尽量保证数据类型完全一致,包括字符集和排序规则也要一致,否则join时也可能出现索引失效。
4.3 用EXPLAIN判断索引是否真的走了
判断索引走没走,别靠猜,直接看EXPLAIN输出。EXPLAIN SELECT ... 会展示MySQL优化器选择的执行计划,关键字段有type、key、rows、Extra。type字段从好到差大致是system > const > eq_ref > ref > range > index > ALL。看到ALL就是全表扫描,index是扫描了整个索引树,range是范围扫描,ref是等值匹配。
一个典型的例子:
EXPLAIN SELECT * FROM orders WHERE user_id = 1024;如果输出里type是ref、key显示用了idx_user_id,rows很小,说明索引生效。如果type是ALL、key是NULL,那就得回头检查条件列、类型、函数等。特别注意,不同MySQL版本EXPLAIN输出字段略有差异,新版还增加了format=tree选项,但核心看type、key、rows这几个就够定位大多数问题。
慢查询日志也是排查利器。开启慢查询日志后,把超过阈值的SQL记录下来,逐条分析,能快速找到集群里拖后腿的查询。
5. 实操指南:给一张订单表把索引建明白
5.1 建索引前先做的事
建索引前,先收集这个表的所有高频SQL,整理出WHERE条件、JOIN字段、ORDER BY字段、GROUP BY字段。然后考虑几点:字段区分度要高,比如“性别”这种只有两个值的字段就别建索引了;字段长度尽量短,过长可以用前缀索引;更新频繁的字段要慎重,因为每次更新都要动索引树。
建索引基本语法:
-- 普通索引 CREATE INDEX idx_user_id ON orders(user_id); -- 联合索引 CREATE INDEX idx_user_status ON orders(user_id, status); -- 唯一索引 CREATE UNIQUE INDEX uk_order_no ON orders(order_no); -- 删除索引 DROP INDEX idx_user_id ON orders;联合索引的字段顺序也有讲究。一般把等值查询的字段放前面,范围查询的字段放后面,因为范围条件后面的列很难继续用上索引。但这个规律不是绝对的,还要结合业务特点调整。
5.2 用一条慢SQL演示完整调优过程
假设线上订单表orders有500万行数据,高频查询是“查某个用户最近一个月的订单”,SQL如下:
SELECT order_id, amount, status FROM orders WHERE user_id = 1024 AND created_at >= '2024-01-01' AND created_at < '2024-02-01' ORDER BY created_at DESC;未加索引时,EXPLAIN输出type=ALL,rows接近500万,查询耗时接近2秒。这时先分析条件:user_id是等值,created_at是范围,所以考虑建联合索引(user_id, created_at)。执行:
ALTER TABLE orders ADD INDEX idx_user_created (user_id, created_at);再次EXPLAIN,type变成range,rows降到几百行,查询耗时降到几十毫秒。然后检查SELECT列的覆盖情况:order_id、amount、status都不在索引里,所以需要回表。如果这个查询量极大,可以进一步把查询列全部包含进去,改成覆盖索引(user_id, created_at, order_id, amount, status),这样Extra里显示Using index,就不用回表了。不过覆盖索引的代价是索引更大、写入更慢,需要根据业务量权衡。
5.3 索引维护:重复索引、碎片与审计带来的争用
索引不是建完就不管了。生产环境里最常见的索引问题是重复索引和闲置索引。比如先建了idx_user_id(user_id),后来又建了idx_user_status(user_id, status),那么idx_user_id大概率是冗余的,因为联合索引已经能覆盖user_id单独查询。可以通过information_schema.statistics查出来:
SELECT table_name, index_name, GROUP_CONCAT(column_name) FROM information_schema.statistics WHERE table_schema = 'your_db' GROUP BY table_name, index_name;再人工判断有没有冗余。删除冗余索引能减少写入开销,还能释放磁盘空间。
频繁增删改的表,索引页会产生碎片。碎片多了,索引树变得稀疏,查询IO次数上升。处理方法是重建索引,常用命令是ALTER TABLE ... ENGINE = InnoDB,或者用OPTIMIZE TABLE。注意这些操作在大型表上可能锁表,要选低峰期执行。
热搜词里还有个很有意思的“数据库开启审计引起索引争用”。我实际遇到过类似情况:某系统为了合规开启了数据库审计功能,审计日志写入量激增,同时审计功能会扫描大量数据页做记录,导致缓冲池中索引页频繁被换出换入,表现为索引命中率下降、CPU和IO升高。排查后确认不是索引本身的问题,而是审计策略太重。解决方式是更换为独立审计通道,或者把审计日志输出到外部系统,避免和业务查询抢资源。
6. 避坑实录与常见问题速查
6.1 为什么有时建了索引却不生效
加索引后查询没变快,是最常见也最让人崩溃的情况。除了前面讲的函数、类型转换、最左前缀原因外,还有几种可能:
一是数据量太小时,优化器觉得走索引还要额外访问索引页,不如直接全表扫描快,于是主动放弃索引。这种情况下TYPE=ALL但rows很小,性能也能接受,不用纠结。
二是统计信息过期。优化器做决策依赖表的统计信息,如果统计信息陈旧,可能做出错误选择。可以执行ANALYZE TABLE 表名刷新统计信息。
三是字符集不一致。两张表join时,如果字段字符集不同,MySQL为了比较可能需要转换,导致索引失效。建表时统一用utf8mb4能减少这类问题。
四是字段允许NULL且查询用了IS NULL、IS NOT NULL。不同数据库和版本对NULL的索引处理差异很大,在MySQL里IS NULL通常还能走索引,但IS NOT NULL可能就不走。这个建议在实际环境中用EXPLAIN验证,不要凭经验拍板。
6.2 索引数量怎么把握
索引不是越多越好,也不是越少越好。每多一个索引,写入时多一份维护成本,磁盘空间也多占用一份。对于写多读少的业务(如日志采集、订单状态频繁更新),索引要精简,能覆盖核心查询就行。对于读多写少的业务(如报表查询、内容展示),可以适当多建几个复合索引来覆盖各种维度。
我个人的经验值是:常规业务表索引控制在5到6个以内,包含联合索引。超过这个数,就要开始质疑每个索引的必要性了。特别忌讳的是给每个字段单独建索引,因为MySQL在一个查询里通常只能利用一个索引,单列索引多了既浪费空间又拖慢写入。
6.3 大表加索引的注意事项
给千万级的大表加索引,最怕的就是长时间锁表、业务抖动。InnoDB支持在线DDL,MySQL 5.6以后ALTER TABLE ADD INDEX通常不会锁全表,但会占用额外空间和IO。实际操作中,我在大表上加索引前会做几件事:确认磁盘剩余空间充足,选择业务低峰期操作,先在测试环境用同样数据量验证执行时间。
如果表实在太大,可以考虑使用在线表结构变更工具,比如pt-online-schema-change。它通过临时表复制数据的方式完成变更,对在线业务影响更小。但工具不是万能的,也可能引入主从延迟,必须提前评估。
加完索引后,还要持续观察一段时间:关注写入性能是否下降、索引是否真的被查询用到、有没有出现新的慢SQL。如果某个索引上线一周都没有被EXPLAIN使用过,基本可以判断是多余索引,该删就删。
我在实际运维中最大的体会是:把索引理解成书的目录之后,很多问题都能自然推导出来,根本不用背。真正决定索引好坏的,是对业务查询模式的理解,而不是对数据结构的死记。以后遇到慢SQL,先别急着加索引,先EXPLAIN看一眼type和rows,再结合字段类型和查询条件判断,大概率能直接定位问题。还是那句话,索引不是越多越好,合适才是最好。