1. 什么时候应该考虑给表加索引
很多人一遇到MySQL查询变慢,第一反应就是“给表加索引”。我做了这么多年数据库优化,可以负责任地告诉你:这个方向是对的,但加索引不是万能药,更不是随便拿个字段建个索引就能解决问题。要搞清楚mysql表添加索引到底该怎么做,先得弄明白“什么时候真的需要加索引”。
1.1 慢查询是最直接的信号
如果你发现某个接口的响应时间从原来的几十毫秒涨到了几百毫秒甚至几秒,第一件事不是去加索引,而是先把那几条慢SQL抓出来。MySQL提供了慢查询日志,默认是关着的,你可以先打开:
sql复制-- 查看当前慢查询设置
SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time';
-- 开启慢查询日志(也可以写到my.cnf里永久生效)
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1; -- 超过1秒的记录
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
这里我习惯把long_query_time设为1秒,线上环境其实建议设到0.5秒甚至更低。因为现在很多业务对接口响应要求就是百毫秒级,1秒才记录的阈值太宽松了,等你能从慢日志里发现问题的时候,用户早就投诉了。
1.2 怎么看一条SQL是不是缺索引
用EXPLAIN就能看个大概。这是加索引前必须做的一步,千万不能省。我见过太多人上来就直接ALTER TABLE ADD INDEX,结果建完索引查询还是慢,最后发现压根不是索引的问题。
sql复制EXPLAIN SELECT * FROM orders WHERE user_id = 123 AND status = 1;
看type字段,如果出现ALL,说明是全表扫描,这就是需要加索引的信号。如果出现ref、range、const这些,说明索引用得还算正常。但这不是绝对的,我后面会详细说各种情况。
1.3 数据量增长带来的性能拐点
还有一个信号特别容易被忽略:数据量还没到一定程度,索引的作用体现不出来。一张表只有几百行数据,加不加索引区别真的不大,MySQL优化器可能直接选全表扫描还更快。但当数据量涨到几十万、上百万行,全表扫描的代价就完全不一样了。
以我的经验,单表数据量在10万行以内,很多查询其实全表扫描也能扛住。到了100万行往上,没有索引的查询基本就会开始出现明显的延迟,这时候mysql表添加索引的收益最大。但也不是说数据量小就不需要索引,还要考虑查询频率——就算表里只有1万行,一秒被查几百次的接口,加索引也能显著降低数据库压力。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 添加索引前的准备工作
真正动手加索引之前,我强烈建议你先花一点时间把下面几件事搞清楚。这一步做得好,后面能省好多事。
2.1 确认哪条SQL是真正的问题源
不要凭感觉猜,直接在慢日志里找。你也可以用performance_schema来统计一下哪些SQL消耗的资源最多:
sql复制-- 查看当前正在执行的SQL
SHOW PROCESSLIST;
-- 查看执行次数多、耗时长的SQL(需要开启performance_schema)
SELECT DIGEST_TEXT, COUNT_STAR, AVG_TIMER_WAIT
FROM performance_schema.events_statements_summary_by_digest
ORDER BY AVG_TIMER_WAIT DESC
LIMIT 10;
这一步的目的是搞清楚:到底是哪条SQL慢?它慢是因为表没索引,还是因为SQL写得太烂(比如SELECT了不需要的字段、JOIN了无用的大表),又或者是锁等待、网络延迟、硬件瓶颈?索引只能解决“全表扫描”这一类问题,其他问题你加了索引也是白搭。
2.2 用EXPLAIN分析执行计划
拿到目标SQL之后,跑一遍EXPLAIN,重点看这几个字段:
| 字段 | 含义 | 关注点 |
|---|---|---|
| type | 访问类型 | ALL(全表扫)、index(全索引扫)要警惕 |
| key | 实际使用的索引 | NULL说明没用索引 |
| rows | 预估扫描行数 | 数值越大,性能通常越差 |
| Extra | 附加信息 | 出现Using filesort、Using temporary要优化 |
我见过一个很典型的案例:一条SQL在WHERE条件里用了status字段做筛选,这个字段上其实是有索引的,但EXPLAIN显示type是ALL。后来一查,发现表里90%的数据都是status=1,MySQL优化器觉得全表扫比走索引还快,就直接放弃索引了。这种情况你加索引也没用,得从SQL改写或数据分布上想办法。
2.3 评估加索引对写入性能的影响
索引不是免费的。每建一个索引,就意味着每次INSERT、UPDATE、DELETE的时候,InnoDB都要额外维护这个索引的B+树结构。对于写多读少的表,无脑加索引反而会把整体性能拉低。
我一般会先看表的读写比例。如果一张表读占90%以上,加索引基本稳赚不赔;如果写操作很频繁,比如每秒上千次INSERT,那每加一个索引,写入性能就可能下降10%到20%。这种情况下就要谨慎了,能用一个组合索引解决的事,绝不建两个单列索引。
注意:这里说的下降比例只是经验值,实际受索引大小、表数据量、硬件性能影响很大。但方向是确定的——索引越多,写维护成本越高。
3. MySQL添加索引的核心语法与常用操作
好,现在到了大家最感兴趣的部分:mysql表添加索引具体怎么操作。我把常用的所有写法都列一遍,每种都解释一下适用场景。
3.1 基础语法:ALTER TABLE 和 CREATE INDEX
给表加索引,其实就是两条路:
sql复制-- 方式一:ALTER TABLE
ALTER TABLE `orders` ADD INDEX `idx_user_id` (`user_id`);
-- 方式二:CREATE INDEX
CREATE INDEX `idx_user_id` ON `orders` (`user_id`);
这两种写法本质上没有任何区别,只是个人习惯问题。我喜欢用ALTER TABLE,因为它的语义更完整,同一个语句里还能同时加多个索引或改表结构:
sql复制ALTER TABLE `orders`
ADD INDEX `idx_user_id` (`user_id`),
ADD INDEX `idx_status` (`status`),
ADD UNIQUE KEY `uk_order_no` (`order_no`);
这里有个细节说一下:索引名在同一张表里必须唯一,但不同表之间可以重名。命名的话我建议统一风格,比如idx_开头表示普通索引,uk_开头表示唯一索引,后面跟字段名。这样后期维护DBA看到索引名就知道大概是什么。
3.2 组合索引的设计:字段顺序决定生死
组合索引是mysql表添加索引里最容易被搞砸的地方。很多人的习惯是“哪个字段用得多就放前面”,这其实不太对,更合理的判断标准是区分度,也就是字段的离散程度。
举个例子,订单表里有user_id和status两个字段,你经常按WHERE user_id = ? AND status = ?查。user_id的取值可能有上万个用户,但status只可能有0、1、2几个值。明显user_id区分度高,应该放前面:
sql复制ALTER TABLE `orders` ADD INDEX `idx_user_id_status` (`user_id`, `status`);
但如果你建的是(status, user_id),那查询条件里如果只带user_id,这个索引就用不上,因为组合索引遵循最左前缀原则。也就是说,status在最左边,查询时只有先用了status才能用user_id,而你的SQL可能压根没按status查,索引自然就废了。
3.3 前缀索引与函数索引
针对VARCHAR长字段,比如TEXT类型或者很长的URL,直接建全字段索引会占很大空间,索引扫描效率也受影响。这种情况可以用前缀索引,只对字段前N个字符建索引:
sql复制ALTER TABLE `articles` ADD INDEX `idx_title_prefix` (`title`(20));
N选多少?我一般是测一下不同前缀长度下的区分度:
sql复制SELECT COUNT(DISTINCT LEFT(title, 10)) AS c10,
COUNT(DISTINCT LEFT(title, 20)) AS c20,
COUNT(DISTINCT LEFT(title, 30)) AS c30
FROM articles;
找一个区分度接近全字段、但长度又不太长的值就行。一般来说,前缀区分度达到全字段的90%以上就算合格。
MySQL 8.0还支持函数索引,比如你经常按DATE(create_time)查当天数据,可以直接建个函数索引:
sql复制ALTER TABLE `orders` ADD INDEX `idx_create_date` ((DATE(`create_time`)));
这个在5.7及以下版本是做不到的,只能用最笨的办法——新建一个冗余字段来存日期。如果你还在用5.7,然后遇到这种需求,评论区留个言,我改天单独写一篇“MySQL 5.7实现函数索引效果”的文章。
4. 索引选型的实战取舍与落地经验
建索引不难,难的是知道该建什么样的索引。这一节我聊聊我平时做索引选型的几个判断原则,这些是在实际项目里被反复验证过的。
4.1 单列索引应该优先解决什么问题
单列索引是基础。做index review的时候,我会先看每一张表的WHERE条件,把最常出现的、区分度最高的字段拎出来建单列索引。注意,区分度高是指这个字段取值种类多,比如订单号、手机号、身份证号,一个值就对应极少数记录。区分度太低,比如gender字段就两个值,你建了索引MySQL也很可能不用。
有个简单的SQL可以算区分度:
sql复制SELECT COUNT(DISTINCT `user_id`) / COUNT(*) AS selectivity FROM orders;
比例越接近1,区分度越高,建索引的价值就越大。低于0.1的话,除非这个字段总是跟其他字段一起出现,否则单独建索引意义不大。
4.2 组合索引才是高性能查询的王牌
单表查询如果只需要一个等值条件,单列索引就够了。但现实里SQL往往带多个筛选条件,这时候组合索引能发挥更大的作用。
组合索引的好处主要有两点。第一,一个索引可以同时覆盖多个筛选条件,减少索引数量,降低写入维护开销。第二,如果查询的字段全部在索引里,那就能实现覆盖索引扫描,不需要回表查磁盘上的真实数据行,性能提升是质的飞跃。
举个具体例子:
sql复制-- 查询某个用户已支付订单的金额
SELECT order_amount FROM orders
WHERE user_id = 123 AND status = 1;
如果只建idx_user_id和idx_status两个单列索引,MySQL最终只会选择其中一个(通常是区分度高那个),另一个就被浪费了。但如果你建了(user_id, status, order_amount)的组合索引,这个查询里所有的条件字段和返回字段都在索引里,InnoDB直接扫索引就能拿到结果,连表都不用回,速度能快一个量级。
4.3 覆盖索引:让查询不走“回头路”
很多初级开发者不理解为什么用SELECT *会让查询变慢。这里就涉及一个核心概念:回表。
InnoDB的索引分两类:聚簇索引(主键索引)和二级索引(普通索引)。聚簇索引的叶子节点存的是整行数据,二级索引的叶子节点存的是索引列的值加上主键值。所以当你用二级索引查询时,先要从二级索引找到主键,再拿主键去聚簇索引里找整行数据,这个二次查找的过程就叫回表。
如果查询需要的所有列都包含在二级索引里,那就不需要回表了,这个二级索引就成了覆盖索引。覆盖索引不是一种特殊的索引类型,而是一种使用技巧。
sql复制-- 假设有组合索引 (user_id, status, order_amount)
SELECT order_amount FROM orders WHERE user_id = 123 AND status = 1;
-- 上面的SQL只需要从索引里拿 order_amount,不需要回表
所以写SQL的时候,尽量把查询字段控制在需要的范围内,然后把常用的查询字段和条件字段一起设计到组合索引里。这又回到了那句话:索引不只是为了WHERE,也是为了SELECT。
5. 大表添加索引的在线DDL与工具选择
上面讲的都是语法和策略,接下来这部分是最多人在真正操作时翻车的地方。如果你的表只有几万行,直接ALTER TABLE加索引完全没问题,通常毫秒级就完成了。但如果你的表已经几千万行,还直接执行ALTER TABLE ADD INDEX,那很可能会把线上业务拖死。
5.1 为什么大表不能直接ALTER TABLE
MySQL在执行ALTER TABLE的时候,具体行为取决于MySQL版本、表引擎和ALGORITHM参数。在InnoDB里,添加索引的底层操作其实是重建表或新建索引结构。旧版本的InnoDB加索引时会在执行期间获取表的元数据锁,导致该表上的所有DML操作(INSERT、UPDATE、DELETE)都被阻塞,查询倒是不受影响。
这就很尴尬:你执行一条加索引的语句,表面上看着在跑,实际上已经把业务写操作堵住了。在访问量大的系统上,这就是事故级别的操作。
MySQL 5.6引入了在线DDL的概念,官方说支持ALGORITHM=INPLACE,理论上加索引时可以并发DML。但实测下来,当索引数据量很大时,这个过程仍然会消耗大量I/O资源,而且执行过程中可能会因为复制延迟、磁盘空间不足等问题导致失败。我见过不止一次线上大表直接加索引,结果主从复制延迟从几秒涨到几十分钟的。
所以我的经验是:单表超过500万行,或者索引列数据量较大(比如长字符串),就不要直接ALTER TABLE了。 优先考虑用工具,或者放到业务低峰期执行。
5.2 pt-online-schema-change:我的首选工具
Percona Toolkit里的pt-osc是我用得最多的在线表结构变更工具。它的原理是:
- 创建一张与原表结构一致的新表。
- 在原表上加一个触发器,把变更过程中的增量数据同步到新表。
- 分批把原表数据拷贝到新表(每次处理一小批,减少锁持有时间)。
- 数据拷贝完成后,通过RENAME TABLE原子切换新旧表。
- 删除触发器、清理旧表。
整个过程中,原表上的读写操作基本不受影响,对于大表加索引来说是目前比较靠谱的方案。
基本用法:
bash复制pt-online-schema-change \
D=my_database,t=orders \
--alter "ADD INDEX idx_user_id_status (user_id, status)" \
--host=127.0.0.1 \
--user=root \
--password=your_password \
--execute
需要先安装Percona Toolkit,在CentOS上可以这样:
bash复制yum install percona-toolkit
执行前建议先加--dry-run参数试跑一下,确认命令无误再真正执行:
bash复制pt-online-schema-change ... --dry-run
5.3 加索引后的校验与回滚预案
加完索引不是就完事了,我一般还会做两件事。
第一,确认索引确实建上了:
sql复制SHOW INDEX FROM `orders`;
第二,重新执行之前的慢SQL,看执行计划是否已经用上了新索引,耗时是否降下来了:
sql复制EXPLAIN SELECT * FROM orders WHERE user_id = 123 AND status = 1;
如果发现MySQL没用上你刚建的索引,不要慌。可能的原因有两种:一是统计信息还没来得及更新,跑一下ANALYZE TABLE;二是优化器觉得全表扫更快(比如数据量小或者筛选条件区分度太低)。这时候你就得回头想想,是不是这个索引设计本身就有问题。
至于回滚预案,我加索引前一定会把原表结构先备份一份,至少记下原来的索引定义:
sql复制SHOW CREATE TABLE `orders`;
万一线上的加索引操作出了问题需要回滚,直接删除新索引就行:
sql复制ALTER TABLE `orders` DROP INDEX `idx_user_id_status`;
6. 索引失效场景与常见问题排查实录
最后这部分,我把我这些年踩过的坑和帮别人排查过的经典问题整理一下。很多情况不是没加索引,而是加了索引但MySQL没用。
6.1 写了索引却不生效的几种典型场景
我的博客维护群里几乎每周都有人来问同一个问题:“我这索引明明建了,EXPLAIN怎么还是全表扫?”我把最高频的几种原因列个表:
| 场景 | 原因 | 解决方案 |
|---|---|---|
WHERE status LIKE '%已完成%' |
前导模糊匹配无法使用B+树索引 | 改用status LIKE '已完成%'或全文索引 |
WHERE YEAR(create_time) = 2024 |
对索引列使用函数,索引失效 | 改成create_time >= '2024-01-01' AND create_time < '2025-01-01' |
WHERE user_id = ? OR status = 1 |
OR条件中某一侧无索引,可能全表扫 | 改写成两个UNION,或保证两侧都有索引 |
WHERE user_id = '123'(user_id是varchar) |
隐式类型转换导致索引失效 | 保持类型一致,别省引号 |
WHERE a.name = b.name(a、b字符集不同) |
跨字符集JOIN导致索引失效 | 统一字符集为utf8mb4 |
WHERE status = 1(status区分度极低) |
优化器认为走索引更慢 | 看实际情况,组合索引或改写SQL |
这些场景里,函数操作和隐式转换是我遇到最多的两类。特别是隐式类型转换,你要是稍不注意,线上就会出现“索引建了但没卵用”的诡异现象。
6.2 隐式类型转换:最常见的隐形杀手
有个经典案例。user_id字段定义是VARCHAR(20),你写SQL的时候忘了加引号:
sql复制SELECT * FROM users WHERE user_id = 123;
user_id是字符串类型,123是整数,MySQL把两者比较时会把字符串转成数字去比较,这时就会对user_id列进行隐式类型转换。一旦对列做了类型转换,索引就失效了。
我之前排查过一个线上事故,一张用户表几百万行,每次查询都要全表扫,接口超时严重。最后查下来就是因为在代码里写SQL时,把user_id这个VARCHAR字段当成了数字去查询。把123改成'123'之后,查询时间直接从800ms降到2ms,前后只变了这一处。
6.3 EXPLAIN关键字段速查表
最后送上一份我经常给团队新人看的EXPLAIN速查表。你以后做索引review,拿这张表对照着看就行:
| 字段 | 常见取值 | 好坏判断 |
|---|---|---|
| type | system、const | 极好,几乎只查一行 |
| type | eq_ref | 很好,JOIN主键或唯一索引时出现 |
| type | ref | 好,等值匹配走普通索引 |
| type | range | 还行,范围扫描,一般可接受 |
| type | index | 一般,全索引扫描,比ALL好一点 |
| type | ALL | 差,全表扫描,优先要优化 |
| key | 实际索引名 | NULL表示没用索引 |
| rows | 预估扫描行数 | 越小越好 |
| Extra | Using index | 好,覆盖索引,没回表 |
| Extra | Using filesort | 差,文件排序,考虑索引排序 |
| Extra | Using temporary | 差,临时表,深究SQL是否可优化 |
| Extra | Using where | 中性,存储引擎返回后再过滤 |
看到Using filesort或者Using temporary的时候,别光想着加索引,先想想能不能通过调整组合索引的字段顺序,让查询直接走索引的顺序,避免额外的排序和临时表。
关于mysql表添加索引,最后再分享几点个人体会
从入行到现在,我给无数张表加过索引,也帮别人擦过不少屁股。说句实在话,mysql表添加索引这件事本身不难,难的是想清楚“这个索引到底该不该加、该怎么加”。我越来越觉得,加索引前把EXPLAIN看明白,比盲目执行ALTER TABLE重要一百倍。
如果你是在开发环境测试,多试试不同的组合索引效果,观察一下执行计划的变化,这比背十篇面试题都有用。如果你是在生产环境操作,记住我的原则:小表直接改,大表用工具,改完验执行计划。这三点做到位,基本不会出大问题。
另外,加完索引之后别以为就万事大吉了。索引的维护是长期的,表的统计信息会变,数据分布会变,之前优化的SQL某一天可能又变慢了。我的习惯是每隔一段时间跑一次慢日志分析,把过期的不用索引定期清掉,把新出现的慢查询重新设计索引。反正,索引不是建完就完的,它需要你持续关注。
