页面一卡就是五六秒,接口动不动就拖垮订单列表------线上一个普通查询从 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=3000000、Extra=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=range、rows≈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 时,写成这三列,让 Extra 变 Using index(覆盖索引),彻底免掉回表。本项目将这列收窄后进一步降到 31ms。
实践清单:① 先开慢查询日志 slow_query_log、long_query_time=1,捞真实业务 SQL,别凭猜;② 每个慢 SQL 先 EXPLAIN,看 type/key/rows/Extra 四件套;③ 建索引前想清楚查询形态(等值/范围/排序),列序决定成败;④ 线上 DDL 用 pt-online-schema-change 或 gh-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 验收 ,让 type 从 ALL 变 range/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 等实践清单。