凌晨三点,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_size 和 max_heap_table_size 参数调优内存临时表上限。一般建议两个参数都设为 64M-256M。
推荐阅读:生产环境 OOM Killer 排查实战 — 内存问题和慢查询经常同时出现,这篇文章覆盖了另一个常见的凌晨告警场景。
总结
复盘这次事故,三个教训刻进骨头里:
- EXPLAIN 要看 FORMAT=JSON——只看 type 列会漏掉 filesort、临时表、实际扫描行数这些杀手指标。cost 值一出来就知道这条 SQL 到底有多贵。
- 根治慢查询不只在数据库层面——我的案例里,业务代码的 N+1 查询额外贡献了 200ms,SELECT * 的大字段贡献了巨大 I/O。加索引只是第一步,查代码才是闭环。
- 监控要跑在故障前面——慢查询日志 + pt-query-digest + Prometheus 告警是三位一体的防护网。别等用户骂了再查。
那一天凌晨 3 点的 PagerDuty 让我少睡了 4 个小时。但这 4 个小时换来的排查经验,让我后面至少避免了几十次类似的线上事故。值。
📖 推荐阅读:Linux 文件 I/O 性能深度剖析:Page Cache、Direct I/O 与 io_uring——搞懂你的数据库为什么时快时慢。