Java

写接口变慢,为什么常常不是 SQL 写法差,而是索引太多了?

写接口越来越慢时,问题常常不在 SQL 语法本身,而在表上不断累加的索引维护成本。把二级索引、页分裂、高频更新字段和事务持锁时间一起看,才更容易解释为什么查询提速了,写路径却被拖重。

  • MySQL
  • 索引
  • 写性能
  • 数据库
  • 性能排查
18 分钟阅读

很多团队一看到写接口 RT 上升,就会先盯住 SQL 文本、锁等待,或者怀疑数据库实例扛不住了。

这些方向当然都值得看,但线上还有一种很常见的退化路径:查询需求一路加码,索引一条条补上去,最后被拖慢的不是读,而是写。

典型现场通常长这样:

  • explain 看起来没什么大问题
  • 查询侧甚至比以前更快
  • 一到写高峰,接口 RT、redo、页分裂、锁持有时间一起变差
  • 真正被拖重的是 insert / update / delete 背后的索引维护

如果你正在看的是这种场景,更值得追问的往往不是“这条 SQL 写得够不够漂亮”,而是:

这次写请求,到底要替这张表维护多少个索引?这些索引今天带来的读收益,还值不值得持续付这笔写成本?

写慢读快时,先把现场分开看

你现在看到的现象建议入口为什么
写接口越来越慢,但 explain、SQL 文本和锁等待都说不出全部原因继续看本文这正是索引写放大页的入口
你还没确认是写 SQL 慢,还是整条数据库等待链在拉长先看数据库 RT / 长事务 / 连接池文章先分清层次
你主要在看查询为什么慢、索引为什么没走上先看慢 SQL 或索引失效页不要把读慢和写放大混在一起
你已经在讨论热点表、热点索引、拆分层级先看热点拆分页那篇更偏架构决策

哪些现场更像索引写放大

如果你已经确认慢点主要落在写路径,而且 explain 和 SQL 本身说不完原因,这篇就值得先看。

它不负责替你定位一次写接口慢事故,也不把读慢、锁等待和写放大混成一类;它最重要的作用,是帮你把索引维护成本、写放大、锁持有时间和事务拉长放到同一个现场里看。

第一轮先核对这些信号

  1. 先分清慢的是插入、更新还是删除,不同写操作的放大点不同。
  2. 再看高频写字段被多少索引覆盖,而不是只看索引总数。
  3. 如果写慢和锁等待、长事务一起升,优先怀疑写放大正在拖长持锁时间。
  4. 再判断这些索引是不是主要为了长尾读场景服务,却把主写路径拖重了。
  5. 最后才回头看 SQL 写法本身,不要一上来就只改语句。

一、先纠正一个误区:索引不是只对查询有影响

很多人理解索引时,只记住了它带来的好处:

  • 查询更快
  • 排序更稳
  • 覆盖索引更省回表

但索引从来不是只读资产,它同时也是写路径上的负担。

只要一条写操作会改动索引列,数据库就不只是“写一行数据”,还要做很多额外动作:

  • 更新相关二级索引
  • 维护索引页结构
  • 可能触发页分裂或页合并
  • 产生更多 redo / undo
  • 拉长事务提交和锁持有时间

也就是说,索引的真实代价不是“建一次就完事”,而是:

之后每一次 insert、update、delete,都要为这些索引继续付费。

所以索引一多,读是受益了,写却一定会更重,只是程度大小不同。

二、为什么索引太多,会先表现成“写接口慢”

因为写接口的真实成本,从来不只是 SQL 文本那一行。

一个看起来很普通的写请求,例如:

  • 插入一条订单
  • 更新一条状态
  • 批量改一批业务标记

在数据库内部,真正做的事情可能包括:

  1. 修改主记录
  2. 维护若干二级索引项
  3. 如果索引列发生变化,还要删旧索引项、插新索引项
  4. 调整 B+ 树页结构
  5. 记录 redo / undo
  6. 等事务提交、刷盘、复制传播

当索引越来越多时,这些步骤里的第 2、3、4、5 步会明显变重。

1. insert 会更重

因为每插入一行,往往就要同时插入多份索引记录。

2. update 某些字段会更重

尤其当更新的是:

  • 高频更新字段
  • 出现在多个联合索引里的字段
  • 状态位、时间戳、排序字段

这时一条 update 的代价可能远超你看到的那一行 SQL。

3. delete 也会更重

因为删除一行不只是删主记录,还要同步清理各个索引项。

三、最容易出问题的,不是“索引多”这四个字,而是“高频写字段被很多索引覆盖”

这里最值钱的判断,不是去数总索引个数,而是先问一句:

当前写接口频繁改动的字段,到底被多少个索引覆盖了?

这句话特别关键,因为:

  • 有些表索引看起来不少,但写热点字段没出现在很多索引里,问题可能没那么大
  • 有些表索引总数不算离谱,但一个高频状态字段被多个联合索引反复覆盖,写成本会非常夸张

最常见的高危字段

例如:

  • status
  • update_time
  • tenant_id
  • biz_type
  • priority
  • deleted
  • 各类排序和筛选标记

如果这些字段同时出现在多条联合索引里,而业务又高频更新它们,那么每次状态流转其实都在做一大串索引维护工作。

四、索引太多为什么经常和锁等待、长事务一起出现

这类问题容易被误判成“锁问题”或“事务问题”,但真实情况往往是:

  • 索引过多先把单次写入变慢
  • 写入变慢后,锁持有时间变长
  • 锁持有时间一长,后续事务开始排队
  • 连接池等待、线程池等待也被继续放大

于是现场最终看到的是:

  • 写接口 RT 上升
  • 锁等待增多
  • 连接池开始紧
  • 长事务数量变多

很多团队会在这里停住,认为问题就是:

  • 锁竞争
  • 长事务

但更前面的第一推动力,可能只是:

这张表为了读路径堆了太多索引,写路径已经被静悄悄拖重了。

所以索引过多这类问题,经常不是单独出现,而是会和数据库等待链连在一起。

五、一个特别容易被忽略的矛盾:查询确实快了,但总系统成本反而更高了

这是这类问题最真实也最麻烦的地方。

加索引通常不是乱加的,很多时候它确实解决过问题:

  • 某个列表查询从 3s 降到 50ms
  • 某个排序不用 filesort 了
  • 某个回查终于走索引了

所以团队很容易形成一种默认判断:

  • 既然每个索引当初都解决过问题,那现在慢肯定不是索引的问题

但数据库设计里,真正要看的不是“某条查询局部值不值”,而是:

这个索引带来的查询收益,是否还值得它在每次写入上持续收的成本?

很多表跑着跑着就会出现这种反转:

  • 读侧收益已经边际递减
  • 写侧成本却在持续累加
  • 最后系统整体吞吐变差

常见高危场景

例如:

  • 审批流、订单流、状态机类表
  • 高频更新状态和时间戳的业务表
  • 一边要在线写入,一边还想兼顾大量后台筛选查询
  • 把报表、管理后台、运营筛选需求也全部堆在在线主表上

这类系统最容易把索引从“性能优化工具”用成“写放大制造器”。

六、一个更接近现场的判断顺序:写慢时先看写放大

以后再碰到“写接口慢”,我更建议按下面顺序判断。

第 1 步:先确认慢的是哪类写

重点分清:

  • insert
  • update
  • delete
  • 还是某些更新字段的写特别慢

因为不同写类型,对索引的敏感点不一样。

第 2 步:再看慢字段是否被很多索引覆盖

重点确认:

  • 高频更新列出现在哪些联合索引里
  • 这些索引是否真的都在用
  • 有没有大量“顺手加上去”的长联合索引

这一步经常比盯执行计划更值钱。

第 3 步:看写慢是不是和锁等待、长事务一起出现

如果同步看到:

  • 锁等待上涨
  • 活跃事务时间拉长
  • 连接池等待上升

不要只把它理解成锁问题,还要继续往前追问:

  • 为什么这条写会持锁这么久?

第 4 步:看索引是否为读侧长尾场景付出了过高代价

重点判断:

  • 这些索引是核心链路高频查询在用,还是后台长尾查询在用
  • 是否有多个近似索引功能重叠
  • 是否为了少数排序 / 筛选场景,把在线主写表压得过重

第 5 步:最后才回到 SQL 写法本身

例如:

  • 批量写是否拆批
  • 更新条件是否过宽
  • 是否存在无谓回写
  • 是否每次都在更新本可不更新的字段

这一步当然要看,但不要把它当唯一入口。

七、一个典型案例:为什么订单状态更新突然越来越慢

假设有一张订单主表,最开始只有几个基础索引,写入一直很稳。

后来随着业务发展,团队不断补索引:

  • 给管理后台按状态 + 时间筛选加索引
  • 给运营导出按租户 + 状态 + 更新时间加索引
  • 给列表页按用户 + 状态 + 更新时间加索引
  • 给对账任务按业务类型 + 状态 + 创建时间再加索引

表面看,每个索引都能解释。

后来出现的现象

  • 订单状态流转接口 RT 从 40ms 涨到 300ms 以上
  • 高峰期锁等待明显增多
  • explain 看查询大多没问题
  • 团队第一反应是热点行竞争

往下翻证据后,通常会看到这些信号

发现:

  • 高频更新字段 statusupdate_time 出现在多条联合索引里
  • 每次状态推进都要维护多份索引记录
  • 写入成本变重后,事务持锁时间自然变长
  • 再叠加热点订单流转,高峰期等待链就被放大了

最后的真实结论

这次问题并不只是“订单热点更新”,而是:

高频更新字段被过多索引覆盖 -> 单次 update 写放大 -> 持锁时间变长 -> 锁等待和写接口 RT 一起上升

这类案例非常典型,也特别能说明:

索引不是只影响读,它会在你最容易忽略的地方一点点拖坏写路径。

八、关键误判

误判 1:查询快了,说明索引只会带来正收益

错。

查询收益和写入成本必须一起算,不能只看一边。

误判 2:写接口慢,只要 explain 没问题就和索引无关

错。

explain 主要解释查询访问路径,并不能直接告诉你索引维护成本有多高。

误判 3:索引总数不算特别夸张,就一定没问题

不一定。

真正关键的是高频写字段是否被多条索引覆盖,而不是单纯数总量。

误判 4:锁等待增多,就一定先怪并发和热点

很多时候热点只是放大器,第一推动力是单次写已经因为索引维护变重了。

误判 5:后台筛选需求、报表需求直接堆索引到主写表,总是值得的

这类做法最容易在业务增长后把在线写路径拖坏。

九、现场里最容易追问的 4 个点

1. 怎么快速判断写慢是不是索引维护拖出来的?

不要先数总索引个数,先看高频写字段出现在多少条索引里,再对照写慢时有没有 redo 压力、页分裂、锁等待和事务时长一起抬头。能对上的越多,越值得优先怀疑写放大。

2. 索引多就一定该删吗?

不一定。更关键的是这批索引是不是还在服务核心读流量,还是只是留给低频筛选、导出、报表。删索引前最好先核对真实访问路径和收益,别把核心查询一并砍掉。

3. 更新不改索引列,是不是就不用担心这类问题?

风险会小不少,但不是完全没影响。整表写压力、页结构维护、事务提交和资源竞争仍然会传导到这类写请求上,只是最敏感的还是那些直接改动索引列的更新。

4. 真要治理,优先动哪几类地方?

通常先看三类:重叠索引、为长尾后台场景服务的联合索引、以及把 statusupdate_time 这类高频更新字段反复覆盖的索引。确认收益低以后,再考虑迁移查询、拆表或拆冷热字段。

十、如果排到这里,基本已经在怀疑写放大了

所属专题

  • 数据库与 MySQL 性能问题

我通常会顺手再对这几篇

如果写慢已经开始拖到别的层

真往下拆时,我一般先做这几步

  1. 先把这篇里的“索引带来查询收益”和“索引制造写放大”放在一张表里重新算一遍,别还停在“查询变快了就算赚”这个视角里。
  2. 如果你还停留在查询视角,再接着看 MySQL 索引失效的 8 个常见原因
  3. 如果写慢已经伴随锁等待和事务拉长,就顺着 锁等待、热点行、事务竞争把接口拖慢时,应该先查什么?事务执行时间过长,真正拖慢系统的往往不只是数据库 往下拆。
  4. 如果写慢已经进一步把连接池和接口 RT 一起拖差,就切到连接池相关文章,别继续只围着索引本身打转。

十一、最后总结:写接口慢时,先把“索引维护账单”算清楚

这类现场最容易误导人的地方,是查询侧的收益还在,于是团队很难第一时间怀疑索引本身。

但如果你已经看到写 RT、锁等待、事务时长一起变差,就值得回头把这笔账重新算一遍:

哪些索引还在创造稳定收益,哪些索引只是把主写路径越拖越重?

把高频写字段覆盖情况、索引真实使用率和长尾查询需求一起摊开看,很多看似像“数据库顶不住”的问题,最后都会落到索引设计失衡,而不是 SQL 语法细节。