From dde942a3460f8c9aa4cb35646de554d9d169b7f6 Mon Sep 17 00:00:00 2001 From: CyC2018 Date: Wed, 4 Nov 2026 22:39:57 +0800 Subject: [PATCH] Update MySQL.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补充自增值的空洞与计数器持久化、GROUP BY 不再隐式排序、关联列字符集不一致、COUNT(*) 选择的索引、分批删除的加锁范围、跨请求读主库等内容,新增附录 A17、A18;调整高频标记。 --- notes/MySQL.md | 100 ++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 90 insertions(+), 10 deletions(-) diff --git a/notes/MySQL.md b/notes/MySQL.md index b02f5cde..0f95ef70 100644 --- a/notes/MySQL.md +++ b/notes/MySQL.md @@ -8,10 +8,10 @@ * [索引的使用条件](#索引的使用条件) 🔥🔥 * [索引优化](#索引优化) 🔥 * [索引失效](#索引失效) 🔥 - * [二、查询性能优化](#二查询性能优化) 🔥🔥 + * [二、查询性能优化](#二查询性能优化) * [一条 SQL 的执行过程](#一条-sql-的执行过程) * [使用 Explain 进行分析](#使用-explain-进行分析) - * [慢查询的定位与优化](#慢查询的定位与优化) + * [慢查询的定位与优化](#慢查询的定位与优化) 🔥🔥 * [优化数据访问](#优化数据访问) * [重构查询方式](#重构查询方式) * [深分页](#深分页) @@ -54,6 +54,8 @@ * [A14 找到持有锁的源头事务](#a14-找到持有锁的源头事务) * [A15 EXPLAIN ANALYZE 的 loops](#a15-explain-analyze-的-loops) * [A16 按自增 id 轮询漏掉的行](#a16-按自增-id-轮询漏掉的行) + * [A17 自增空洞、分组与 COUNT 的索引](#a17-自增空洞分组与-count-的索引) + * [A18 分批删除的加锁范围](#a18-分批删除的加锁范围) * [参考资料](#参考资料) @@ -134,6 +136,8 @@ InnoDB 的 B+Tree 索引分为两种: 二级索引的叶子节点保存主键而不是行的地址,这是因为页分裂、合并时行会移动到别的页,保存主键就不用随之修改二级索引;代价是多一次回表,并且主键越长,每个二级索引都越大。所以主键一般用较短的自增整数,自增还能让新行总是追加到最后一页,不会因为在中间插入而频繁分裂页,自增主键与 UUID 主键的实测对比见 [分布式 ID](分布式.md#八分布式-id)。 +需要注意的是,自增值分配出去后不会归还,事务回滚、INSERT IGNORE 遇到唯一键冲突都会留下空洞,所以自增主键只保证递增、不保证连续(MySQL 8.0 实测,见[附录 A17](#a17-自增空洞分组与-count-的索引))。8.0 起自增计数器的最大值写入 redo log,正常重启后不会像 5.7 那样回退到表中现有的最大值;异常退出时还没刷盘的部分仍可能被重新分配。 + #### 2. 哈希索引 哈希索引能以 O(1) 时间进行等值查找,但是失去了有序性: @@ -301,7 +305,7 @@ SELECT user_id, status, create_time, id FROM orders WHERE user_id = 1001; 对应的写法: - 把函数改写为范围:`create_time >= '2025-01-12' AND create_time < '2025-01-13'`,type 变为 range;不能改写 SQL 时,MySQL 8.0.13 起可以建函数索引 `INDEX ((DATE(create_time)))`,原来的查询就能按 ref 查找(见[附录 A2](#a2-索引的执行计划)); -- 字符串列用字符串比较:`order_no = '7007'`,type 变为 ref。这是因为 '7007'、' 7007'、'7007a' 转换成数字后都等于 7007,没法按索引的顺序查找;反过来,整数列与字符串比较(`id = '1001'`)仍然走主键,因为被转换的是常量而不是列; +- 字符串列用字符串比较:`order_no = '7007'`,type 变为 ref。这是因为 '7007'、' 7007'、'7007a' 转换成数字后都等于 7007,没法按索引的顺序查找;反过来,整数列与字符串比较(`id = '1001'`)仍然走主键,因为被转换的是常量而不是列。两张表关联时,关联列的字符集或类型不一致也是这样,被转换的那一侧用不上索引,例如 latin1 的列与 utf8mb4 的列关联,latin1 一侧的表作被驱动表时只能扫描整个索引(见[附录 A17](#a17-自增空洞分组与-count-的索引)),所以关联列要用相同的类型、字符集和排序规则; - 只在右边模糊匹配:`LIKE '7007%'`,type 为 range;按关键词搜索可以改用全文索引或 Elasticsearch; - OR 两侧的列都有索引时,可以分别扫描两个索引再合并结果,type 为 index_merge。它同样按成本选择,例如 20 万行的表上 a = 5 OR b = 7 匹配约 2000 行时用 index_merge,改为 a = 5 OR b < 10、匹配约 2 万行时就扫描全表(见[附录 A2](#a2-索引的执行计划))。 @@ -324,7 +328,7 @@ WHERE user_id = 5 AND status > 0 AND create_time > '2025-01-05' -- key_len = 5 需要注意的是,是否使用索引是优化器按成本估算决定的。走二级索引的每一行都要回表,匹配的行越多回表越多,超过一定比例后全表扫描反而更快。这个比例并不固定(早期版本按 30% 判断,现在由代价估算决定):`create_time > '2025-01-23'` 匹配 9.9 万行,走 idx_create_time;`create_time > '2025-01-02'` 匹配 191 万行,就选择了全表扫描。IN 也一样,`user_id IN` 5 个值、1 万个值时都走索引,2 万个值时就改为全表扫描。因此同一种写法有时走索引有时不走,可能是因为匹配的数据量不同,也可能是统计信息不准(见[分析原因](#2-分析原因))。 -## 二、查询性能优化 🔥🔥 +## 二、查询性能优化 ### 一条 SQL 的执行过程 @@ -358,7 +362,7 @@ Explain 用来查看优化器选定的执行计划,语句本身并不执行, - ref:与索引比较的是常量(const)还是其它表的列; - rows:估算的扫描行数; - filtered:按其余条件过滤后剩下的行占 rows 的百分比,也是估算值; -- Extra:额外信息,Using where 表示存储引擎返回的行还要在 Server 层按 WHERE 条件过滤,走了索引时也可能出现(见[附录 A7](#a7-慢查询的定位与优化));Using index 表示使用了覆盖索引,不需要回表;Using index condition 表示使用了索引下推,只按索引列做范围查询、没有其它条件可下推时也会出现,这时并不减少回表;Using filesort 表示无法利用索引的顺序,需要额外排序;Using temporary 表示使用了临时表,常见于 GROUP BY 的列无法利用索引的顺序,或者 GROUP BY 和 ORDER BY 的列不一致。 +- Extra:额外信息,Using where 表示存储引擎返回的行还要在 Server 层按 WHERE 条件过滤,走了索引时也可能出现(见[附录 A7](#a7-慢查询的定位与优化));Using index 表示使用了覆盖索引,不需要回表;Using index condition 表示使用了索引下推,只按索引列做范围查询、没有其它条件可下推时也会出现,这时并不减少回表;Using filesort 表示无法利用索引的顺序,需要额外排序;Using temporary 表示使用了临时表,常见于 GROUP BY 的列无法利用索引的顺序,或者 GROUP BY 和 ORDER BY 的列不一致。MySQL 8.0 的 GROUP BY 不再隐式排序,分组列用不上索引、又没有 ORDER BY 时,Extra 只有 Using temporary,不再有 Using filesort;分组列是索引的最左前缀、只求下一列的 MIN() 或 MAX() 时,优化器可以用松散索引扫描,Extra 为 Using index for group-by,每组只读一条索引(见[附录 A17](#a17-自增空洞分组与-count-的索引))。 出现 ALL、Using filesort 或 Using temporary,并且 rows 远多于需要的行时,通常需要调整索引或者改写查询。 @@ -387,9 +391,9 @@ Explain 只显示估算值,MySQL 8.0.18 起可以用 EXPLAIN ANALYZE 实际执 (cost=166757 rows=177198) (actual time=11.8..216 rows=86400 loops=1) ``` -输出从内向外读:范围扫描估算 177198 行,实际读了 86400 行,用了 216 ms,几乎是全部的耗时。actual time 的两个数字分别是返回第一行和全部行的时间(毫秒)。loops 是这一步执行的次数,大于 1 时 time 和 rows 都是每次的平均值,例如连接中被驱动表显示 rows=2.5 loops=100,实际共返回 250 行(MySQL 8.0 实测,见[附录 A15](#a15-explain-analyze-的-loops))。怎样优化这条语句见下一节。 +输出从内向外读:范围扫描估算 177198 行,实际读了 86400 行,用了 216 ms,几乎是全部的耗时。估算约为实际的 2 倍,原因可能在 InnoDB 的估算方法:叶子层最多读 10 个页,读不完就按上一层的页数推算,跨过的层数较多、结果又不精确时把结果乘 2(源码注释说这种算法容易低估),但不超过全表行数的一半。估算偏高时,优化器可能更早改用全表扫描。actual time 的两个数字分别是返回第一行和全部行的时间(毫秒)。loops 是这一步执行的次数,大于 1 时 time 和 rows 都是每次的平均值,例如连接中被驱动表显示 rows=2.5 loops=100,实际共返回 250 行(MySQL 8.0 实测,见[附录 A15](#a15-explain-analyze-的-loops))。怎样优化这条语句见下一节。 -### 慢查询的定位与优化 +### 慢查询的定位与优化 🔥🔥 排查慢查询一般分三步:先找出哪些语句慢,再分析为什么慢,最后修改并验证效果。 @@ -468,6 +472,10 @@ do { } while rows_affected > 0 ``` +需要注意的是,可重复读下 DELETE 会给扫描到的每一行加锁,不论它是否满足条件。create_time 没有索引时,每批都按主键从头扫描,最后一轮已经没有要删的行,却要扫描并锁住整张表,期间新消息的修改和插入都被阻塞(MySQL 8.0 实测,见[附录 A18](#a18-分批删除的加锁范围))。 + +所以条件列要有索引;没有索引时可以按主键区间分批(WHERE id > ? AND id <= ?),每批只锁一个区间,或者在读已提交下执行,不满足条件的行判断后就释放锁。 + #### 2. 分解大连接查询 将一个大连接查询分解成对每一个表进行一次单表查询,然后在应用程序中进行关联,这样做的好处有: @@ -534,7 +542,7 @@ Elasticsearch 同样限制翻页的深度,默认不允许 from + size 超过 1 需要注意的是,分页时常常还要显示总记录数。InnoDB 没有保存表的行数,因为并发的事务看到的行数可能不同,COUNT(*) 需要扫描整个索引,上面的表执行一次约需 130 ms。数据量大时可以缓存总数、显示估算值,或者只提供“下一页”。 -COUNT(*) 和 COUNT(1) 在 InnoDB 中的处理方式相同,都统计行数,优化器会选最小的二级索引来扫描,上面的表用的是 idx_create_time;COUNT(列) 只统计这一列不为 NULL 的行,要扫描包含这一列的索引(见[附录 A7](#a7-慢查询的定位与优化))。 +COUNT(*) 和 COUNT(1) 在 InnoDB 中的处理方式相同,都统计行数,优化器会选最小的二级索引来扫描,“最小”指索引定义的长度(各列的字节长度之和)最短,不看索引实际占了多少页,上面的表用的是 idx_create_time;COUNT(列) 只统计这一列不为 NULL 的行,要扫描包含这一列的索引(见[附录 A7](#a7-慢查询的定位与优化))。 ## 三、存储引擎 @@ -714,7 +722,7 @@ ShardingSphere 等中间件负责按分片键路由 SQL、改写分页条件和 连接由从服务器发起:I/O 线程告诉主服务器从 binlog 的哪个位置开始发送(使用全局事务 ID,即 GTID 时,告诉它已经执行过哪些事务),之后由 dump 线程主动推送新的 binlog,Canal 这类同步工具也是这样伪装成从库的。 -同步到 Elasticsearch 等下游时,按自增 id 或 updated_at 定时轮询会漏数据:后分配 id 的事务可能先提交,updated_at 记录的也是语句执行的时刻而不是提交的时刻。例如事务 A 插入了 id 为 1 的行还没提交,B 插入 id 为 2 的行并提交,轮询读到 2 并记下位置,A 提交后按 id > 2 或者按 updated_at 都拉不到 1(MySQL 8.0 实测,见[附录 A16](#a16-按自增-id-轮询漏掉的行))。事务在提交阶段才写入 binlog,订阅端按 binlog 的位置继续读,不会因为行的 id 或 updated_at 小于已读到的最大值而漏掉它。 +同步到 Elasticsearch 等下游时,按自增 id 或 updated_at 定时轮询会漏数据:后分配 id 的事务可能先提交,updated_at 记录的也是语句执行的时刻而不是提交的时刻。例如事务 A 插入了 id 为 1 的行还没提交,B 插入 id 为 2 的行并提交,轮询读到 2 并记下位置,A 提交后按 id > 2 或者按 updated_at 都拉不到 1(MySQL 8.0 实测,见[附录 A16](#a16-按自增-id-轮询漏掉的行))。事务在提交阶段才写入 binlog,订阅端按 binlog 的位置继续读,不会因为行的 id 或 updated_at 小于已读到的最大值而漏掉它。轮询还看不到物理删除的行,只能改成软删除并按 updated_at 轮询,前面说的漏读依然存在,所以同步一般订阅 binlog。 MySQL 8.0.27 起 replica_parallel_workers 默认为 4,从服务器上实际是一个协调线程读取中继日志,再把事务分给 4 个工作线程并行重放,以减小复制延迟。哪些事务可以并行,由主库写在 binlog 中的依赖信息决定:8.0 默认(binlog_transaction_dependency_tracking 为 COMMIT_ORDER)只让主库上同时处于提交阶段的事务并行,主库并发低、事务一个接一个提交时,从库上仍是串行重放;设为 WRITESET 后还按修改的行判断,能让更多互不冲突的事务并行(写集为空或不完整、修改没有主键和唯一键的表或外键中的父表时,仍按提交顺序判断),8.4 删除了这个参数,固定使用 WRITESET。 @@ -731,7 +739,7 @@ MySQL 8.0.27 起 replica_parallel_workers 默认为 4,从服务器上实际是 - 大多数业务读多写少,读请求可以分散到多个从服务器上,主服务器只处理写入; - 统计、报表这类耗时的查询放在从服务器上,不占用主服务器的 CPU 和 I/O。 -读写分离可以由代理(例如 ProxySQL)转发,对应用透明但多了一跳网络,也可以在应用中选择数据源(例如 ShardingSphere-JDBC)。同一个事务中的读一般也发到主库,否则读不到本事务刚写入的数据。 +读写分离可以由代理(例如 ProxySQL)转发,对应用透明但多了一跳网络,也可以在应用中选择数据源(例如 ShardingSphere-JDBC)。同一个事务中的读一般也发到主库,否则读不到本事务刚写入的数据。在应用中用注解切换数据源时,切换要在事务取得连接之前完成,通常让切换数据源的切面排在事务切面之前:Spring 的事务一般在开始时就取得连接并绑定到线程,之后再切换不起作用。

@@ -747,6 +755,8 @@ MySQL 默认是异步复制,主服务器提交事务后不等从服务器, - 写入后记下这个事务的 GTID,读从库前先执行 WAIT_FOR_EXECUTED_GTID_SET,等从库执行完这个事务后,在新开始的事务或快照中读取,超时就改读主库; - 监控从库的延迟,延迟过大的从库暂时不分配读请求。 +“写入后短时间内”跨了请求时,可以在写入后把用户的标记存入 Redis 或 Cookie,有效期略大于平时的复制延迟,标记存在时读主库。这是经验做法,延迟突然变大时标记已经过期,仍可能读到旧数据,要严格保证时用等待 GTID 的方法。 + 使用缓存时,未命中的读请求还可能把从库中的旧值写回缓存,见 [缓存一致性](缓存.md#缓存一致性)。 异步复制时主库宕机,已经提交、还没有发给从库的事务在切换后就丢失了。半同步复制让至少一个从库把 binlog 写入中继日志并刷盘后,主库才返回提交成功。默认的 AFTER_SYNC 在主库的存储引擎提交之前等待确认,等待期间主库上的其它会话也看不到这个事务;改为 AFTER_COMMIT 时先提交再等待,其它会话可能已经读到一个主库宕机后会丢失的事务。半同步复制并不保证从库已经执行完,读从库仍可能读到旧数据;等待超时后主库会退回异步复制。 @@ -1346,6 +1356,76 @@ A: COMMIT 表中: (1, 07:30:55.516700) (2, 07:30:55.528103) ``` +### A17 自增空洞、分组与 COUNT 的索引 + +MySQL 8.0.44,Apple M1 Pro。 + +自增值:表 ai(id INT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(10) UNIQUE),插入 'a';开事务插入 'b' 后回滚;INSERT IGNORE 再插入 'a';最后插入 'c': + +```text +SELECT id, code FROM ai ORDER BY id -> ((1, 'a'), (4, 'c')) +``` + +分组:表 g 有 2 万行和索引 idx_ab(a, b),c 列没有索引: + +```text +EXPLAIN SELECT c, COUNT(*) FROM g GROUP BY c -> key None | Extra Using temporary +EXPLAIN SELECT a, MIN(b) FROM g GROUP BY a -> key idx_ab | Extra Using index for group-by +``` + +COUNT(*):表 cnt3 有 5 万行,k_name(name VARCHAR(255)) 的值都是 'x',k_b(b1 BIGINT, b2 BIGINT): + +```text +n_leaf_pages (('PRIMARY', 138), ('k_b', 83), ('k_name', 39)) +EXPLAIN SELECT COUNT(*) FROM cnt3 -> key k_b key_len 18 +``` + +k_name 实际只有 39 个叶子页,但定义长度为 1022 字节,优化器选了定义更短的 k_b。 + +关联列的字符集:o1.no 为 utf8mb4、o2.no 为 latin1,两列都有索引,各 3000 行: + +```text +EXPLAIN SELECT * FROM o1 JOIN o2 ON o1.no = o2.no WHERE o1.id = 5 + o1 const PRIMARY rows 1 + o2 index no rows 3000 Using where; Using index +EXPLAIN SELECT * FROM o2 JOIN o1 ON o1.no = o2.no WHERE o2.id = 5 + o2 const PRIMARY rows 1 + o1 ref no rows 1 Using where; Using index +``` + +o2 作被驱动表时,o2.no 要先转换成 utf8mb4 再比较,只能扫描整个索引;o1 作被驱动表时,被转换的是从驱动表取出的值,o1.no 上的索引可以正常查找。 + +### A18 分批删除的加锁范围 + +MySQL 8.0.44,Apple M1 Pro。表 messages(id INT AUTO_INCREMENT PRIMARY KEY, create_time DATETIME, content VARCHAR(100)),前 6000 行是 2026-01 的旧消息,后 4000 行是 2026-10 的新消息。每批在一个事务中执行下面的语句,提交前从 performance_schema.data_locks 统计记录锁的个数,并用另一个会话修改 id = 9000 的新消息、插入一条新消息(innodb_lock_wait_timeout = 1): + +```sql +DELETE FROM messages WHERE create_time < '2026-08-01' LIMIT 1000; +``` + +```text +create_time 没有索引,可重复读(EXPLAIN key = NULL) + 第 1 批:删除 1000 行,记录锁 1002 个;修改 id=9000 成功;插入新消息成功 + 第 7 批:删除 0 行,记录锁 4011 个;修改 id=9000 报 1205 锁等待超时;插入新消息报 1205 锁等待超时 +create_time 没有索引,读已提交 + 第 1 批:删除 1000 行,记录锁 1000 个;修改 id=9000 成功;插入新消息成功 + 第 7 批:删除 0 行,记录锁 0 个;修改 id=9000 成功;插入新消息成功 +create_time 有索引,可重复读(EXPLAIN key = idx_ct) + 第 1 批:删除 1000 行,记录锁 2001 个;修改 id=9000 成功;插入新消息成功 + 第 7 批:删除 0 行,记录锁 1560 个;修改 id=9000 成功;插入新消息成功 +``` + +有索引时第 7 批的记录锁加在已删除、还没有被 purge 清理的索引记录上;删完后等待 20 秒,purge 清理完这些记录再执行同一条语句,只剩 2 个记录锁。 + +按主键区间分批(create_time 没有索引,可重复读),语句改为 DELETE FROM messages WHERE id > ? AND id <= ? AND create_time < '2026-08-01': + +```text +id 在 (0, 1000]:删除 1000 行,记录锁 1002 个;修改 id=9000 成功;插入新消息成功 +id 在 (9000, 10000]:删除 0 行,记录锁 1004 个;修改 id=9000 成功;插入新消息报 1205 锁等待超时 +``` + +最后一个区间到达表尾,锁住了最后一行之后的间隙,这一批执行期间新消息的插入要等待。 + ## 参考资料 - [MySQL 8.0: InnoDB Restrictions and Limitations](https://dev.mysql.com/doc/refman/8.0/en/innodb-restrictions-limitations.html)