加索引写入TPS掉到1/3?订单表慢查询的五个反直觉真相

简介: 以五个真实排障案例拆解MySQL慢查询的反直觉真相:type=index不等于快、索引会拖垮写入、LIMIT救不了深分页、大JOIN未必优于小查询、慢SQL常常是受害者不是凶手。主张收到慢查询先证明瓶颈,动索引前先做减法。

大家好,我是数据库小学妹👋 我踩过的坑,你别再踩。

周一刚坐下,开发小哥就甩来一条慢查询:“这表索引都建了,怎么还这么慢?”我看了眼他发来的SQL,第一反应也是老毛病:加索引。话到嘴边又咽了回去。就是加索引这个习惯,让我翻过太多次车。今天这篇,就聊聊我踩过之后才想明白的五件事,每一件都挺反直觉。

五个真相,先看总表

真相 反直觉在哪 一句话做法
EXPLAIN的type会骗人 type=index也是全扫 别只看key,看type和rows
索引不是免费的 读快了,写可能被拖垮 加之前先查有没有冗余
LIMIT救不了深分页 取20行,开销在前面10万行 用keyset分页
大JOIN不一定比小查询快 拆开跑反而更快 先看驱动表选对没
慢的不一定是凶手 单跑快、线上慢,锅常在库外 复盘那一刻的实例状态

这五条不是背的,全在生产环境里磨出来的。下面逐个拆。

一、EXPLAIN的type会骗人:type=index也是全扫

先讲个自己丢人的事。有段时间我判断SQL慢不慢,只看key有没有值。key不为空,我就觉得走了索引,问题不在我。后来被一条SQL打脸。

-- 查最新10条带售后标记的订单
SELECT id, order_no, pay_time
FROM t_order
WHERE refund_flag = 1
ORDER BY pay_time DESC
LIMIT 10;

t_order三百多万行,带售后标记的不到1%。表上只有idx_pay_time,没建refund_flag的索引。EXPLAIN结果:

type key rows Extra
index idx_pay_time 3240000 Using where

key有值吧?type还不是ALL。我第一次看,觉得没问题,实际慢到怀疑人生。

要解释它为什么慢,得先看清InnoDB的索引结构。主键索引是聚簇索引,叶子节点直接存整行数据;二级索引是另一棵B+树,叶子节点只存主键值。走二级索引取数,必须拿主键值再去聚簇索引里找一遍,这一步叫回表,本质是又一次B+树查找,通常要走两三层。

这条SQL的症结就在type=index上,它代表全索引扫描。优化器为了蹭pay_time的有序性,选了idx_pay_time从最新一条开始往下捋,每捋一条就回表验证一次refund_flag。带售后标记的订单埋在一堆正常订单下面,它得一路回表翻,翻到攒够10条为止。三万条里才出一条,多数回表落空。这不是定位,是扫街。

EXPLAIN的type是有高低的,从差到好依次是:

ALL < index < range < ref < eq_ref < const

ALL是全表扫,index是全索引扫,两者本质都靠遍历,区别只是扫聚簇索引还是扫二级索引。真正算得上“用索引定位”的,是range、ref往上的级别。所以看执行计划,别只看key有没有值。key有值、type=index、rows接近全表,一样是灾难。type和rows才是真信号。

打那以后我养成了习惯:EXPLAIN先看type和rows,再看Extra里有没有Using filesort、Using temporary。

二、索引不是免费的:加索引把写入拖垮过

第二个坑,学费更贵。

之前有个订单服务,读写比大概7:3,不算极端。开发为了一条报表,一口气给t_order加了三个二级索引,想让查询走覆盖索引。查询确实快了,从800ms降到120ms,我当时还挺高兴。三天后主库开始不对劲,写入往下掉,从库延迟从几百毫秒涨到几十秒,磁盘IO使用率天天顶着。我一度怀疑是机器问题,查了一圈,最后把目光停在刚加的三个索引上。

要算清这笔账,得看一次写入到底动了多少东西。InnoDB里每个二级索引都是一棵独立的B+树。插入一行,主键索引写一次,三个二级索引各写一次,等于四棵树都要动。如果更新的列恰好是某个二级索引的一部分,InnoDB不会原地改,而是先给旧条目打删除标记、再插入新条目,这一个索引就要写两遍。更要命的是,二级索引的叶子页不按主键顺序排列,插入基本都是随机写,页写满了还得分裂。change buffer能暂缓一部分变更,可buffer pool空间有限,扛不住的时候照样要落盘刷脏。写放大就是这么来的。

对照压测结果很直观:

场景 写TPS P99延迟
加索引前 ~5200 45ms
加3个二级索引后 ~1800 265ms

读快了680ms,写吞吐掉到三分之一。对一个7:3读写的服务,这笔账算不过来。

后来怎么处理的?查冗余,做减法:

-- 找出长期没被用到的索引
SELECT * FROM sys.schema_unused_indexes;

-- 确认没有业务引用后,再删
ALTER TABLE t_order DROP INDEX idx_report_xxx;

sys.schema_unused_indexes是MySQL 5.7起自带的统计视图,读performance_schema里的索引使用记录,筛出长期没被访问的索引。版本更老没有它,就自己拿information_schema.statistics跟慢日志手工比对。

删完再压测,写入缓回来了。那条报表查询改走另一条更合适的联合索引,也没慢到哪去。用不用这索引,得算总账。查快了,写入的利息交不交得起。

三、LIMIT救不了深分页

第三个坑,在分页接口上。

运营后台有个订单流水列表,翻到后面就卡。第一页30ms,翻到一万页直接两秒起步。我一开始也以为是表太大,直到认真看了那条SQL:

-- 第10001页,每页20条
SELECT * FROM t_order_log
ORDER BY id DESC
LIMIT 200000, 20;

LIMIT 200000,20看着是取20条,坑却埋在偏移量上。MySQL执行时得把前二十万行一条条数过去,再丢掉。这不是MySQL偷懒,是B+树压根做不到“跳过N行直接定位”,它只支持从某个位置开始顺序遍历叶子链表。要第20万行之后的数据,只能从第一行数起。真正贵的从来不是那个20,是前面那个200000。

如果ORDER BY的列还没有索引支撑,会更糟。MySQL先对所有候选行做一次filesort,结果放内存排序缓冲,数据量大就溢到磁盘用归并排序,排完再数偏移量。

解法是换一种分页姿势。排序字段单调的话,用keyset分页:

-- 记下上一页最后一条id,下一页从这里开始
SELECT * FROM t_order_log
WHERE id < :last_id
ORDER BY id DESC
LIMIT 20;

为什么快?last_id有主键索引,MySQL能沿B+树二分定位到这一条,然后只顺序读后面的20条叶子。执行代价从“数N行”变成“一次定位加读20行”。

业务复杂改不了,还有个通用套路叫延迟关联:先在子查询里只查主键、分好页,再JOIN回原表取整行。这样深翻页时那些大字段不会跟着前二十万行一起被反复搬运,回表次数也被压到最少。

运营后台那种深翻页,后来我们直接限制了最大翻页深度,再深就提示用时间筛选。用户不会真的翻到十万页,但爬虫会。别让分页接口成了慢查询制造机。

四、大JOIN拆成小查询,反而更快

第四个真相,当年颠覆我的认知。

一张报表SQL,JOIN了五张表,跑30秒。我第一反应是优化JOIN:加索引、调buffer,都试了没起色。最后是个老DBA给我一句话:“拆了它。”我当时不信,查询次数变多了怎么可能更快?试完沉默了。拆成三条单表查询,加应用层组装,整页300ms。

要讲清楚为什么,得从MySQL怎么执行JOIN说起。它用的是Nested-Loop Join:先挑一张表当驱动表,拿它每一行去另一张表里找匹配。被驱动表要是能在JOIN列上用索引,一次匹配就是一次索引查找;要是没索引,就退化成都拿内存里的join buffer跟整张表比对,buffer装不下还得把被驱动表反复读好几遍。驱动表越大,循环次数越多,中间还会攒下大量中间行,超过阈值就落临时表,列再宽一点就溢到磁盘。

第一件事,是看驱动表选对没有。EXPLAIN结果的第一行,就是驱动表:

-- EXPLAIN结果
id | table      | type | rows
1  | t_product  | ALL  | 1200000   -- 驱动表选了百万行的商品表
1  | t_order    | ref  | 50

驱动表应该选过滤后行数最少的那张。那张报表真正的时间窗里只有5000笔订单,先拿订单主表当入口,把5000个order_id查出来,再去查明细、商品、商家,每步都走索引,反而快。优化器会选错驱动表,多半是统计信息不准,或者过滤条件写在后面关联的表上,它先把大表放进了循环。

拆查询也不是无脑拆。小表JOIN、两边过滤后都很小,一条JOIN没问题。怕的是大表、大范围、多层JOIN叠在一起。碰到这种,先看EXPLAIN第一行,驱动表不对就先纠正,纠正不了再考虑拆,或者用hint显式指定驱动表兜底。

五、慢查询的锅,常常不在SQL

最后一个,最气人也最容易被忽略。

开发拿着一条SQL来找我:“测试库5ms,生产慢日志天天有它,你们库是不是有问题?”我单独一跑,确实5ms,执行计划干干净净。可慢日志不会说谎。后来我学乖了:慢查询要连“那一刻”一起看。生产不是测试库,是几十个业务挤在一起的,那条SQL慢的时候,同一秒里可能正发生这些事:

  • 别的业务在跑凌晨的数据批处理,把磁盘IO打满
  • 一个大事务攥着行锁不放,后面一堆等锁的查询全排队
  • buffer pool被别的扫描挤占,热点数据被踢出去,缓存命中率往下掉
  • 更隐蔽的一种:一条没提交的事务握着表的元数据锁(MDL),整张表的查询和DDL全被堵在门口

这条慢查询很可能只是受害者,凶手在旁边。我当时用系统表抓了一次现场:

-- 看那段时间到底谁在跑、跑了多久、卡在什么状态
SELECT id, user, time, state, left(info, 80)
FROM information_schema.processlist
WHERE time > 5
ORDER BY time DESC;

结果排在最前面的,是一条凌晨批处理的UPDATE,一跑十几分钟,把整个实例的IO拖垮。后面等锁的查询,State一水是Waiting for table metadata lock,光看单条SQL永远查不出名堂。
后来再碰这类问题,我会补一张锁等待视图,把谁在等谁、持锁跑了多久、执行的是什么SQL一次拉全:

-- 看谁在等谁、持锁事务跑了多久、执行的是什么SQL
-- MySQL 8.0+ 用 performance_schema.data_lock_waits
-- MySQL 5.7 用 sys.innodb_lock_waits

持锁方、等待方,一摆出来就能对上。要是卡的是Waiting for table metadata lock,那是另一条链路。MDL不归前面两个InnoDB锁视图管,得去performance_schema.metadata_locks或者sys.schema_table_lock_waits里找,别在行锁视图里空转。

所以我现在排查慢查询的顺序是:先看执行计划,确认SQL本身没毛病。再看慢日志的时间点,那个时刻实例整体什么状态。有没有锁等待、IO打满、大事务。SQL、数据、环境三样一起看,才能定位真凶。

避坑清单

上面几个坑,每个都是拿真金白银换的。收尾前,把最容易再犯的三件事唠叨一遍。

第一件,看执行计划别只盯着key有没有值。type=index名字里带index,其实是把整棵索引树从头扫到尾,跟全表扫是难兄难弟。我第一次看明白这点,是在白折腾了一整天之后,不冤。

第二件,加索引前先做减法。我在那张订单表上吃过亏,当时光顾着加,忘了翻翻老索引还在不在。sys.schema_unused_indexes一查,好几个建了就没被碰过。删掉之后,写入才缓过劲来。

第三件,慢查询别急着给SQL定罪。单跑5ms、线上天天上榜的SQL,我最后查出来,问题大多不在SQL本身,是大事务攥着锁,是批处理把IO打满,SQL常常只是背锅的。

写在最后

说了这么多,其实就一个意思:收到慢查询,第一反应别是加索引。先让执行计划说话,再看那段时间实例的状态,最后才轮到动索引。动的时候,先想能不能删,再想能不能加。

索引不是银弹。你手里的执行计划,才是真相。

你最近处理过最“反直觉”的一次慢查询是什么?是加索引加出来的,还是根子根本不在SQL上?评论区聊聊。

我是数据库小学妹,帮你少走弯路少踩坑,咱们下篇见👋

相关文章
|
1月前
|
SQL 人工智能 运维
3个月AI Agent运维实测:慢SQL它管,根因还得我上
以三个月实测的视角,划清AI Agent自治运维的真实能力边界:巡检、慢SQL发现等重复活已可替代,复杂根因、变更审批、数据兜底仍需人把关,探讨DBA角色从救火队员向定规则、把关人的转型。
|
1月前
|
SQL 关系型数据库 MySQL
死锁报错看了三遍没看懂?我拆给你看(附定位SQL)
从一次真实死锁现场切入,讲清行锁、间隙锁、插入意向锁的加锁机制与死锁形成原理,手把手教你怎么用show engine innodb status和information_schema定位死锁,并给出加锁顺序设计等避坑清单。
|
1月前
|
存储 关系型数据库 MySQL
面试总问的B+树,我把磁盘IO到底怎么算的讲清楚了
从磁盘IO的底层约束讲起,逐层对比哈希、二叉、红黑树、B树与B+树,讲清MySQL为什么选B+树(矮胖树、顺序IO、范围查询、查询稳定),并用这套底层理解反过来指导覆盖索引、前缀索引、最左前缀等日常索引设计。
|
1月前
|
存储 SQL 容灾
共享存储集群 vs 分布式多副本:同城双活两条技术路线怎么选?
同城双活正在成为金融、政务等核心系统的容灾标配——RPO=0、RTO<30秒。但真正的落地远不止“两个机房各放一套数据库”那么简单。网络延迟的容忍度、脑裂预防机制、同步复制的性能代价、以及故障切换后的数据回滚,每一个环节都可能成为“最后一公里”的绊脚石。本文从容灾架构演进入手,拆解同城双活的核心技术原理、关键挑战与应对方案,并结合同城双中心方案及实测数据进行深度解析。
|
1月前
|
SQL 存储 关系型数据库
分区裁剪失效、DDL卡死、元数据爆炸:分区表的3个真实代价
很多人觉得分区表是“轻量级分库分表”——数据分开放、查询只扫一个区、过期数据直接DROP分区,听起来很完美。但分区表有严格的适用边界和隐藏代价:分区键选错导致所有查询都扫全部分区、分区数量过多导致DDL巨慢、跨分区查询比普通表还慢……本文从分区表的核心原理出发,拆解4种分区类型、3个真实踩坑场景,以及分区表与分库分表的本质区别,帮你一次性搞清楚到底该不该用。
|
1月前
|
SQL 运维 关系型数据库
2026年分布式数据库有哪些主流选择?先评估这5个维度再决定
分布式数据库选型,99%的文章在列表格比参数——但真正的决策关键不在厂商PPT里,在上线后的真实运维里。本文从网络延迟容忍度、SQL兼容性验证、在线扩容能力、全局索引代价、运维工具链成熟度5个维度出发,给出可落地的评估方法和决策建议,帮助你在选型阶段避开那些“只有上线后才会发现”的坑。
|
1月前
|
JSON 关系型数据库 MySQL
MySQL 5.7升级到8.0之后,JSON查询的性能瓶颈真的解决了吗?
MySQL 5.7引入原生JSON类型,8.0支持多值索引,至今已近十年。但大量开发人员仍在把JSON当“万能兜底字段”——不管什么数据都往里塞,等查询慢到怀疑人生才想起来排查。本文拆解JSON字段查询的5个高频踩坑场景,给出虚拟列索引、多值索引等正确的优化方案。
|
1月前
|
SQL 运维 算法
订单表上亿行,我按用户ID拆成128片之后怎么样了
从单表几千万行慢查询的痛点出发,讲清垂直拆分与水平拆分的区别、分片键怎么选、分片算法(hash取模/range/一致性hash)怎么权衡,以及分库分表带来的分布式ID、跨片查询、分布式事务等问题,给出避免过度拆分的避坑清单。
|
1月前
|
SQL 关系型数据库 MySQL
别再盯着EXPLAIN的rows列了,8.0.18之后有更好的选择
EXPLAIN是DBA最常用的工具之一,但大多数人还在看type、rows、Extra这些传统字段——然后靠经验猜。MySQL 8.0.18开始引入的EXPLAIN ANALYZE,直接把实际执行时间和行数输出给你看,不用猜了。本文对比传统EXPLAIN和EXPLAIN ANALYZE的差异,展示如何用新工具把执行计划分析这件事从“猜”变成“看”。
|
1月前
|
SQL Java 数据库连接
1万行插入13秒到0.9秒:ORM批量插入只差一个参数
从一次列表接口慢的排障讲起,发现2000多条一模一样的N+1查询。文章拆开ORM生成慢SQL的三类典型病:N+1懒加载、逐条批量插入(只差一个rewriteBatchedStatements参数)、隐式转换让索引白建。给出JOIN/批量IN/@BatchSize的取舍、MyBatis与JPA各自的修法,以及用performance_schema按SQL指纹抓N+1、测试环境打印真实SQL的协作办法。