聊到 MySQL 索引,大部分人的第一反应是查询提速神器。但在我做数据库运维和性能优化的这几年里,索引从来不是什么免费的午餐,它更像是贷款买房——前期帮你快速住进新房,但每个月都得还月供,还不上或者还错了,照样能把日子过乱。
最近正好在帮一个业务团队做线上数据库的索引体检,发现几张核心表的索引数量已经膨胀到了七八个,写入延迟从原来的 3 毫秒涨到了 25 毫秒,而其中好几个索引实际上压根没被查询用过。这个场景太典型了,今天就把 MySQL 索引的缺点和代价一次性说透,包括索引为什么会让写入变慢、什么情况下索引会失效甚至帮倒忙、索引设计失误埋下的坑,以及我平时做索引取舍时的一些判断方法。
如果你正在用 MySQL,或者正准备给业务表加索引,这篇文章值得读完。它可能不会告诉你索引有多好用,但能帮你避开那些把数据库搞到半夜报警的愚蠢操作。
1. 索引的本质是一份"额外的账本":先搞清楚代价从哪来
很多开发者在理解索引时,脑子里只有一个模糊概念:索引能让查询变快。至于为什么快,快的同时付出了什么,基本没细想。这里我换个生活化的说法:一张没有索引的表就像一堆散落在地上的快递,你要找一个包裹只能一个个翻。而索引就是一本按序排列的登记册,告诉你第几排第几格放着谁的包裹。
听起来很美好对吧?但这本登记册本身是有成本的,而且要一直维护。我们要聊缺点,首先得把这本账算清楚。
1.1 存储空间是明面上的第一笔开销
MySQL 的 InnoDB 存储引擎里,数据本身存在聚簇索引的 B+ 树中,而每一个二级索引都是一棵独立的 B+ 树。也就是说,你每建一个索引,MySQL 就要额外创建一棵树出来。
这棵树里存了什么东西?索引列的值,加上对应的主键值。注意,不是存完整行数据,而是索引列和主键。比如一张一千万行的用户表,一个status字段的索引,可能就要额外占用几百 MB 甚至上 GB 的空间。如果这张表再建上四五个索引,索引占用的总空间比数据本身还大,这种情况我见得太多了。
提示:InnoDB 表的数据和索引一旦占用空间变大,备份恢复时间、缓冲池命中率都会受到连锁影响。很多团队只盯着查询耗时,很少去看表空间大小,结果磁盘报警时才发现某个索引占了几个 G。
1.2 写入链路变重的三个关键环节
空间开销只是最直观的一层,真正让数据库变慢的是写入链路的放大效应。插入一行数据时,InnoDB 要做的事远不止往聚簇索引里插一条记录:
- 写入聚簇索引(主键对应的 B+ 树)
- 写入每一个二级索引(如果有多个,就得写多棵树)
- 如果二级索引的页满了,需要触发页分裂
更新操作更复杂。如果更新的是普通列,那相对好办,只改聚簇索引里的那条记录就行,二级索引不用动。但如果你更新了一个索引列,那情况完全不同——InnoDB 需要"删除"旧索引记录,再"插入"新索引记录,标记为删除的那部分还得等后台 purge 线程清理。至于更新主键,那就更劝退了:所有二级索引里的指针全部要跟着改,等于是把这张表的所有索引都重写一遍。
删除操作也没好到哪里去。InnoDB 的删除是标记删除,数据不是立刻物理消失,而是先在 undo log 里记一笔,记录对应的二级索引位置也要标记删除。大量删除之后,历史版本堆积,purge 线程忙不过来,undo log 膨胀,查询的可见性判断也会变慢。
所以你看,每增加一个索引,表面上只是多了一条ALTER TABLE ADD INDEX的语句,实际上每一次 INSERT、UPDATE、DELETE 都在给这个新索引付费。
1.3 页分裂与随机 IO 的连锁反应
二级索引的写入还牵扯到磁盘 IO 的随机性问题。自增主键的写入是顺序的,新数据永远往 B+ 树最右边追加,写起来很顺。但如果你在一个非顺序的列上建索引,比如 UUID、随机字符串,新插入的数据可能在 B+ 树的任意位置,页满了就要分裂,把一部分数据移到新页里。
页分裂是什么概念?想象一个书架每层都摆满了书,新书来了,你得把一层书挪到另一层去。这个挪的过程在数据库里就是额外的 IO 和锁开销,而且分裂后留下来的页可能会填充不满,产生碎片。碎片多了,即使查询走索引,扫描的页数量也会增加,本来很快的查询会慢慢变钝。
MySQL 的 change buffer 机制可以在一定程度上缓冲二级索引的写入,但它对唯一索引不生效,而且如果二级索引命中率不高,后台合并刷盘时反而会造成更高的 IO 压力。所以别指望 change buffer 能完全抹平索引写入成本。
2. 查询变快,写入变慢:索引对 DML 语句的真实拖累
讲清楚代价来源之后,我们来看实际场景。很多时候,业务上线初期数据量小,几十万行,索引再多也不会慢。但数据量涨到千万级、亿级,写入吞吐量开始暴跌,这个时候你才会意识到索引的拖累有多明显。
2.1 INSERT 的"乘法效应"
插入性能与索引数量基本是近似乘法关系。我做过一个简单的压测对比:一张 500 万行的表,不加二级索引时,批量插入一秒钟能跑 8000 条;加上两个索引后,直接掉到 2500 条;加到四个索引,只剩 800 条左右。这个比例不是精确值,受字段宽度、缓冲区命中情况影响,但趋势非常明显——每加一个索引,插入吞吐都会掉一大截。
原因不难理解:每插入一行,所有二级索引的 B+ 树都要定位插入位置,而定位本身涉及树的遍历和页的读写。如果多个索引列的值都是无规律的,那每次插入都可能触发多次随机 IO,这个开销远大于往聚簇索引尾部追加数据。
所以,我在处理定时任务、数据迁移、ETL 这类批量导入场景时,有个固定操作习惯:先ALTER TABLE ... DROP INDEX把不必要的索引删掉,跑完数据再重新建。实测下来,一个大表的数据导入时间能从四十多分钟缩短到十分钟左右,差距就是这么大。
2.2 UPDATE 的隐藏代价
UPDATE 的代价要分情况看,很多开发者在评估索引对 UPDATE 的影响时容易想当然,觉得所有更新都慢。其实这里有个关键区别:
- 更新非索引列:只需要在聚簇索引中找到那条记录,改掉字段值即可,二级索引完全不受影响。
- 更新索引列:等于先删除旧索引条目,再插入新索引条目,涉及两棵 B+ 树的操作,代价翻倍。
- 更新主键:所有二级索引的引用都要变更,这个操作通常不应该出现在线上,但如果真有人这么写,后果就是卡到怀疑人生。
另外还有个容易忽略的细节:UPDATE 的 WHERE 条件如果走了索引,那查找记录本身是快的,这个没问题。但如果你更新的是索引列,并且 WHERE 条件本身还能命中索引,那 InnoDB 需要先通过索引找到旧记录,再把旧索引记录标记删除,同时插入新记录到新位置。InnoDB 为了保持索引有序,新位置往往和旧位置不在一起,又引入随机 IO。
2.3 DELETE 的"标记删除"与堆积问题
DELETE 在 InnoDB 里并不是立刻物理删除,而是先在记录上打删除标记,真正的清理交给后台 purge 线程。这个机制本身是为了支持多版本并发控制,但它带来的缺点也很明显。
当你的二级索引很多、删除量又大时,purge 线程需要处理所有二级索引上的历史记录标记,CPU 和 IO 都会受到影响。更麻烦的是,删除操作会产生大量 undo log,如果 purge 跟不上,undo log 会膨胀,甚至出现history list length居高不下的情况,直接拖慢所有查询的 ReadView 判断。
在实际运维中,我遇到过一张大表定期清理过期数据,因为索引过多,每次 DELETE 几万行都能把主库延迟打满,从库复制直接告警。后来把负责排序的一个超大索引去掉,同样批量的 DELETE 时间缩短了一半以上。
2.4 日志表和流水表为什么要克制索引
基于上面这几点,我给自己定了个经验法则:写多读少的表,索引一定克制。最典型的就是操作日志表、流水表、埋点数据表。这些表的特征是写入量巨大、查询场景单一(通常只按时间或某个业务 ID 查最近数据),查询频率远比写入频率低。
这种表上每多一个索引,都是在给核心写入链路加负担。很多时候,一个二级索引的收益是一天只跑几次的报表查询,代价却是每秒钟几千次的写入都在为它买单。这笔账怎么算都不划算。
反过来,读多写少的表,比如配置表、商品基础信息表,索引适当多一些没问题。判断标准不是"索引多不多",而是"读写比例和查询模式"。
3. 索引并非万能:失效场景与"帮倒忙"的典型情况
索引的缺点不止是性能代价,还有一个更隐蔽的问题:你以为它一定能帮上忙,结果在特定条件下它根本不生效,甚至让查询变得更慢。这不是说索引没用,而是很多人对索引的能力边界缺乏认知。
3.1 最左前缀原则:联合索引的顺序陷阱
联合索引看起来可以覆盖多个列,但它遵循最左前缀原则。我经常遇到一类问题:业务方建了一个(user_id, order_status, create_time)的联合索引,结果查询条件是where order_status = 1 and create_time = '2024-01-01',没带user_id,索引直接无法使用。
为什么会这样?联合索引在 B+ 树里的排序规则是先按第一个列排序,第一列相同再按第二列排序,依次类推。如果查询条件里没有第一列,那在索引树里就没法确定搜索起点,只能全树扫描,优化器通常不会选这条路。
联合索引的缺点在于它看似灵活,实则僵硬。你要它覆盖更多的查询场景,就得精确设计列的顺序;设计错了,这个索引就成了一个占地大、写入贵、但查询用不上的摆设。
注意:这里的核心结论是,联合索引可以覆盖多个列,但绝不能认为"建了联合索引就万事大吉"。顺序和查询条件的匹配关系必须在设计阶段就考虑清楚,否则就是花钱买罪受。
3.2 隐式类型转换和函数操作让索引失效
另一个高频踩坑点是隐式类型转换。比如表中phone字段是 varchar 类型,查询却写成where phone = 13800138000,MySQL 会把字段转换成数字再比较,这种情况下字段上即使有索引也无法正常使用。还有更常见的,在索引列上套函数:
SELECT * FROM order_info WHERE DATE(create_time) = '2024-06-01';create_time上建了索引也没用,因为DATE()函数会先把每一行的create_time取出来运算一遍,索引树里存的是原始值,没法直接定位。正确写法是改成范围查询:
SELECT * FROM order_info WHERE create_time >= '2024-06-01 00:00:00' AND create_time < '2024-06-02 00:00:00';为什么这类问题属于索引的缺点?因为很多人建了索引之后,下意识认为"我有索引,查询肯定走索引"。但实际上,索引能不能被用上,取决于你的 SQL 写法是否符合索引的匹配规则。一个带函数的条件就能让精心设计的索引直接失效。
3.3 低选择性索引:优化器宁愿全表扫描
还有一种情况更让开发者难受:索引建了,SQL 也没写错,但执行计划出来还是全表扫描。原因出在索引的选择性上。
选择性可以简单理解为:这个索引列有多少个不同的值。性别列可能只有 0 和 1 两个值,状态列可能只有三五个值,这些低区分度的列上建索引,优化器经过成本估算后,会觉得走索引和全表扫描差不多,甚至全表扫描更便宜,于是放弃索引。
之前我接手过一个案例,业务方给一个is_deleted字段建了索引,这个字段只有 0 和 1 两个取值。查询时优化器预计会扫出一半的行,走索引需要大量回表,不如直接全表扫描。结果好端端一个索引,既占空间又拖慢写入,查询却一次都没被用到。
这说明一个扎心的事实:索引并不是建了就会被用,它只是给了优化器一个可选方案。如果这个方案不够好,优化器会毫不留情地忽略它。
3.4 回表代价被低估
二级索引的叶子节点存储的是索引列和主键值,不是完整数据行。当查询需要的数据列不在索引中时,MySQL 需要根据主键回到聚簇索引里找完整行,这个过程就是回表。
回表不是免费的,每回表一次就是一次主键查找。如果查询命中了大量二级索引记录,比如一万行,那可能要回表一万次。在数据量小的时候没什么感觉,数据量大且内存紧张时,这上万次回表产生的随机 IO 足以让查询慢到令人崩溃。
一个常见的解决思路是覆盖索引,也就是把查询需要的列都放进索引里,避免回表。但这里有个两难:覆盖索引要求索引包含更多列,而索引列越多,写入成本越高,索引占用空间也越大。于是你又回到了我们前面说的那个矛盾——查询想快,就要加索引;写入想快,就要减索引。真实业务环境里,这个平衡永远在动态摇摆。
4. 过度索引与设计失误:比没有索引更麻烦
如果说前面聊的都是索引本身的"物理缺点",那接下来要说的就是人的问题——过度设计、重复建设、粗心大意。这些问题在线上经常比索引的物理代价更致命。
4.1 冗余索引和重复索引:数据库里最隐蔽的浪费
冗余索引指的是两个索引的前缀完全相同,比如建了(user_id, order_status),又单独建了(user_id)。后者其实被前者覆盖,完全没必要。重复索引则是同一列在多个索引中反复出现,比如某个字段既在联合索引里,又有自己的独立索引。
我见过最离谱的一张表,一共 12 个字段,建了 9 个索引。其中user_id同时出现在 6 个索引里,order_id出现在 5 个索引里。这不是业务需要,而是不同开发者在不同时期各自加了自己的索引,没有做全局梳理。
这种冗余的直接后果有两点:第一,写入时要维护的 B+ 树从 9 棵变成实际有效的三四棵,白白增加成本;第二,查询时优化器需要从更多索引候选中做成本评估,评估本身也有开销。虽然这个开销通常很小,但在高并发环境下,能省则省。
MySQL 其实自带了发现工具,用起来很方便:
SELECT * FROM sys.schema_redundant_indexes;这个视图会直接列出冗余索引和重复索引,拿这个结果去和开发确认,基本一删一个准。
4.2 大字段索引与索引列过宽
索引列本身越宽,代价越高。有些开发者在 text 类型或者超长 varchar 字段上直接建索引,这样一棵 B+ 树的体积会非常夸张,每个数据页能存储的索引记录数量变少,查询时扫描的页数变多,性能反而不如不加索引。
针对长文本字段,前缀索引是个折中方案:
ALTER TABLE article ADD INDEX idx_title(title(20));只对title字段的前 20 个字符建立索引,体积大幅缩小。但前缀索引也有局限:无法用于覆盖索引,因为索引里没有完整值;排序时可能不准确,因为只取了一部分字符。所以这始终是个退而求其次的办法,能不用就别用,实在没办法再考虑。
还有一个容易被忽略的细节:单列索引的列过长会导致单页能放下的记录变少,B+ 树层数可能增加。从三层变成四层,就意味著每次索引查找多一次磁盘 IO。别小看这一次 IO,千万级数据量的表上,这可能是几十毫秒的差距。
4.3 索引维护引发锁竞争与碎片化
索引维护还会带来锁和碎片问题。InnoDB 的 B+ 树在插入时如果发生页分裂,可能需要持有相关的锁,高并发写入时锁等待概率会增加。虽然 InnoDB 在很多时候能通过乐观插入避免加锁,但分裂操作依然是最容易产生锁竞争的点之一。
碎片化问题前面提过一次,这里展开说。碎片的主要来源是随机顺序的插入和大量删除。碎片多的索引,逻辑上相邻的记录在物理页上不连续,范围扫描时读到的页更多,性能下降。解决办法是定期做ALTER TABLE ... ENGINE=InnoDB或者OPTIMIZE TABLE来重建表,但这又是一个运维窗口问题——大表做 OPTIMIZE 时锁表时间长,直接影响线上业务。
这就是索引维护的隐性成本:你不仅要为查询加速付费,还要为索引的"保养"付费。很多小团队的运维节奏根本跟不上,等索引碎片累积到一定程度,才会在某个深夜被慢查询报警打到魂飞魄散。
5. 我的取舍经验:什么时候该砍索引,怎么判断
讲了这么多缺点,不是劝大家不建索引。相反,一个设计良好的索引能带来的查询收益是不可替代的。问题是,你要清楚手上的索引哪些在创造价值,哪些只在消耗成本,然后果断砍掉后者。
5.1 先找出"吃了资源不出力"的索引
我每次做索引体检,第一件事是查索引的实际使用情况。MySQL 的 performance_schema 里记录了每个索引的读写次数,直接查这个表就能知道哪些索引几乎没被用过:
SELECT OBJECT_SCHEMA, OBJECT_NAME, INDEX_NAME, COUNT_STAR AS io_count, COUNT_READ AS read_count, COUNT_WRITE AS write_count FROM performance_schema.table_io_waits_summary_by_index_usage WHERE OBJECT_SCHEMA = 'your_db' ORDER BY COUNT_STAR DESC;COUNT_WRITE很高但COUNT_READ几乎为零的索引,基本就是纯粹的负担。正常情况下我会把这个列表拉出来和开发同学逐条确认,能删就删。
还有一个配合手段是慢查询日志加 EXPLAIN。慢慢把线上的慢 SQL 收集起来,逐条看执行计划,确认哪些索引在真正服务这些慢查询。两条交叉验证下来,索引的去留就很清楚了。
5.2 读写比例与查询模式:一个简单的判断框架
如果没有条件做全量监控,可以按一套简单的经验框架来判断:
- 一次性业务(如临时导入、一次性报表):用完就删,不要让临时索引长期驻留在表上。
- 高频写入表(日志、流水、消息记录):每个索引都要有明确且高频的查询场景支撑,否则不加。
- 高频查询表(商品、用户、配置):索引多一些可以接受,但必须检查冗余。
- 明显低区分度字段(状态、类型、是否删除):除非与其他字段组合后区分度明显提高,否则不要单独建索引。
- 大字段(text、超长 varchar):不建全字段索引,实在需要就用前缀索引。
这套框架不复杂,但很实用。遇到拿不准的情况,宁可先不加索引,等查出慢查询再加,也好过加了一堆无用的索引然后在某一天被写入性能反噬。
5.3 删索引不是小事:时机和工具都得讲究
删除索引千万别在业务高峰期直接执行。DROP INDEX在 InnoDB 里会触发表级元数据锁(MDL),虽然不同的 DDL 策略表现不同,但高并发时刻执行 DDL 永远是在刀尖上跳舞。我见过不止一次因为半夜赶工在线上直接删索引,结果把整个业务的写入全部堵死的情况。
正确做法是把 DDL 放在低峰期执行,提前在测试环境验证表结构和数据访问不受影响。如果表太大,或者目标表是核心大表,建议使用在线变更工具,比如pt-online-schema-change或 GitHub 的gh-ost。这类工具通过临时表、触发器或 binlog 同步的方式,在不停服的情况下完成索引变更和重建,风险会小很多。
删完之后也别急着收工。观察至少一到两个完整的业务周期,确认慢查询没有突增,写入延迟确实下降,再算彻底完成。如果发现删错了,重建索引也不难,但每一次加删索引都是一次折腾,所以动手之前宁可多确认几次。
我个人的习惯是:每次调整索引之前,把当前表的 DDL、索引使用统计、典型慢查询和执行计划四个东西截图存档。这样即使调挂了,也有据可查、能快速回滚。数据库这个东西,稳永远比炫重要。
索引就像生活中的收纳柜,合理的分类能让你几秒钟找到东西,但如果你什么杂物都往里塞,柜子本身就成了新的灾难源头。对 MySQL 索引的态度也一样:把它当成一种成本来管理,而不是一种奖励来堆砌。每一棵 B+ 树都要有它存在的理由,没有理由的索引,早删早轻松。