生产环境 MySQL 慢查询排查实战:从凌晨告警到根治的全链路复盘(2026)

📝 572 字 · ☕ 2 分钟阅读

凌晨三点,PagerDuty 响了

那天我记得特别清楚——刚合上一个 PR,正准备关电脑睡觉,手机震了。

「订单服务 P99 延迟 8.2 秒,阈值 500ms」——告警信息简短得令人窒息。

打开 Grafana 一看,QPS 没涨,CPU 没飙,内存正常。但 P99 延迟从平时 200ms 直接飞天。直觉告诉我:数据库出问题了

这篇文章就是我那天凌晨 3 点到早上 7 点的完整排查记录。不是教科书式的「优化三步走」,而是一个真实的生产事故——从脑子一片空白到最终根治的全过程。

第一步:快速止血——找到那条该死的 SQL

先止血再找根因。这是我在无数次线上事故中学到的第一条铁律。

登上去一看,MySQL 的 Threads_running 飙到 120+(平时 5-8)。大量连接在堆积,说明有慢查询在持有锁或消耗资源。

开启慢查询日志

如果你的 MySQL 还没开慢查询日志——赶紧开。这不是可选项,是生产环境标配。

-- 检查当前配置
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';

-- 动态开启(不需要重启)
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 0.5;  -- 超过500ms就记录
SET GLOBAL log_queries_not_using_indexes = ON;  -- 未使用索引的也记录

但如果等慢查询日志慢慢积累,等你分析完用户已经跑光了。这时候用 SHOW FULL PROCESSLIST 或者 performance_schema 看实时状态更快:

-- 查看当前正在执行的查询(运行超过5秒的)
SELECT * FROM information_schema.PROCESSLIST 
WHERE COMMAND != 'Sleep' AND TIME > 5
ORDER BY TIME DESC;

一眼就看到一条跑了两分钟的查询:

SELECT o.*, u.username, p.product_name, p.category
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
LEFT JOIN products p ON o.product_id = p.id
WHERE o.status = 'pending'
  AND o.created_at >= '2026-06-01'
ORDER BY o.created_at DESC
LIMIT 50;

看起来平平无奇对吧?但这条查询扫描了 300 万行。

先止血:把慢查询 kill 掉,临时把接口降级(返回缓存数据),P99 马上回到 300ms。用户不骂了,接下来才是真正的排查。

相关阅读:Redis 生产环境踩坑实录:缓存穿透、雪崩、热点Key — 同样是凌晨告警,同样是数据库层面排查,方法论相通。

第二步:深度诊断——EXPLAIN 不只是看 type

很多人拿到 EXPLAIN 输出只看 type 列——看到 ALL 就说「要加索引」,看到 ref 就觉得 OK。这太粗糙了。

用 EXPLAIN FORMAT=JSON 能看到更多细节:

EXPLAIN FORMAT=JSON
SELECT o.*, u.username, p.product_name, p.category
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
LEFT JOIN products p ON o.product_id = p.id
WHERE o.status = 'pending'
  AND o.created_at >= '2026-06-01'
ORDER BY o.created_at DESC
LIMIT 50;

输出长这样(关键字段标注):

{
  "query_cost": "128497.50",        ← 12.8万cost
  "ordering_operation": {
    "using_temporary_table": true,  ← 用了临时表排序
    "using_filesort": true          ← 文件排序,磁盘IO
  },
  "table": {
    "table_name": "orders",
    "access_type": "ALL",           ← 全表扫描
    "rows_examined_per_scan": 2893521,  ← 289万行
    "filtered": 10.00,              ← 只有10%符合WHERE条件
    "attached_condition": "..."
  }
}

三个致命问题:

  • 全表扫描 289 万行:orders 表没有覆盖 status + created_at 的复合索引
  • filesort:ORDER BY created_at DESC 在 WHERE 和 JOIN 之后再做排序,额外磁盘 I/O
  • 临时表:JOIN 结果集太大,MySQL 被迫在磁盘上建临时表

现在再看现象——P99 飙升但 CPU 没飙——就说得通了。瓶颈在磁盘 I/O,不是在 CPU。filesort + 临时表都在疯狂读写磁盘,SSD 的 IOPS 被打满了。

第三步:根治——不是所有慢查询都要加索引

常规思路是「加个联合索引完事」:

ALTER TABLE orders ADD INDEX idx_status_created (status, created_at);

确实,单加这个索引就能让查询从 120s 降到 0.05s。但这就够了吗?

抛开这条具体的 SQL,我发现这个场景有更深层的问题:

问题一:SELECT * 在 JOIN 场景下是毒药

三表 JOIN 时 SELECT o.* 会把 orders 表所有列都拉出来——包括一个存 JSON 的 extra_data 字段,平均每条 8KB。300 万行 × 8KB = 24GB 数据在内存和磁盘之间搬运。

改成只选需要的列:

SELECT o.id, o.order_no, o.amount, o.status, o.created_at,
       u.username, p.product_name
FROM orders o
LEFT JOIN users u ON o.user_id = u.id
LEFT JOIN products p ON o.product_id = p.id
WHERE o.status = 'pending'
  AND o.created_at >= '2026-06-01'
ORDER BY o.created_at DESC
LIMIT 50;

配合覆盖索引,这条查询甚至不需要回表:

ALTER TABLE orders ADD INDEX idx_cover_pending (
  status, created_at, id, order_no, amount, user_id, product_id
);

EXPLAIN 再跑一次——Using index(覆盖索引),零回表,查询成本从 12.8 万降到 47。

问题二:业务代码在循环里查数据库

查了代码,发现有个更恶心的问题。拿到 50 条订单后,业务代码在 for 循环里逐一查物流状态:

// 原代码:N+1 查询
orders = getPendingOrders();      // 1 次查询
for (order : orders) {
    order.logistics = db.query(   // 50 次查询
        "SELECT * FROM logistics WHERE order_id = ?", order.id
    );
}
// 总共 51 次数据库查询

改成一次 IN 查询:

// 一次查询搞定,总共 2 次数据库查询
orderIds = orders.stream().map(o -> o.id).toList();
logisticsMap = db.query(
    "SELECT * FROM logistics WHERE order_id IN (?)", orderIds
).stream().collect(toMap(l -> l.orderId, l -> l));

这一改,接口整体耗时又砍了 200ms。

第四步:建防护——让慢查询在爆炸前被发现

问题解决了,但我问自己:为什么等告警响了才知道?这套路我不能再走第二次。

1. 慢查询日志 + pt-query-digest 定时巡检

Percona Toolkit 的 pt-query-digest 可以分析慢查询日志,按耗时排序找出 top N:

# 分析昨天的慢查询 Top 10
pt-query-digest /var/log/mysql/slow.log --since=yesterday \
  --limit=10 --order-by=Query_time:sum

# 配合 crontab 每天早上 9 点跑一次,结果发到 Slack
0 9 * * * pt-query-digest /var/log/mysql/slow.log \
  --since=yesterday --limit=10 | \
  curl -X POST -d @- https://hooks.slack.com/...

2. Prometheus + mysqld_exporter 指标监控

这几个指标配告警基本能覆盖 90% 的慢查询问题:

指标 正常范围 告警阈值
Threads_running 5-15 > 40
Slow_queries rate < 5/min > 20/min
Innodb_row_lock_waits 0 > 10/min
Created_tmp_disk_tables < 10/min > 50/min

延伸阅读:Linux 性能剖析实战:perf 工具从 CPU 采样到火焰图生成 — 当慢查询不是磁盘 I/O 而是 CPU 瓶颈时,perf + 火焰图是更合适的排查工具。

3. 应用层 query timeout——最后的防火墙

不管你多信任自己的 SQL,永远在应用层设超时:

# Python / SQLAlchemy
engine = create_engine(
    DATABASE_URL,
    connect_args={
        'connect_timeout': 5,
        'read_timeout': 10,     # 查询超10秒 → 直接抛异常
    }
)

# 或者用语句级超时(MySQL 5.7+)
SET SESSION max_execution_time = 10000;  -- 10秒

常见踩坑——我走过的弯路

Q: 加了索引为什么 EXPLAIN 还是 ALL?

三种可能:(1) 索引列有函数包裹,比如 WHERE DATE(created_at) = '2026-06-01',需要改成范围查询;(2) 字符集不一致导致隐式转换——utf8mb4 的列和 utf8 的 JOIN 条件会让索引失效;(3) 优化器认为全表扫描更便宜(小表或数据极度倾斜),可以用 FORCE INDEX 验证是否真的是索引问题。

Q: filesort 一定是坏事吗?

不一定。如果排序的数据集很小(几十行),filesort 甚至比索引排序更快——因为避免了随机 I/O。但像本文这种几百万行的场景,filesort 就是灾难。判断标准看 EXPLAIN 的 rows 列和实际 sort_buffer 使用量。

Q: 怎么看磁盘临时表的大小?

SHOW GLOBAL STATUS LIKE 'Created_tmp_disk_tables'; 如果这个值在你执行某条查询后暴涨,说明那条查询产生了大量磁盘临时表。配合 tmp_table_sizemax_heap_table_size 参数调优内存临时表上限。一般建议两个参数都设为 64M-256M。

推荐阅读:生产环境 OOM Killer 排查实战 — 内存问题和慢查询经常同时出现,这篇文章覆盖了另一个常见的凌晨告警场景。

总结

复盘这次事故,三个教训刻进骨头里:

  1. EXPLAIN 要看 FORMAT=JSON——只看 type 列会漏掉 filesort、临时表、实际扫描行数这些杀手指标。cost 值一出来就知道这条 SQL 到底有多贵。
  2. 根治慢查询不只在数据库层面——我的案例里,业务代码的 N+1 查询额外贡献了 200ms,SELECT * 的大字段贡献了巨大 I/O。加索引只是第一步,查代码才是闭环。
  3. 监控要跑在故障前面——慢查询日志 + pt-query-digest + Prometheus 告警是三位一体的防护网。别等用户骂了再查。

那一天凌晨 3 点的 PagerDuty 让我少睡了 4 个小时。但这 4 个小时换来的排查经验,让我后面至少避免了几十次类似的线上事故。值。

📖 推荐阅读:Linux 文件 I/O 性能深度剖析:Page Cache、Direct I/O 与 io_uring——搞懂你的数据库为什么时快时慢。

📤 分享这篇文章