一文吃透 MySQL 性能优化:从执行计划到架构设计
掌握这 20+ 核心概念,告别“SELECT *”和“全表扫描”
写在前面
作为一名后端开发,你一定遇到过这样的场景:明明功能逻辑没问题,但接口响应时间却长达几秒甚至几十秒。打开慢查询日志一看,罪魁祸首往往是一两句“简单”的 SQL。
MySQL 性能优化不是玄学,而是一套有章可循的方法论。本文将从SQL 优化、索引设计、数据库结构、架构演进、配置调优五个维度,系统梳理 MySQL 性能优化的核心概念,帮助你建立起自己的优化知识体系。
阅读本文后,你将收获:
- 掌握 EXPLAIN 执行计划的每个关键字段含义
- 理解 B+Tree 索引的工作原理及失效场景
- 学会 10+ 条 SQL 改写技巧
- 了解读写分离、分库分表等架构演进路径
一、优化金字塔:找对发力点
在开始之前,先建立正确的优化优先级认知:
/\
/ \ ① SQL 与索引优化(效果最明显,覆盖 80% 问题)
/____\
/ \ ② 数据库结构优化(表设计、范式、分区)
/________\
/ \ ③ 架构优化(读写分离、缓存、分库分表)
/____________\
/ \④ 硬件与配置优化(最后手段,成本高,收益有限)
一个常见误区:上来就申请更高配置的服务器,或者盲目调整几十个数据库参数。实际上,绝大多数性能问题都源于糟糕的 SQL 写法或缺失的索引。
二、SQL 与索引优化(重中之重)
2.1 EXPLAIN:你的 SQL 体检报告
EXPLAIN 是分析 SQL 性能的第一工具,它输出 MySQL 执行计划的每一步。
EXPLAIN SELECT * FROM user WHERE age = 18;
输出字段详解(重点关注加粗部分):
| 字段 | 含义 | 优化目标 |
|---|---|---|
| type | 访问类型,性能从好到差:system > const > eq_ref > ref > range > index > ALL | 至少达到 range,争取 ref |
| key | 实际使用的索引 | 不能为 NULL |
| rows | 预估扫描的行数 | 越小越好 |
| Extra | 额外信息 | 避免 Using filesort、Using temporary |
| possible_keys | 可能使用的索引 | - |
| filtered | 过滤后剩余行的百分比 | 越高越好 |
type 字段图解:
性能差 ←——————————————————————————————→ 性能好
ALL → index → range → ref → eq_ref → const → system
(全表扫描) (全索引扫描) (范围) (等值) (唯一) (常量)
Extra 中需要警惕的信号:
Using filesort:需要额外排序,通常需要优化 ORDER BYUsing temporary:使用临时表,常见于 GROUP BY 或 DISTINCTUsing index condition:使用了索引下推(5.6+ 的特性,好事)Using where:用 WHERE 过滤,如果 type 是 ALL,问题很大
2.2 索引失效的 7 种经典场景
索引不是建了就一定生效,以下情况会导致索引失效(面试高频):
-- ① 左模糊查询(索引失效)
SELECT * FROM user WHERE name LIKE '%张三';
-- ✅ 改为右模糊(索引有效)
SELECT * FROM user WHERE name LIKE '张三%';
-- ② 类型隐式转换(索引失效)
SELECT * FROM user WHERE phone = 13800000000; -- phone 是 varchar
-- ✅ 加引号
SELECT * FROM user WHERE phone = '13800000000';
-- ③ 对索引列使用函数(索引失效)
SELECT * FROM user WHERE DATE(create_time) = '2024-01-01';
-- ✅ 改为范围查询
SELECT * FROM user WHERE create_time BETWEEN '2024-01-01' AND '2024-01-01 23:59:59';
-- ④ 对索引列进行计算(索引失效)
SELECT * FROM user WHERE age + 1 = 18;
-- ✅ 计算移到等号右边
SELECT * FROM user WHERE age = 17;
-- ⑤ OR 连接(两边都有索引才有效)
SELECT * FROM user WHERE name = '张三' OR age = 18;
-- 如果只有 name 有索引,age 没有,整体索引失效
-- ⑥ 联合索引不满足最左前缀
-- 索引为 (a, b, c),以下查询不走索引
SELECT * FROM table WHERE b = 1 AND c = 2;
-- ✅ 必须包含 a
SELECT * FROM table WHERE a = 1 AND b = 2;
-- ⑦ NOT IN、<> 操作(索引可能失效)
SELECT * FROM user WHERE status <> 0;
2.3 覆盖索引与回表
回表(Back to Table):通过二级索引找到主键,再根据主键到聚簇索引查找完整数据行。这个过程需要两次 B+Tree 查找,产生随机 I/O。
覆盖索引(Covering Index):查询需要的所有列都在索引中,MySQL 可以直接从索引返回数据,无需回表。
-- 索引:idx_name_age (name, age)
-- 需要回表:因为要查 phone,索引里没有
SELECT name, age, phone FROM user WHERE name = '张三';
-- 覆盖索引:所有需要的数据都在索引中
SELECT name, age FROM user WHERE name = '张三';
-- EXPLAIN 的 Extra 会显示 Using index
优化技巧:尽量让 SELECT 只取需要的列,避免 SELECT *。
2.4 索引下推(ICP,Index Condition Pushdown)
MySQL 5.6 引入的重要优化。在没有 ICP 之前,索引只能定位到 name 匹配的记录,然后回表,再用 age 过滤。有了 ICP 后,可以在索引层先过滤 age。
-- 联合索引 (name, age)
SELECT * FROM user WHERE name LIKE '张%' AND age = 20;
没有 ICP:找到所有 张* 的记录 → 回表 → 过滤 age=20
有 ICP:在索引层同时判断 age=20 → 符合条件的才回表
EXPLAIN Extra 中看到
Using index condition,说明 ICP 已生效。
2.5 查询重写:5 条 SQL 改写技巧
-- ① 避免 SELECT *
-- 差:返回不需要的列,浪费 I/O
SELECT * FROM user WHERE id = 1;
-- 好:只取需要的列
SELECT id, name, age FROM user WHERE id = 1;
-- ② 大分页优化
-- 差:offset 100000,MySQL 要扫描 100020 行
SELECT * FROM user ORDER BY id LIMIT 100000, 20;
-- 好:先找到起始位置,再取数据
SELECT * FROM user
WHERE id > (SELECT id FROM user ORDER BY id LIMIT 100000, 1)
LIMIT 20;
-- ③ UNION 换成 UNION ALL
-- UNION 会去重(代价高),确定无重复就用 UNION ALL
SELECT id FROM user WHERE status = 1
UNION ALL -- 而不是 UNION
SELECT id FROM order WHERE status = 1;
-- ④ 用 EXISTS 代替 IN(子表数据量大时)
-- 差:IN 子查询会先执行
SELECT * FROM user WHERE id IN (SELECT user_id FROM order);
-- 好:EXISTS 以主查询驱动
SELECT * FROM user u
WHERE EXISTS (SELECT 1 FROM order o WHERE o.user_id = u.id);
-- ⑤ 批量操作代替循环
-- 差:循环 100 次
UPDATE user SET age = age + 1 WHERE id = 1;
-- 好:一次批量
UPDATE user SET age = age + 1 WHERE id IN (1,2,3,4,5);
三、索引设计优化
3.1 索引类型选择
| 索引类型 | 引擎支持 | 特点 | 适用场景 |
|---|---|---|---|
| B+Tree | InnoDB/MyISAM | 默认索引,支持等值和范围查询 | 绝大多数场景 |
| Hash | Memory | 等值查询极快,不支持范围 | 精确查找,如 KV 缓存 |
| Full-Text | InnoDB/MyISAM | 全文检索 | 文章搜索、关键词匹配 |
| Spatial | InnoDB/MyISAM | GIS 地理数据 | 位置服务、距离计算 |
3.2 三星索引(Three-Star Index)
这是一个评估索引好坏的经典模型:
- 一星:索引包含所有等值 WHERE 条件的列
- 二星:索引顺序与 ORDER BY 一致,避免 filesort
- 三星:索引包含所有查询列(覆盖索引)
-- 查询
SELECT id, name, age FROM user
WHERE city = '北京' AND gender = 1
ORDER BY create_time;
-- 三星索引:(city, gender, create_time, name, age)
-- 一星:city 和 gender 是等值条件
-- 二星:create_time 在索引中,且顺序正确
-- 三星:name 和 age 也在索引中,无需回表
3.3 基数(Cardinality)与选择性
基数:索引列中唯一值的数量。
选择性:基数 / 总行数,越接近 1 说明索引效果越好。
-- 查看索引基数
SHOW INDEX FROM user;
经验法则:
- 性别(基数 2):选择性差,不适合建索引
- 身份证号(基数 ≈ 总行数):选择性好,适合建索引
- 状态字段(如 0/1/2):如果大部分查询只查某一种状态,索引可能无效
3.4 联合索引的最左前缀原则
联合索引 (a, b, c) 相当于创建了三个索引:(a)、(a,b)、(a,b,c)。
能走索引的情况:
WHERE a = 1
WHERE a = 1 AND b = 2
WHERE a = 1 AND b = 2 AND c = 3
WHERE a = 1 ORDER BY b -- 利用索引排序
不能走索引的情况:
WHERE b = 2
WHERE c = 3
WHERE a = 1 AND c = 3 -- 跳过 b,只能用到 a
设计建议:把区分度高的列放在最左边。
四、数据库结构优化
4.1 范式 vs 反范式
| 范式(3NF) | 反范式 | |
|---|---|---|
| 原则 | 减少冗余,避免更新异常 | 适当增加冗余,减少 JOIN |
| 优点 | 数据一致性好,节省存储 | 查询性能高,无需多表关联 |
| 缺点 | 查询需要 JOIN,性能下降 | 更新时需要维护多处,可能不一致 |
| 适用 | 写多读少、对一致性要求高 | 读多写少、报表类场景 |
反范式示例:订单表存商品名称,而不是存商品 ID 去关联商品表。
4.2 表分区(Partitioning)
将一张大表物理拆分成多个文件,逻辑上仍是一张表。
-- 按年份 RANGE 分区
CREATE TABLE orders (
id INT,
order_date DATE,
amount DECIMAL(10,2)
)
PARTITION BY RANGE (YEAR(order_date)) (
PARTITION p2022 VALUES LESS THAN (2023),
PARTITION p2023 VALUES LESS THAN (2024),
PARTITION p2024 VALUES LESS THAN (2025)
);
适用场景:
-
历史数据可清理(直接 DROP 分区,比 DELETE 快得多)
-
查询有明显的时间/范围规律
-
单表数据量超过 1000 万行
注意:分区键必须在 WHERE 条件中,否则会扫描所有分区。
4.3 水平分表 vs 垂直分表
水平分表:将行分散到多张结构相同的表。
user_0 (id % 4 = 0)
user_1 (id % 4 = 1)
user_2 (id % 4 = 2)
user_3 (id % 4 = 3)
垂直分表:将列拆分到不同表。
user_base (id, name, age) -- 热数据
user_profile (id, address, bio) -- 冷数据、大字段
常用中间件:ShardingSphere、MyCAT、Vitess
4.4 数据类型优化
核心原则:
-
越小越好:能用 TINYINT 不用 INT,能用 INT 不用 BIGINT
-
越简单越好:整数比字符串比较快,DATETIME 比 VARCHAR 存时间好
-
避免 NULL:NULL 让索引、统计更复杂,尽量 NOT NULL + 默认值
实战建议:
| 场景 | 推荐类型 | 不推荐 |
|---|---|---|
| 年龄 | TINYINT UNSIGNED | INT、VARCHAR |
| 状态(0-255) | TINYINT | ENUM |
| IP 地址 | INT(用 INET_ATON) | VARCHAR(15) |
| 金额 | DECIMAL(10,2) | FLOAT、DOUBLE |
| 文本 | VARCHAR(变长) | CHAR(定长浪费) |
五、架构优化
5.1 读写分离
架构图:
写操作 主库
↓ ↓
应用程序 → 同步复制 → 从库1
↑ ↑
读操作 从库2
实现方式:
-
代码层:配置多个数据源,用 AOP 切面路由
-
中间件:ShardingSphere-JDBC、MyCAT
代价:从库有复制延迟,不适合对实时性要求极高的场景。
5.2 缓存策略
多级缓存架构:
请求 → 本地缓存(Caffeine)→ Redis → MySQL
↑ ↑
命中返回 命中返回
缓存更新策略:
-
Cache Aside(旁路缓存):读时更新,写时删除缓存
-
Write Through:写时同时更新缓存和数据库
-
Write Behind:先写缓存,异步刷到数据库
注意:MySQL 8.0 已移除 Query Cache,不要在数据库层做查询缓存。
5.3 连接池配置
连接池复用数据库连接,避免频繁的三次握手和四次挥手。
HikariCP 推荐配置(Spring Boot 默认):
spring:
datasource:
hikari:
maximum-pool-size: 20 # 最大连接数
minimum-idle: 10 # 最小空闲连接
connection-timeout: 30000 # 连接超时(ms)
idle-timeout: 600000 # 空闲超时
max-lifetime: 1800000 # 连接最大生命周期
经验值:连接数不是越大越好,太多会增加上下文切换。一般公式:(CPU 核心数 * 2) + 磁盘数
5.4 消息队列削峰
场景:秒杀、日志写入、订单异步处理
高并发写入 → MQ(Kafka/RocketMQ)→ 消费者批量写入 MySQL
好处:将瞬时高并发变为平稳的持久化写入,保护数据库。
六、服务器配置优化
6.1 最重要的 5 个参数
| 参数 | 推荐值 | 说明 |
|---|---|---|
innodb_buffer_pool_size | 物理内存的 70-80% | InnoDB 缓存数据和索引,最重要 |
innodb_log_file_size | 1-4 GB | Redo 日志大小,影响写入性能 |
max_connections | 500-2000 | 最大连接数,过大增加上下文切换 |
innodb_flush_log_at_trx_commit | 1(强一致)/2(高性能) | 刷盘策略 |
tmp_table_size / max_heap_table_size | 64-256 MB | 内存临时表上限,超过用磁盘 |
6.2 Linux 层面的优化
# 修改 open files 限制
ulimit -n 65535
# 禁用 swap(数据库应该尽量使用内存)
sysctl -w vm.swappiness=1
# 文件系统推荐 XFS 或 EXT4,开启 noatime
mount -o noatime,nodiratime /dev/sda1 /data
七、监控与诊断工具
7.1 慢查询日志
-- 开启慢查询日志
SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 2; -- 超过 2 秒记录
SET GLOBAL log_queries_not_using_indexes = ON; -- 记录没走索引的 SQL
-- 查看慢查询日志位置
SHOW VARIABLES LIKE 'slow_query_log_file';
分析工具:
mysqldumpslow -s t -t 10 slow.log(MySQL 自带)pt-query-digest slow.log(Percona Toolkit,推荐)
7.2 Performance Schema
MySQL 5.6+ 内置的性能监控库。
-- 查看当前正在执行的 SQL
SELECT * FROM performance_schema.threads
WHERE PROCESSLIST_STATE IS NOT NULL;
-- 查看语句执行阶段的耗时
SELECT * FROM performance_schema.events_statements_summary_by_digest
ORDER BY SUM_TIMER_WAIT DESC LIMIT 10;
7.3 第三方监控方案
- Prometheus + Grafana:可视化监控趋势
- Percona Monitoring and Management (PMM):Percona 开源的 MySQL 监控全家桶
- SkyWalking / Pinpoint:APM 工具,可追踪 SQL 到具体接口
八、优化优先级速查表
| 优先级 | 优化手段 | 预期提升 | 实施难度 | 风险 |
|---|---|---|---|---|
| 1 | 加索引、改 SQL | 10-100 倍 | 低 | 低 |
| 2 | 改查询逻辑(缓存、分页) | 5-10 倍 | 低 | 低 |
| 3 | 调整 buffer_pool 大小 | 2-5 倍 | 低 | 低 |
| 4 | 读写分离 | 2-3 倍(读场景) | 中 | 中 |
| 5 | 表分区 | 2-5 倍 | 中 | 中 |
| 6 | 水平分表 | 5-10 倍 | 高 | 高 |
| 7 | 升级硬件 | 1.5-2 倍 | 高(贵) | 低 |
九、实战案例:优化一个慢查询
问题描述:用户列表页加载 6 秒,SQL 如下:
SELECT * FROM user
WHERE create_time BETWEEN '2024-01-01' AND '2024-12-31'
ORDER BY create_time
LIMIT 100000, 20;
优化步骤:
Step 1:看执行计划
EXPLAIN SELECT ...
输出:type: ALL(全表扫描),rows: 500万,Extra: Using filesort
Step 2:加索引
ALTER TABLE user ADD INDEX idx_time (create_time);
Step 3:再看计划
type: range,rows: 200万,仍然有 Using filesort
Step 4:优化 SQL 写法
-- 先找起始 ID,再取数据
SELECT * FROM user
WHERE create_time BETWEEN '2024-01-01' AND '2024-12-31'
AND id > (
SELECT id FROM user
WHERE create_time BETWEEN '2024-01-01' AND '2024-12-31'
ORDER BY create_time LIMIT 100000, 1
)
ORDER BY create_time LIMIT 20;
Step 5:最终结果 执行时间从 6 秒降到 0.05 秒。
写在最后
MySQL 性能优化不是一蹴而就的,而是一个持续迭代的过程。建议从以下路径入手:
-
开启慢查询日志,找到问题 SQL
-
用 EXPLAIN 分析执行计划,定位瓶颈
-
加索引 + 改 SQL,解决 80% 的问题
-
监控 + 复盘,防止性能回退
记住:索引不是越多越好,SQL 不是越短越好,优化不是越复杂越好。适合业务场景的方案才是最好的。
如果觉得有帮助,欢迎点赞、收藏、评论三连支持~