1. 一条慢SQL引发的加索引决策
1.1 从慢查询日志开始:全表扫描的成本到底有多高
在我接手维护的线上系统里,有一张订单明细表,数据量大概在800万行左右。某天下午监控告警突然刷屏——有个接口的P99延迟从80ms直接飙到了2.3秒。翻出慢查询日志,锁定的是一条看起来很简单的查询:
sql复制SELECT id, order_no, user_id, amount
FROM order_detail
WHERE user_id = 10023456
ORDER BY create_time DESC
LIMIT 20;
当时这张表的情况是:主键是自增id,外加一个order_no的唯一索引。user_id和create_time上没有任何索引,意味着这个查询只能走全表扫描。我拉了一下执行计划,type明明是ALL,rows估算值到了789万,Extra里还挂着Using filesort——扫描将近八百万行,再在内存里排个序,不快才怪。
这里牵扯到一个关键问题:全表扫描的成本到底有多高。MySQL InnoDB存储引擎默认每次IO读取16KB的数据页,如果一张800万行的表,每行平均占用大约800字节,那么全表数据量大概在6.4GB左右,换算下来需要读取40万个数据页。即使数据页大部分在Buffer Pool里,这种规模的扫描对CPU和内存的消耗也非常可观,更别提冷数据场景下要发生多少次磁盘随机IO。
加索引之后的效果立竿见影:B+树从根节点到叶子节点一般只有2到3层,也就是说,定位到user_id对应的索引记录只需要2到3次逻辑IO,再加上回表读取完整行,整体操作能压缩到个位数的IO次数。我在本地压测环境做了对比,同样的查询,全表扫描耗时1.8秒,加了索引之后直接降到15ms。
1.2 不是所有查询都该加索引:先看清楚查询模式
看到这里,有人可能觉得那直接把所有WHERE条件涉及的字段都加上索引不就行了?这是个非常典型的误区。索引不是免费的午餐,每多一个索引,写入数据时就要多维护一棵B+树。你可以把索引理解为图书的目录页——目录越精细,查询越方便,但每新增一本书,你就得多抄一份目录。
我见过一个生产事故:某团队给一张日增百万行的大宽表加了8个索引,单条插入的延迟从5ms涨到120ms,直接拖垮了写入链路。所以,加索引之前必须先分析查询模式。
具体来说,要看三个方面:
- 高频查询的WHERE条件:哪些字段是等值匹配,哪些字段是范围匹配
- 排序和分组字段:ORDER BY、GROUP BY、DISTINCT背后的字段有没有索引支撑
- 关联查询的ON字段:JOIN条件里的字段,驱动表和被驱动表分别需不需要建索引
在OLTP场景下,我会坚持一个原则:索引宁缺毋滥,每个索引必须对应一个真实的业务查询场景。如果某个字段虽然在WHERE里出现,但查询频率极低,建了索引反而得不偿失。像上面的订单明细表,user_id是用户查询订单的必经之路,create_time则是排序的刚需,这两个字段建联合索引是完全合理的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 索引底层为什么选B+树:顺序比命中更值钱
2.1 哈希表与B+树的取舍
很多刚接触MySQL的人会问:为什么索引数据结构不用哈希表?哈希表查找不是O(1)吗?这个问题问得好,但要分场景来看。哈希索引确实能做到等值查询的极致速度,但它的致命弱点有两个:不支持范围查询,也不支持排序。
回到订单明细表的场景,WHERE user_id = 10023456 ORDER BY create_time DESC,如果用的是哈希索引,虽然等值匹配user_id很快,但ORDER BY create_time还是得把所有匹配行加载到内存里再做一次排序。而B+树天然有序——叶子节点上的数据按索引键值排序存储,节点之间通过双向指针串联,范围扫描和排序都能直接利用索引的有序性,省掉了filesort。
MySQL确实提供了哈希索引选项,但只在Memory引擎里默认支持,InnoDB的哈希索引是自适应的,由存储引擎内部管理,DBA无法手动创建。我们可以把B+树理解成一个有序的跳表结构:从根节点一路向下,每一层都做一次范围判断,最终定位到叶子节点;叶子节点上的数据是按照索引列有序排列的,直接顺序遍历即可。这个设计让B+树在等值、范围、排序三种场景下都能用,性价比远高于哈希表。
2.2 主键索引与普通索引:回表与覆盖索引
InnoDB的主键索引是聚簇索引,它的叶子节点直接存放整行数据。你可以把它想象成一本按拼音排序的字典——查到字,旁边就写着完整释义。普通索引是非聚簇索引,叶子节点只存放索引列的值和主键值。查询时如果SELECT的字段不在普通索引里,必须先通过普通索引找到主键值,再回到聚簇索引里查一遍完整数据,这个过程叫回表。
回表的代价不容忽视。假设一页能存100条索引记录,通过普通索引定位到100条主键值,然后每条主键值都要回聚簇索引查一次,这100次回表可能触发100次随机IO。所以,在设计索引时,我会想办法避免回表,让索引覆盖查询所需的所有字段。
举个例子,如果查询改成SELECT user_id, create_time FROM order_detail WHERE user_id = 10023456 ORDER BY create_time DESC,那我把user_id和create_time建一个联合索引,查询所需的列就全在索引里了,MySQL可以直接扫描索引返回结果,Extra里会出现Using index,这就是覆盖索引优化。这种技巧在报表类和大数据量查询场景里非常实用。
2.3 区分度、基数与选择性:判断字段该不该建索引
索引建在哪些字段上,不是拍脑袋决定的,背后有几个量化指标可以辅助判断。
- 基数(Cardinality):索引列上不同值的个数。基数越接近表的行数,说明这个列的重复度越低
- 选择性(Selectivity):基数除以表总行数。选择性在0到1之间,越接近1越好
- 区分度:日常开发中常说的“这个字段值够不够散”
用SQL可以直观算出来:
sql复制SELECT
COUNT(DISTINCT user_id) / COUNT(*) AS user_sel,
COUNT(DISTINCT status) / COUNT(*) AS status_sel
FROM order_detail;
我在真实表上跑出来的结果:user_id的选择性大概是0.98,status的选择性只有0.0001。这意味着user_id列上每个值平均能过滤掉98%的数据,而status列上每个值只能过滤掉万分之一的数据。如果给status这种性别、状态类的低基数列加索引,就算命中了索引,回表的数据量依然巨大,优化器算下来可能还是全表扫描更快,索引建了也白建。
低基数字段不是完全不能用,而是要和其它字段组成联合索引放在合适的位置。比如状态字段status虽然只有几个枚举值,但配合create_time做范围查询时,把status放在联合索引的前导列,可以快速剔除大量无效数据。
3. 加索引的完整实操:从语句到验证
3.1 ALTER TABLE还是CREATE INDEX:两种方式怎么选
MySQL里添加索引有两种常用语法,功能上基本等价,但适用的批量操作场景不同。
sql复制-- 方式一
ALTER TABLE order_detail ADD INDEX idx_user_create (user_id, create_time);
-- 方式二
CREATE INDEX idx_user_create ON order_detail(user_id, create_time);
单纯从建索引的角度来看,两者没有区别。但ALTER TABLE的能力更强,它可以同时执行多个操作,比如既加索引又改列类型还改表注释。如果一张表需要做多项变更,我会用一条ALTER TABLE写在一起,这样MySQL有可能共用一次表重建过程,减少整体开销。
sql复制ALTER TABLE order_detail
ADD INDEX idx_user_create (user_id, create_time),
MODIFY COLUMN remark VARCHAR(256) NOT NULL DEFAULT '';
特别提醒一点:线上大表做结构变更前,务必先看表大小和当前负载。几百万行以上的表,直接ALTER TABLE加索引可能需要长时间持有元数据锁,影响业务写入,这个话题后面第5节会专门展开。
3.2 EXPLAIN验证:别加完索引就以为万事大吉
加索引不等于结束,真正的工作是验证索引有没有被优化器正确使用。我每次加完索引都会立刻跑一遍EXPLAIN,重点看这几列:
- type:至少要达到range,最理想是ref或const,如果是ALL说明索引没生效
- key:实际用到的索引名称,为NULL说明没走任何索引
- rows:优化器估计扫描的行数,加索引后应该大幅下降
- Extra:出现Using index表示覆盖索引,Using index condition表示索引下推,Using filesort说明排序没走索引,需要警惕
以订单明细表为例,加索引后的执行计划应该长这样:
sql复制EXPLAIN SELECT id, order_no, user_id, amount
FROM order_detail
WHERE user_id = 10023456
ORDER BY create_time DESC
LIMIT 20;
type列从ALL变成ref,key列显示idx_user_create,rows骤降到几十行,Extra里的Using filesort也消失了。到了这一步,才真正可以说这条SQL优化完成。
3.3 SHOW INDEX:检查重复索引和冗余索引
很多表经过长期迭代,积累了多个功能重叠的索引。比如同一个user_id既建了单列索引,又和create_time建了联合索引,那单列索引就完全多余了,因为联合索引的最左前缀已经能覆盖user_id的等值查询。
用SHOW INDEX可以快速盘点表上的索引情况:
sql复制SHOW INDEX FROM order_detail;
重点关注Cardinality字段:如果某个索引的Cardinality远小于表行数,说明这个索引的选择性很差,建了意义不大。另一个技巧是观察索引名和列名,把功能覆盖的冗余索引清掉。我在实际运维中见过一张表上有6个索引,其中有3个都冗余了,删掉之后写入性能明显提升,磁盘占用也降了好几个GB。
4. 联合索引与最左前缀:别让索引白建
4.1 联合索引的字段顺序怎么定
联合索引是MySQL优化里最核心的一环,而字段顺序直接决定索引能不能被查询命中。判断顺序时,我遵循两条经验法则,优先级从高到低。
第一条:区分度高的字段放在前面。 前面算过user_id的选择性接近0.98,create_time是连续递增的时间值,对应范围查询。如果把create_time放在联合索引第一位,WHERE user_id = ?条件下,索引必须先扫描大量create_time区间再过滤user_id,效率远不如把user_id放前面直接等值定位。
第二条:等值查询放在前面,范围查询放在后面。 联合索引在多个等值条件下可以精确跳转,但一旦遇到范围条件,后面的字段就无法用于索引内定位了。比如WHERE user_id = 100 AND create_time > '2024-01-01',联合索引(user_id, create_time)可以让user_id精确命中,再在create_time上做范围扫描,这是最优的。
有一种常见争议:单个字段选择性都很高的情况下,到底谁放前面?比如一个是user_id(0.98),一个是order_no(0.99),单独看order_no更高,但如果业务查询绝大多数是user_id等值配合其它字段,那user_id必须放前面,因为索引的设计要服务于真实查询模式,而不是纸面上的选择性数值。
4.2 最左前缀与索引下推:MySQL 5.6带来的关键优化
最左前缀原则是联合索引使用的铁律:查询必须从联合索引最左边的列开始匹配,跳过了前面的列,后面的列就无法用于索引定位。但MySQL 5.6之后引入了索引下推优化(Index Condition Pushdown,ICP),在一定程度上缓解了部分情况下的回表问题。
举个例子,假设联合索引是(user_id, create_time, status),查询是:
sql复制SELECT * FROM order_detail
WHERE user_id = 10023456
AND create_time > '2024-01-01'
AND status = 1;
在没有ICP的版本里,存储引擎通过联合索引先定位user_id=10023456且create_time>'2024-01-01'的数据,然后逐条回表读取整行,再在server层过滤status=1。有了ICP之后,存储引擎会在索引层直接判断status=1是否满足,把不满足的行过滤掉,只有满足条件的才回表。这大大减少了回表次数和IO量。
判断SQL有没有走ICP,看EXPLAIN的Extra字段有没有Using index condition即可。这个优化对我们的启示是:虽然范围查询后面的字段不能用于索引定位,但依然可以在索引层做过滤,所以联合索引里多放一个过滤条件字段经常是有价值的,尤其在表数据量大、回表成本高的场景。
4.3 用联合索引优化ORDER BY排序
ORDER BY造成的filesort是慢查询的重灾区。排序字段如果没有索引支撑,MySQL必须把结果集加载到内存或临时文件里排序,数据量大时相当耗时。利用B+树叶子节点天然有序的特性,只要ORDER BY字段满足最左前缀原则,排序就可以直接走索引,Extra里不会再出现Using filesort。
我最常遇到的一个组合是:
sql复制SELECT id, user_id, amount
FROM order_detail
WHERE user_id = 10023456
ORDER BY create_time DESC, id DESC
LIMIT 10;
这种查询在只建user_id单列索引时,虽然能快速定位user_id,但create_time排序仍然需要filesort。如果联合索引建的是(user_id, create_time),排序就能走索引,user_id已经等值匹配,create_time的索引顺序就是物理顺序,直接按索引倒序扫描就行。注意,ORDER BY direction也要一致,如果一个是ASC一个是DESC,在MySQL 8.0之前还可能导致无法利用索引排序。
5. 加索引时的锁与在线DDL:线上操作怎么不掉链子
5.1 MySQL 5.6之后的在线DDL能力
早年做数据库运维的人都有过这种经历:在线上大表执行一条ALTER TABLE,卡在那里好几个小时,业务写入直接全部阻塞。根本原因是早期版本加索引需要COPY整张表的数据,并且全程持有排他锁。MySQL 5.6引入了在线DDL功能,很多结构变更可以INPLACE执行,不需要拷贝全表数据。
以InnoDB为例,常见操作的执行策略可以这样理解:
- ADD INDEX:默认走INPLACE,不需要复制整表数据,但具体能不能完全无锁,取决于系统变量old_alter_table是否开启
- MODIFY COLUMN:部分场景需要重建表
- ADD COLUMN:某些情况可以INPLACE完成
加索引时,我可以显式指定执行算法和锁策略,控制线上影响范围:
sql复制ALTER TABLE order_detail
ADD INDEX idx_user_create (user_id, create_time),
ALGORITHM=INPLACE,
LOCK=NONE;
LOCK=NONE的意思是允许加索引期间并发读写表。如果MySQL评估下来无法满足这个要求,会直接报错,这时候就要考虑换工具了。不要忽略这一点,改用LOCK=SHARED(允许读,不允许写)虽然也能完成加索引,但对高可用要求的业务来说,依然会造成写入长时间阻塞。
5.2 大表加索引用pt-online-schema-change
在实际生产环境里,面对千万级甚至亿级数据量的表,我不建议直接跑ALTER TABLE。即使MySQL支持INPLACE,大表重建索引的过程依然会消耗大量IO和CPU资源,还可能造成主从延迟。我的方案是用percona-toolkit里的pt-online-schema-change工具。
它的工作逻辑是:
- 在原表上创建一个结构相同的新表结构,加上目标索引
- 在原表上创建三个触发器,分别捕获INSERT、UPDATE、DELETE操作,把增量数据实时同步到新表
- 分批把原表的数据拷贝到新表,每批1000到10000行,批与批之间休眠一段时间,降低对主库的压力
- 数据拷贝完成后,在短暂的低峰窗口内做一次原子表替换(RENAME TABLE)
命令长这样:
bash复制pt-online-schema-change \
D=your_database,t=order_detail \
--alter "ADD INDEX idx_user_create (user_id, create_time)" \
--host=127.0.0.1 \
--user=admin \
--password=your_password \
--chunk-size=2000 \
--max-lag=5 \
--execute
--chunk-size控制每批处理的行数,--max-lag=5表示如果主从延迟超过5秒就暂停拷贝。这个工具虽然名字听着复杂,但线上加索引的可靠性远高于手动ALTER TABLE。
5.3 加完索引后的统计信息更新
加完索引之后,有一件事经常被忽略——更新表的统计信息。MySQL优化器依赖统计信息来估算扫描行数、决定执行计划,如果统计信息过旧,优化器可能不选新索引。
数据量变化较大的表,加完索引后跑一遍:
sql复制ANALYZE TABLE order_detail;
在MySQL 8.0之前,统计信息会随表的部分操作自动更新,但主动ANALYZE依然是最稳妥的。我见过一个案例:索引明明建好了,EXPLAIN就是不走,rows估算值还是几百万行,最后执行了一次ANALYZE TABLE,执行计划立刻就变了。排查这个问题时不要上来就怀疑索引没建成功,先查统计信息。
6. 索引为什么会失效:加完索引不等于万事大吉
6.1 隐式类型转换与函数操作
这是开发中最容易踩的坑之一。表里的字段是VARCHAR类型,查询条件却传了数字:
sql复制SELECT * FROM user
WHERE mobile = 13800138000;
如果mobile列在创建时是VARCHAR(20),MySQL会把查询条件里的数字隐式转换成字符串再比较。但反过来的情况更隐蔽:如果字段本身是字符串类型,传入条件是用数字类型,MySQL会在字段上做隐式CAST操作,导致该列的索引无法使用。因为索引是针对原始字段值构建的,一旦对字段应用函数或类型转换,B+树的有序性就被破坏了。
同样的问题还有对索引列使用函数:
sql复制SELECT * FROM order_detail
WHERE DATE(create_time) = '2024-06-01';
即使create_time上有索引,DATE()函数包裹之后,索引也会失效。正确的写法是范围查询:
sql复制SELECT * FROM order_detail
WHERE create_time >= '2024-06-01 00:00:00'
AND create_time < '2024-06-02 00:00:00';
6.2 LIKE前置通配符与OR条件
LIKE模糊查询是索引失效的经典场景。WHERE name LIKE '张%'能走索引,WHERE name LIKE '%张'或WHERE name LIKE '%张%'就很难走索引,因为B+树是有序排列的,前缀匹配能利用有序性定位到起点,而前缀不确定的模糊查询无法确定搜索起点,只能全量扫描。
OR条件也有类似的问题。如果OR两边的条件里,只有一边走索引,另一边走全表扫描,优化器可能会放弃索引,直接全表扫。举个例子:
sql复制SELECT * FROM order_detail
WHERE user_id = 10023456 OR status = 1;
user_id有索引,status没有索引,优化器评估后可能直接扫全表。解决办法是把OR拆成UNION ALL:
sql复制SELECT * FROM order_detail WHERE user_id = 10023456
UNION ALL
SELECT * FROM order_detail WHERE status = 1;
或者给status也加上低成本的索引,让OR两边都能走索引。
6.3 索引失效的排查清单
我在日常巡检中总结了一份排查清单,每次遇到SQL变慢,会按这个顺序逐项检查:
| 场景 | 判断特征 | 解决方案 |
|---|---|---|
| 隐式类型转换 | 字段是VARCHAR,条件传数字 | 参数类型和字段类型保持一致 |
| 函数包裹字段 | WHERE DATE(create_at) | 改为范围查询或冗余函数列 |
| LIKE前置通配符 | LIKE '%keyword' | 业务允许时改前缀匹配,或考虑全文索引 |
| OR条件部分无索引 | EXPLAIN里type为ALL | 拆分为UNION ALL或补全索引 |
| 使用了NOT IN/!= | 优化器认为索引收益低 | 评估业务,改等值或范围查询 |
| 联合索引不满足最左前缀 | WHERE跳过第一列 | 调整查询条件或联合索引顺序 |
排查时最直接的手段还是EXPLAIN,养成写每条SQL后先看执行计划的习惯,能省下大量线上调试时间。
回到文章开头那条慢SQL,它最终给所有线上数据库加索引的操作都沉淀成了一套标准流程:先看查询模式,再算区分度,设计联合索引字段顺序,用EXPLAIN验证,用锁策略和在线DDL工具控制风险,最后更新统计信息收尾。我自己的体会是,加索引这件事,80%的收益来自于对查询模式的准确理解和字段顺序的合理设计,剩下的操作层面反而很简单。每个DBA或后端开发都值得在测试环境亲手用大表做几次加索引实验,感受一下B+树在数据量不同时的行为差异,这种直觉是任何文档都给不了的。
