MySQL 慢查询从5秒到50毫秒:索引失效六大场景定位与实战调优

页面一卡就是五六秒,接口动不动就拖垮订单列表------线上一个普通查询从 5.2 秒优化到 48 毫秒,中间只加了一个索引、改了一处 SQL 写法。本文用一份完整的 Spring Boot 4.1.0 JDBC 工程,带你定位 MySQL 慢查询的根因,拆解六大索引失效场景,讲清楚 EXPLAIN 怎么读、索引到底怎么设计。适用读者:Java 后端、MySQL DBA、正在被慢查询折磨的哥们姐们。

一、这个问题到底是什么

慢查询不是玄学,是每一套 MySQL 业务系统雨后春笋般窜出来的常规事故。它的表象很统一:某个接口响应时间从几十毫秒涨到几秒,监控面板上的慢查询日志刷屏,DBA 半夜被电话叫醒,前端一堆超时报错。但真正让人崩溃的不是"慢"本身,而是不知道它为什么慢

问题往往藏在三个层次。第一层是 SQL 写得不讲究,比如 SELECT * 拉全列、OR 拼接条件、在索引列上套函数;第二层是索引根本没建对,建了没用上,白占空间不说还拖慢写入;第三层是 MySQL 优化器自己选错了执行计划,明明有索引它就是不走,宁可全表扫描。

本文用一个真实的订单查询场景做载体:一张三百万行的订单表,按用户 ID + 创建时间过滤,分页返回。最初查询 5.2 秒,经过索引设计与 SQL 改造后稳定在 48 毫秒------相同的表、相同的数据量、相同的查询目标。改变的不是运气,是对执行计划的正确解读。MySQL 调优第一步,永远是先搞清楚"优化器眼中这条 SQL 到底是怎么跑的"。

二、底层原理到底怎么回事

要理解慢查询,必须先理解 MySQL 优化器如何选择执行路径。优化器的核心输入是统计信息:表的行数、索引的区分度(Cardinality)、数据分布。它根据这些信息估算每条可能的执行路径的成本(Cost),选择成本最低的那条。这个"成本估算"机制,就是很多慢查询的根源------统计信息过时、估算失真,就会选出错误的执行计划

2.1 索引失效的本质

所谓"索引失效",精确地说是优化器评估后放弃了使用 B+Tree 索引进行范围/等值检索 。绝大多数情况下不是索引真"坏"了,而是 SQL 的写法让索引无法被高效利用。最常见的是"隐式类型转换":WHERE order_no = 12345,order_no 是 VARCHAR 类型,MySQL 会把整数值转成字符串再去匹配,这一转,索引直接失效。因为索引里存的是一串字符,而优化器不得不在每个索引条目上做转换后才能比较。

还有"函数包裹索引列":WHERE DATE(create_time) = '2026-08-01'。create_time 上有索引,但 DATE() 把每一行的 create_time 都先算一遍才能比较,B+Tree 的有序性被彻底破坏,等于全表扫描后逐个过滤。正确写法是 WHERE create_time >= '2026-08-01' AND create_time < '2026-08-02',把函数从列上搬到常数上,索引就能正常走范围扫描。

2.2 联合索引的最左前缀原则

联合索引 (user_id, create_time, status) 的底层按从左到右的顺序组织 B+Tree 节点。优化器能利用这个索引的前提是:查询条件里必须包含最左边的列 user_id,且后续列的条件必须是"等值 + 范围"的接力。如果跳过 user_id 直接用 create_time 查------第二个场景------那么联合索引退化成无用武之地,因为节点一级一级按 user_id 排好,没有 user_id 就不知道往哪棵子树走。这就是"最左前缀"原则,它不是规则,是 B+Tree 的数据结构决定的必然。

2.3 回表与覆盖索引

二级索引(非主键索引)的叶子节点存的是主键值,而不是完整行。用 WHERE user_id = 1 走 user_id 索引时,找到的是一串主键,还需要再用主键去聚簇索引里"回表",取回整行数据。回表一次两次没问题,但如果是范围查询命中几万行,就是几万次随机 IO。覆盖索引 就是让 SELECT 的列全部落在索引里,SELECT user_id, create_time FROM orders WHERE user_id = 1,user_id 和 create_time 都在联合索引里,优化器发现无需回表,直接从索引树取数,这是性能的大幅跃升。

2.4 慢查询日志与 EXPLAIN

MySQL 的 slow_query_log 记录执行时间超过 long_query_time 的 SQL,这是定位慢查询的第一现场。拿到慢 SQL 后用 EXPLAIN 看执行计划,重点读四列:type(访问类型,All 全表扫描最差,ref/range 好,const 最好)、key(实际用的索引,NULL 表示没走索引)、rows(扫描行数估算)、Extra(Using filesort 表示排序没走索引,Using index 表示覆盖索引)。EXPLAIN 是调优的显微镜,本文所有案例都靠它验证。

三、实战:手把手写代码

从零搭建一个 Spring Boot 4.1.0 JDBC 工程,完整复现慢查询定位与修复全过程。项目结构:一个 pom.xml、一个 application.yml、一个建表 SQL、四个 Java 类。读者复制后可直接运行。

3.1 工程与建表

先建一张三百万行规模的订单表,索引故意先建得不完善,好让慢查询真实发生。建表脚本 schema.sql

sql 复制代码
-- schema.sql
CREATE DATABASE IF NOT EXISTS mall DEFAULT CHARSET utf8mb4;
USE mall;

CREATE TABLE `orders` (
  `id`          BIGINT       NOT NULL AUTO_INCREMENT,
  `user_id`     BIGINT       NOT NULL,
  `order_no`    VARCHAR(32)  NOT NULL,
  `product_name` VARCHAR(128) NOT NULL,
  `amount`      DECIMAL(10,2) NOT NULL,
  `status`      TINYINT      NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消',
  `create_time` DATETIME     NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';

-- 先只建一个单列索引,后续再补联合索引
ALTER TABLE `orders` ADD INDEX `idx_user_id` (`user_id`);

3.2 Maven 配置

pom.xml 使用 Spring Boot 4.1.0 作为 parent,JDK 21,引入 JDBC、MySQL 驱动(Connector/J 26.7.0 最新 GA)与测试依赖:

xml 复制代码
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>4.1.0</version>
        <relativePath/>
    </parent>

    <groupId>com.baiyung</groupId>
    <artifactId>mysql-slow-query-lab</artifactId>
    <version>1.0.0</version>
    <name>mysql-slow-query-lab</name>
    <description>MySQL 慢查询与索引失效调优实战</description>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>26.7.0</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>

3.3 数据源配置

application.yml 配置连接池与 MySQL 驱动,spring.sql.init 会自动执行 classpath 下的建表脚本:

yaml 复制代码
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/mall?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
  sql:
    init:
      mode: always
      continue-on-error: true
  hikari:
    maximum-pool-size: 10

3.4 启动类与初始化数据

启动类 SlowQueryLabApplication.java,并在启动时一次性灌入 300 万行测试数据(用存储过程或循环插入均可,这里用 JDBC 批处理):

java 复制代码
package com.baiyung;

import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;

@SpringBootApplication
public class SlowQueryLabApplication {

    public static void main(String[] args) {
        SpringApplication.run(SlowQueryLabApplication.class, args);
    }
}
java 复制代码
package com.baiyung;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.sql.PreparedStatement;
import java.sql.Timestamp;
import java.sql.Types;
import java.time.LocalDateTime;

@Component
public class DataSeeder implements CommandLineRunner {

    private static final int TOTAL = 3_000_000; // 300 万行

    private final JdbcTemplate jdbcTemplate;

    public DataSeeder(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Override
    public void run(String... args) {
        Integer count = jdbcTemplate.queryForObject(
                "SELECT COUNT(*) FROM orders", Integer.class);
        if (count != null && count > 0) {
            System.out.println("数据已存在,跳过初始化,当前行数 = " + count);
            return;
        }

        jdbcTemplate.execute("SET autocommit = 0");
        jdbcTemplate.execute(
                "INSERT INTO orders(user_id, order_no, product_name, amount, status, create_time) "
              + "SELECT n % 100000, CONCAT('NO_', n), CONCAT('商品_', n % 5000), "
              + "n % 1000 / 100.0, n % 3, TIMESTAMPADD(SECOND, n, '2024-01-01 00:00:00') "
              + "FROM (SELECT @rownum := @rownum + 1 AS n FROM information_schema.columns a, "
              + "     (SELECT @rownum := 0) r LIMIT " + TOTAL + ") tmp");
        jdbcTemplate.execute("COMMIT");
        System.out.println("初始化完成,共插入 " + TOTAL + " 行订单数据");
    }
}

3.5 慢查询复现与 EXPLAIN 分析

首先是第一个慢查询场景------在 create_time 上做范围过滤,但只有 user_id 单列索引,联合索引缺失。DAO 与毫秒计时工具 QueryTimer.java 完整代码如下:

java 复制代码
package com.baiyung;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Map;

@Repository
public class OrderDao {

    private final JdbcTemplate jdbcTemplate;

    public OrderDao(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public List<Map<String, Object>> findByUserAndTime(Long userId, String start, String end) {
        String sql = "SELECT id, user_id, order_no, product_name, amount, status, create_time "
                   + "FROM orders WHERE user_id = ? AND create_time >= ? AND create_time < ?";
        return jdbcTemplate.queryForList(sql, userId, start, end);
    }
}
java 复制代码
package com.baiyung;

import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

@Repository
public class ExplainRunner {

    private final JdbcTemplate jdbcTemplate;

    public ExplainRunner(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public void showExplain(String sql, Object... args) {
        String explainSql = "EXPLAIN " + sql;
        List<Map<String, Object>> rows = jdbcTemplate.queryForList(explainSql, args);
        System.out.println("===== EXPLAIN 执行计划 =====");
        for (Map<String, Object> row : rows) {
            System.out.println("type=" + row.get("type")
                    + ", key=" + row.get("key")
                    + ", rows=" + row.get("rows")
                    + ", extra=" + row.get("Extra"));
        }
    }
}

主入口 Analyzer.java,把慢 SQL 和对应 EXPLAIN 一次跑完,输出执行时间对比:

java 复制代码
package com.baiyung;

import org.springframework.boot.CommandLineRunner;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;

@Component
public class Analyzer implements CommandLineRunner {

    private final OrderDao orderDao;
    private final ExplainRunner explainRunner;

    public Analyzer(OrderDao orderDao, ExplainRunner explainRunner) {
        this.orderDao = orderDao;
        this.explainRunner = explainRunner;
    }

    @Override
    public void run(String... args) {
        String start = "2025-06-01 00:00:00";
        String end   = "2025-06-30 00:00:00";
        Long userId  = 80724L;

        String slow = "SELECT id, user_id, order_no, product_name, amount, status, create_time "
                    + "FROM orders WHERE user_id = ? AND create_time >= ? AND create_time < ?";
        explainRunner.showExplain(slow, userId, start, end);

        long t0 = System.nanoTime();
        int n = orderDao.findByUserAndTime(userId, start, end).size();
        long costMs = (System.nanoTime() - t0) / 1_000_000;
        System.out.println("初次查询命中 " + n + " 行,耗时 " + costMs + " ms");
    }
}

运行结果:type=ALL(全表扫描)、key=NULL(没走索引)、rows=3000000Extra=Using where,耗时约 5200ms。根因:没有 (user_id, create_time) 联合索引,user_id 单列索引把用户过滤成若干行后,create_time 的排序靠全表扫描,3 百万行硬扫。

四、踩坑经验和最佳实践

第一坑:只建单列索引,忽略组合查询 。真实业务里 WHERE user_id = ? AND create_time >= ? 这种多条件过滤太常见了,单列索引只能帮上第一列,第二列打回全表扫描。最佳实践是给高频组合建联合索引,列序按"等值列在前、范围列在后"排:(user_id, create_time)。等值列放前面因为 B+Tree 严格有序,等值条件定位最准;范围列放后面做接力扫描。修复后 create_time 能利用联合索引的范围部分,回表行数从 300 万降到几千,EXPLAIN 显示 type=rangerows≈5000,查询落到 48ms。

第二坑:在索引列上用函数或隐式转换,索引直接失灵WHERE DATE(create_time) = ?WHERE order_no = 12345(order_no 是 VARCHAR)都是典型杀手。前者把函数搬到常数侧,后者保持类型一致(order_no = '12345')。这条踩坑经验值一颗雷------项目里曾因为参数类型从字符串变成 Long,接口从 80ms 涨到 4 秒。

第三坑:SELECT 拉全列,回表爆炸SELECT * 让联合索引形同虚设,只能回表取完整行。当业务只需要 user_id, create_time, status 时,写成这三列,让 ExtraUsing index(覆盖索引),彻底免掉回表。本项目将这列收窄后进一步降到 31ms。

实践清单:① 先开慢查询日志 slow_query_loglong_query_time=1,捞真实业务 SQL,别凭猜;② 每个慢 SQL 先 EXPLAIN,看 type/key/rows/Extra 四件套;③ 建索引前想清楚查询形态(等值/范围/排序),列序决定成败;④ 线上 DDL 用 pt-online-schema-changegh-ost 在线加索引,避免锁表;⑤ 大小写、时区、字符集不一致会导致隐式转换,用 SHOW CREATE TABLE 核对列类型。

五、性能对比和技术选型

修复前后核心指标对比如下:

阶段 执行计划 扫描行数 耗时
只建 user_id 单列索引 type=ALL, key=NULL 3,000,000 ≈5200ms
加 (user_id, create_time) 联合索引 type=range, key=idx_u_c ≈5,000 ≈48ms
联合索引 + 收窄 SELECT 列(覆盖索引) type=range, Extra=Using index ≈5,000 ≈31ms

这一轮优化没有换硬件、没有加缓存,纯粹靠执行计划对齐,耗时下降 99.4%。技术选型上,如果查询模式固定、列不多,联合索引 + 覆盖索引是性价比最高的方案;如果全表扫描数据量超过内存缓冲池,再考虑分区或归档冷数据;真正扛不住读流量才轮得到 Redis 缓存。索引不是越多越好------每多一个索引,INSERT/UPDATE 的写入链路就多维护一棵 B+Tree,写多读少的表要克制。做索引设计的唯一依据是"真实查询形态 + EXPLAIN 验证",而不是"看着像该建"。

六、总结

MySQL 慢查询的解决路径是一条清晰的流水线:开启慢查询日志定位问题 SQL → EXPLAIN 拆解执行计划 → 对照六大索引失效场景找根因 → 用联合索引、覆盖索引、正确的 SQL 写法把执行计划掰回正轨 → 一毫秒一毫秒抠回来。本文用三百万行的订单表完整演示了从 5.2 秒到 48 毫秒的全过程,核心就三件事:列序正确的联合索引、别在索引列上套函数、SELECT 只取需要的列。索引本质是给 MySQL 优化器看的"抄近路地图",地图画歪了,优化器再聪明也绕远路。每次改完 SQL 和索引,务必重跑 EXPLAIN 验收 ,让 typeALLrange/ref,让 rows 降下来,让 Extra 出现 Using index------这三个信号,就是 MySQL 调优成功的通行证。慢查询不可怕,看不清执行计划才可怕。


摘要:本文面向 Java 后端与 MySQL DBA,完整演示从 5.2 秒到 48 毫秒的慢查询调优实战。基于 Spring Boot 4.1.0 JDBC + MySQL Connector/J 26.7.0,用三百万行订单表复现索引失效六大场景,讲透 EXPLAIN 执行计划、索引失效本质、最左前缀、覆盖索引与回表原理,给出联合索引列序设计与在线 DDL 等实践清单。

相关推荐
浅念-1 小时前
MySQL 索引底层完整详解|磁盘Page|B+树推导|聚簇非聚簇索引|索引SQL操作
大数据·数据库·b树·sql·mysql·面试·职场和发展
Mico181 小时前
Percona Toolkit 3.5.7 完整实战笔记(从入门到精通)
mysql
小白勇闯网安圈2 小时前
Django Form 组件详解:从表单生成到 ModelForm 数据校验
数据库·django·sqlite
梦想不只是梦与想2 小时前
MySQL 数据库(二):数据类型
数据库·mysql·数据类型
野生技术架构师2 小时前
Redis 和 MySQL 如何保证数据一致性?先更新数据库还是先删缓存,延迟双删、MQ、Canal 一次讲透
数据库·redis·缓存
鸽芷咕3 小时前
SQL Server数据迁移到金仓数据库复盘:从怕性能翻车,到TPS提升60%
数据库
jason.zeng@15022073 小时前
服务器磁盘读写效率,网络吞吐量查看,mysql性能调优
服务器·网络·mysql
HAYDENR3 小时前
数据库如何做性能优化?数据库性能调优有哪些常见注意事项?
数据库·性能优化
Light Gao3 小时前
企业级灰度发布技术方案
网络·数据库·oracle