MySQL 性能优化完全手册(索引 + SQL 写法 + 架构实战)
内容很干,建议收藏后慢慢看。
本手册整合了 MySQL 性能优化的三个层次,从底层数据结构一路讲到生产架构:
- 索引层:理解 B+ 树为什么快,避开 6 种索引失效场景,掌握联合索引设计铁律;
- SQL 写法层:读懂 EXPLAIN 的每一列,改写 6 种低效写法,搞清 JOIN / 子查询 / 分页的取舍;
- 架构层:大事务拆分、冷热分离、读写分离延迟处理、分库分表决策、连接池与缓存,以及实例参数调优、分区表、生产监控定位。
索引和 SQL 写法优化能解决的场景有限;当单表超过 500 万行、QPS 超过 5000、或事务执行时间超过 1 秒,架构层面的改造就不可避免了。全文全部来自生产环境的实践经验,总计约 1.3 万字。
目录
- 一、B+ 树:索引为什么快
- 二、索引失效的 6 种场景
- 三、联合索引设计的 3 条铁律
- 四、范围查询对联合索引的截断效应
- 五、函数索引与表达式索引
- 六、主键索引的选择策略
- 七、冗余索引与重复索引的清理
- 八、索引维护的 3 个核心监控指标
- 九、EXPLAIN 输出列的完整解读
- 十、ICP 索引下推的原理与验证
- 十一、6 种低效写法的 EXPLAIN 分析
- 十二、深分页的另外两种解法
- 十三、JOIN 的执行流程与成本估算
- 十四、子查询与 JOIN 的取舍
- 十五、IN vs EXISTS 的取舍
- 十六、UNION 优化
- 十七、COUNT 查询优化
- 十八、大事务:拆小事务 + 异步化
- 十九、冷热分离:实施流程与代码模版
- 二十、读写分离:延迟处理策略
- 二十一、分库分表前的决策清单
- 二十二、连接池与缓存配置
- 二十三、数据库实例参数调优
- 二十四、分区表:分库分表的轻量替代
- 二十五、生产环境监控与问题定位
- 结语
一、B+ 树:索引为什么快
MySQL InnoDB 的索引结构是 B+ 树。理解 B+ 树的结构,才能理解索引的边界在哪里。
B+ 树有三个核心特征。
特征一:非叶子节点只存键值
一个 16KB 的 InnoDB 数据页,如果存储的是整型主键(8 字节)+ 指针(6 字节),一个非叶子节点大约能存 16 * 1024 / (8 + 6) ≈ 1170 条索引记录。三层 B+ 树就能存储 1170 * 1170 * 16 ≈ 2190 万条数据。这意味着查询两千万条数据,只需要三次磁盘 IO。
特征二:叶子节点双向链表连接
这使得范围查询(BETWEEN、>、<)变得高效。找到第一个满足条件的记录之后,沿着链表顺序读取即可,不需要多次随机 IO。
特征三:数据只在叶子节点且按序排列
这意味着索引天然就是有序的,ORDER BY 可以利用索引排序,避免 filesort。
理解这三个特征,就可以推导出索引优化的全部规则。下面逐一展开。
二、索引失效的 6 种场景
场景 1:索引列使用函数
CREATE INDEX idx_created ON t(create_time);
-- 失效
SELECT * FROM t WHERE DATE(create_time) = '2024-01-01';
-- 生效
SELECT * FROM t WHERE create_time >= '2024-01-01' AND create_time < '2024-01-02';
MySQL 在索引列上应用函数后,B+ 树的有序性被破坏了。任何一种函数——DATE()、LEFT()、UPPER()、LOWER()、SUBSTR()、YEAR()、MONTH()——都会使优化器放弃该索引。解决方案只有一个:把函数挪到值一侧。
场景 2:隐式类型转换
CREATE INDEX idx_phone ON user(phone);
-- phone 字段类型是 VARCHAR
-- 失效
SELECT * FROM user WHERE phone = 13800138000;
-- 生效
SELECT * FROM user WHERE phone = '13800138000';
当传入的参数类型与字段类型不一致时,MySQL 会将字段类型隐式转换为参数类型。转换动作本质上是 CAST(phone AS SIGNED),等于对索引列应用了函数,索引失效。
场景 3:LIKE 前模糊匹配
CREATE INDEX idx_name ON user(name);
-- 失效
SELECT * FROM user WHERE name LIKE '%三';
-- 失效
SELECT * FROM user WHERE name LIKE '%三%';
-- 生效
SELECT * FROM user WHERE name LIKE '张%';
B+ 树的叶子节点按字典序排列。前缀匹配可以利用这个顺序进行范围扫描,但后模糊匹配无法利用,因为不知道从哪里开始扫描。
场景 4:OR 条件部分无索引
-- status 有索引,type 无索引
-- 失效
SELECT * FROM order WHERE status = 1 OR type = 3;
-- 生效方案一:给 type 加索引
-- 生效方案二:用 UNION 替代
SELECT * FROM order WHERE status = 1
UNION
SELECT * FROM order WHERE type = 3;
OR 本质上是取两个条件的并集。MySQL 的索引只能走单一索引路径,无法在索引层面做并集运算。如果 OR 两边的列不是都有索引,优化器会直接选择全表扫描。
场景 5:联合索引违反最左前缀
CREATE INDEX idx_a_b_c ON t(a, b, c);
-- 生效
WHERE a = 1
WHERE a = 1 AND b = 2
WHERE a = 1 AND b = 2 AND c = 3
-- 部分生效
WHERE a = 1 AND c = 3 -- a 用到索引,c 用不到
-- 失效
WHERE b = 2 -- 跳过了 a
WHERE c = 3 -- 跳过了 a 和 b
WHERE b = 2 AND c = 3 -- 跳过了 a
B+ 树联合索引的排序规则是:先按 a 排,a 相同再按 b 排,b 相同再按 c 排。跳过 a 直接查 b,相当于在乱序中查找,B+ 树无法定位。
场景 6:NOT IN 和 != 操作
CREATE INDEX idx_status ON t(status);
-- 如果 status 的区分度低,以下查询会全表扫描
SELECT * FROM t WHERE status != 1;
SELECT * FROM t WHERE status NOT IN (1, 2);
MySQL 优化器对不等于操作的成本估算通常偏高。它认为走索引需要扫描大部分数据,不如直接全表扫描快。实际是否走索引取决于数据的分布,可以通过 FORCE INDEX 强制指定。
补充(常见误区修正):
!=/NOT IN并不"必然"全表扫描。当列的选择性很高(区分度接近 1)时,优化器完全可以走range索引扫描,只是扫描的行数通常比等值查询多。真正的大坑在NOT IN (子查询):如果子查询的结果里含有 NULL,整个NOT IN表达式按三值逻辑判定为 UNKNOWN,外层查询会返回空集而非"排除这些值"——这往往不是性能问题,而是正确性问题。遇到NOT IN (子查询)时,优先改写成NOT EXISTS(见第十五章)。
三、联合索引设计的 3 条铁律
铁律 1:区分度高的列放在最左侧
CREATE TABLE user (
id INT PRIMARY KEY,
sex CHAR(1), -- 2 种值,区分度 0.00001
city VARCHAR(50), -- 约 1000 种值,区分度 0.01
last_login DATETIME -- 几乎无重复,区分度接近 1
);
-- 推荐索引顺序
CREATE INDEX idx_user_query ON user(last_login, city, sex);
区分度的计算方式:SELECT COUNT(DISTINCT col) / COUNT(*) FROM table。结果越接近 1 的列,越应该放在联合索引的左侧。这能让 B+ 树更早地缩小扫描范围。
铁律 2:高频等值查询列放在范围查询列前面
-- 查询模式:status=1 AND create_time BETWEEN '2024-01-01' AND '2024-01-31'
-- 推荐索引
CREATE INDEX idx_status_time ON t(status, create_time);
-- 不推荐索引
CREATE INDEX idx_time_status ON t(create_time, status);
在 EXPLAIN 的 Extra 列中,等值条件可以精准定位到某一棵子树,而范围条件只能锁定一个区间。如果把范围列放在前面,等值列在后面的索引就无法继续利用索引过滤了。
铁律 3:利用覆盖索引减少回表
-- 高频查询:SELECT order_no, amount FROM order WHERE status = 1
-- 只建单列索引 (status)
-- 执行流程:索引找到 status=1 的位置 -> 获取主键 ID -> 回表查 order_no 和 amount
-- Extra 显示:Using where
-- 建覆盖索引 (status, order_no, amount)
-- 执行流程:索引直接找到 status=1 的位置,order_no 和 amount 已在索引中
-- Extra 显示:Using where; Using index
两个方案的区别在于回表操作。InnoDB 的二级索引叶子节点不包含完整行数据,只包含索引列和主键。查询的字段如果不在索引中,就必须根据主键回聚簇索引查完整行。回表是随机 IO,层数越深代价越大。
四、范围查询对联合索引的截断效应
联合索引(如 (a, b, c))在 B+ 树里是先按 a、再按 b、最后按 c 排序的。这意味着:一旦某一列用了范围查询,它右侧的列就再也用不上索引的有序性了。
CREATE INDEX idx_a_b_c ON t(a, b, c);
-- a 等值、b 范围、c 等值
EXPLAIN SELECT * FROM t WHERE a = 1 AND b > 10 AND c = 2;
-- key 用到了 idx_a_b_c,但真正生效的只有 (a, b) 前缀
-- c = 2 的过滤退化为回表后的 WHERE 校验(Extra: Using where; Using index condition)
B+ 树能定位到 a=1 AND b>10 的起点,但 b 是一个区间,区间内的 c 不再全局有序,所以 c 无法继续走索引定位,只能拿到候选行后回表过滤(或靠 ICP 在引擎层过滤,见第十章)。
对比三种写法:
| 写法 | 索引用到 |
|---|---|
WHERE a=1 AND b=2 AND c=3 | (a, b, c) 全用 |
WHERE a=1 AND b>10 AND c=2 | 只用 (a, b) |
WHERE a=1 AND b>10 ORDER BY c | b 范围后 c 无序,ORDER BY c 仍需 filesort |
设计启示(呼应第三章铁律 2):联合索引里把区分度高的等值列放左边,范围列尽量放最右。如果业务既要在中间列做范围、又要在右侧列精确过滤,可以接受右侧列走 ICP / 回表,或考虑下一章的函数索引 / 冗余索引来补位。
五、函数索引与表达式索引
第二章「场景 1」讲过,索引列上套函数(DATE()、LEFT()、UPPER()…)会让索引失效,因为破坏了 B+ 树的有序性。传统解法只有"把函数挪到值一侧"。但在 MySQL 8.0 之后,还有更彻底的解法:直接对表达式建索引。
MySQL 8.0 函数索引
-- 直接对表达式建索引
CREATE INDEX idx_created_date ON t((DATE(create_time)));
-- 现在下面这句能走索引了
EXPLAIN SELECT * FROM t WHERE DATE(create_time) = '2024-01-01';
-- Extra: Using where; Using index
MySQL 5.7 虚拟列 + 索引
5.7 不支持函数索引,但可以用虚拟列(Generated Column)曲线救国:
ALTER TABLE t ADD COLUMN created_date DATE
GENERATED ALWAYS AS (DATE(create_time)) VIRTUAL;
CREATE INDEX idx_created_date ON t(created_date);
SELECT * FROM t WHERE created_date = '2024-01-01';
使用要点
- 查询里的表达式必须和索引里的表达式逐字符一致(包括空格、参数形式),否则优化器认不出。
- 函数索引本质是"预先计算并排序",代价是写入时多算一次、索引体积略增。
- 其它常见用法:
UPPER(name)、YEAR(create_time)、JSON 字段提取((CAST(data->>'$.city' AS CHAR)))。 - 注意:函数索引救不了后模糊匹配(
LIKE '%三'),那种需要全文索引 / 倒排索引。
六、主键索引的选择策略
CREATE TABLE t (
id BIGINT AUTO_INCREMENT PRIMARY KEY, -- 推荐
uuid VARCHAR(36) PRIMARY KEY, -- 不推荐
);
B+ 树的叶子节点按主键顺序存储。自增主键插入时,新数据追加到叶子节点的末尾,页分裂的频率最低。UUID 主键插入时,新数据可能插入到任意位置,导致频繁的页分裂和碎片整理。
基于 InnoDB 的聚簇索引特性,建议:
- 主键使用自增整数类型(INT 或 BIGINT)
- 不要使用业务字段做主键(如身份证号、手机号)
- 不要使用 UUID 或雪花算法生成的随机长字符串
七、冗余索引与重复索引的清理
在实际维护中,同一张表上往往积累了大量冗余索引。以下三条规则可用于清理。
规则一:联合索引覆盖单列索引
CREATE INDEX idx_a ON t(a); -- 可以被 idx_a_b 替代
CREATE INDEX idx_a_b ON t(a, b); -- 保留
联合索引 (a, b) 可以覆盖单列索引 (a),但无法覆盖 (b)。
规则二:查询 information_schema 识别重复索引
SELECT
TABLE_NAME,
GROUP_CONCAT(INDEX_NAME ORDER BY SEQ_IN_INDEX) AS redundant_indexes
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'your_db'
GROUP BY TABLE_NAME, INDEX_NAME, COLUMN_NAME
HAVING COUNT(*) > 1;
规则三:使用 pt-duplicate-key-checker
pt-duplicate-key-checker h=localhost,D=your_db
八、索引维护的 3 个核心监控指标
指标一:索引使用率
SELECT
index_name,
stat_value AS pages_io
FROM mysql.innodb_index_stats
WHERE database_name = 'your_db'
AND table_name = 'your_table'
AND stat_name = 'size';
结合 performance_schema 可以查到每个索引的实际使用频率。超过三个月未使用的索引可以删除。
指标二:索引选择性
SELECT
COLUMN_NAME,
CARDINALITY / (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'your_db'
AND TABLE_NAME = 'your_table') AS selectivity
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = 'your_db'
AND TABLE_NAME = 'your_table';
选择性低于 0.2 的索引,优化器大概率不会使用,可以考虑删除。
指标三:索引碎片率
SELECT
TABLE_NAME,
ROUND(DATA_FREE / (DATA_LENGTH + INDEX_LENGTH + DATA_FREE) * 100, 2) AS fragment_rate
FROM information_schema.TABLES
WHERE TABLE_SCHEMA = 'your_db'
AND TABLE_NAME = 'your_table';
碎片率超过 30% 的表,执行 ALTER TABLE your_table ENGINE=InnoDB 进行重建。注意该操作会锁表,建议在低峰期执行。
九、EXPLAIN 输出列的完整解读
索引建对了,不代表 SQL 就跑得快。EXPLAIN 输出的每一列都有明确的含义,读懂了就知道 SQL 到底慢在哪。
示例 SQL:
EXPLAIN SELECT * FROM order WHERE status = 1 AND amount > 100 ORDER BY create_time DESC LIMIT 10;
输出包含 12 列。重点解析以下 6 列。
1.1 id 列
id 表示查询中 SELECT 的序号。id 越大优先级越高,id 相同则从上往下执行。子查询和 UNION 会产生多个 id。
1.2 select_type 列
| 值 | 含义 |
|---|---|
| SIMPLE | 简单查询,不包含子查询和 UNION |
| PRIMARY | 最外层查询 |
| SUBQUERY | 子查询中的第一个 SELECT |
| DEPENDENT SUBQUERY | 子查询,依赖外层结果,每行执行一次 |
| DERIVED | 派生表(FROM 子句中的子查询) |
| UNION / UNION RESULT | UNION 查询中的后续 SELECT |
DEPENDENT SUBQUERY 是性能杀手。例如:
SELECT * FROM t1 WHERE id IN (SELECT id FROM t2 WHERE t2.type = t1.type);
t1 每扫描一行,t2 的子查询就执行一次。这种写法建议改成 JOIN 或 EXISTS。
1.3 type 列
按性能从高到低排列:
| type | 含义 | 典型场景 |
|---|---|---|
| NULL | 不访问表和索引 | SELECT 1 |
| const | 最多匹配一行 | 主键或唯一索引等值查询 |
| eq_ref | JOIN 被驱动表用主键匹配 | 主键 JOIN |
| ref | 普通索引等值查询 | WHERE status = 1 |
| range | 索引范围扫描 | >、<、BETWEEN、IN |
| index | 全索引扫描 | 遍历整棵索引树 |
| ALL | 全表扫描 | 需要优化 |
优化目标:避免 ALL 和 index,至少做到 range。
1.4 possible_keys 和 key 列
possible_keys 列出 MySQL 认为可能用到的索引,key 列出实际使用的索引。
如果 possible_keys 不为空但 key 为 NULL,说明优化器判定走索引不如全表扫描。常见原因:
- 查询条件筛选率太低(低于 20%)
- 索引列参与了函数运算或类型转换
- 查询涉及大部分数据,走索引需要大量随机 IO
1.5 rows 列
优化器估算的需要扫描的行数。这个值是估算值,不精确,但数量级值得参考。rows 从 1000 万优化到 1000,执行时间大概率会下降三个数量级。
1.6 Extra 列
Extra 包含大量辅助信息,以下是需要重点关注的条目。
| Extra 内容 | 含义 | 评价 |
|---|---|---|
| Using index | 覆盖索引,不回表 | 最优 |
| Using index condition | 索引下推(ICP),MySQL 5.6+ | 好 |
| Using where | 回表后在数据行过滤 | 可接受 |
| Using filesort | 无法利用索引排序,额外排序 | 差,需优化 |
| Using temporary | 使用临时表存储中间结果 | 差,需优化 |
| Using join buffer | JOIN 时被驱动表无索引 | 差,需加索引 |
十、ICP 索引下推的原理与验证
ICP(Index Condition Pushdown)从 MySQL 5.6 起默认开启,是减少回表随机 IO 的关键手段。
没有 ICP 时:存储引擎按索引定位到候选行的主键 → 回表取完整行 → 交给 Server 层,再用 WHERE 里"只涉及索引列"的条件过滤。大量本可提前丢弃的行白回了表。
有 ICP 时:存储引擎在索引层就先用 WHERE 中"只涉及索引列"的条件做过滤,过滤掉的行的主键根本不回表。
CREATE INDEX idx_zip_last ON people(zipcode, lastname);
-- zipcode 等值(走索引),lastname 后模糊(索引无法定位)
-- 但 lastname 的条件只涉及索引列,可下推到引擎层
EXPLAIN SELECT * FROM people
WHERE zipcode = '91111' AND lastname LIKE '%etun';
-- Extra: Using index condition
验证方式:
- EXPLAIN 的 Extra 出现
Using index condition即生效。 - 开关由优化器参数控制:
optimizer_switch='index_condition_pushdown=on'(默认开)。
限制:涉及非索引列、子查询、存储函数的条件无法下推;下推只能减少回表行数,不能改变索引的定位能力。
十一、6 种低效写法的 EXPLAIN 分析
2.1 SELECT * 导致无法覆盖索引
CREATE INDEX idx_status_amount ON `order`(status, amount);
-- 写法一
EXPLAIN SELECT * FROM `order` WHERE status = 1;
-- Extra: Using where(回表查全部字段)
-- 写法二
EXPLAIN SELECT id, status, amount FROM `order` WHERE status = 1;
-- Extra: Using where; Using index(不回表)
两个查询的 type 都是 ref,rows 相同。但写法一要回表查完整行,涉及随机 IO;写法二直接从索引取数据。数据量大时,两者的性能差距是数量级的。
2.2 LIMIT 深分页的 IO 放大
-- 翻到第 1000 页
EXPLAIN SELECT * FROM operation_log ORDER BY id DESC LIMIT 19980, 20;
这个 SQL 的 EXPLAIN 输出中,type=index(全索引扫描),rows=20000。但实际上 MySQL 扫描了前 20000 行,只返回了最后 20 行。前 19980 行都属于白扫描。
优化后的写法:
SELECT * FROM operation_log
WHERE id >= (SELECT id FROM operation_log ORDER BY id DESC LIMIT 19980, 1)
ORDER BY id DESC LIMIT 20;
EXPLAIN 分析时,子查询用到了覆盖索引,type=index,rows=20000。外层查询 type=range,rows=20。总扫描行数从 20000 降到 20+1。实际执行时间从 2.8 秒降到 15 毫秒。
2.3 JOIN 关联列无索引
EXPLAIN SELECT o.*, oi.*
FROM `order` o
JOIN order_item oi ON o.id = oi.order_id
WHERE o.status = 1;
如果 oi.order_id 没有索引,EXPLAIN 的结果是:
| 表 | type | rows | Extra |
|---|---|---|---|
| order | ref | 5000 | Using where |
| order_item | ALL | 600000 | Using where; Using join buffer |
order 表有 5000 行满足条件,每行都要跟 60 万行的 order_item 做 Block Nested Loop 连接。总扫描行数:5000 * 600000 ≈ 30 亿。
加上索引之后:
CREATE INDEX idx_order_id ON order_item(order_id);
EXPLAIN 变为:
| 表 | type | rows | Extra |
|---|---|---|---|
| order | ref | 5000 | Using where |
| order_item | ref | 3 | Using where |
总扫描行数:5000 * 3 ≈ 15000。差距是 30 亿 vs 1.5 万。
2.4 GROUP BY 无法利用索引
CREATE INDEX idx_status ON `order`(status);
CREATE INDEX idx_status_create ON `order`(status, create_time);
-- 写法一
EXPLAIN SELECT status, COUNT(*) FROM `order` GROUP BY status;
-- Extra: Using temporary; Using filesort(需要临时表和排序)
GROUP BY 的语义等效于 ORDER BY + 聚合。MySQL 默认会对 GROUP BY 的列做排序。如果 GROUP BY 的列有索引,可以避免 filesort;如果索引覆盖了所有查询字段,还可以避免临时表。
2.5 ORDER BY 无法利用索引导致 filesort
CREATE INDEX idx_amount ON `order`(amount);
EXPLAIN SELECT * FROM `order` WHERE amount > 100 ORDER BY create_time DESC;
-- Extra: Using where; Using filesort(create_time 没有索引,需要额外排序)
-- 改为以下索引之一
-- (amount, create_time) 覆盖等值和排序
-- 或者单独为 create_time 建索引
2.6 NOT IN 导致的全表扫描
EXPLAIN SELECT * FROM order WHERE status NOT IN (2, 3);
-- type=ALL,rows 接近全表
NOT IN 的语义相当于 status != 2 AND status != 3,本质是不等值查询。优化建议:
- 如果不是必须排除,用等值查询代替
- 如果 status 的取值有限,使用范围查询:
WHERE status = 0 OR status = 1
注意:
!=在索引区分度高时优化器可走range,并非一定全表扫描(见第二章场景 6)。NOT IN真正的雷是子查询含 NULL——此时整条查询返回空集(三值逻辑陷阱),应改用NOT EXISTS(详见第十五章)。
十二、深分页的另外两种解法
第十一章 2.2 讲了深分页的子查询解法。这里补齐另外两种更优、更常用的方案。三种方案对比如下:
方案一:子查询法(回顾,详见第十一章 2.2)
SELECT * FROM operation_log
WHERE id >= (SELECT id FROM operation_log ORDER BY id DESC LIMIT 19980, 1)
ORDER BY id DESC LIMIT 20;
总扫描从 20000 降到 20001,执行 2.8s → 15ms。
方案二:延迟关联(Deferred Join)
先只在覆盖索引上扫描并取出目标 id,再按主键 JOIN 回原表,避免对几万行无关注录回表。
SELECT o.* FROM operation_log o
JOIN (
SELECT id FROM operation_log ORDER BY id DESC LIMIT 19980, 20
) t ON o.id = t.id
ORDER BY o.id DESC;
子查询只在索引上取 20 个 id(不回表),外层按主键精准取 20 行。对 SELECT *、宽表、需回表取很多字段的场景,比方案一更省 IO。
方案三:游标分页 / Keyset Pagination
业务上用"上一页最后一条的排序键值"作为下一页起点,彻底消灭 OFFSET:
-- 第一页
SELECT * FROM operation_log ORDER BY id DESC LIMIT 20;
-- 下一页(假设上一页最小 id = 19981)
SELECT * FROM operation_log WHERE id < 19981 ORDER BY id DESC LIMIT 20;
优点:无论翻多深,扫描行数恒定 = LIMIT 值,性能不随页数衰减。
代价:不支持"跳到第 N 页"的随机跳转,只能上一页 / 下一页;排序键需唯一(通常加 id 兜底,如 ORDER BY create_time DESC, id DESC)。
| 方案 | 扫描行数 | 能否跳页 | 适用 |
|---|---|---|---|
| 子查询法 | ~OFFSET+limit | 能 | 通用 |
| 延迟关联 | limit | 能 | 宽表 / SELECT * |
| 游标分页 | limit | 不能 | 翻页 / 信息流 |
十三、JOIN 的执行流程与成本估算
MySQL 执行 JOIN 的标准算法是 Nested Loop Join。工作原理:
for each row in t1 matching range {
for each row in t2 matching join condition {
for each row in t3 matching join condition {
send to client;
}
}
}
3.1 成本估算公式
总扫描行数 = t1_rows * (t2_rows_per_match) * (t3_rows_per_match)
以两表 JOIN 为例:
- 总扫描行数 = 驱动表扫描行数 * 被驱动表每次匹配的平均扫描行数
3.2 优化策略
策略一:让扫描行数更小的表做驱动表。驱动表的 rows 直接影响总扫描行数。
策略二:被驱动表的关联列必须有索引。没有索引时,匹配动作变为全表扫描,t2_rows_per_match 接近表的总行数。
策略三:使用 STRAIGHT_JOIN 可以强制指定驱动表。
SELECT * FROM t1 STRAIGHT_JOIN t2 ON t1.id = t2.t1_id;
十四、子查询与 JOIN 的取舍
MySQL 5.6 之前,子查询的执行效率普遍低于 JOIN。5.6 引入了子查询优化(子查询物化),5.7 进一步增强了半连接(Semi-join)优化。
以下两种写法在 MySQL 5.7+ 中等效:
-- 写法 A:子查询(会被优化为半连接)
SELECT * FROM t1 WHERE id IN (SELECT t1_id FROM t2 WHERE status = 1);
-- 写法 B:JOIN(可能会产生重复行,需要 DISTINCT)
SELECT DISTINCT t1.* FROM t1 JOIN t2 ON t1.id = t2.t1_id WHERE t2.status = 1;
在 5.7+ 版本中,EXPLAIN 显示两种写法的执行计划一致。但以下情况子查询比 JOIN 更优:
SELECT * FROM t1
WHERE amount > (
SELECT AVG(amount) FROM t1
);
标量子查询只执行一次,JOIN 无法等价改写。
十五、IN vs EXISTS 的取舍
IN 和 EXISTS 都能表达"子集包含",但执行模型不同,选错可能差几个数量级。
-- IN:先执行子查询,把结果集物化,再拿去驱动外层
SELECT * FROM t1 WHERE id IN (SELECT t1_id FROM t2 WHERE status = 1);
-- EXISTS:以外层 t1 的每一行,去子查询里探测"是否存在"
SELECT * FROM t1 WHERE EXISTS (
SELECT 1 FROM t2 WHERE t2.t1_id = t1.id AND t2.status = 1
);
经验法则(MySQL 5.7 之前尤其明显):
- 子查询结果集小、外层大 → 用
IN:物化一个小结果集去驱动大表,效率高。 - 子查询结果集大、外层小 → 用
EXISTS:用小表逐行去探测,避免物化一个大结果集。 - 两边都有索引时差异不大;
EXISTS更不容易因为子查询含NULL而出错。
MySQL 5.7+ 引入**半连接(Semi-join)**优化,会把 IN 改写成类似 EXISTS 的执行方式,很多时候两者 EXPLAIN 完全一致。仍建议:用 EXPLAIN 对比 rows 和 cost 再决定,不要死记。
补充坑点:
IN列表过长(几千个值)会让执行计划膨胀,考虑改JOIN或分批。NOT IN (子查询)若子查询结果含NULL,整条查询返回空集(三值逻辑陷阱),此时应改用NOT EXISTS。
十六、UNION 优化
UNION 默认会去重,去重操作需要创建临时表。如果不需要去重,使用 UNION ALL 替代:
-- 不需要去重时,UNION ALL 性能远超 UNION
SELECT * FROM order WHERE status = 1
UNION ALL
SELECT * FROM order WHERE type = 3;
UNION ALL 直接拼接结果集,不做去重。EXPLAIN 显示 Extra 中不会出现 Using temporary。
十七、COUNT 查询优化
COUNT 是大表里最常见的"看起来简单、实际很慢"的查询。
COUNT(*) / COUNT(1) / COUNT(col) 的区别
SELECT COUNT(*) FROM t; -- 统计行数,不忽略 NULL
SELECT COUNT(1) FROM t; -- 与 COUNT(*) 等价
SELECT COUNT(name) FROM t; -- 只统计 name 非 NULL 的行
常见误区:以为 COUNT(*) 比 COUNT(1) 慢。InnoDB 里优化器对二者处理完全一致,没有性能差别;COUNT(col) 因为要判空,反而略重。
大表 COUNT(*) 为什么慢
InnoDB 不存储总行数(MVCC 下不同事务看到不同行数),COUNT(*) 必须实时扫。优化手段:
- 近似值:业务允许误差时,用
EXPLAIN SELECT COUNT(*) ...的rows估算,或读information_schema.TABLES.TABLE_ROWS(InnoDB 是估算值)。 - 覆盖索引:
COUNT一个二级索引列比扫主键快,因为二级索引叶子更小。例如SELECT COUNT(1) FROM t USE INDEX(idx_status)。 - 实时精确计数:用计数表(counter 表)+ 业务双写,或 Redis 维护(
INCR/DECR),适合首页展示总数等场景。 - COUNT(DISTINCT col):先
WHERE过滤再DISTINCT;数据倾斜严重时可用近似方案(如 HyperLogLog /APPROX_COUNT_DISTINCT类实现)。 - 分组统计
COUNT(*) ... GROUP BY配合索引可避免临时表(详见第十一章 2.4)。
十八、大事务:拆小事务 + 异步化
1.1 大事务的危害量化
一条 SQL 在事务中的执行流程:BEGIN -> 获取行锁 -> 执行 DML -> 写 undo log -> 写 redo log -> 持有锁 -> COMMIT -> 释放锁。
大事务执行时间长,意味着:
- 行锁持有时间 = 事务开始到提交的总时间。持有锁期间,其他请求对该行的所有操作都要排队。
- undo log 在事务提交前不能被 purge,事务越大保留的 undo 量越大,影响 MVCC 的 Read View 构建。
- 主从复制中,从库需要完整重放大事务的 binlog。一个大事务从执行到提交,从库无法并行回放,造成主从延迟。
实测数据:一个处理 500 条记录的事务,执行时间 35 秒。拆分成 500 个独立事务后,平均每个事务 2 毫秒。行锁持有时间从 35 秒缩减到 2 毫秒。
1.2 拆事务的通用模版
-- 原始写法(大事务)
BEGIN;
FOR record IN (SELECT * FROM batch_data WHERE status = 0):
UPDATE target SET status = 1 WHERE id = record.id;
INSERT INTO log(user_id, action) VALUES (record.user_id, 'process');
COMMIT;
-- 改造后(拆小事务)
FOR record IN (SELECT * FROM batch_data WHERE status = 0 LIMIT 1):
BEGIN;
UPDATE target SET status = 1 WHERE id = record.id;
INSERT INTO log(user_id, action) VALUES (record.user_id, 'process');
COMMIT;
改造要点:把循环体内的 SQL 移到事务外部,每次只处理一条记录。用 LIMIT 1 逐条消费,避免一次加载过多数据到内存。
1.3 异步化的三种方案
| 方案 | 适用场景 | 实现方式 |
|---|---|---|
| MQ 异步处理 | 非实时场景 | 生产者发消息后立即返回,消费者逐条消费 |
| 延迟队列 | 需要批量写入的场景 | 按固定时间窗口合并处理 |
| 独立 Worker 进程 | 无法引入 MQ 的简单场景 | 定时扫描待处理记录,每次处理固定条数后提交 |
十九、冷热分离:实施流程与代码模版
2.1 适用条件
单表超过 500 万行,并且数据有明确的时间边界。例如:操作日志、订单流水、交易记录。超过某个时间点之后的数据极少被访问。
2.2 实施步骤
第一步:拆表
-- 热表:存放近期频繁访问的数据
CREATE TABLE operation_log_active LIKE operation_log;
-- 冷表:存放历史冷数据
-- 原表 operation_log 重命名为 operation_log_archive
RENAME TABLE operation_log TO operation_log_archive;
第二步:写入路由
-- 根据时间决定写入哪张表
INSERT INTO operation_log_active (...) VALUES (...); -- 当天数据
-- 或者统一写入热表,定时任务迁移
第三步:定时迁移任务
-- 迁移超过 90 天的数据到冷表
INSERT INTO operation_log_archive
SELECT * FROM operation_log_active
WHERE create_time < DATE_SUB(NOW(), INTERVAL 90 DAY)
LIMIT 1000; -- 分批迁移,避免大事务
DELETE FROM operation_log_active
WHERE create_time < DATE_SUB(NOW(), INTERVAL 90 DAY)
LIMIT 1000;
迁移必须分批进行,每批 1000 条左右。单次迁移数据量过大会造成主从延迟。
第四步:查询路由
-- 伪代码表示查询逻辑
if (查询时间范围 <= 90天) {
SELECT * FROM operation_log_active WHERE ...;
} else {
SELECT * FROM operation_log_archive WHERE ...;
-- 提示用户:查询历史数据,响应可能较慢
}
2.3 冷热分离的效果参考
一个 8000 万行的日志表实施冷热分离后:
- 热表 300 万行,查询响应时间从 2.8 秒降到 30 毫秒
- 日常 90% 的查询只查热表
- 冷表查询仍然需要 2~3 秒,但查询频率极低
二十、读写分离:延迟处理策略
3.1 主从延迟的原因分析
主从延迟的核心原因是:主库是并发写入,从库是单线程回放(MySQL 5.6 及之前)。MySQL 5.7 引入了逻辑时钟并行复制,MySQL 8.0 进一步增强了 WriteSet 并行复制,但在写入量大的场景下依然可能出现延迟。
常见延迟值:
- 正常情况下:1 秒以内
- 主库批量写入时:2~5 秒
- 大事务执行时:10~30 秒以上
3.2 处理延迟的四级策略
第一级:强制读主(秒级容忍)
发帖/下单等关键操作后,业务层标记需要读主。实现方式:在 Redis 中存储 userId + 时间戳,查询时判断是否在最近 3 秒内有过写入。
if (redis.get("master_read:" + userId) != null) {
// 走主库
} else {
// 走从库
}
第二级:版本号检测(毫秒级容忍)
数据写入时生成版本号(可以是时间戳或自增序列),查询时比对版本号。如果从库版本落后,等待或转主库。
第三级:半同步复制(一主一从)
设置 rpl_semi_sync_master_wait_point=AFTER_SYNC,主库等待至少一个从库确认接收到 binlog 后才提交。这能保证主库提交时从库一定有数据,延迟从秒级降到毫秒级。
-- 主库配置
plugin-load=rpl_semi_sync_master=semisync_master.so
rpl_semi_sync_master_enabled=1
rpl_semi_sync_master_wait_for_no_slave=1
rpl_semi_sync_master_timeout=1000 -- 超时后降级为异步
-- 从库配置
plugin-load=rpl_semi_sync_slave=semisync_slave.so
rpl_semi_sync_slave_enabled=1
第四级:业务容忍
对时效性不敏感的业务(如报表、统计分析),接受秒级延迟,不做特殊处理。
二十一、分库分表前的决策清单
分库分表会引入分布式事务、跨分片查询、全局主键生成、数据迁移等一系列复杂问题。在决定分库分表之前,先检查以下清单。
4.1 尝试过的优化方案
- 索引是否合理且使用正确
- SQL 写法是否经过 EXPLAIN 验证
- 是否做过冷热分离
- 是否需要增加缓存层
- 是否过度使用 SELECT * 和 JOIN
如果以上尝试都没有效果,再考虑分库分表。
4.2 分库分表类型选择
| 拆分类型 | 说明 | 适用场景 |
|---|---|---|
| 垂直拆分 | 按业务模块拆到不同数据库 | 微服务架构 |
| 水平分表 | 同一张表按分片键分散到多张结构相同的表 | 单表超过 2000 万行 |
分片键选择原则:选择查询频率最高的字段。如果查询条件中经常出现 user_id,就用 user_id 做分片键。
4.3 分库分表的常见陷阱
陷阱一:跨分片查询无法避免。
SELECT * FROM order WHERE order_no = 'xxx'
如果分片键不是 order_no,需要广播到全部分片。
陷阱二:分页/排序需要合并。
SELECT * FROM order ORDER BY create_time DESC LIMIT 20
需要在每个分片查 20 条,然后内存中合并排序。
陷阱三:分布式事务。 跨分片的数据一致性需要引入 XA 或 TCC 事务。
陷阱四:数据迁移。 水平扩容从 4 个分片扩到 8 个分片,需要全量数据重分布。
二十二、连接池与缓存配置
5.1 HikariCP 连接池配置
Spring Boot 默认使用 HikariCP。核心配置参数:
spring.datasource.hikari.maximum-pool-size=20 # 最大连接数
spring.datasource.hikari.minimum-idle=5 # 最小空闲连接
spring.datasource.hikari.idle-timeout=300000 # 空闲超时(毫秒)
spring.datasource.hikari.connection-timeout=30000 # 获取连接超时(毫秒)
spring.datasource.hikari.max-lifetime=1800000 # 连接最大存活时间(毫秒)
最大连接数的计算公式:((core_count * 2) + effective_spindle_count)。四核四线程的机器,建议 maximum-pool-size 不超过 12。连接数并不是越大越好,过多的连接会导致 MySQL 上下文切换和锁竞争加剧。
5.2 缓存策略
引入缓存可以大幅减轻数据库的压力。但缓存引入不当也会引入一致性问题。
缓存模式选择:Cache-Aside
# 读流程
data = cache.get(key)
if (data == null):
data = db.query(sql)
cache.set(key, data, ttl=3600) # 设置过期时间
# 写流程
db.update(sql)
cache.del(key) # 删除缓存,而不是更新
删除缓存而不是更新缓存的原因:避免并发写导致的缓存与数据库不一致。
5.3 缓存穿透的防护
缓存穿透:查询一个不存在的数据,缓存和数据库都查不到。高并发下会让请求直接打到数据库。
防护方式一:缓存空值
if (data == null && !cache.exists(key)):
data = db.query(sql)
if (data == null):
cache.set(key, "NULL", ttl=60) # 缓存空值
else:
cache.set(key, data, ttl=3600)
查不到数据时在缓存中存一个空标记,TTL 设置较短(60 秒以内)。
防护方式二:布隆过滤器
将所有存在的 ID 存入布隆过滤器,查询前先判断 ID 是否存在。
二十三、数据库实例参数调优(实例层配置)
很多团队花几周改 SQL、加索引,却从不动实例参数。事实上,实例层参数调优的成本极低,收益却可能立竿见影——尤其是还没到分库分表程度的中等规模实例。
6.1 最重要的参数:innodb_buffer_pool_size
Buffer Pool 是 InnoDB 缓存数据和索引的内存区域,命中率直接决定性能。经验法则:专用数据库服务器设为物理内存的 60%~75%。8G 内存机器设 5G,32G 设 24G,128G 设 90G。
-- 观察命中率
SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';
-- 命中率 = 1 - (Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests)
-- 低于 99% 说明 buffer pool 偏小,应调大
实测案例:一个 16G 内存、单表 3000 万行的实例,buffer pool 从默认 128M 调到 10G 后,缓存命中率从 91% 升到 99.7%,整体查询耗时下降约 40%。
6.2 写入性能相关参数
redo log 与 binlog 的刷盘策略,是「性能 vs 持久性」的核心权衡点。
| 参数 | 推荐值 | 说明 |
|---|---|---|
innodb_log_file_size | 1G~2G | redo log 单文件大小,太小会频繁触发 checkpoint 刷盘 |
innodb_flush_log_at_trx_commit | 1(核心交易)/ 2(日志统计) | 1=每次提交刷盘最安全;2=提交写缓存每秒刷盘,崩溃最多丢 1 秒 |
sync_binlog | 1(核心交易)/ 0 或 N(日志类) | 1=每次提交刷 binlog;0=由 OS 刷,性能高 |
日志类、统计类、埋点类业务可把 innodb_flush_log_at_trx_commit=2 且 sync_binlog=0,吞吐量通常能提升 2~3 倍,代价是极端宕机时最多丢 1 秒数据。
6.3 innodb_io_capacity:别让 SSD 闲着
这个参数告诉 InnoDB 磁盘的 IO 能力,控制脏页刷新速度。默认 200(按机械盘设定),在 SSD 上会严重限制吞吐。
innodb_io_capacity = 2000 # SATA SSD
innodb_io_capacity_max = 4000 # 突发上限
# NVMe SSD 可设到 4000 / 8000
6.4 连接数不是越大越好
max_connections = 500
thread_cache_size = 50
每个连接占用约 4~8MB 内存,连接数过大会导致内存暴涨和大量上下文切换。配合前面 HikariCP 的连接池上限,整体连接数应控制在 max_connections 的 70% 以内,给管理命令和突发留余量。
二十四、分区表:分库分表的轻量替代
分库分表改造量大、坑多。在单表 1000 万~5000 万行、还没到分库分表程度时,分区表往往是更划算的选择。
7.1 适用场景
- 单表千万级以上,查询开始变慢,但业务量还撑不起分库分表
- 需要按时间快速清理历史数据:
DROP PARTITION比DELETE快几个数量级(不记 undo、不碎表) - 查询条件中高频出现时间范围
7.2 RANGE 分区实战
CREATE TABLE operation_log (
id BIGINT,
user_id BIGINT,
create_time DATETIME,
...
) PARTITION BY RANGE (TO_DAYS(create_time)) (
PARTITION p202601 VALUES LESS THAN (TO_DAYS('2026-02-01')),
PARTITION p202602 VALUES LESS THAN (TO_DAYS('2026-03-01')),
PARTITION pmax VALUES LESS THAN MAXVALUE
);
分区表对应用完全透明,业务 SQL 不用改。当 WHERE 带了分区键(这里是 create_time)时,MySQL 会做分区裁剪(partition pruning),只扫描命中的分区,而非全表。
7.3 三种方案的权衡
| 方案 | 改造量 | 应用透明性 | 适用规模 |
|---|---|---|---|
| 分区表 | 低 | 透明(只改建表 DDL) | 千万 ~ 亿级单表 |
| 冷热分离 | 中 | 需改读写路由代码 | 单表超 5000 万 |
| 分库分表 | 高 | 需引入 ShardingSphere 等中间件 | 超亿级 |
7.4 分区表的坑
- 主键必须包含分区键,否则建表直接报错
- 跨分区无法保证唯一约束(除非唯一键里也含分区键)
- 含
NULL的分区键值会落入第一个分区,时间字段建议设NOT NULL - 分区过多(上千个)会让元数据分析变慢,按月分通常足够
二十五、生产环境监控与问题定位
再好的方案也要先「发现问题」。以下是从故障现场反推出来的定位三板斧。
8.1 开启慢查询日志
slow_query_log = 1
slow_query_log_file = /var/log/mysql/slow.log
long_query_time = 1 # 超过 1 秒记为慢查询
log_queries_not_using_indexes = 1 # 没走索引的也记录
用 Percona Toolkit 的 pt-query-digest 汇总 top SQL:
pt-query-digest /var/log/mysql/slow.log > slow_report.txt
报告按「总耗时」排序,直接告诉你该优化哪条 SQL,而不是凭感觉。
8.2 实时排查:show processlist
数据库突然变慢时,第一反应是看一眼当前连接:
SHOW PROCESSLIST;
-- 重点看 State 列:
-- Sending data —— 正在读数据,可能是全表扫描或大结果集
-- Locked / Waiting —— 锁等待,上游有大事务或死锁
-- Copying to tmp table —— 正在建临时表,多因 GROUP BY / ORDER BY 无索引
-- 一键捞出执行超过 10 秒的非空闲连接
SELECT * FROM information_schema.processlist
WHERE command != 'Sleep' AND time > 10
ORDER BY time DESC;
定位到阻塞源后,用 KILL <id> 优先干掉源头连接,恢复业务。
8.3 sys schema 快速定位
MySQL 5.7+ 自带的 sys 库封装了 performance_schema,开箱即用:
-- 全表扫描次数最多的语句(最该加索引)
SELECT * FROM sys.statements_with_full_table_scans LIMIT 10;
-- IO 最重的表文件(最该进 buffer pool / 做冷热分离)
SELECT * FROM sys.io_global_by_file_by_bytes LIMIT 10;
-- 锁等待详情
SELECT * FROM sys.innodb_lock_waits\G
以上三条在日常巡检里每周跑一次,能提前发现 80% 的隐患。
结语
MySQL 性能优化的三个层次,本质上是一条「由浅入深、由低成本到高成本」的递进路线:
- 索引层的核心是三条:让 B+ 树能定位到数据、让 B+ 树少回表、让 B+ 树少维护。所有的规则都源自 B+ 树的数据结构特征,理解了数据结构就不需要死记硬背。
- SQL 写法层的核心是:让 EXPLAIN 输出的 type 尽可能高、rows 尽可能少、Extra 尽可能干净。每次上线前对核心 SQL 做一次 EXPLAIN 分析,可以拦截 90% 的慢查询。
- 架构层遵循一个原则:能用低成本方案解决的问题,不用高成本方案。
- 冷热分离能解决 80% 的容量问题
- 读写分离能解决 80% 的读压力问题
- 缓存能解决 80% 的热点问题
- 实例参数调优和分区表,是分库分表之前两道低成本防线
只有这些招都用尽了,才轮得到分库分表。
文章结束,喜欢就给个一键三连吧,你的肯定是我最大的动力,点赞上一千我就是脑瘫也出下章
更多推荐


所有评论(0)