内容很干,建议收藏后慢慢看。

本手册整合了 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 cb 范围后 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 RESULTUNION 查询中的后续 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_refJOIN 被驱动表用主键匹配主键 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 bufferJOIN 时被驱动表无索引差,需加索引

十、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 的结果是:

表typerowsExtra
orderref5000Using where
order_itemALL600000Using 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 变为:

表typerowsExtra
orderref5000Using where
order_itemref3Using 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(*) 必须实时扫。优化手段:

  1. 近似值:业务允许误差时,用 EXPLAIN SELECT COUNT(*) ... 的 rows 估算,或读 information_schema.TABLES.TABLE_ROWS(InnoDB 是估算值)。
  2. 覆盖索引:COUNT 一个二级索引列比扫主键快,因为二级索引叶子更小。例如 SELECT COUNT(1) FROM t USE INDEX(idx_status)。
  3. 实时精确计数:用计数表(counter 表)+ 业务双写,或 Redis 维护(INCR / DECR),适合首页展示总数等场景。
  4. COUNT(DISTINCT col):先 WHERE 过滤再 DISTINCT;数据倾斜严重时可用近似方案(如 HyperLogLog / APPROX_COUNT_DISTINCT 类实现)。
  5. 分组统计 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_size1G~2Gredo log 单文件大小,太小会频繁触发 checkpoint 刷盘
innodb_flush_log_at_trx_commit1(核心交易)/ 2(日志统计)1=每次提交刷盘最安全;2=提交写缓存每秒刷盘,崩溃最多丢 1 秒
sync_binlog1(核心交易)/ 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% 的热点问题
    • 实例参数调优和分区表,是分库分表之前两道低成本防线

只有这些招都用尽了,才轮得到分库分表。


文章结束,喜欢就给个一键三连吧,你的肯定是我最大的动力,点赞上一千我就是脑瘫也出下章

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐