Java

我怎么沿着一条慢 SQL,把“像索引问题”一路查到真实瓶颈

慢 SQL 排查最容易一上来就盯 explain,然后把所有问题都讲成索引问题。这篇不做总纲,只沿一条真实排查线往下走:从接口变慢开始,先确认是不是这条 SQL,为什么 explain 看起来还行却还是慢,最后怎么把瓶颈坐实到锁等待和连接占用上。

  • MySQL
  • 慢查询
  • SQL优化
  • 数据库
14 分钟阅读

慢 SQL 这件事,最容易把人带进一个很熟悉的动作:先把 SQL 拿出来,跑一遍 EXPLAIN,然后开始盯 typerowskey

这个动作当然不算错,但我后来越来越警惕一件事:很多“慢 SQL 排查”其实根本不是被执行计划卡住,而是被我们太早把问题定性成“索引问题”。

这篇不打算再把慢查询排查讲成一套大而全总纲。我只想讲一条我自己觉得很典型的线:

  • 一个订单列表接口在高峰期突然变慢
  • 我们第一反应觉得是分页 SQL 退化了
  • EXPLAIN 看起来也不能说太差
  • 但线上就是慢,而且越高峰越明显
  • 最后真正坐实的瓶颈,不是执行计划本身,而是锁等待把连接占用时间拖长了

我很喜欢这条线,因为它几乎把慢 SQL 现场最常见的误判都带了一遍:

  • 看见列表查询,就默认深分页或索引有问题
  • explain 还行,就以为数据库没问题
  • 接口耗时高,就把“SQL 执行慢”和“连接等待长”混成一件事

如果你也经常碰到“看起来像 SQL 慢,但又不完全像”的现场,这条线比总览更接近真实排查。

一、事情是从一个很普通的慢接口开始的

那次先冒出来的是订单后台列表。

现象很常见:

  • 高峰期 RT 从几百毫秒慢慢抬到两三秒
  • 低峰又会恢复
  • 不是所有接口都慢,主要集中在订单写后立刻查询、运营筛单这几个入口
  • 应用日志里能看到数据库调用时间变长,但错误率并不高

因为是列表查询,团队第一反应很自然:

  • 是不是分页太深了
  • 是不是 order by create_time desc 没吃到索引
  • 是不是最近数据量涨到某个拐点,访问路径退化了

这些怀疑都很合理,所以我们先把最可疑的那条 SQL 捞了出来。

大致长这样:

select id, order_no, user_id, status, create_time
from orders
where tenant_id = ?
  and status = ?
order by create_time desc
limit 20;

它看起来非常像那种“如果索引没配好就会慢”的标准案例。

二、第一轮看 explain,确实像是条值得怀疑的 SQL,但还没到能直接定罪的程度

我们先跑了 EXPLAIN

第一眼看下来,不算完美,但也没有差到一眼宣判:

  • 用到了联合索引
  • type 不是 ALL
  • rows 估算值也还在能接受的范围
  • Extra 里虽然有需要继续警惕的地方,但不像那种完全失控的全表扫

这一步给我的感觉是:

这条 SQL 不是完全没问题,但它也不像“为什么高峰期会突然慢成这样”的全部答案。

因为如果执行计划本身已经坏到很离谱,那低峰通常也不会那么稳。可当时的现象是:

  • 平时还能跑
  • 流量一上来就明显变差
  • 甚至同样一条 SQL,有时 200ms,有时 2s+

这就让我不太愿意太早把结论落成“去补索引”或“重写 SQL”。

为什么我没有停在 explain 这一步

因为 explain 更擅长回答的是:访问路径像不像有问题。

但那次我真正想确认的是另一句:

这条 SQL 之所以慢,到底是它自己执行得慢,还是它在某个等待上把时间耗掉了?

这两种情况在应用里都会表现成“数据库耗时高”,但处理方式完全不同。

三、接下来我先去对时间窗,而不是继续猜 SQL 文本

那次真正把方向拉回来的,不是继续盯 SQL 本身,而是把几个时间窗对在一起看:

  • 接口 RT 的抬升时间
  • 数据库调用耗时的抬升时间
  • 连接池 pending 和获取连接耗时
  • 数据库里的活跃事务、锁等待和慢 SQL 数量

一对就发现了一个很关键的细节:

  • 慢 SQL 数量确实在涨
  • 但连接池 pending 上升得更早
  • 锁等待耗时也比慢 SQL TopN 更早冒头

这个先后关系很重要。

如果真是 SQL 访问路径先退化,通常我更期待先看到:

  • 这条 SQL 本身执行时间明显拉长
  • 慢日志里它稳定上榜
  • 然后连接池才因为连接占用变长被拖起来

可那次不是这个节奏。更像是:

  • 数据库里某种等待先发生了
  • 连接归还慢了
  • 应用获取连接时间变长
  • 最后业务统一感知成“这个列表 SQL 变慢了”

也就是说,SQL 确实慢了,但慢得不一定只在执行本身。

四、这时再回头看 SQL,问题就不再是“它走没走索引”,而是“它为什么会被拖在这里”

方向一变,排查重点也跟着变了。

我不再只问:

  • key 用得对不对
  • rows 大不大
  • 有没有 filesort

而是开始问:

  • 这条查询是不是刚好在读某批被频繁更新的订单
  • 高峰期是不是有写事务把这些记录锁得更久
  • 列表查询本身不慢,但它是不是经常排在等待后面

后来顺着事务和锁去查,现场就越来越像了。

那段时间刚好有一组订单状态批量更新任务,和在线订单写链路撞在一起。它们不是每次都锁很久,但高峰时竞争明显变多。

于是就出现了一个特别容易误判的表象:

  • 列表查询作为读请求,本身 SQL 文本看着没那么糟
  • explain 也没有明显失控
  • 但它经常卡在前面事务释放之后才能真正跑起来

换句话说,接口侧感知到的“这条 SQL 很慢”,里面其实混了等待时间。

五、真正把问题坐实的,不是 explain,而是锁等待和连接占用一起对上了

最后最关键的证据,其实有两组。

第一组:数据库内部等待

我们看到:

  • 活跃事务在高峰时明显拉长
  • 锁等待耗时和热点对象集中在订单相关记录
  • 慢查询抬升和锁等待时间窗高度重合

这说明所谓“慢 SQL”至少有相当一部分,不是纯扫描慢、排序慢,而是在等。

第二组:应用侧连接池

同时还能看到:

  • 获取连接耗时在同一时间窗里上升
  • pending 数量先涨起来
  • 一部分接口的数据库总耗时里,连接等待已经占了可观比例

这两组证据一拼,就比“这条 SQL explain 不够漂亮”更有说服力了。

因为它把整条链串起来了:

  • 写事务竞争加重
  • 锁等待变长
  • 连接占用时间被拖长
  • 获取连接开始排队
  • 最后接口统一表现成数据库查询慢

这时候再回头看,一开始那句“这是一条慢 SQL”并不算错,但它其实太粗了。更准确一点说,是:

这条 SQL 在高峰期被锁等待和连接占用一起拖慢了。

六、最后怎么处理,反而不是先改 explain 里最显眼的那一点

既然瓶颈已经收窄到等待链,动作就不会只盯 SQL 文本本身。

那次我们优先做的几件事,大致是:

  • 先把高峰期撞上的批量更新任务错峰
  • 收紧某段事务边界,避免拿着连接做过多非必要逻辑
  • 复查热点写路径的更新方式,减少同批记录上的竞争
  • 最后才回头检查列表查询索引是否还能再收紧一点

这里最想强调的一点是:索引和 SQL 优化不是没价值,而是那次它不是第一落点。

如果一开始就把全部精力花在 explain 上,最后可能也会做一点优化,但止血速度不会快,因为真正把时间拖长的那一截没有被先拿掉。

七、这条线给我最大的提醒,是慢 SQL 里那个“慢”字一定要拆开问

我现在再碰到类似现场,脑子里通常会先过一遍下面这几个问题:

1. 这条 SQL 是一直慢,还是只在高峰慢

如果只在高峰慢,我会更警惕等待链、资源争抢、事务竞争,而不是立刻把锅甩给访问路径。

2. explain 是真差,还是只是没那么漂亮

如果 explain 已经明显失控,那当然要优先处理。但如果 explain 只是“能优化”,却不足以解释现场抖成那样,就别太早定罪。

3. 应用感知到的数据库耗时里,混没混获取连接时间

这一步特别关键。很多“SQL 慢”其实已经掺进了连接等待。

4. 数据库里这个“慢”到底更多发生在执行,还是发生在等待

锁等待、长事务、热点写冲突,都会把“慢 SQL”这四个字变得很含糊。

八、如果你也在排一条慢 SQL,我建议先把这一条线记住

不是说不要看 explain,而是别只看 explain。

更接近真实现场的顺序,通常是:

  1. 先确认是不是这条 SQL 真在出问题
  2. 看 explain,但只把它当成访问路径证据
  3. 把接口 RT、数据库调用耗时、连接池、事务、锁等待放到同一时间窗
  4. 先分清慢在执行,还是慢在等待
  5. 最后再决定动作该落在索引、SQL 写法、事务边界,还是任务错峰

慢 SQL 最容易误导人的地方,就是那个“慢”字太像一个结论。

可真实线上里,它常常只是一个表象。

有时候慢的是扫描,有时候慢的是排序,有时候慢的是锁,有时候慢的是连接,有时候慢的是整条等待链一起被放长。

把这件事拆清楚,才比较像真的在排障,而不是在对着一条 SQL 做想象题。