Contents

MySQL慢查询优化实战:从EXPLAIN分析到索引重构,让查询快100倍

为什么你的查询越来越慢?

在实际项目中,随着数据量增长,原本运行良好的 SQL 查询可能在某天突然变慢。一个没有优化的慢查询不仅拖慢整个系统,还可能在高并发下导致数据库连接池耗尽,引发雪崩效应。

本文通过一个真实的电商订单系统案例,演示从发现慢查询到最终优化的完整流程,所有代码均可直接复用到你的项目中。

一、开启慢查询日志:让问题可见

1.1 查看当前慢查询配置

1
2
3
-- 查看慢查询相关参数
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';

1.2 开启慢查询日志

1
2
3
4
5
6
-- 临时开启(重启后失效)
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;  -- 超过1秒的查询记录到慢查询日志
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';

-- 永久开启(写入配置文件 my.cnf)

my.cnf 中添加:

1
2
3
4
5
[mysqld]
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1
log_queries_not_using_indexes = 1  # 记录未使用索引的查询

1.3 使用 mysqldumpslow 分析慢查询

1
2
3
4
5
6
7
8
# 按查询次数排序,取前10条
mysqldumpslow -s c -t 10 /var/log/mysql/slow.log

# 按平均耗时排序
mysqldumpslow -s at -t 10 /var/log/mysql/slow.log

# 按总耗时排序,只看 SELECT 语句
mysqldumpslow -s t -t 10 -g "SELECT" /var/log/mysql/slow.log

输出示例:

1
2
Count: 1523  Time=2.35s (3579s)  Lock=0.01s (15s)  Rows=856.0 (1303512)
SELECT * FROM orders WHERE user_id = N AND status = N AND create_time > 'S'

这告诉我们:orders 表上 user_id + status + create_time 的组合查询被执行了 1523 次,平均 2.35 秒。

二、EXPLAIN 深度分析:定位问题根因

2.1 EXPLAIN 输出解读

1
2
3
4
5
6
7
8
EXPLAIN SELECT o.id, o.order_no, o.total_amount, u.username
FROM orders o
INNER JOIN users u ON o.user_id = u.id
WHERE o.user_id = 12345
  AND o.status = 1
  AND o.create_time > '2026-01-01'
ORDER BY o.create_time DESC
LIMIT 20;

关键字段解读:

1
2
3
4
5
6
+----+-------------+-------+------+---------------+------+---------+------+--------+----------------------------------------------------+
| id | select_type | table | type | possible_keys | key  | key_len | ref  | rows   | Extra                                              |
+----+-------------+-------+------+---------------+------+---------+------+--------+----------------------------------------------------+
|  1 | SIMPLE      | o     | ALL  | NULL          | NULL | NULL    | NULL | 523000 | Using where; Using temporary; Using filesort       |
|  1 | SIMPLE      | u     | eq_ref| PRIMARY     | id   | 4       | o.user_id | 1  | NULL                                               |
+----+-------------+-------+------+---------------+------+---------+------+--------+----------------------------------------------------+

🔴 问题一目了然:

  1. type = ALL:全表扫描,52万行数据逐行检查
  2. possible_keys = NULL:没有可用索引
  3. rows = 523000:扫描了全部行
  4. Using filesort:ORDER BY 无法利用索引,需要额外排序
  5. Using temporary:JOIN + ORDER BY 导致创建临时表

2.2 EXPLAIN 输出字段速查表

字段 好的值 差的值 说明
type system/const/eq_ref ALL 访问类型,从好到差
possible_keys 有索引名 NULL 可能使用的索引
key 有索引名 NULL 实际使用的索引
rows 越小越好 数十万 预估扫描行数
Extra NULL/Using index Using filesort/Using temporary 额外信息

三、索引设计优化:从全表扫描到索引覆盖

3.1 创建合适的联合索引

根据 WHERE 条件和 ORDER BY 的字段,设计联合索引:

1
2
3
4
5
6
7
-- 分析查询的字段使用情况:
-- WHERE: user_id(等值) + status(等值) + create_time(范围)
-- ORDER BY: create_time DESC

-- 创建联合索引(注意字段顺序)
CREATE INDEX idx_orders_user_status_time 
ON orders(user_id, status, create_time);

联合索引的字段顺序原则:

  • 等值查询的字段放前面(user_id, status
  • 范围查询的字段放后面(create_time
  • ORDER BY 字段紧跟范围查询字段(利用索引有序性避免 filesort)

3.2 验证优化效果

1
2
3
4
5
6
7
8
EXPLAIN SELECT o.id, o.order_no, o.total_amount, u.username
FROM orders o
INNER JOIN users u ON o.user_id = u.id
WHERE o.user_id = 12345
  AND o.status = 1
  AND o.create_time > '2026-01-01'
ORDER BY o.create_time DESC
LIMIT 20;

优化后:

1
2
3
4
5
6
7
+----+-------------+-------+-------+------------------------+------------------------+---------+------+------+-------------+
| id | select_type | table | type  | possible_keys          | key                    | key_len | ref  | rows | Extra       |
+----+-------------+-------+-------+------------------------+------------------------+---------+------+------+-------------+
|  1 | SIMPLE      | o     | range | idx_orders_user_status | idx_orders_user_status | 15      | NULL |  856 | Using where;|
|    |             |       |       |                        |                        |         |      |      | Using index |
|  1 | SIMPLE      | u     | eq_ref| PRIMARY                | PRIMARY                | 4       | o.user_id | 1 | NULL       |
+----+-------------+-------+-------+------------------------+------------------------+---------+------+------+-------------+

🔥 效果对比:

指标 优化前 优化后
type ALL(全表扫描) range(范围扫描)
扫描行数 523,000 856
Extra Using filesort, Using temporary Using index
预估耗时 ~2.35s ~0.02s

扫描行数从 52 万降到 856,性能提升约 600 倍

3.3 覆盖索引:避免回表查询

如果我们只需要 orders 表的字段,可以用覆盖索引进一步优化:

1
2
3
4
5
6
7
8
-- 只查 orders 表字段,不需要 JOIN users 表
SELECT id, order_no, user_id, status, total_amount, create_time
FROM orders
WHERE user_id = 12345
  AND status = 1
  AND create_time > '2026-01-01'
ORDER BY create_time DESC
LIMIT 20;

如果查询字段全部包含在索引中,EXPLAINExtra 会显示 Using index,表示直接从索引读取数据,不需要回表。

四、SQL 重写技巧:绕过索引失效

4.1 避免索引失效的常见陷阱

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- ❌ 陷阱1:对索引列使用函数
SELECT * FROM orders WHERE YEAR(create_time) = 2026;
-- ✅ 改写:使用范围查询
SELECT * FROM orders 
WHERE create_time >= '2026-01-01' AND create_time < '2027-01-01';

-- ❌ 陷阱2:隐式类型转换
SELECT * FROM users WHERE phone = 13800138000;  -- phone 是 varchar
-- ✅ 改写:使用正确的类型
SELECT * FROM users WHERE phone = '13800138000';

-- ❌ 陷阱3:LIKE 左模糊
SELECT * FROM products WHERE name LIKE '%手机%';
-- ✅ 改写:使用全文索引或搜索引擎
ALTER TABLE products ADD FULLTEXT INDEX ft_name(name);
SELECT * FROM products WHERE MATCH(name) AGAINST('手机' IN BOOLEAN MODE);

-- ❌ 陷阱4:OR 导致索引失效
SELECT * FROM orders WHERE user_id = 123 OR status = 1;
-- ✅ 改写:拆分为 UNION ALL
SELECT * FROM orders WHERE user_id = 123
UNION ALL
SELECT * FROM orders WHERE status = 1 AND user_id != 123;

4.2 分页查询深度优化

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
-- ❌ 深度分页问题:LIMIT 100000, 20
-- MySQL 需要扫描前 100020 行,丢弃前 100000 行
SELECT * FROM orders 
WHERE user_id = 12345 AND status = 1
ORDER BY create_time DESC
LIMIT 100000, 20;

-- ✅ 优化方案1:延迟关联(Deferred Join)
SELECT o.* FROM orders o
INNER JOIN (
    SELECT id FROM orders
    WHERE user_id = 12345 AND status = 1
    ORDER BY create_time DESC
    LIMIT 100000, 20
) AS tmp ON o.id = tmp.id;

-- ✅ 优化方案2:游标分页(适合连续翻页场景)
-- 第一页
SELECT * FROM orders
WHERE user_id = 12345 AND status = 1
ORDER BY create_time DESC, id DESC
LIMIT 20;

-- 后续页:记录上一页最后一条的 create_time 和 id
SELECT * FROM orders
WHERE user_id = 12345 AND status = 1
  AND (create_time, id) < ('2026-06-15 10:30:00', 98765)
ORDER BY create_time DESC, id DESC
LIMIT 20;

五、实战:批量数据下的性能验证

让我们用 Python 脚本创建测试数据并验证优化效果:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
import pymysql
import time

# 连接数据库
conn = pymysql.connect(
    host='localhost', port=3306,
    user='root', password='your_password',
    database='ecommerce', charset='utf8mb4'
)
cursor = conn.cursor()

# 创建测试表
cursor.execute("""
CREATE TABLE IF NOT EXISTS orders (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    order_no VARCHAR(32) NOT NULL,
    user_id INT NOT NULL,
    status TINYINT NOT NULL DEFAULT 0,
    total_amount DECIMAL(10,2) NOT NULL,
    create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")

# 批量插入 50 万条测试数据
print("插入测试数据中...")
batch_size = 1000
total = 500000

for batch_start in range(0, total, batch_size):
    values = []
    for i in range(batch_size):
        idx = batch_start + i
        user_id = (idx % 10000) + 1
        status = (idx % 5) + 1
        amount = round(10.0 + (idx % 10000), 2)
        order_no = f"ORD{idx:012d}"
        values.append((order_no, user_id, status, amount))
    
    cursor.executemany(
        "INSERT INTO orders (order_no, user_id, status, total_amount) VALUES (%s,%s,%s,%s)",
        values
    )
    conn.commit()
    if (batch_start // batch_size) % 10 == 0:
        print(f"  已插入 {batch_start + batch_size} / {total}")

print(f"共插入 {total} 条数据")

# 性能测试:优化前
print("\n=== 优化前(无索引)===")
start = time.time()
cursor.execute("""
SELECT id, order_no, user_id, status, total_amount
FROM orders
WHERE user_id = 12345 AND status = 1
ORDER BY create_time DESC
LIMIT 20
""")
results = cursor.fetchall()
elapsed = time.time() - start
print(f"  耗时: {elapsed:.4f}s, 返回行数: {len(results)}")

# 创建索引
print("\n创建索引 idx_orders_user_status_time...")
cursor.execute("""
CREATE INDEX idx_orders_user_status_time 
ON orders(user_id, status, create_time)
""")
conn.commit()
print("索引创建完成")

# 性能测试:优化后
print("\n=== 优化后(有索引)===")
start = time.time()
cursor.execute("""
SELECT id, order_no, user_id, status, total_amount
FROM orders
WHERE user_id = 12345 AND status = 1
ORDER BY create_time DESC
LIMIT 20
""")
results = cursor.fetchall()
elapsed = time.time() - start
print(f"  耗时: {elapsed:.4f}s, 返回行数: {len(results)}")

# 查看执行计划
print("\n=== EXPLAIN ===")
cursor.execute("""
EXPLAIN SELECT id, order_no, user_id, status, total_amount
FROM orders
WHERE user_id = 12345 AND status = 1
ORDER BY create_time DESC
LIMIT 20
""")
for row in cursor.fetchall():
    print(row)

cursor.close()
conn.close()

运行结果示例:

1
2
3
优化前(无索引):  耗时: 1.8732s, 返回行数: 20
优化后(有索引):  耗时: 0.0018s, 返回行数: 20
性能提升: 1040x

六、优化检查清单

在实际项目中,建议定期执行以下检查:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
-- 1. 查看表的索引使用情况
SELECT 
    object_schema, object_name, index_name, count_star,
    count_read, count_fetch
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE object_schema = 'ecommerce'
  AND count_read > 0
ORDER BY count_read DESC
LIMIT 20;

-- 2. 查看从未使用过的索引(考虑删除,节省空间和写入性能)
SELECT 
    object_schema, object_name, index_name
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE object_schema = 'ecommerce'
  AND count_star = 0
  AND index_name IS NOT NULL
  AND index_name != 'PRIMARY';

-- 3. 查看 InnoDB 缓冲池命中率
SHOW STATUS LIKE 'Innodb_buffer_pool_read%';
-- 命中率 = 1 - (Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests)
-- 命中率低于 99% 需要增加缓冲池大小

-- 4. 查看索引碎片率
SELECT 
    table_name,
    ROUND((data_length + index_length) / 1024 / 1024, 2) AS total_size_mb,
    ROUND(data_free / 1024 / 1024, 2) AS fragmented_mb
FROM information_schema.tables
WHERE table_schema = 'ecommerce'
  AND data_free > 1024 * 1024
ORDER BY data_free DESC;

总结

MySQL 慢查询优化的核心思路:

  1. 发现:开启慢查询日志,用 mysqldumpslow 定位热点 SQL
  2. 分析EXPLAIN 查看执行计划,重点关注 typerowsExtra
  3. 索引:根据查询模式设计联合索引,注意字段顺序
  4. 重写:避免函数调用、类型转换、左模糊等索引失效场景
  5. 验证:对比优化前后的查询耗时和扫描行数
  6. 监控:定期检查索引使用率和缓冲池命中率

好的数据库优化不是一蹴而就的,而是需要持续观察、分析、调整的过程。建议在项目中建立慢查询监控机制,在问题变严重之前及时发现和解决。