前两周线上监控突然弹了一条告警:某条 SQL 平均耗时 1023ms,峰值到过 1.8 秒。我当时第一反应就是“纳尼?”——这条 SQL 是典型的单表查询,WHERE 条件里一个订单号、一个状态,索引都建得明明白白,执行计划之前看过无数遍都是 type=ref,明明应该 5ms 以内返回。结果它硬生生跑了一秒多,线上订单查询接口跟着抖,业务方直接甩了张超时截图过来。
这条 SQL 长这样:
SELECT id, order_no, status, user_id, amount FROM orders WHERE order_no = 'SO202501150001' AND status = 1;orders 表大概 800 万行,order_no 有唯一索引 idx_order_no,status 上有个普通索引 idx_status。从任何角度看,它都不该慢。但数据库优化这行干久了就明白一件事:慢 SQL 最怕的不是那种一眼看过去就很复杂的查询,而是这种“看起来简单却突然变慢”的查询。它背后往往是隐式转换、统计信息失真、锁等待、环境抖动这些隐蔽因素在叠加。
这篇文章我就把这次排查过程完整复盘一遍,从慢查询日志、EXPLAIN、performance_schema,到索引重构和 SQL 改写,把我踩过的坑和最终验证结果都写出来。适合正在跟慢 SQL 缠斗的同学,尤其是刚接触数据库性能优化的新手——看完你能少走一大半弯路。
1. 从一条“简单SQL”说起:问题是怎么暴露的
1.1 现象描述:一条本以为不会出问题的查询
那天的告警信息其实很简短:orders表某条 SELECT 语句平均耗时 1023ms,最近 10 分钟出现次数 2000+。我打开慢查询日志一看,就是开头那条 SQL。用户侧的表现是订单列表页打开变慢,偶尔超时,但又不是完全不可用,属于那种“让人烦躁但还没到事故级别”的故障。
这种问题最坑的地方在于,第一次看代码和索引设计时,你找不到任何明显的毛病。order_no 是业务单号,有唯一索引;status 是订单状态,有普通索引;SQL 里没写SELECT *,只取了五个字段;连接池配置正常;数据库负载看着也不算高。按照常规经验,这种查询应该走idx_order_no,扫描 1 行,然后回表判断status = 1,整个过程最多几毫秒。
但实际表现就是慢。所以排查的第一步,不是去改 SQL,而是把所有“想当然”都清零,从现场数据开始看。
1.2 第一反应:看执行计划,排除“想当然”
我拉了一次 EXPLAIN:
EXPLAIN SELECT id, order_no, status, user_id, amount FROM orders WHERE order_no = 'SO202501150001' AND status = 1;结果如下:
| 列名 | 值 |
|---|---|
| table | orders |
| type | ref |
| possible_keys | idx_order_no, idx_status |
| key | idx_order_no |
| rows | 1 |
| Extra | Using where |
type=ref、key=idx_order_no、rows=1,这个执行计划堪称完美。可越是这样越说明问题不在“这条 SQL 本身的结构”,而在数据库没按我们以为的路径去走。
这里有个很容易被忽略的细节:EXPLAIN的结果是优化器根据当前统计信息估算出来的,它反映的是“优化器打算怎么做”,不一定等于“实际执行时真正做了什么”。如果统计信息过期,或者某个环节触发了索引失效,执行计划可能完全不同。我当时就在想,是不是线上实际的执行计划和这个“标准答案”不一样?
1.3 别急着动手,先把瓶颈分成三类
排查慢 SQL,我习惯先把问题归到三类里,避免像无头苍蝇一样乱试。
- 第一类:存储引擎和 IO 层。比如数据量暴涨、索引碎片化严重、缓冲池命中率低、磁盘 IO 抖动、网络延迟变高。这类问题通常表现为“执行计划没问题,但耗时就是降不下来”。
- 第二类:优化器选择层。比如统计信息过期、隐式转换导致索引失效、多列索引顺序不合适、参数嗅探导致执行计划不稳定。这类问题需要重点看 EXPLAIN 和实际耗时分布。
- 第三类:锁与事务层。比如长事务持锁、批量更新阻塞、undo log 膨胀、死锁重试。这类问题用
SHOW ENGINE INNODB STATUS和information_schema.innodb_trx能很快定位。
这个分类方法就像排查快递为什么送得慢:可能是仓库爆仓、可能是快递员路线规划得不对、也可能是门锁坏了根本送不进去。三者的处理方式完全不同。这条 SQL 最终被我归到了第二类,但中间也踩了第一类和第三类的坑,下面挨个说。
2. 排查过程复盘:从慢查询日志到执行计划
2.1 开启慢查询日志,找出现场
要看线上真实的慢 SQL,最直接的办法就是慢查询日志。我当时的命令很简单:
SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1;查询确认开启状态:
SHOW VARIABLES LIKE 'slow_query_log%'; SHOW VARIABLES LIKE 'long_query_time';这里有一个经验:线上生产环境不要长时间开着全局慢查询日志,尤其是大并发业务,日志量会非常恐怖,本身也会拖累磁盘 IO。我一般是在定位窗口期临时开 1 到 2 个小时,把long_query_time设置成 1 秒,抓到现场后马上关掉。
除了慢日志,还可以用SHOW FULL PROCESSLIST看当前正在执行的会话。如果那条 SQL 在告警时刻还在跑,你直接能看到它的执行状态和耗时。我当时从慢日志里锁定了几个时间点,然后用SHOW FULL PROCESSLIST抓到一批同样 SQL 的会话,发现它们的State基本都是Sending data。这个状态其实不能说明太多,因为很多 SQL 在返回结果集的过程中都会停在这里,但它至少帮我把问题从“客户端到服务端网络”缩小到了“数据库内部执行”。
2.2 EXPLAIN 执行计划该怎么读:type、key、rows、Extra
很多同学看 EXPLAIN 只看有没有用到索引,这是远远不够的。我建议所有做数据库优化的人都把下面这张表背下来,遇到慢 SQL 时逐个字段过一遍。
| type | 含义 | 是否需要警惕 |
|---|---|---|
| ALL | 全表扫描 | 高。OLTP 场景下通常是大问题 |
| index | 全索引扫描,扫了整个索引树 | 一般,看扫描行数 |
| range | 索引范围扫描 | 通常可接受 |
| ref | 等值匹配,返回多行 | 通常可接受 |
| eq_ref | 最多返回一行,常见于主键或唯一索引连接 | 很好 |
| const | 最多一行,主键/唯一索引等值匹配 | 理想 |
真正要盯死的几列:
type:看访问路径。key:实际用了哪个索引。如果这里显示 NULL,说明索引完全没生效。rows:优化器预估要读多少行。注意是“预估”,不是实际值。Extra:这里的信息量最大。出现Using temporary说明有临时表;Using filesort说明要额外排序;Using where说明存储引擎返回后又在 server 层过滤;Using index则是好事,表示覆盖索引,不用回表。
我当时第二次抓到的真实执行计划是这个:
| type | key | rows | Extra |
|---|---|---|---|
| ref | idx_status | 3200000 | Using where |
同一个 SQL,真实执行用了idx_status索引,预估扫描 320 万行,而 EXPLAIN 单独执行时却显示idx_order_no、rows=1。这就矛盾了:为什么同一个 SQL,优化器在不同时刻选择完全不同的执行路径?
实际上,线上真实的执行计划是慢查询日志里记录的、加了ANALYZE之后才看到的,或者通过 performance_schema 抓到的实际执行计划。单独执行EXPLAIN时由于传入参数的值不同、统计信息状态不同,都可能得到不同的结果。这正是慢 SQL 优化最核心的一个认知:执行计划是“动态”的,不是“静态”的。
2.3 用 profile 和 performance_schema 看真实耗时分布
EXPLAIN 只能告诉我们“打算怎么执行”,但执行过程中时间到底花在哪一步,还得靠更细的工具。
MySQL 5.7 可以用:
SET profiling = 1; SELECT * FROM orders WHERE order_no = 20250115001 AND status = 1; SHOW PROFILE FOR QUERY 1;MySQL 8.0 中SHOW PROFILE已经废弃,但 performance_schema 完全可以顶上。我习惯查这个:
SELECT EVENT_NAME, TIMER_WAIT / 1000000000 AS time_ms FROM performance_schema.events_stages_history_long WHERE THREAD_ID = ...;或者更直接一点,用 sys 库:
SELECT * FROM sys.statement_analysis WHERE query LIKE '%orders%'\G当时从 profile 的结果看,耗时占比最高的是Sending data。这个阶段包括了扫描行、回表、组装结果集的所有动作,它最耗时恰恰说明扫描行数太多了。再结合真实执行计划里 rows=3200000,基本可以确认问题出在“优化器选择了一个扫描大量行的索引”。
同样的问题在 SQL Server 里可以用SET STATISTICS IO ON和SET STATISTICS TIME ON看逻辑读和 CPU 耗时;Oracle 可以用AWR或者v$SQL看执行计划和实际成本。跨数据库排查的思路完全一致:先确认访问路径,再确认耗时分布。
3. 为什么“简单SQL”会跑出 1000ms:五大隐藏元凶
3.1 索引设计坑:索引该有却没用上
先说最经典的一类原因:索引建了,但设计上存在缺陷,导致优化器用不上。
最常见的是多列索引的列顺序问题。比如很多业务表上有这样一个索引idx_status_created(status, created_at),但业务查询却是WHERE created_at >= ? AND status = ?。这种情况下,优化器只能拿created_at去做范围匹配,status的等值条件没法充分利用索引,导致扫描范围变大。
我当时检查 orders 表结构时发现,除了idx_order_no和idx_status,还有一个idx_user_status(user_id, status),表面上看没问题,但分析全部订单查询 SQL 后发现,大部分查询是WHERE user_id = ? ORDER BY created_at DESC,这个索引根本覆盖不了排序字段,所以还要filesort。索引不是你建了它就一定为你服务,设计时必须考虑查询条件和排序字段的组合。
此外,主键设计也容易出问题。如果主键用 UUID 或者随机字符串,写入时会产生大量页分裂和索引碎片。之前有个项目的主键默认值就是UUID(),数据量大之后,即使是主键查询也会因为碎片和页分裂问题从几毫秒变成几百毫秒。碎片率可以通过information_schema.tables的表数据空间和空余空间大致判断,或者用SHOW TABLE STATUS LIKE 'orders'\G查看Data_free字段。
这次排查里,idx_order_no本身没有失效,但碎片率偏高,导致回表效率打折。真正让问题爆发的,是下面这个原因。
3.2 隐式转换与函数包裹:索引如何被“跳过”
第二个大坑就是隐式转换。这个坑我几乎每年都要见几次,而且每次都伪装得很深。
那天的 SQL 看起来是:
SELECT id, order_no, status, user_id, amount FROM orders WHERE order_no = 20250115001 AND status = 1;注意,order_no在表结构里是VARCHAR(50),但业务代码里传过来的却是整数类型。MySQL 在比较时会自动把字符串列转换成数字,也就是在order_no上执行了一个隐式的 CAST,导致idx_order_no索引完全失效。
这个规则一定要记牢:当索引列是字符串类型,而查询参数是数字类型时,MySQL 会自动把列值 CAST 成数字来比较,索引无法使用。反过来,如果索引列是数字类型,参数是字符串,通常也能被优化器处理,但也不保证百分百走索引。最稳妥的做法是让参数类型和列类型保持一致。
函数包裹也是一样的道理。比如WHERE DATE(created_at) = '2025-01-15',只要索引列被函数包住,MySQL 就不会走该字段的索引。这种场景应该改写为范围查询:
WHERE created_at >= '2025-01-15 00:00:00' AND created_at < '2025-01-16 00:00:00';对隐式转换的判断方法很简单:把 SQL 里的参数去掉引号,如果原来的 SQL 带了引号,去引号后 EXPLAIN 变差了,就说明触发了类型转换。其实在 EXPLAIN 的Extra列里并不会直接显示隐式转换,但有些版本会把Convert相关的提示放在执行计划里,或者用explain format=tree看更详细的信息。
3.3 统计信息失真:优化器选错了执行路径
隐式转换让idx_order_no失效之后,优化器只能在idx_status和全表扫描之间做选择。正常情况下,如果status = 1只占全表的 1%,优化器肯定会选idx_status,扫描大约 8 万行,这个量级还不至于太慢。但现实是,那段时间订单状态分布发生了明显变化:大量历史订单被批量脚本刷成了status = 1,实际占比从 1% 涨到了 40% 以上。
而优化器用的统计信息还是早期采集的,它以为status = 1只有 1%。于是它选择了idx_status,实际扫描了 320 万行,再回表判断order_no = 20250115001,最终只有 1 行满足条件。这就相当于快递员按照一张一个月前的地图送件,地图上显示目的地只有几步路,实际跑过去发现目的地搬走了,来回折腾一大圈。
处理方式很直接,更新统计信息:
ANALYZE TABLE orders;更新前耐心等等,大表的ANALYZE也可能执行几十秒。更新之后再跑一次 EXPLAIN,果然变回key=idx_order_no, rows=1。线上单条查询耗时从 1000ms 降到了 12ms 左右。
这里提醒一句:ANALYZE TABLE在 InnoDB 上通常不会阻塞读写,但会消耗一点 IO;在业务高峰期执行前最好评估一下。另外,MySQL 8.0 支持直方图,如果你有字段值分布极度不均匀的情况,可以考虑对关键列建立直方图,帮助优化器做得更准确。
3.4 行锁、间隙锁与事务等待:查询被“卡住”
第三类隐藏元凶是锁和事务。有些简单 SQL 本身执行只要几毫秒,但碰上了锁等待,就只能干等。
排查锁问题,先看当前活跃事务:
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY trx_started;再看是否有阻塞关系:
SELECT * FROM sys.innodb_lock_waits\G在 MySQL 8.0 中,sys.innodb_lock_waits会把等待链展示得很清楚:谁持锁、谁在等、等了多久。如果发现有长事务一直没提交,并且它更新过 orders 表,那这条简单 SQL 就可能被它拖住。
还有一类容易被忽略的问题:长事务会导致 undo log 膨胀。即使没有直接的锁冲突,MVCC 在读已提交或可重复读隔离级别下,也需要根据 undo 构造旧版本数据。长事务不结束,undo log 越积越多,查询就会在回滚段扫描上浪费大量时间。我当时通过SHOW ENGINE INNODB STATUS看到了正常的锁信息,但顺道发现有一个跑了 40 分钟的报表事务,这个事务也解释了为什么那段时间整体查询性能都在下降。所以如果遇到“简单 SQL 忽然变慢”且执行计划正常,一定要去查事务列表,什么都可能藏在哪里。
3.5 环境级因素:缓冲池、网络、CPU抖动
最后一类原因是环境层面的。执行计划没问题、SQL 写得也没问题,但数据库所在实例的资源被别的东西抢走了。
我常看的几个指标:
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read_requests'; SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_reads';前者是逻辑读请求,后者是物理读次数。如果物理读占比突然上升,说明 Buffer Pool 命中率下降,大量冷数据被读入内存,查询就会变慢。另一个经典场景是云上 RDS 磁盘被限流,或者 SSD 在跑日常备份和批量任务时 IOPS 被打满,这类问题看监控会更直观。
网络层也不能完全排除。如果慢查询日志记录的是数据库内部执行时间,而应用层看到的是整体超时,那就要对比应用监控和数据库监控。有一些看似慢 SQL 的告警,实际上是连接池满了、CPU 上下文切换过高,或者是应用和数据库之间的网络 RPC 延迟变大。遇到这种问题,先看同一时段 DB CPU、IO、连接数三条曲线,往往一眼就能找到真凶。
这次案例里,环境因素不是主因,但它确实把问题放大了。同一时间段有一个报表任务在跑大查询,把磁盘 IO 占掉了一部分,导致本来要 400ms 的查询被拖到了 1000ms。这让我意识到一个问题:优化慢 SQL 时不能只看单条 SQL 的局部,还要看整个实例的资源水位。
4. 优化实操:把 1000ms 压到 50ms 的具体改法
4.1 索引重构:从全表扫描到聚簇索引覆盖
这次问题的最直接修复,是让idx_order_no重新生效,并避免回表。隐式转换修复后,SQL 会走idx_order_no,但由于查询需要返回status、user_id、amount等字段,而idx_order_no是二级索引,MySQL 还得回聚簇索引拿这些字段。
如果要彻底压耗时,可以考虑覆盖索引。所谓覆盖索引,就是让 SELECT 要的字段全都包含在同一个索引里,查询时不需要回表。例如:
ALTER TABLE orders ADD INDEX idx_order_no_cover(order_no, status, user_id, amount);变成覆盖索引后,WHERE order_no = 'SO20250115001' AND status = 1这个查询可以直接从索引里拿到所有字段。
不过要注意,索引不是越多越好。每增加一个索引,写入时都要额外维护 B+ 树,会增加写入延迟并占用磁盘空间。像这种高频查询但字段固定的 SQL,适合做覆盖索引;如果是低频报表查询,就要权衡收益和代价。我这次没有直接加覆盖索引,因为修复参数类型后,单条查询已经回到 10ms 左右,已经满足业务要求。覆盖索引只是一张“更好的牌”,但不一定每局都要打出去。
4.2 SQL 改写技巧:避免 SELECT *、统一类型、改写 OR
除了索引重构,SQL 本身的写法也值得打磨。这次问题的直接根因是隐式转换,所以第一步就是把参数类型和列类型统一:
-- 修复前(order_no 索引失效) SELECT id, order_no, status, user_id, amount FROM orders WHERE order_no = 20250115001 AND status = 1; -- 修复后 SELECT id, order_no, status, user_id, amount FROM orders WHERE order_no = 'SO20250115001' AND status = 1;在代码层面,更应该用参数化查询,而不是手动拼字符串。这样既能从源头避免类型不一致,也能规避 SQL 注入风险。我见过太多因为字符串拼接导致的慢 SQL,也见过因为拼接引发安全问题的案例,这个问题永远不值得赌。
SQL 改写还有几个常见技巧:
- 不要用
SELECT *,只取需要的字段,减少回表和数据传输量,还能给覆盖索引创造机会。 WHERE status = 1 OR status = 2可以改写为WHERE status IN (1, 2)。尤其是 OR 的两侧字段不同时,优化器往往会把 path 搞得很差,甚至放弃索引合并。- 对数据量非常大的表,避免使用负向查询,比如
WHERE status != 1,因为这种条件很难走索引,优化器经常选择全表扫。
4.3 统计信息与参数调优:给优化器“重新带路”
这次案例里,统计信息失真是放大伤害的核心因素之一。所以优化动作里必须包含统计信息更新。
MySQL 的统计信息更新命令:
ANALYZE TABLE orders;SQL Server:
UPDATE STATISTICS orders;Oracle 则常用:
BEGIN DBMS_STATS.GATHER_TABLE_STATS(ownname => 'APP', tabname => 'ORDERS'); END;统计信息更新之后,执行计划已经正常。但如果你的场景里优化器仍然“固执己见”,可以用FORCE INDEX让它走指定索引,不过这是兜底方案,不是长期方案。强制索引一旦数据分布再次变化,可能让执行计划比优化器自己选的更差。正确做法是先搞清楚它为什么选错,是统计信息过期还是索引选择逻辑有问题,再对症下药。
参数调优这块要非常谨慎。比如:
SET SESSION sort_buffer_size = 8 * 1024 * 1024;这个操作只对当前会话生效,而且sort_buffer_size配得过大,在并发场景下可能造成内存暴涨。我的建议是,除非你已经定位到明确的排序瓶颈,否则不要盲目调这些参数。数据库的性能问题多数情况下是执行路径和索引结构的问题,参数只是辅助手段。
4.4 分页、去重、窗口函数场景下的慢SQL优化
这次案例本身不是分页问题,但排查的过程中,我在慢查询日志里顺便发现了一批其他慢 SQL,它们的高频原因集中在两个模式:深分页和去重。顺手说一下,因为这些模式在电商报表场景里实在太常见。
深分页是指这种 SQL:
SELECT * FROM orders ORDER BY created_at DESC LIMIT 100000, 20;LIMIT 的 OFFSET 越大,MySQL 需要扫描并丢弃的行就越多,100000 的偏移量可能意味着它要扫描几百万行。优化方式有两种:
- 延迟关联:先查主键,再回表取数据。
- 游标分页:用
WHERE created_at < 上次最大值代替 OFFSET。
延迟关联示例:
SELECT o.id, o.order_no, o.status, o.amount FROM orders o JOIN ( SELECT id FROM orders ORDER BY created_at DESC LIMIT 100000, 20 ) t ON o.id = t.id ORDER BY o.created_at DESC;去重场景也有类似问题。如果对一个大表的文本字段做DISTINCT,MySQL 可能需要生成临时表并排序,就会非常慢。可以尝试:
- 只对必要的字段去重,而不是全字段
DISTINCT。 - 用
GROUP BY配合MIN/MAX提取关键信息,减少排序范围。
窗口函数是解决“取分组内最新一条”的利器。比如每个用户的最新订单:
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY created_at DESC) AS rn FROM orders ) t WHERE rn = 1;但要注意,窗口函数通常需要全量排序,如果底层没有合适索引,数据量大时也很慢。可以在PARTITION BY user_id ORDER BY created_at DESC对应的字段上建组合索引,至少让扫描范围可控。
4.5 优化验证:before/after 对比与回归测试
所有优化动作做完之后,必须做严密验证。不能只看一两次的执行时间,因为数据库有缓存,连续执行同一条 SQL 第二次往往快很多。
正确的验证方式是同一查询条件执行 5 到 10 次,取 P95 或者取多次平均,避免缓存带来的假象。我当时验证数据大致如下:
| 阶段 | 访问路径 | 预估扫描行数 | 平均耗时(5次) |
|---|---|---|---|
| 优化前 | idx_status 回表 | 3200000 | 1023ms |
| 修复隐式转换后 | idx_order_no 回表 | 1 | 38ms |
| 更新统计信息后 | idx_order_no 回表 | 1 | 12ms |
| 加覆盖索引(测试) | idx_order_no_cover | 1 | 5ms |
还要做功能回归测试:查询结果集必须和修复前完全一致,包括字段内容、排序顺序、NULL 值处理等。数据量对比可以用COUNT(*)、金额汇总、最新订单号抽查等方式。
上线前一定要做变更评审。如果决定加索引,要评估对INSERT/UPDATE/DELETE的影响;如果在生产直接跑ANALYZE TABLE,要错开业务高峰。所有的 DDL 变更都建议先在一台从库或者低峰期执行,确认无误后再上主库。
5. 实用速查:慢SQL常见问题与排查清单
5.1 高频问题速查表
| 问题现象 | 常见原因 | 快速验证方法 | 处理方案 |
|---|---|---|---|
| 有索引但 type=ALL | 隐式转换 / 函数包裹 / 统计信息过期 | 去掉参数引号后 EXPLAIN 对比 | 统一参数类型、禁止函数包裹、ANALYZE TABLE |
| 走错索引,rows 异常大 | 统计信息过期 / 数据分布不均 | 查看实际执行计划和真实 rows | 更新统计信息或建直方图 |
| 执行计划好但耗时高 | 锁等待 / undo 膨胀 / IO 抖动 | 查 innodb_trx、innodb_lock_waits、IO 监控 | 杀掉长事务、优化批量任务、排查实例资源 |
| 深分页变慢 | OFFSET 过大 | 看 LIMIT 偏移量 | 延迟关联或游标分页 |
| SQL Server 同一条 SQL 时快时慢 | 参数嗅探 | 对比带固定值和带变量的执行计划 | 使用 OPTION(RECOMPILE) 或强制参数化 |
| DISTINCT/GROUP BY 慢 | 临时表和文件排序 | EXPLAIN 显示 Using temporary/filesort | 减少去重字段,优化索引 |
| 网络延迟高导致应用层超时 | 客户端和服务端 RPC 慢 | 对比应用监控和慢查询日志 | 排查网络链路,优化连接池 |
这里再加一条安全提醒:所有 SQL 优化都必须建立在参数化查询的基础上,不要为了改类型方便就在代码里拼接 SQL。拼接字符串既可能导致慢查询,也可能引入注入风险。慢 SQL 可以慢慢优化,安全问题不能等。
5.2 排查工具清单与建议阈值
不同数据库各有好用的排查工具,我整理了一份清单:
- MySQL:慢查询日志、
EXPLAIN、EXPLAIN FORMAT=tree、performance_schema、sys 库、pt-query-digest、Percona Toolkit。 - SQL Server:
SET STATISTICS IO ON/TIME ON、SSMS 执行计划图形化工具、sys.dm_exec_query_stats、Missing Index 动态管理视图。 - Oracle:AWR 报告、v$SQL、SQL Tuning Advisor、10046 事件跟踪。
关于阈值,我给一个经验值但反对照搬。OLTP 业务里,单条简单查询 P95 超过 100ms 就应该警惕;复杂报表或批量任务超过 1 秒可以接受。慢查询日志的long_query_time默认建议 1s,如果业务对延迟敏感,可以先设置为 100ms,看一周再调回。核心是让监控系统能抓到增量问题,而不是把所有日志都存下来。
5.3 线上排障的安全注意事项
线上排查慢 SQL,最怕的是为了解决问题制造更大的问题。我给自己定了几条铁律:
- 不在高峰期直接对超大表执行
ANALYZE TABLE。如果必须做,先评估表大小,并用--sampling之类的方式控制开销。 - 不轻易在生产加索引。先在一台从库或灰度环境验证,确认不影响写入再上。
- 所有锁相关操作,先查清楚持锁事务的业务来源,不要盲目
KILL。曾经有人杀掉了一个跑了两小时的导出任务,结果业务方那边直接断了一大片。 - 保留好现场:慢查询日志、EXPLAIN 结果、监控截图、事务列表,这些是后续复盘和写故障报告的第一手资料。
- 变更后准备好回滚方案。索引加错了可以 DROP,参数调大了要立刻恢复,无回滚计划的变更在线上等于裸奔。
复盘这件事,我自己的体会是,大多数“简单 SQL 突然变慢”的故障都不是单点原因,更像是一连串小问题叠加出来的结果:隐式转换让索引失效,统计信息失真让优化器选错索引,大查询抢占了 IO,最终把一个本应 10ms 的查询拖到 1000ms。排查时如果只盯着其中一个环节,很容易修完一个又一个,却始终看不到全貌。
最后分享一个我现在养成的习惯:每次上线一个新的 SQL,我都会顺手保存一份当时的执行计划和 rows 估算值,并在监控里对这几条关键 SQL 做耗时基线。这样等某天它突然变慢时,我能立刻对比“之前什么样、现在什么样、中间改了什么”。很多问题在十分钟内就能定位清楚,靠的就是这份提前准备的基线数据。
下次你再看到“纳尼,一条简单 SQL 居然超过 1000ms”的告警,先别急着怀疑数据库疯了,按从执行计划到统计信息到锁等待的顺序查一遍,大概率能在很短时间内找到那个藏在细节里的真凶。