写接口变慢,为什么常常不是 SQL 写法差,而是索引太多了?
写接口越来越慢时,问题常常不在 SQL 语法本身,而在表上不断累加的索引维护成本。把二级索引、页分裂、高频更新字段和事务持锁时间一起看,才更容易解释为什么查询提速了,写路径却被拖重。
很多团队一看到写接口 RT 上升,就会先盯住 SQL 文本、锁等待,或者怀疑数据库实例扛不住了。
这些方向当然都值得看,但线上还有一种很常见的退化路径:查询需求一路加码,索引一条条补上去,最后被拖慢的不是读,而是写。
典型现场通常长这样:
- explain 看起来没什么大问题
- 查询侧甚至比以前更快
- 一到写高峰,接口 RT、redo、页分裂、锁持有时间一起变差
- 真正被拖重的是 insert / update / delete 背后的索引维护
如果你正在看的是这种场景,更值得追问的往往不是“这条 SQL 写得够不够漂亮”,而是:
这次写请求,到底要替这张表维护多少个索引?这些索引今天带来的读收益,还值不值得持续付这笔写成本?
写慢读快时,先把现场分开看
| 你现在看到的现象 | 建议入口 | 为什么 |
|---|---|---|
| 写接口越来越慢,但 explain、SQL 文本和锁等待都说不出全部原因 | 继续看本文 | 这正是索引写放大页的入口 |
| 你还没确认是写 SQL 慢,还是整条数据库等待链在拉长 | 先看数据库 RT / 长事务 / 连接池文章 | 先分清层次 |
| 你主要在看查询为什么慢、索引为什么没走上 | 先看慢 SQL 或索引失效页 | 不要把读慢和写放大混在一起 |
| 你已经在讨论热点表、热点索引、拆分层级 | 先看热点拆分页 | 那篇更偏架构决策 |
哪些现场更像索引写放大
如果你已经确认慢点主要落在写路径,而且 explain 和 SQL 本身说不完原因,这篇就值得先看。
它不负责替你定位一次写接口慢事故,也不把读慢、锁等待和写放大混成一类;它最重要的作用,是帮你把索引维护成本、写放大、锁持有时间和事务拉长放到同一个现场里看。
第一轮先核对这些信号
- 先分清慢的是插入、更新还是删除,不同写操作的放大点不同。
- 再看高频写字段被多少索引覆盖,而不是只看索引总数。
- 如果写慢和锁等待、长事务一起升,优先怀疑写放大正在拖长持锁时间。
- 再判断这些索引是不是主要为了长尾读场景服务,却把主写路径拖重了。
- 最后才回头看 SQL 写法本身,不要一上来就只改语句。
一、先纠正一个误区:索引不是只对查询有影响
很多人理解索引时,只记住了它带来的好处:
- 查询更快
- 排序更稳
- 覆盖索引更省回表
但索引从来不是只读资产,它同时也是写路径上的负担。
只要一条写操作会改动索引列,数据库就不只是“写一行数据”,还要做很多额外动作:
- 更新相关二级索引
- 维护索引页结构
- 可能触发页分裂或页合并
- 产生更多 redo / undo
- 拉长事务提交和锁持有时间
也就是说,索引的真实代价不是“建一次就完事”,而是:
之后每一次 insert、update、delete,都要为这些索引继续付费。
所以索引一多,读是受益了,写却一定会更重,只是程度大小不同。
二、为什么索引太多,会先表现成“写接口慢”
因为写接口的真实成本,从来不只是 SQL 文本那一行。
一个看起来很普通的写请求,例如:
- 插入一条订单
- 更新一条状态
- 批量改一批业务标记
在数据库内部,真正做的事情可能包括:
- 修改主记录
- 维护若干二级索引项
- 如果索引列发生变化,还要删旧索引项、插新索引项
- 调整 B+ 树页结构
- 记录 redo / undo
- 等事务提交、刷盘、复制传播
当索引越来越多时,这些步骤里的第 2、3、4、5 步会明显变重。
1. insert 会更重
因为每插入一行,往往就要同时插入多份索引记录。
2. update 某些字段会更重
尤其当更新的是:
- 高频更新字段
- 出现在多个联合索引里的字段
- 状态位、时间戳、排序字段
这时一条 update 的代价可能远超你看到的那一行 SQL。
3. delete 也会更重
因为删除一行不只是删主记录,还要同步清理各个索引项。
三、最容易出问题的,不是“索引多”这四个字,而是“高频写字段被很多索引覆盖”
这里最值钱的判断,不是去数总索引个数,而是先问一句:
当前写接口频繁改动的字段,到底被多少个索引覆盖了?
这句话特别关键,因为:
- 有些表索引看起来不少,但写热点字段没出现在很多索引里,问题可能没那么大
- 有些表索引总数不算离谱,但一个高频状态字段被多个联合索引反复覆盖,写成本会非常夸张
最常见的高危字段
例如:
statusupdate_timetenant_idbiz_typeprioritydeleted- 各类排序和筛选标记
如果这些字段同时出现在多条联合索引里,而业务又高频更新它们,那么每次状态流转其实都在做一大串索引维护工作。
四、索引太多为什么经常和锁等待、长事务一起出现
这类问题容易被误判成“锁问题”或“事务问题”,但真实情况往往是:
- 索引过多先把单次写入变慢
- 写入变慢后,锁持有时间变长
- 锁持有时间一长,后续事务开始排队
- 连接池等待、线程池等待也被继续放大
于是现场最终看到的是:
- 写接口 RT 上升
- 锁等待增多
- 连接池开始紧
- 长事务数量变多
很多团队会在这里停住,认为问题就是:
- 锁竞争
- 长事务
但更前面的第一推动力,可能只是:
这张表为了读路径堆了太多索引,写路径已经被静悄悄拖重了。
所以索引过多这类问题,经常不是单独出现,而是会和数据库等待链连在一起。
五、一个特别容易被忽略的矛盾:查询确实快了,但总系统成本反而更高了
这是这类问题最真实也最麻烦的地方。
加索引通常不是乱加的,很多时候它确实解决过问题:
- 某个列表查询从 3s 降到 50ms
- 某个排序不用 filesort 了
- 某个回查终于走索引了
所以团队很容易形成一种默认判断:
- 既然每个索引当初都解决过问题,那现在慢肯定不是索引的问题
但数据库设计里,真正要看的不是“某条查询局部值不值”,而是:
这个索引带来的查询收益,是否还值得它在每次写入上持续收的成本?
很多表跑着跑着就会出现这种反转:
- 读侧收益已经边际递减
- 写侧成本却在持续累加
- 最后系统整体吞吐变差
常见高危场景
例如:
- 审批流、订单流、状态机类表
- 高频更新状态和时间戳的业务表
- 一边要在线写入,一边还想兼顾大量后台筛选查询
- 把报表、管理后台、运营筛选需求也全部堆在在线主表上
这类系统最容易把索引从“性能优化工具”用成“写放大制造器”。
六、一个更接近现场的判断顺序:写慢时先看写放大
以后再碰到“写接口慢”,我更建议按下面顺序判断。
第 1 步:先确认慢的是哪类写
重点分清:
insert慢update慢delete慢- 还是某些更新字段的写特别慢
因为不同写类型,对索引的敏感点不一样。
第 2 步:再看慢字段是否被很多索引覆盖
重点确认:
- 高频更新列出现在哪些联合索引里
- 这些索引是否真的都在用
- 有没有大量“顺手加上去”的长联合索引
这一步经常比盯执行计划更值钱。
第 3 步:看写慢是不是和锁等待、长事务一起出现
如果同步看到:
- 锁等待上涨
- 活跃事务时间拉长
- 连接池等待上升
不要只把它理解成锁问题,还要继续往前追问:
- 为什么这条写会持锁这么久?
第 4 步:看索引是否为读侧长尾场景付出了过高代价
重点判断:
- 这些索引是核心链路高频查询在用,还是后台长尾查询在用
- 是否有多个近似索引功能重叠
- 是否为了少数排序 / 筛选场景,把在线主写表压得过重
第 5 步:最后才回到 SQL 写法本身
例如:
- 批量写是否拆批
- 更新条件是否过宽
- 是否存在无谓回写
- 是否每次都在更新本可不更新的字段
这一步当然要看,但不要把它当唯一入口。
七、一个典型案例:为什么订单状态更新突然越来越慢
假设有一张订单主表,最开始只有几个基础索引,写入一直很稳。
后来随着业务发展,团队不断补索引:
- 给管理后台按状态 + 时间筛选加索引
- 给运营导出按租户 + 状态 + 更新时间加索引
- 给列表页按用户 + 状态 + 更新时间加索引
- 给对账任务按业务类型 + 状态 + 创建时间再加索引
表面看,每个索引都能解释。
后来出现的现象
- 订单状态流转接口 RT 从 40ms 涨到 300ms 以上
- 高峰期锁等待明显增多
- explain 看查询大多没问题
- 团队第一反应是热点行竞争
往下翻证据后,通常会看到这些信号
发现:
- 高频更新字段
status、update_time出现在多条联合索引里 - 每次状态推进都要维护多份索引记录
- 写入成本变重后,事务持锁时间自然变长
- 再叠加热点订单流转,高峰期等待链就被放大了
最后的真实结论
这次问题并不只是“订单热点更新”,而是:
高频更新字段被过多索引覆盖 -> 单次 update 写放大 -> 持锁时间变长 -> 锁等待和写接口 RT 一起上升
这类案例非常典型,也特别能说明:
索引不是只影响读,它会在你最容易忽略的地方一点点拖坏写路径。
八、关键误判
误判 1:查询快了,说明索引只会带来正收益
错。
查询收益和写入成本必须一起算,不能只看一边。
误判 2:写接口慢,只要 explain 没问题就和索引无关
错。
explain 主要解释查询访问路径,并不能直接告诉你索引维护成本有多高。
误判 3:索引总数不算特别夸张,就一定没问题
不一定。
真正关键的是高频写字段是否被多条索引覆盖,而不是单纯数总量。
误判 4:锁等待增多,就一定先怪并发和热点
很多时候热点只是放大器,第一推动力是单次写已经因为索引维护变重了。
误判 5:后台筛选需求、报表需求直接堆索引到主写表,总是值得的
这类做法最容易在业务增长后把在线写路径拖坏。
九、现场里最容易追问的 4 个点
1. 怎么快速判断写慢是不是索引维护拖出来的?
不要先数总索引个数,先看高频写字段出现在多少条索引里,再对照写慢时有没有 redo 压力、页分裂、锁等待和事务时长一起抬头。能对上的越多,越值得优先怀疑写放大。
2. 索引多就一定该删吗?
不一定。更关键的是这批索引是不是还在服务核心读流量,还是只是留给低频筛选、导出、报表。删索引前最好先核对真实访问路径和收益,别把核心查询一并砍掉。
3. 更新不改索引列,是不是就不用担心这类问题?
风险会小不少,但不是完全没影响。整表写压力、页结构维护、事务提交和资源竞争仍然会传导到这类写请求上,只是最敏感的还是那些直接改动索引列的更新。
4. 真要治理,优先动哪几类地方?
通常先看三类:重叠索引、为长尾后台场景服务的联合索引、以及把 status、update_time 这类高频更新字段反复覆盖的索引。确认收益低以后,再考虑迁移查询、拆表或拆冷热字段。
十、如果排到这里,基本已经在怀疑写放大了
所属专题
- 数据库与 MySQL 性能问题
我通常会顺手再对这几篇
如果写慢已经开始拖到别的层
真往下拆时,我一般先做这几步
- 先把这篇里的“索引带来查询收益”和“索引制造写放大”放在一张表里重新算一遍,别还停在“查询变快了就算赚”这个视角里。
- 如果你还停留在查询视角,再接着看 MySQL 索引失效的 8 个常见原因。
- 如果写慢已经伴随锁等待和事务拉长,就顺着 锁等待、热点行、事务竞争把接口拖慢时,应该先查什么? 和 事务执行时间过长,真正拖慢系统的往往不只是数据库 往下拆。
- 如果写慢已经进一步把连接池和接口 RT 一起拖差,就切到连接池相关文章,别继续只围着索引本身打转。
十一、最后总结:写接口慢时,先把“索引维护账单”算清楚
这类现场最容易误导人的地方,是查询侧的收益还在,于是团队很难第一时间怀疑索引本身。
但如果你已经看到写 RT、锁等待、事务时长一起变差,就值得回头把这笔账重新算一遍:
哪些索引还在创造稳定收益,哪些索引只是把主写路径越拖越重?
把高频写字段覆盖情况、索引真实使用率和长尾查询需求一起摊开看,很多看似像“数据库顶不住”的问题,最后都会落到索引设计失衡,而不是 SQL 语法细节。