一次慢 SQL 不是数据库的锅:我怎么用 EXPLAIN 和采样日志定位真正瓶颈

昨天凌晨,运维同事在群里@我:“二白,查一下这个慢SQL,数据库CPU飙到80%了。”

我看了一眼监控,确实有一个查询耗时3.2秒。但仔细一看,这个查询很简单,单表查询,索引也建了。

搞了两个小时,最后发现——这个慢SQL根本不是数据库的锅。

01. 问题现场:一个看起来很无辜的慢SQL

监控报警的SQL长这样:

SELECT * FROM orders 
WHERE user_id = ? AND status = ? 
ORDER BY created_at DESC 
LIMIT 20;

索引建得挺好:

KEY idx_user_status_time (user_id, status, created_at)

按理说不应该慢。但事实是,这个查询在高峰期确实卡住了。

第一反应:索引是不是失效了?

我快速跑了个 EXPLAIN

EXPLAIN SELECT * FROM orders 
WHERE user_id = 123456 AND status = 'PAID' 
ORDER BY created_at DESC 
LIMIT 20;

结果:

id: 1
select_type: SIMPLE
table: orders
type: ref
possible_keys: idx_user_status_time
key: idx_user_status_time
key_len: 98
ref: const,const
rows: 1523
Extra: Using where; Using index

没问题啊! 索引用上了,rows也不多,应该很快才对。

02. 第一次排查:以为是索引问题

既然 EXPLAIN 看起来正常,我怀疑是不是参数传的问题。于是直接在数据库里手动执行:

# 直接在MySQL客户端执行
mysql> SELECT * FROM orders 
    -> WHERE user_id = 123456 AND status = 'PAID' 
    -> ORDER BY created_at DESC 
    -> LIMIT 20;
Empty set (0.02 sec)

0.02秒,飞快。

这就尴尬了。SQL本身没问题,那为什么应用里会慢?

03. 第二次排查:采样日志揪出真凶

我打开了应用的慢查询日志采样。我们有个脚本会定期采集应用侧的查询耗时:

#!/bin/bash
# slow_query_sampler.sh
# 每5秒采样一次慢查询日志

LOG_FILE="/var/log/mysql/slow.log"
SAMPLE_INTERVAL=5

while true; do
  echo "=== $(date '+%Y-%m-%d %H:%M:%S') ==="
  # 提取最近5秒的慢查询
  tail -n 100 "$LOG_FILE" | grep "Query_time" | tail -5
  sleep "$SAMPLE_INTERVAL"
done

运行脚本后,我发现了一个有趣的现象:

同样的查询,在应用侧的耗时分布非常不均匀。

=== 2026-04-09 03:15:00 ===
Query_time: 3.214s  Rows_sent: 0  Rows_examined: 1523
Query_time: 0.023s  Rows_sent: 20  Rows_examined: 20
Query_time: 2.876s  Rows_sent: 0  Rows_examined: 1523
Query_time: 0.018s  Rows_sent: 20  Rows_examined: 20

关键发现:当 Rows_sent: 0 时,查询耗时飙升到3秒;而当 Rows_sent: 20 时,只需要0.02秒。

这说明什么?

04. 真相大白:不是数据库慢,是网络和应用层的问题

进一步分析应用代码,我发现了问题的根源:

// 问题代码示例
public List<Order> getOrders(Long userId, String status) {
    String sql = "SELECT * FROM orders WHERE user_id = ? AND status = ? ORDER BY created_at DESC LIMIT 20";
    
    // 问题1:每次都重新建立连接
    Connection conn = dataSource.getConnection();
    
    // 问题2:没有使用PreparedStatement缓存
    PreparedStatement stmt = conn.prepareStatement(sql);
    stmt.setLong(1, userId);
    stmt.setString(2, status);
    
    ResultSet rs = stmt.executeQuery();
    
    // 问题3:结果集处理不当
    List<Order> orders = new ArrayList<>();
    while (rs.next()) {
        Order order = new Order();
        // ... 大量字段映射
        orders.add(order);
    }
    
    conn.close(); // 每次都关闭连接
    return orders;
}

三个致命问题

  1. 连接池未复用:每次查询都新建连接,高峰期连接池耗尽
  2. PreparedStatement未缓存:每次都重新解析SQL
  3. 结果集处理效率低:大量反射和字段映射

更关键的是:当查询结果为空时(Rows_sent: 0),应用层还会做额外的检查:

if (orders.isEmpty()) {
    // 触发降级逻辑,查询其他数据源
    return fallbackQuery(userId, status);
}

这个 fallbackQuery 会去查另一个更慢的数据库,导致整体耗时飙升。

05. 解决方案:三步优化

第一步:启用连接池复用

// 使用HikariCP连接池
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:mysql://localhost:3306/orders");
config.setMaximumPoolSize(20);
config.setMinimumIdle(5);
config.setConnectionTimeout(30000);
HikariDataSource dataSource = new HikariDataSource(config);

第二步:缓存PreparedStatement

// 启用Statement缓存
config.addDataSourceProperty("cachePrepStmts", "true");
config.addDataSourceProperty("prepStmtCacheSize", "250");
config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");

第三步:优化结果集处理

// 使用更高效的结果集映射
public List<Order> getOrdersOptimized(Long userId, String status) {
    return jdbcTemplate.query(
        sql,
        new Object[]{userId, status},
        (rs, rowNum) -> new Order(
            rs.getLong("id"),
            rs.getLong("user_id"),
            rs.getString("status"),
            rs.getTimestamp("created_at")
        )
    );
}

优化后效果

优化前:
Query_time: 3.214s  Rows_sent: 0
Query_time: 2.876s  Rows_sent: 0

优化后:
Query_time: 0.045s  Rows_sent: 0
Query_time: 0.038s  Rows_sent: 0

性能提升70倍。

06. 总结:慢SQL排查的正确姿势

这次排查让我学到了一个教训:慢SQL不一定都是数据库的锅。

正确的排查流程应该是:

第一步:用EXPLAIN验证索引

EXPLAIN SELECT ...;

keyrows,确认索引是否生效。

第二步:用采样日志分析耗时分布

# 采样脚本
tail -f /var/log/mysql/slow.log | grep "Query_time"

关注 Rows_sentRows_examined 的比例。

第三步:排查应用层代码

  • 连接池是否复用?
  • PreparedStatement是否缓存?
  • 结果集处理是否高效?

第四步:检查网络和中间件

  • 网络延迟如何?
  • 是否有代理或中间件增加了开销?

07. 写在最后

这次故障排查花了2小时,但真正解决问题只用了10分钟。大部分时间都在"以为是数据库问题"的错误方向上。

关键教训:遇到慢SQL,先别急着改索引,用EXPLAIN和采样日志确认瓶颈在哪一层。

工具分享:我写了一个自动化采样脚本,放在 GitHub 了,大家可以拿去用。


相关阅读

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐