概述
衔接前文
本系列从 MySQL 分层架构出发,逐层深入 InnoDB 存储引擎的 B+Tree 索引原理、事务与 MVCC 机制、行锁与间隙锁实现、SQL 优化器决策逻辑、主从复制与 GTID 架构、分库分表与 ShardingSphere 内核、慢查询与性能诊断体系,以及连接管理与连接池全局协调策略。这些知识点构成了数据库稳定运行的完整理论底座。然而,知晓原理并不能保证在生产环境中正确应用------反模式往往潜藏在微小的配置偏差和日常编码习惯中,一旦触发,便会引发连锁故障。
本文作为系列收官之作,将前 9 篇的全部核心技术点投射到 26 个真实故障场景中,通过"反模式分析 → 故障排查 → 精准修复"的闭环,帮助读者将分散的知识点内化为系统化的排障直觉。全文严格遵循"错误示例 → 现象描述 → 排查思路 → 根因分析(显式映射前文原理与源码细节) → 修正方案 → 最佳实践"的六步诊断法,整合 MySQL 全工具链诊断矩阵与多层级标准化排查决策树,并与 JDBC 系列第 10 篇《JDBC 反模式与排查宝典》形成从应用到数据库的全链路排障闭环。
总结性引言
"CPU 飙高但 QPS 平稳""查询突然变慢但索引未变""主从延迟忽高忽低""Too many connections 突然爆发"------这些典型的 MySQL 线上故障,根源往往不是某个技术组件自身存在缺陷,而是使用方式违背了其设计本意。本文将 MySQL 生态中最常见、破坏力最大的反模式归纳为索引、SQL与查询、事务与锁、复制与架构、DDL与表设计、连接与配置 六大领域,每个领域进一步细分为设计反模式 与运行时反模式 两个子维度。26 个真实案例均采用标准六步诊断法逐层展开,每个案例的根因直接追溯到前文的具体原理甚至源码级细节。同时,本文还将 MySQL 内置诊断视图、performance_schema、pt-query-digest、EXPLAIN ANALYZE、PMM 以及 JDBC 端的 Arthas、连接池 Metrics 等工具编织成一张严密的全景排查网络,并绘制了覆盖四大核心故障类型的多层级标准化决策树。最后一章以 18 道高频面试故障排查题收束,与前 9 篇的原理面试题形成完整的知识验证闭环。
核心要点
- 六大反模式领域(设计+运行时):索引、SQL、事务锁、复制架构、DDL 表设计、连接配置,共计 26 个真实案例。
- 六步诊断法:错误示例→现象描述→排查思路→根因分析(显式关联前文原理与源码)→修正方案→最佳实践。
- 全链路排查闭环:与 JDBC 系列第 10 篇跨系列联动,覆盖从 Java 应用到 MySQL 数据库的完整调用链。
- 诊断工具集与决策树 :
SHOW ENGINE INNODB STATUS、sys schema、performance_schema、pt-query-digest、EXPLAIN ANALYZE、PMM、Arthas、连接池 Metrics 等工具速查;四大典型故障的多层级标准化排查决策路径。 - 面试故障排查专题:18 道真实故障场景题,每题附有详细的排查命令、日志解读、根因回溯与修复方案。
文章组织架构图
架构图说明
总览说明
全文共 9 个模块,以前 6 个反模式领域的 26 个真实案例为主体,每个案例均从设计反模式 和运行时反模式两个维度切入,严格采用六步诊断法,根因分析显式关联前文第 2 篇(B+Tree 索引结构)、第 3 篇(MVCC 与 Undo Log)、第 4 篇(行锁与间隙锁加锁规则)、第 5 篇(SQL 优化器代价估算与执行计划)、第 6 篇(主从复制原理与 Binlog 格式)、第 9 篇(连接管理)等核心技术原理。模块 7 提供可打印的诊断工具速查表与现象→工具映射矩阵,模块 8 绘制覆盖"慢查询突增、锁等待激增、主从延迟、连接数耗尽"四大核心故障的多层级标准化排查决策树,模块 9 以 18 道面试故障排查题收束,与前 9 篇原理面试题形成完整的理论与实践校验闭环。
逐模块说明
- 模块 1:索引反模式(2 设计 + 3 运行时) :涵盖冗余索引与重复索引、复合索引列顺序错误、隐式类型转换、LIKE 前缀通配、统计信息过时,结合
sys.schema_redundant_indexes、EXPLAIN key_len、SHOW WARNINGS等工具精准定位。 - 模块 2:SQL 与查询反模式(2 设计 + 3 运行时) :剖析
SELECT *导致回表与失去覆盖索引、ORDER BY RAND()的灾难性排序、深度分页的性能退化、依赖子查询的多次执行以及 Join 驱动表选择错误,借助EXPLAIN FORMAT=TREE、EXPLAIN ANALYZE揭示执行细节。 - 模块 3:事务与锁反模式(1 设计 + 4 运行时) :从事务边界包含外部 I/O、长事务阻塞 Purge、无索引 UPDATE 全表加锁、交叉锁死锁、到 RR 隔离级别下快照读丢失更新,深度使用
SHOW ENGINE INNODB STATUS、sys.innodb_lock_waits、performance_schema.data_locks进行锁分析。 - 模块 4:复制与架构反模式(1 设计 + 2 运行时) :解决 STATEMENT 格式导致的非确定性函数不一致、主从延迟致读写分离读到旧数据,以及分片键数据倾斜,依托
pt-heartbeat、pt-table-checksum和 ShardingSphere 监控。 - 模块 5:DDL 与表设计反模式(1 设计 + 2 运行时) :讨论外键级联锁危害、直接
ALTER TABLE的元数据锁风暴、VARCHAR过大导致行溢出,推荐pt-online-schema-change和gh-ost。 - 模块 6:连接与配置反模式(1 设计 + 3 运行时) :强调
max_connections全局协调、maxLifetime与wait_timeout不等式、idleTimeout与Sleep连接堆积、connect_timeout协调问题,与 JDBC 系列第 10 篇联动使用 Arthas 和连接池 Metrics。 - 模块 7:诊断工具集与映射表:汇总 MySQL 端 7 种核心诊断工具和 JDBC 端 4 种工具,形成不少于 14 行的"现象→工具→命令"速查表,并绘制诊断工具全景图。
- 模块 8:多层级标准化排查决策树:为四大核心故障分别绘制详细的决策流程图,每个节点包含具体的诊断命令和前文原理引用,确保故障发生时能够按图索骥、逻辑推演。
- 模块 9:面试高频故障排查专题:18 道题目全部来源于线上真实故障场景,涵盖索引失效、锁问题、复制延迟、连接异常、慢查询、DDL 事故等,每题提供完整排查步骤、关键命令输出解读、根因分析和最佳实践,并附加一道综合系统设计题。
关键结论
MySQL 反模式的根因几乎无一例外都可以追溯到前 9 篇的核心原理。掌握六步诊断法、设计/运行时双视角分析范式、多层级标准化排查决策树,以及与 JDBC 系列联动的全链路排障能力,是将理论知识转化为实际排障战斗力的唯一途径。
1. 索引反模式
索引是数据库性能最关键的杠杆,也是反模式最为集中的领域。设计阶段的冗余、列顺序错误,运行时的隐式类型转换、通配符滥用、统计信息过时,均能使精心设计的索引完全失效。
1.1 设计反模式案例 1:冗余索引与重复索引
1.1.1 错误示例
sql
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
create_time DATETIME NOT NULL,
INDEX idx_user_id (user_id), -- 冗余
INDEX idx_user_status (user_id, status), -- 前导列与 idx_user_id 重复
INDEX idx_user_time (user_id, create_time) -- 前导列与 idx_user_id 重复
) ENGINE=InnoDB;
1.1.2 现象描述
- 磁盘空间异常增长 :
information_schema.TABLES中INDEX_LENGTH显著高于预期,总索引体积是必要值的 2~3 倍。 - 写入性能退化 :
INSERT语句的 p99 延迟从 2ms 上升至 15ms。performance_schema.table_io_waits_summary_by_index_usage显示这三个索引的COUNT_INSERT完全相同,证明每次插入都要同时维护三棵 B+Tree。 - 优化器偶发选错索引 :统计信息波动时,优化器可能选择
idx_user_time而非覆盖更优的idx_user_status,导致额外的回表和排序。 sys.schema_redundant_indexes直接给出证据:
sql
SELECT * FROM sys.schema_redundant_indexes WHERE table_name = 'orders'\G
-- redundant_index_name: idx_user_id
-- dominant_index_name: idx_user_status
-- sql_drop_index: ALTER TABLE order_db.orders DROP INDEX idx_user_id
1.1.3 排查思路
- 审查索引结构 :定期对核心表执行
SHOW INDEX FROM orders,关注具有相同前导列的索引组合。 - 量化索引维护成本 :查询
performance_schema.table_io_waits_summary_by_index_usage,对比各索引的COUNT_INSERT、COUNT_UPDATE、COUNT_DELETE,若多个索引的写入次数高度一致,则强关联冗余。 - 使用自动化工具 :将
pt-duplicate-key-checker集成到 CI/CD 流程,或定期执行sys.schema_redundant_indexes生成报告。 - 评估查询覆盖度 :确认最宽的复合索引(如
idx_user_status(user_id, status))是否已经能够满足所有以user_id开头的查询需求。
1.1.4 根因分析
根因详见第 2 篇 B+Tree 索引结构与最左前缀原则 。InnoDB 中每个二级索引都是一棵独立的 B+Tree,叶子节点存储主键值。复合索引 idx_user_status(user_id, status) 的最左列 user_id 完全可以充当单列索引 idx_user_id(user_id) 的角色,任何等值查询、范围查询或排序只要以 user_id 开始,都能利用该复合索引。保留冗余索引意味着:
- 写入放大 :每次
INSERT都要分别在三个 B+Tree 上执行定位、插入、可能的页分裂操作,触发额外的 redo log 和 undo log。 - 内存浪费:三个索引均竞争 InnoDB Buffer Pool 的宝贵空间,降低整体缓存命中率。
- 优化器负担:更多的候选索引增加了查询优化阶段的分析成本。
源码层面,row_ins_sec_index_entry_low() 函数负责将记录插入每个二级索引,冗余索引的数量直接线性增加了该函数的调用次数。
1.1.5 修正方案
sql
-- 保留覆盖范围最广的复合索引,并增加 create_time 以满足排序需求
ALTER TABLE orders
DROP INDEX idx_user_id,
DROP INDEX idx_user_time,
ADD INDEX idx_user_status_time (user_id, status, create_time);
执行后验证:
sql
EXPLAIN SELECT * FROM orders WHERE user_id = 100;
-- key: idx_user_status_time, key_len: 8 (只用到了 user_id), type: ref
EXPLAIN SELECT * FROM orders WHERE user_id = 100 AND status = 1;
-- key: idx_user_status_time, key_len: 9, type: ref
1.1.6 最佳实践
- 新建索引前强制审查 :使用
sys.schema_redundant_indexes检查是否已有可覆盖的索引前缀。 - 复合索引优先原则:当多个查询共用某个前导列时,倾向于创建一个宽复合索引而非多个单列索引。
- 定期清理(月度) :在业务低峰期根据
sys.schema_unused_indexes和schema_redundant_indexes删除未使用或冗余的索引,但务必确保监控周期已覆盖所有业务场景。 - CI/CD 自动化 :集成
pt-duplicate-key-checker作为上线前的检查关卡。
1.2 设计反模式案例 2:复合索引列顺序错误
1.2.1 错误示例
sql
CREATE TABLE orders (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL, -- 区分度极高(800 万去重值)
status TINYINT NOT NULL, -- 区分度极低(仅 5 种状态)
create_time DATETIME NOT NULL,
INDEX idx_status_user_time (status, user_id, create_time) -- 低区分度列在最左
) ENGINE=InnoDB;
1.2.2 现象描述
典型查询 SELECT * FROM orders WHERE user_id = 12345 AND status = 2 ORDER BY create_time DESC LIMIT 20 的 EXPLAIN 输出:
sql
+----+-------------+--------+------+-----------------------+-----------------------+---------+-------+--------+-----------------------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+--------+------+-----------------------+-----------------------+---------+-------+--------+-----------------------------+
| 1 | SIMPLE | orders | ref | idx_status_user_time | idx_status_user_time | 1 | const | 450000 | Using index condition; Using filesort |
+----+-------------+--------+------+-----------------------+-----------------------+---------+-------+--------+-----------------------------+
key_len=1表示仅使用了索引的第一列status(TINYINT 占 1 字节),user_id列完全未发挥作用。rows=450000说明扫描了所有status=2的行,然后逐行过滤user_id。Extra包含Using filesort,表明无法利用索引完成排序。- 实际执行时间从预期的 5ms 膨胀到 3.2 秒,CPU 和 IO 飙升。
1.2.3 排查思路
- 分析慢查询指纹 :
pt-query-digest识别出该查询消耗了 40% 的总响应时间。 - 计算列区分度:
sql
SELECT COUNT(DISTINCT status) AS status_card,
COUNT(DISTINCT user_id) AS user_id_card
FROM orders;
-- status_card=5, user_id_card=8000000
- 深入 EXPLAIN :使用
EXPLAIN FORMAT=JSON查看"key_length": 1,确认索引使用不充分。 - 对比索引调整前后 :在测试环境创建以
user_id开头的索引,执行时间降至 15ms,key_len变为 9,Extra无filesort。
1.2.4 根因分析
根因详见第 2 篇 B+Tree 最左前缀原则与索引列顺序对查询性能的影响 。复合索引在 B+Tree 中首先按第一列排序,第一列相同再按第二列排序,以此类推。当最左列为低区分度的 status 时,索引能够被优化器选中(因为 status 是等值条件),但 status=2 对应的索引叶子节点范围极大(覆盖 450000 行),其内部的 user_id 和 create_time 并未全局有序,只是在 status 分组内局部有序。因此:
- 无法通过
user_id=12345进行精确跳跃,只能扫描所有status=2的记录,然后再在 Server 层过滤。 ORDER BY create_time无法利用索引顺序,因为索引在status组内的排序是user_id优先,而非create_time优先,所以必须进行filesort。
如果把 user_id 放在最左,索引就能够通过二分查找直接定位到 user_id=12345 的范围,然后在其中迅速筛选 status=2 并按 create_time 有序扫描。
1.2.5 修正方案
sql
ALTER TABLE orders DROP INDEX idx_status_user_time;
ALTER TABLE orders ADD INDEX idx_user_status_time (user_id, status, create_time);
修正后 EXPLAIN:key_len=9,rows=20,Extra=Using index condition,无排序。
1.2.6 最佳实践
- 区分度决定左前缀:复合索引最左列必须是 WHERE 条件中出现频率高且区分度大的列。
- 等值→范围→排序:索引列顺序应遵循"等值查询列 → 范围查询列 → 排序列"的原则。
- 验证 key_len :
key_len应当等于使用到的所有索引列的字节宽度之和,差值即暗示列未被有效利用。 - 定期使用
pt-index-usage分析慢查询日志,发现低效索引。
1.3 运行时反模式案例 1:隐式类型转换导致索引失效
1.3.1 错误示例
java
// MyBatis Mapper 方法,参数为 Long
@Select("SELECT * FROM users WHERE mobile = #{mobile}")
User findByMobile(@Param("mobile") Long mobile);
数据库表结构:mobile VARCHAR(11) NOT NULL, INDEX idx_mobile(mobile)。 实际执行 SQL:SELECT * FROM users WHERE mobile = 13800138000;(参数未加引号)。
1.3.2 现象描述
EXPLAIN输出:
sql
+----+-------------+-------+------+---------------+------+---------+------+---------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+------+---------------+------+---------+------+---------+-------------+
| 1 | SIMPLE | users | ALL | idx_mobile | NULL | NULL | NULL | 5000000 | Using where |
+----+-------------+-------+------+---------------+------+---------+------+---------+-------------+
type=ALL 全表扫描,key=NULL 索引完全未使用,rows=5000000。
SHOW WARNINGS显示优化器重写后的语句:SELECT * FROM users WHERE CAST(mobile AS SIGNED) = 13800138000,证明发生了隐式类型转换。- 查询响应时间从 5ms 暴增至 2.8s,数据库 CPU 使用率从 30% 升至 90%。
1.3.3 排查思路
- 对比测试 :在数据库直接执行
WHERE mobile = '13800138000'(带引号)与WHERE mobile = 13800138000(不带引号),执行时间差异悬殊。 - 抓取隐式转换证据 :对可疑 SQL 执行
EXPLAIN后立即SHOW WARNINGS,查看是否出现CAST、CONVERT等函数。 - 追溯应用代码 :通过 Arthas 或日志确认传入参数的实际 Java 类型,找到
Long到VARCHAR的类型失配。 - 使用 optimizer_trace:
sql
SET optimizer_trace='enabled=on';
SELECT * FROM users WHERE mobile = 13800138000;
SELECT * FROM information_schema.OPTIMIZER_TRACE\G
在输出的 "considered_execution_plans" 中,会看到优化器评估 range 访问路径时明确提到需要 cast 函数,因此放弃索引。
1.3.4 根因分析
根因详见第 2 篇 B+Tree 索引结构、第 5 篇优化器索引选择规则与类型转换逻辑 。MySQL 在处理字符串列与数值的比较时,会依据一套类型转换规则,将字符串列的值逐行转换为数值(等价于 CAST(mobile AS SIGNED))再进行比较。由于索引存储的是原始字符串,一旦在索引列上施加函数,B+Tree 的有序性便无法直接用于范围定位或二分查找。在源码 sql/sql_optimizer.cc 的 ref_access 和 find_best_ref 函数中,优化器检查 WHERE 条件是否能够直接映射到索引键值,若发现隐式转换,则判定为不可进行 ref 或 range 访问,只能退化为全表扫描。
1.3.5 修正方案
方案一(推荐) :修改 Java 代码,将 mobile 字段类型改为 String,确保 PreparedStatement 设置参数时类型匹配。
java
@Select("SELECT * FROM users WHERE mobile = #{mobile}")
User findByMobile(@Param("mobile") String mobile);
方案二:如果短期内无法修改应用代码,可创建虚拟列索引作为过渡:
sql
ALTER TABLE users ADD COLUMN mobile_int BIGINT
GENERATED ALWAYS AS (CAST(mobile AS SIGNED)) STORED;
ALTER TABLE users ADD INDEX idx_mobile_int (mobile_int);
方案三:在 SQL 中显式转换参数,避免在列上使用函数:
sql
SELECT * FROM users WHERE mobile = CAST(13800138000 AS CHAR);
1.3.6 最佳实践
- 类型一致性原则:数据库列类型与应用层字段类型必须严格一致,纳入 Code Review 检查清单。
- 告警配置 :设置
log_warnings=2,MySQL 会将隐式类型转换告警写入错误日志,便于事后审计。 - 监控慢查询 :对
type=ALL且key=NULL的查询设置阈值告警,及时介入排查。 - 测试覆盖:在测试环境中模拟生产数据类型,确保所有 SQL 均能使用预期索引。
1.4 运行时反模式案例 2:LIKE '%xxx' 前缀通配导致索引失效
1.4.1 错误示例
sql
SELECT * FROM products WHERE product_name LIKE '%手机%';
1.4.2 现象描述
EXPLAIN输出:type=ALL, rows=500000, Extra=Using where。- 每次搜索触发全表扫描,并发搜索稍高便导致 CPU 100%,大量
Sending data状态堆积。 SHOW STATUS LIKE 'Handler_read%'中Handler_read_rnd_next数值急剧增长,表明大规模全表扫描正在进行。
1.4.3 排查思路
- 确认慢查询指纹 :
pt-query-digest显示LIKE '%手机%'类型的查询占总响应时间的 60% 以上。 - EXPLAIN 验证 :与
LIKE '手机%'对比,后者type=range使用索引,前者type=ALL。 - 检查索引结构 :确认
product_name上虽然有普通索引,但无法用于前缀通配的场景。 - 业务评估:与产品确认是否必须支持任意位置的模糊匹配,是否可以考虑搜索引擎方案。
1.4.4 根因分析
根因详见第 2 篇 B+Tree 有序存储与范围扫描机制 。B+Tree 的叶子节点按照索引列的字典序排序,优化器可以将 LIKE '手机%' 转换为 product_name >= '手机' AND product_name < '手环' 的范围扫描,精确界定扫描起止位置。而 '%手机%' 的通配符在开头,无法确定扫描的起始键值,优化器无法将其转化为范围条件,只能选择全表扫描逐行执行模式匹配。源码 sql/sql_optimizer.cc 中的 check_quick_select() 函数会判断 LIKE 模式是否可转化为 range,遇到前导通配符直接返回失败。
1.4.5 修正方案
方案一:全文索引(适合中等规模的文本搜索):
sql
ALTER TABLE products ADD FULLTEXT INDEX ft_product_name (product_name);
SELECT * FROM products
WHERE MATCH(product_name) AGAINST('手机' IN NATURAL LANGUAGE MODE)
LIMIT 20;
方案二:Elasticsearch 等搜索引擎(推荐高并发、大数据量场景):通过 Canal 或 DataX 将数据实时同步至 ES,将全文搜索流量转移至 ES。
方案三:引导业务改造 :如果业务可以接受,将搜索改为后缀匹配 LIKE '手机%',或使用分类筛选缩小扫描范围。
方案四:逆向函数索引(适合后缀匹配,如邮箱域名):
sql
ALTER TABLE products ADD COLUMN reversed_name VARCHAR(200)
GENERATED ALWAYS AS (REVERSE(product_name)) STORED;
ALTER TABLE products ADD INDEX idx_reversed (reversed_name);
SELECT * FROM products WHERE REVERSE(product_name) LIKE REVERSE('%@example.com');
1.4.6 最佳实践
- 设计阶段评估搜索需求 :若频繁需要全模糊搜索,应一开始就引入全文索引或 ES,避免使用
LIKE '%xxx%'作为主力搜索方式。 - 监控全表扫描 :通过
sys.schema_tables_with_full_table_scans定期审查全表扫描语句。 - 索引前缀长度优化:若必须使用后缀匹配,注意前缀索引长度需平衡区分度与空间。
1.5 运行时反模式案例 3:索引统计信息过时导致优化器选错索引
1.5.1 错误示例
某订单表 orders 在"双 11"大促后通过批量导入新增了 2000 万行数据,导入后未执行 ANALYZE TABLE。随后高峰期大量查询 SELECT * FROM orders WHERE user_id=12345 AND create_time > '2024-11-11' 的执行计划发生变化。
1.5.2 现象描述
EXPLAIN显示优化器选择了idx_create_time(范围扫描),估算rows=500000,实际执行耗时 2.5 秒。- 若强制使用
FORCE INDEX(idx_user_status)(user_id, status复合索引),EXPLAIN显示rows=45,执行耗时仅 15ms。 SHOW INDEX FROM orders中idx_create_time的Cardinality值仍为导入前的 20 万,而实际create_time的唯一值已超 500 万。
1.5.3 排查思路
- 对比估算与实际行数 :
EXPLAIN ANALYZE输出actual time显示真实扫描行数 450 万,远大于优化器估算的 50 万。 - 检查统计信息 :
SELECT * FROM mysql.innodb_index_stats WHERE table_name = 'orders';发现stat_value严重偏离当前数据量。 - 查看统计信息更新时间 :
SELECT TABLE_NAME, UPDATE_TIME FROM information_schema.TABLES WHERE TABLE_NAME='orders';发现UPDATE_TIME为导入前一天。 - 强制索引对比 :分别使用
FORCE INDEX强制走不同索引,记录实际执行时间,确认idx_user_status为最优索引。 - 分析数据分布 :
SELECT COUNT(DISTINCT create_time) FROM orders;发现唯一值远大于统计信息记录。
1.5.4 根因分析
根因详见第 5 篇 SQL 优化器代价估算模型与索引选择算法,以及第 2 篇索引结构对代价计算的影响 。优化器依赖索引的 Cardinality(基数)来估算索引选择率和扫描行数。Cardinality 通过 InnoDB 的随机采样算法生成,样本页数量由 innodb_stats_persistent_sample_pages 控制(默认 20)。批量导入大量数据后,旧的统计信息无法反映真实数据分布,导致优化器对 idx_create_time 的范围扫描成本估算严重偏低(认为只有 50 万行,实际 2000 万行)。在 sql/sql_optimizer.cc 的 best_access_path() 中,根据错误的 rows 计算出极低的 cost,使其"胜出"了实际上更优的 idx_user_status。
1.5.5 修正方案
立即修复:
sql
ANALYZE TABLE orders;
永久配置:
sql
SET GLOBAL innodb_stats_persistent_sample_pages = 100;
ALTER TABLE orders STATS_SAMPLE_PAGES = 200;
直方图辅助(MySQL 8.0+):
sql
ANALYZE TABLE orders UPDATE HISTOGRAM ON user_id, create_time, status WITH 100 BUCKETS;
紧急临时方案 :应用层使用 FORCE INDEX 提示避开错误计划。
1.5.6 最佳实践
- 批量数据变更后强制更新 :ETL 任务、大促导入后立即执行
ANALYZE TABLE。 - 提高采样精度 :对大表增大采样页数,确保
Cardinality准确。 - 直方图:针对数据分布严重倾斜的列创建直方图,提供更细粒度的选择率估算。
- 监控优化器异常 :当慢查询中
rows估算值与实际值差距超过 10 倍时,主动触发统计信息更新。
索引失效排查流程图
使用了函数或运算"] G --> H2["检查是否存在隐式类型转换
使用 SHOW WARNINGS 确认"] G --> H3["检查 LIKE 是否以
前导通配符开头"] G --> H4["检查索引统计信息是否过时
ANALYZE TABLE 验证"] H1 --> I1["修正:移除函数或使用虚拟列索引"] H2 --> I2["修正:统一类型,应用层类型匹配"] H3 --> I3["修正:使用全文索引或 ES"] H4 --> I4["修正:更新统计信息,增加采样页数"] F -- 否 --> J["无可用索引,需创建合适索引"] C -- 否 --> K{"key_len 是否符合预期列宽"} K -- 否 --> L["key_len 小于预期,复合索引列未被完全使用"] L --> M1["检查索引列顺序是否符合最左前缀"] L --> M2["检查 WHERE 条件是否跳过了索引前导列"] M1 --> N1["修正:调整复合索引列顺序"] M2 --> N2["修正:添加缺失的前导列条件或创建新索引"] K -- 是 --> O["索引使用正常,排查其他因素"] classDef decision fill:#fff4e6,stroke:#ff9800,stroke-width:2px,color:#333 classDef process fill:#f4f4f4,stroke:#333,stroke-width:1px class C,F,K decision class A,B,D,E,G,H1,H2,H3,H4,I1,I2,I3,I4,J,L,M1,M2,N1,N2,O process
图说明:
- 从慢查询告警触发,通过
EXPLAIN观察type和key。 - 若索引存在但未使用,按"函数/运算 → 隐式转换 → 前缀通配 → 统计信息过时"的顺序逐层排查,每一层都有明确的检查命令(
SHOW WARNINGS、EXPLAIN FORMAT=JSON、ANALYZE TABLE等)。 - 若索引被部分使用(
key_len异常),则回归最左前缀原则,检查列顺序与 WHERE 条件。
2. SQL 与查询反模式
本章聚焦于 SQL 书写层面和查询执行过程中的典型陷阱,涉及设计阶段的 SELECT * 和 ORDER BY RAND(),以及运行时出现的深度分页、相关子查询、Join 驱动表错误等。
2.1 设计反模式案例 1:SELECT * 导致覆盖索引失效与大量回表
2.1.1 错误示例
java
@Select("SELECT * FROM orders WHERE user_id = #{userId} ORDER BY create_time DESC LIMIT 20")
List<Order> findByUserId(@Param("userId") Long userId);
2.1.2 现象描述
EXPLAIN显示使用了idx_user_id索引,但Extra中出现Using where; Using filesort。- 查询扫描了 450 行并进行了外部排序,尽管 LIMIT 仅为 20。
- 实际执行时间从预期的 5ms 增长到 120ms,Buffer Pool 回表引发的随机 I/O 增加。
2.1.3 排查思路
- 分析 EXPLAIN Extra 列 :
Using filesort意味着 MySQL 无法利用索引完成排序,必须额外排序。 - 检查索引是否覆盖 :对比
SELECT涉及的列与索引包含的列,发现索引只包含user_id和主键id,其余列需要回表。 - 覆盖索引验证 :创建一个包含所有查询列和排序/过滤列的复合索引,执行
EXPLAIN观察Extra变为Using index。 - 慢查询统计 :
sys.statements_with_sorting可以定位存在排序开销的语句。
2.1.4 根因分析
根因详见第 2 篇覆盖索引原理与回表代价 。二级索引的叶子节点只存储索引列和主键值。SELECT * 要求返回表中所有列,但 idx_user_id 仅包含 user_id 和主键 id。为了获取 order_no、amount 等未包含在索引中的列,必须通过主键回表到聚簇索引中获取完整行。由于需要回表,优化器无法使用"索引条件下推"完成排序,只能在读取所有候选行后执行 filesort。同时,即使 LIMIT 为 20,也必须扫描所有匹配的 450 行并进行回表和排序。
2.1.5 修正方案
sql
-- 创建覆盖索引
ALTER TABLE orders ADD INDEX idx_user_cover (user_id, create_time, order_no, amount, status);
-- 只 SELECT 需要的列
SELECT order_no, amount, status, create_time
FROM orders
WHERE user_id = 12345
ORDER BY create_time DESC
LIMIT 20;
修正后 EXPLAIN 显示 Extra=Using index,无需回表,无需排序,扫描行数仅为 20。
2.1.6 最佳实践
- 禁止
SELECT *:纳入静态代码检查规则,强制显式指定所需列。 - 设计覆盖索引:对 Top N 高频查询,建立覆盖索引以消除回表和排序。
- 权衡索引宽度:覆盖索引列数建议不超过 5~6 列,避免过度影响写入性能和占用过多 Buffer Pool。
2.2 设计反模式案例 2:ORDER BY RAND() 导致全表扫描与文件排序
2.2.1 错误示例
sql
SELECT * FROM products WHERE status = 1 ORDER BY RAND() LIMIT 10;
2.2.2 现象描述
EXPLAIN显示type=ALL, Extra=Using where; Using temporary; Using filesort。- 数据库 CPU 使用率飙升至 100%,
SHOW PROCESSLIST中大量连接处于Creating sort index状态。 SHOW STATUS LIKE 'Created_tmp_disk_tables'指标迅速上升,表明大量临时表被写入磁盘。
2.2.3 排查思路
- 慢查询日志分析 :
pt-query-digest显示ORDER BY RAND()消耗了最多的总响应时间。 - 检查临时表使用 :
sys.statements_with_temp_tables定位到该语句,且磁盘临时表占比高。 - 代码搜索 :在业务代码中搜索
ORDER BY RAND(),确认随机推荐、抽奖等功能使用了该方式。 - 对比验证 :将
ORDER BY RAND()改为应用层生成随机 ID 列表,性能提升 100 倍以上。
2.2.4 根因分析
根因详见第 5 篇排序机制与临时表使用条件 。ORDER BY RAND() 的执行过程为:扫描所有满足 status=1 的行,为每行计算一个随机数,将所有行放入临时表,然后对临时表进行 filesort 排序,最后返回前 10 行。由于 RAND() 函数对每一行都产生不同的值,无法利用任何索引,且必须把所有结果行都计算一遍才能排序。高并发下,大量的临时表和排序操作会迅速耗尽内存和 CPU。源码 sql/item_func.cc 中 Item_func_rand::val_real() 标记为每行重新计算,并强制优化器使用表扫描。
2.2.5 修正方案
方案一(推荐):应用层随机 + 主键查询
java
// 获取 ID 范围
long maxId = mapper.selectMaxId();
long minId = mapper.selectMinId();
Set<Long> randomIds = new HashSet<>();
while (randomIds.size() < 20) {
randomIds.add(minId + (long)(Math.random() * (maxId - minId + 1)));
}
// 批量查询
List<Product> products = mapper.selectByIds(new ArrayList<>(randomIds), 10);
sql
SELECT * FROM products WHERE id IN (id_list) AND status = 1 LIMIT 10;
方案二:随机池表 :定期更新一张 product_random_pool 表,存储随机排序后的 ID 列表,查询时直接取前 N 条即可。
2.2.6 最佳实践
- 严禁在 SQL 中使用
ORDER BY RAND(),随机化逻辑必须在应用层实现。 - 监控
Created_tmp_disk_tables和Created_tmp_tables,异常波动时排查排序语句。 - 对必须随机展示的场景,优先使用缓存或预计算随机池。
2.3 运行时反模式案例 1:深度分页 LIMIT 100000, 20
2.3.1 错误示例
sql
SELECT * FROM orders WHERE user_id = 12345 ORDER BY create_time DESC LIMIT 100000, 20;
2.3.2 现象描述
EXPLAIN显示key=idx_user_cover(覆盖索引),但rows=100020,即使走索引也要扫描前 100020 行并丢弃前 100000 行。- 第 1 页查询耗时 5ms,第 10000 页查询耗时 3.5 秒,延迟随翻页深度线性增长。
- 数据库 CPU 和 IO 使用率随之攀升,高并发下容易引发雪崩。
2.3.3 排查思路
- 定位分页偏移量:从慢查询日志中提取 LIMIT 语句,观察偏移量大小。
- 线性测试 :模拟
LIMIT 0,20、LIMIT 1000,20、LIMIT 100000,20,记录执行时间和扫描行数。 - 检查索引使用 :
EXPLAIN显示使用了索引,但rows仍然等于offset + limit,说明 MySQL Server 层需要逐行跳过。 - 审查业务需求:评估用户是否真的需要跳转到第 10000 页,是否可以改为游标分页或限制翻页深度。
2.3.4 根因分析
根因详见第 5 篇 LIMIT 执行机制与优化器扫描策略 。MySQL 处理 LIMIT offset, count 时,存储引擎会返回前 offset + count 行给 Server 层,Server 层在 sql/sql_executor.cc 的 read_record() 中逐行迭代,丢弃前 offset 行后返回结果。即使索引扫描能够精确定位第一行,Server 层仍然需要遍历 offset 次才能到达目标位置,这是 LIMIT 语义无法避免的。因此,翻页越深,丢弃的无用行越多,性能下降越明显。
2.3.5 修正方案
方案一(推荐):游标分页(Seek Method)
sql
-- 第一页
SELECT * FROM orders WHERE user_id = 12345
ORDER BY create_time DESC, id DESC LIMIT 20;
-- 第二页(基于上一页最后一条的 create_time 和 id)
SELECT * FROM orders WHERE user_id = 12345
AND (create_time < '2024-11-15 10:30:00'
OR (create_time = '2024-11-15 10:30:00' AND id < 98765))
ORDER BY create_time DESC, id DESC LIMIT 20;
每一页都通过上一页的最后一条记录作为游标直接定位,EXPLAIN 的 rows 仅等于 LIMIT 的值。
方案二:限制最大翻页深度:在应用层强制校验 offset 上限,超过 5000 条时提示用户使用搜索或筛选。
2.3.6 最佳实践
- 优先采用游标分页:App 端无限滚动、API 分页接口,应使用游标分页替代传统偏移分页。
- 唯一排序键 :游标分页需要保证排序键唯一,建议组合
ORDER BY create_time DESC, id DESC。 - 监控翻页深度:通过慢查询日志的 LIMIT 偏移量分布,发现并告警异常的大偏移量查询。
2.4 运行时反模式案例 2:子查询未物化导致 DEPENDENT SUBQUERY
2.4.1 错误示例
sql
SELECT user_id,
(SELECT AVG(amount) FROM orders o2 WHERE o2.user_id = o1.user_id) AS avg_amt
FROM orders o1
WHERE amount > (SELECT AVG(amount) FROM orders o2 WHERE o2.user_id = o1.user_id);
2.4.2 现象描述
EXPLAIN 输出:
sql
+----+--------------------+-------+------+---------------+-------------+---------+---------------------+--------+-------------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+--------------------+-------+------+---------------+-------------+---------+---------------------+--------+-------------+
| 1 | PRIMARY | o1 | ALL | NULL | NULL | NULL | NULL | 500000 | Using where |
| 3 | DEPENDENT SUBQUERY | o2 | ref | idx_user_id | idx_user_id | 8 | order_db.o1.user_id | 50 | NULL |
| 2 | DEPENDENT SUBQUERY | o2 | ref | idx_user_id | idx_user_id | 8 | order_db.o1.user_id | 50 | NULL |
+----+--------------------+-------+------+---------------+-------------+---------+---------------------+--------+-------------+
select_type为DEPENDENT SUBQUERY,表示子查询依赖于外部查询的每一行。- 外部扫描 50 万行,每行触发 2 次子查询,总计 100 万次子查询执行。每次子查询虽然使用了索引,但 50 行的 ref 扫描 × 100 万次 = 5000 万行处理,整体查询耗时超过 30 秒。
2.4.3 排查思路
- 检查 EXPLAIN select_type :
DEPENDENT SUBQUERY或UNCACHEABLE SUBQUERY是危险的信号。 - 查看 EXPLAIN FORMAT=TREE:清晰地显示子查询对外部列的依赖链。
- 改写为 JOIN 或 CTE :将子查询预先物化,对比执行计划,确认
select_type变为DERIVED。 - 使用
EXPLAIN ANALYZE:观察实际执行中循环次数,验证子查询被调用的次数。
2.4.4 根因分析
根因详见第 5 篇子查询优化机制与物化策略 。在 sql/sql_optimizer.cc 的 optimize_subqueries() 中,优化器判断子查询是否包含外部列引用(o1.user_id)。若包含,则判定为依赖子查询,不能独立执行并物化为临时表。外部查询的每一行都会触发子查询的重新执行,形成嵌套循环。这是执行计划中最糟糕的模式之一。
2.4.5 修正方案
使用 CTE 预先计算每个用户的平均金额:
sql
WITH user_avg AS (
SELECT user_id, AVG(amount) AS avg_amt FROM orders GROUP BY user_id
)
SELECT o.user_id, ua.avg_amt
FROM orders o
JOIN user_avg ua ON o.user_id = ua.user_id
WHERE o.amount > ua.avg_amt;
修正后 select_type=DERIVED,子查询物化为临时表,只执行一次,外部查询直接 JOIN。
2.4.6 最佳实践
- 依赖子查询改写:绝大多数字查询都可以改写为 JOIN 或 CTE。
- EXPLAIN 是红灯 :出现
DEPENDENT SUBQUERY必须立即优化。 - EXISTS 替代 IN :在适当场景下可以使用
EXISTS,但也需注意避免依赖关系。
2.5 运行时反模式案例 3:Join 驱动表选择错误导致 Block Nested-Loop
2.5.1 错误示例
sql
SELECT o.*, u.user_name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.create_time > '2024-11-01';
2.5.2 现象描述
EXPLAIN显示第一行为users(type=ALL, rows=50000),第二行为orders(type=ref, rows=100)。- 优化器选择了
users作为驱动表,先全表扫描 5 万用户,然后对每个用户在orders上进行 100 次索引查找,总循环 500 万次。 - 若使用
STRAIGHT_JOIN强制orders作为驱动表(range 扫描 1000 行),被驱动表users使用主键 eq_ref(rows=1),总循环仅 1000 次,性能提升数百倍。
2.5.3 排查思路
- EXPLAIN 首行即驱动表 :查看各表
rows乘积,估算 JOIN 复杂度。 - 使用 STRAIGHT_JOIN 对比:强制两种顺序,记录实际执行时间。
- 检查统计信息 :确认
orders和users的统计信息是否准确,尤其是create_time的选择率。 - Hash Join 是否启用 :MySQL 8.0.18+ 支持 Hash Join,检查
optimizer_switch中hash_join=on。
2.5.4 根因分析
根因详见第 5 篇 Join 算法与驱动表选择策略 。优化器在 choose_table_order() 中基于统计信息和代价模型决定连接顺序。如果统计信息显示 users 表较小(5 万行),且 orders 表上 user_id 的索引选择率估算偏差,优化器可能错误地认为用 users 驱动 orders 的 Nested-Loop Join 代价更低。但实际 orders 经过 create_time 过滤后行数很少,本应作为驱动表。MySQL 8.0 中,如果启用了 Hash Join,可能在一定程度上缓解错误,但仍应追求最优驱动顺序。
2.5.5 修正方案
方案一:更新统计信息
sql
ANALYZE TABLE orders;
ANALYZE TABLE users;
方案二:STRAIGHT_JOIN 强制顺序
sql
SELECT STRAIGHT_JOIN o.*, u.user_name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.create_time > '2024-11-01';
方案三:使用子查询先缩小 orders 范围
sql
SELECT sq.*, u.user_name
FROM (SELECT * FROM orders WHERE create_time > '2024-11-01') sq
JOIN users u ON sq.user_id = u.id;
2.5.6 最佳实践
- 定期 ANALYZE:保证优化器获得准确的数据分布。
- 大表驱动小表检查 :人工判断过滤后结果集较小的表作为驱动表,若有出入,用
STRAIGHT_JOIN验证。 - 索引覆盖:确保被驱动表的连接列上有索引,避免退化为 Block Nested-Loop。
3. 事务与锁反模式
事务和锁的滥用是导致系统卡顿、死锁甚至服务不可用的核心原因之一。本章涵盖事务边界设计、长事务、无索引 DML、死锁以及 RR 隔离级别下的写入保护缺失等 5 个典型案例。
3.1 设计反模式案例 1:事务边界过大,包含外部 I/O 调用
3.1.1 错误示例
java
@Transactional
public OrderResult createOrder(OrderRequest request) {
orderMapper.insert(order); // DB 操作
inventoryMapper.decreaseStock(...); // DB 操作
PaymentResponse payResp = paymentService.pay(order); // 外部 HTTP,耗时 2s
pdfGenerator.generate(order); // 文件 IO,耗时 200ms
orderMapper.updateStatus(order.getId(), PAID); // DB 操作
return buildResult(order);
}
3.1.2 现象描述
information_schema.INNODB_TRX中大量事务运行时间超过 5 秒,最长达到 10 秒以上。- 行锁被长时间持有,
sys.innodb_lock_waits中阻塞链不断增长,导致其他线程锁等待超时。 - 连接池快速耗尽,应用频繁抛出
Cannot acquire connection from pool。 SHOW ENGINE INNODB STATUS的History list length由于长事务阻碍 Purge 而飙升。
3.1.3 排查思路
- 定位长事务:
sql
SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS sec, trx_query
FROM information_schema.INNODB_TRX
WHERE sec > 60;
- 查找对应连接 :通过
trx_mysql_thread_id关联SHOW FULL PROCESSLIST,发现连接处于Sleep状态(空闲但事务未提交)。 - 追踪方法耗时 :使用 Arthas
trace命令监控createOrder方法,发现大量时间消耗在外部 RPC 调用上。 - 审查事务边界 :确认
@Transactional注解范围过大,囊括了所有非数据库操作。
3.1.4 根因分析
根因详见第 3 篇 MVCC 与事务隔离性实现、第 4 篇行锁与间隙锁的持有周期。在 InnoDB 中,事务一旦开始,其获取的行锁(X 锁)和读视图(Read View)会一直保持到事务提交或回滚。将外部 I/O 操作置于事务内,相当于将数据库资源的持有时间从毫秒级人为延长至秒级甚至分钟级。这直接导致:
- 锁竞争加剧,其他事务被阻塞。
- Undo Log 无法被 Purge 线程清理,导致 Undo 表空间膨胀和版本链过长。
- 连接被长时间占用,连接池可用连接减少,新请求等待或超时。
3.1.5 修正方案
将核心数据库操作封装为独立短事务,外部调用异步化或移出事务:
java
public OrderResult createOrder(OrderRequest req) {
Order order = createOrderInTx(req); // 独立短事务,timeout=5s
CompletableFuture.runAsync(() -> {
paymentService.pay(order);
pdfGenerator.generate(order);
updateStatus(order.getId(), PAID); // 独立事务
});
return buildResult(order);
}
@Transactional(timeout = 5)
private Order createOrderInTx(OrderRequest req) {
orderMapper.insert(order);
inventoryMapper.decreaseStock(...);
return order;
}
3.1.6 最佳实践
- 事务最小化原则:事务应只包含必须原子化的数据库操作,严禁包含网络调用、文件 I/O、消息发送等。
- 显式超时:所有事务必须设置合理的超时时间(如 5~10 秒)。
- 编程式事务管理 :对于复杂流程,优先使用
TransactionTemplate精确控制事务边界。 - 监控事务时长 :通过
INNODB_TRX和 APM 监控长事务,及时告警。
3.2 运行时反模式案例 1:长事务导致 Undo Log 膨胀与 Purge 延迟
3.2.1 错误示例
sql
START TRANSACTION;
UPDATE orders SET status = 5 WHERE status IN (1,2) AND create_time < '2023-01-01';
-- 更新 120 万行,事务持续 30 分钟
COMMIT;
3.2.2 现象描述
SHOW ENGINE INNODB STATUS中History list length飙升至 25000+(正常应 < 200)。- Undo 表空间文件大小从 10GB 增长到 80GB,磁盘告警。
- 所有基于该表的历史查询变慢,因为需要遍历更长的 Undo 版本链。
- Purge 线程延迟严重,
INNODB_METRICS中trx_rseg_history_len持续上升。
3.2.3 排查思路
- 监控 History list length:
sql
SELECT name, count FROM information_schema.INNODB_METRICS WHERE name = 'trx_rseg_history_len';
- 定位长事务 :查询
information_schema.INNODB_TRX,按trx_started排序找到最早的事务。 - 查看 Undo 使用量 :
SELECT TABLESPACE_NAME, FILE_SIZE FROM information_schema.FILES WHERE TABLESPACE_NAME LIKE 'innodb_undo%'; - 评估影响:检查锁等待和慢查询是否与 Undo 膨胀的时间段吻合。
3.2.4 根因分析
根因详见第 3 篇 Undo Log 生命周期与 Purge 机制 。MVCC 要求保留所有可能被其他活跃事务的读视图所见的旧版本数据。当事务长时间未提交时,其开启时的 Read View 成为了 Purge 线程清理 Undo Log 的"高水位"。期间产生的所有 Undo 记录都无法被清除,因为 Purge 必须假设该长事务可能还需要读取这些旧版本(尽管实际上可能不再访问)。这导致 Undo Log 无限堆积,进而拖慢所有需要遍历版本链的查询。源码 trx_purge_fetch_next_rec() 中会跳过所有事务 ID 大于最小活跃 Read View 的 Undo 记录。
3.2.5 修正方案
拆分大事务为小批量提交:
sql
DELIMITER //
CREATE PROCEDURE batch_update_orders()
BEGIN
DECLARE done INT DEFAULT 0;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = 1;
REPEAT
START TRANSACTION;
UPDATE orders SET status = 5
WHERE status IN (1,2) AND create_time < '2023-01-01'
LIMIT 1000;
COMMIT;
DO SLEEP(0.5);
UNTIL done END REPEAT;
END //
DELIMITER ;
或使用 pt-archiver 工具以低侵入方式执行大批量操作。
3.2.6 最佳实践
- 单事务影响行数控制:建议不超过 10000 行,大操作分批执行。
- History list length 告警:设置阈值 > 500 即告警。
- Purge 线程调优 :多核机器可增加
innodb_purge_threads(默认 4)。 - 避免空闲事务:应用侧确保事务开启后尽快执行 DML 并提交。
3.3 运行时反模式案例 2:无索引条件 UPDATE 导致全表加锁
3.3.1 错误示例
sql
UPDATE orders SET status = 4 WHERE status = 3 AND amount > 1000;
-- status 和 amount 列均无索引
3.3.2 现象描述
EXPLAIN UPDATE ...显示type=ALL, rows=5000000,全表扫描。sys.innodb_lock_waits中出现大量等待该UPDATE的线程,被阻塞的 SQL 甚至包括UPDATE orders SET ... WHERE id = xxx。- 事务持有的行锁数量极大,
SHOW ENGINE INNODB STATUS的TRANSACTIONS段显示该事务锁定了数十万行。
3.3.3 排查思路
- 查看阻塞链源头 :
SELECT * FROM sys.innodb_lock_waits\G找到blocking_trx_id。 - 分析阻塞 SQL :查询
performance_schema.events_statements_current获取阻塞事务正在执行的语句。 - 执行计划分析 :
EXPLAIN确认是全表扫描,索引未使用。 - 评估索引缺失 :
SHOW INDEX FROM orders发现status和amount均无索引。
3.3.4 根因分析
根因详见第 4 篇行锁加锁规则与扫描过程中锁的获取 。UPDATE 语句在扫描每一行时都会尝试获取该行的排他锁(X-Lock)。如果 WHERE 条件无法利用索引,UPDATE 将执行全表扫描,对扫描到的每一行都加 X 锁,即使该行并不满足最终的过滤条件(在 REPEATABLE READ 隔离级别下尤为严重)。这导致表上几乎所有行都被锁定,其他任何事务的 DML 操作都将被阻塞,直到该 UPDATE 提交。源码 row_search_mvcc() 中,全表扫描时每读取一行都会调用 lock_clust_rec_read_check_and_lock() 加锁。
3.3.5 修正方案
紧急添加索引:
sql
ALTER TABLE orders ADD INDEX idx_status_amount (status, amount);
修正后 EXPLAIN 显示 type=range, rows=100000,只锁满足条件的行。
若无法立即添加索引,分批更新:
sql
UPDATE orders SET status = 4 WHERE status = 3 AND amount > 1000 LIMIT 1000;
-- 循环执行直到 affected_rows = 0
3.3.6 最佳实践
- 铁律 :
UPDATE/DELETE的 WHERE 条件必须能够利用索引,上线前强制 EXPLAIN 检查。 - 开启安全模式 :在开发环境设置
sql_safe_updates=ON,禁止全表扫描的 DML。 - 索引设计前瞻:业务中常作为过滤条件的列,应提前评估是否需要建立索引。
3.4 运行时反模式案例 3:以不同顺序更新相同行导致死锁
3.4.1 错误示例
sql
-- 事务 A
START TRANSACTION;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 100; -- 锁定 100
-- 业务逻辑...
UPDATE inventory SET stock = stock - 1 WHERE product_id = 200; -- 等待 200
-- 事务 B
START TRANSACTION;
UPDATE inventory SET stock = stock - 1 WHERE product_id = 200; -- 锁定 200
-- 业务逻辑...
UPDATE inventory SET stock = stock - 1 WHERE product_id = 100; -- 等待 100,死锁!
3.4.2 现象描述
SHOW ENGINE INNODB STATUS的LATEST DETECTED DEADLOCK段清晰记录:
ini
(1) TRANSACTION: UPDATE inventory ... WHERE product_id = 100
(1) WAITING FOR THIS LOCK TO BE GRANTED: ... product_id = 200
(2) TRANSACTION: UPDATE inventory ... WHERE product_id = 200
(2) WAITING FOR THIS LOCK TO BE GRANTED: ... product_id = 100
*** WE ROLL BACK TRANSACTION (2)
- 应用日志出现
Deadlock found when trying to get lock; try restarting transaction。 - 死锁通常发生在批量任务(如结算、导入)或并发下单场景。
3.4.3 排查思路
- 收集死锁日志 :开启
innodb_print_all_deadlocks=ON,将所有死锁信息记录到错误日志。 - 提取竞争资源:从死锁日志中提取 table、index、锁类型和等待的具体行。
- 代码审查:检查应用层对资源列表的访问顺序,发现批量更新时未排序。
- 复现验证 :模拟两个线程以不同顺序更新
product_id,立即复现死锁。
3.4.4 根因分析
根因详见第 4 篇死锁检测与加锁规则。InnoDB 的死锁检测算法基于等待图(Wait-for Graph),检测线程定期遍历所有锁等待关系,若发现环(A 等 B,B 等 A)则回滚其中一个事务。本例中,事务 A 和 B 交叉请求对方已持有的锁,形成经典的死锁环。根本原因在于应用层未保证资源访问顺序的一致性。
3.4.5 修正方案
方案一:统一排序
java
// 对 productId 列表排序,保证所有事务以相同顺序加锁
List<Long> sortedIds = productIds.stream().sorted().collect(Collectors.toList());
for (Long id : sortedIds) {
inventoryMapper.decreaseStock(id, quantity);
}
方案二:使用 SKIP LOCKED(MySQL 8.0+)
sql
SELECT * FROM inventory WHERE product_id IN (100, 200) FOR UPDATE SKIP LOCKED;
-- 跳过已锁定的行,避免等待
方案三:乐观锁重试
sql
UPDATE inventory SET stock = stock - 1, version = version + 1
WHERE product_id = 100 AND version = #{expectedVersion};
3.4.6 最佳实践
- 资源排序原则:涉及批量修改多个资源时,必须按照统一的顺序(如 ID 升序)处理。
- 死锁重试 :应用层捕获
DeadlockLoserDataAccessException,实现指数退避重试。 - 监控死锁频率:持续监控死锁次数,突然增多时排查新上线的业务逻辑。
3.5 运行时反模式案例 4:RR 隔离级别下未使用 SELECT ... FOR UPDATE 保护写入
3.5.1 错误示例
java
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
Account from = accountMapper.selectById(fromId); // 快照读
Account to = accountMapper.selectById(toId);
accountMapper.updateBalance(fromId, from.getBalance().subtract(amount));
accountMapper.updateBalance(toId, to.getBalance().add(amount));
}
3.5.2 现象描述
并发转账测试出现余额丢失(更新丢失)。两个事务同时读到相同余额 1000,各自减 100 后都更新为 900,导致其中一个事务的扣款被覆盖。检查隔离级别:
sql
SELECT @@transaction_isolation;
-- REPEATABLE-READ
3.5.3 排查思路
- 审查转账代码 :确认先读后写,且读操作使用的是普通
SELECT。 - 模拟并发:使用 JMeter 或单元测试模拟两个线程同时转账,检查账户余额是否正确。
- 查看锁情况 :在
PERFORMANCE_SCHEMA.data_locks中查看SELECT并未加锁。 - 对比 FOR UPDATE :使用
SELECT ... FOR UPDATE后,在data_locks中看到X锁,并发转为串行化。
3.5.4 根因分析
根因详见第 3 篇 MVCC 快照读与第 4 篇锁定读 。在 REPEATABLE READ 隔离级别下,普通的 SELECT 是快照读,读取的是事务开始时的数据版本,并且不加任何行锁。因此两个事务可能同时读到相同的余额值,随后各自基于旧值进行更新,导致先提交的更新被后提交的覆盖(丢失更新)。SELECT ... FOR UPDATE 会对目标行加 X 锁,强制进行当前读,阻止并发更新,保证读-写序列化。
3.5.5 修正方案
java
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
Account from = accountMapper.selectByIdForUpdate(fromId); // FOR UPDATE
Account to = accountMapper.selectByIdForUpdate(toId);
accountMapper.updateBalance(fromId, from.getBalance().subtract(amount));
accountMapper.updateBalance(toId, to.getBalance().add(amount));
}
3.5.6 最佳实践
- 先读后写原则 :在 RR 隔离级别下,任何基于当前数据值进行更新的操作,必须先使用
FOR UPDATE锁定相关行。 - 乐观锁替代方案 :对于低冲突场景,使用版本号(
version字段)实现乐观锁,避免锁竞争。 - 隔离级别选择:了解业务需求,必要时评估是否适合使用 READ COMMITTED 隔离级别。
死锁排查序列图
WHERE product_id=100 DB-->>A: 获取 product_id=100 的 X-Lock 成功 B->>DB: T2: UPDATE inventory SET stock=stock-1
WHERE product_id=200 DB-->>B: 获取 product_id=200 的 X-Lock 成功 A->>DB: T3: UPDATE inventory SET stock=stock-1
WHERE product_id=200 DB-->>A: 等待 product_id=200 的 X-Lock(被事务 B 持有) B->>DB: T4: UPDATE inventory SET stock=stock-1
WHERE product_id=100 DB-->>B: 等待 product_id=100 的 X-Lock(被事务 A 持有) Note over DB: T5: 死锁检测线程发现等待环
A 等待 B 持有的 200
B 等待 A 持有的 100
形成环 A → B → A DB->>DB: lock_deadlock_check_and_resolve()
选择回滚代价最小的事务(B) DB-->>B: T6: 回滚事务 B
释放 product_id=200 的 X-Lock A->>DB: T7: 获取 product_id=200 的 X-Lock 成功 A->>DB: T8: COMMIT DB-->>A: 提交成功,释放所有锁 B->>B: T9: 应用捕获死锁异常
实现重试逻辑
图说明:
- T1-T2 阶段两个事务分别成功获取不同资源的锁,构成死锁的前提条件。
- T3-T4 阶段发生交叉等待,事务 A 等待 B 释放 200,事务 B 等待 A 释放 100。
- T5 触发 InnoDB 的死锁检测,
lock_deadlock_check_and_resolve()函数遍历锁等待图发现环。 - T6 选择事务 B 作为牺牲品回滚,释放锁,事务 A 得以继续执行并成功提交。
- 事务 B 的应用层需要捕获
DeadlockLoserDataAccessException并实施重试。
4. 复制与架构反模式
复制架构的反模式可能导致数据丢失、不一致或严重的延迟,直接影响业务正确性。
4.1 设计反模式案例 1:STATEMENT 格式导致主从数据不一致
4.1.1 错误示例
sql
SET GLOBAL binlog_format = STATEMENT;
INSERT INTO audit_log (user_id, action, create_time) VALUES (123, 'login', NOW());
4.1.2 现象描述
pt-table-checksum校验发现audit_log表主从数据不一致,create_time列存在秒级差异。- 从库错误日志中未见异常,复制线程正常运行。
mysqlbinlog查看 Binlog,记录的是原始 SQL 语句:INSERT INTO audit_log ... VALUES (..., NOW())。
4.1.3 排查思路
- 确认 Binlog 格式 :
SHOW VARIABLES LIKE 'binlog_format'; - 解析 Binlog :
mysqlbinlog --base64-output=DECODE-ROWS -v mysql-bin.000123查看包含NOW()的语句。 - 数据校验 :
pt-table-checksum输出差异,明确哪些行的create_time不同。 - 评估影响范围 :检查是否还有其他使用非确定性函数(
UUID()、RAND())的写入。
4.1.4 根因分析
根因详见第 6 篇 Binlog 格式与复制原理 。STATEMENT 格式记录的是执行的 SQL 语句,从库重新执行该语句时,NOW() 等非确定性函数会返回从库的当前时间,而非主库写入时的原始时间。类似地,LIMIT 无 ORDER BY、DELETE 无排序、触发器、存储过程等都可能产生主从差异。这是 STATEMENT 格式的天然缺陷。
4.1.5 修正方案
sql
SET GLOBAL binlog_format = ROW;
SET GLOBAL binlog_row_image = FULL;
对于已不一致的数据,使用 pt-table-sync --execute 基于主库数据修复从库。
4.1.6 最佳实践
- 生产环境强制 ROW 格式:MySQL 8.0 默认即为 ROW,不要回退到 STATEMENT。
- 全量日志 :
binlog_row_image=FULL便于数据恢复和审计。 - 定期校验 :使用
pt-table-checksum每周末低峰期自动校验主从一致性。
4.2 运行时反模式案例 1:主从延迟导致读写分离读到旧数据
4.2.1 错误示例
java
orderMapper.insert(order); // 写主库
Order latest = orderReadMapper.selectById(order.getId()); // 读从库(延迟)
return latest; // 可能为 null 或旧状态
4.2.2 现象描述
- 用户创建订单后跳转详情页显示"订单不存在",刷新后正常。
SHOW SLAVE STATUS\G显示Seconds_Behind_Master=3。- 应用监控显示"写后立即读"接口的异常率与主从延迟呈正相关。
4.2.3 排查思路
- 监控主从延迟 :使用
pt-heartbeat或SHOW SLAVE STATUS查看实时延迟。 - 应用数据源分析:在应用日志中增加数据源标记,确认写后读的查询走了从库。
- GTID 一致性检查 :
SELECT @@gtid_executed在主从库对比。 - 等待 GTID 应用 :尝试
SELECT WAIT_FOR_EXECUTED_GTID_SET('...', 3)验证是否能等待到最新。
4.2.4 根因分析
根因详见第 6 篇主从复制异步机制。默认异步复制下,主库事务提交和从库应用之间存在不可避免的延迟,延迟时间取决于网络、从库负载、事务大小。写后立即读从库必然面临读到旧版本的风险。
4.2.5 修正方案
- 写后读强制走主库 :通过
@Master注解或在ThreadLocal中设置上下文路由。 - GTID 等待 (半同步或 MySQL 8.0):
SELECT WAIT_FOR_EXECUTED_GTID_SET(gtid, timeout);等待从库同步完成后再读。 - 中间件一致性保证:ProxySQL、ShardingSphere 等支持基于 GTID 的读写分离一致性策略。
4.2.6 最佳实践
- 区分读写场景:核心业务(如订单创建、支付)的写后读必须读主库;列表查询等可容忍短暂延迟的可以读从库。
- 延迟告警与降级:当主从延迟 > 5 秒时,自动将所有读请求切换到主库。
- 避免大事务:大事务是主从延迟的最大来源之一,必须拆分。
4.3 运行时反模式案例 2:分片键选择不当导致数据严重倾斜
4.3.1 错误示例
选择 order_status(仅 5 个值)作为分库分表的分片键。
4.3.2 现象描述
- 监控显示某个分片(如存储
status=3的分片)的磁盘使用率达到 90%,而其他分片仅 30%。 - 该分片的 QPS 和 CPU 负载远高于其他分片,经常成为性能瓶颈。
SHOW DATASHARD或 ShardingSphere 监控显示分片数据量严重不均。
4.3.3 排查思路
- 统计各分片行数 :直接连接各分片
SELECT COUNT(*)或使用 ShardingSphere 的SHOW TABLE RULE查看分布。 - 分析业务流量:确认大部分查询和写入是否集中在某几个状态值。
- 评估分片键区分度 :
SELECT COUNT(DISTINCT order_status) FROM orders,仅为 5。
4.3.4 根因分析
根因详见第 7 篇分库分表策略与分片键选择原则 。分片键必须具有高区分度且分布均匀,才能使数据和流量大致均摊到所有分片。使用低区分度的 order_status 作为分片键,必然导致数据倾斜,违背分片的均衡性初衷。
4.3.5 修正方案
- 重新选择分片键 :改为
user_id或order_id进行哈希分片。 - 数据迁移 :通过
ShardingSphere-Scaling或自行编写迁移程序,将数据重新路由至新分片。
4.3.6 最佳实践
- 高区分度、业务导向:选择与大多数查询强相关、区分度极高的字段作为分片键。
- 预演分布:在分片前使用采样数据模拟分片分布,确保均匀。
- 避免跨分片查询:分片键应能使 80% 以上的查询定位到单一分片。
5. DDL 与表设计反模式
表设计与 DDL 操作的失当,会造成锁表事故、级联瓶颈或空间浪费。
5.1 设计反模式案例 1:滥用外键导致级联锁与死锁
5.1.1 错误示例
sql
CREATE TABLE orders (id BIGINT PRIMARY KEY, ...);
CREATE TABLE order_items (
id BIGINT PRIMARY KEY,
order_id BIGINT NOT NULL,
FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE
);
5.1.2 现象描述
- 删除一个订单时,
sys.innodb_lock_waits显示大量order_items上的行锁等待。 - 高并发下单出现多个事务因外键约束互相阻塞,甚至引发死锁。
- 数据迁移、归档操作时受外键约束影响,变得异常复杂。
5.1.3 排查思路
- 检查外键定义 :
SELECT * FROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_NAME IS NOT NULL; - 死锁日志分析 :死锁事件中涉及父表和子表,通常伴随
FOREIGN KEY约束。 - 测试移除外键:在测试环境移除物理外键,观察并发删除的性能提升和死锁消失。
- 应用层完整性检查:确认应用代码已经通过事务保证了先删子表再删父表的逻辑。
5.1.4 根因分析
根因详见第 4 篇外键的加锁机制 。InnoDB 在 ON DELETE CASCADE 时,会在子表上逐行加 X 锁并删除匹配行,这个操作是在父表事务上下文中完成的。如果两个事务分别删除不同的订单,但它们的订单明细行在页内相近或存在间隙锁,就可能产生交叉锁死锁。同时,插入子表时需要在父表对应行上加 S 锁以验证父行存在,在高并发插入下,父表上的 S 锁成为热点,引发性能瓶颈。
5.1.5 修正方案
移除外键,改用应用层维护参照完整性,并创建必要的索引优化查询:
sql
ALTER TABLE order_items DROP FOREIGN KEY fk_order_items_order;
ALTER TABLE order_items DROP INDEX fk_order_items_order; -- 外键自动创建的索引需手动删除
ALTER TABLE order_items ADD INDEX idx_order_id (order_id);
5.1.6 最佳实践
- 互联网 OLTP 场景避免物理外键:通过应用层逻辑和定期对账保证数据一致性。
- 保留逻辑外键:ER 图、代码规范中注明关联关系。
- 级联操作慎用 :即使保留外键,也尽量避免
CASCADE,改为手动控制。
5.2 运行时反模式案例 1:直接执行 ALTER TABLE 导致锁表
5.2.1 错误示例
sql
-- 业务高峰期直接修改列类型
ALTER TABLE orders MODIFY COLUMN order_no VARCHAR(64);
-- 该操作需要 ALGORITHM=COPY, LOCK=EXCLUSIVE
5.2.2 现象描述
SHOW PROCESSLIST中出现大量Waiting for table metadata lock状态,业务读写全部阻塞。- 应用大面积超时,错误日志大量
Communications link failure。 - 元数据锁等待可能持续数小时,直到
ALTER TABLE完成。
5.2.3 排查思路
- 紧急查看阻塞源 :
SELECT * FROM sys.schema_table_lock_waits;找到持有 MDL_EXCLUSIVE 的ALTER TABLE线程。 - 检查 MDL 锁 :
SELECT * FROM performance_schema.metadata_locks WHERE OBJECT_TYPE='TABLE' AND LOCK_STATUS='PENDING'; - 评估 ALTER 算法 :通过
ALTER TABLE ... ALGORITHM=?, LOCK=?测试可用的在线 DDL 算法。 - 是否有长事务 :
INFORMATION_SCHEMA.INNODB_TRX查看是否有长时间未提交的事务阻塞了 MDL 锁的获取。
5.2.4 根因分析
根因详见第 9 篇 Online DDL 与元数据锁机制 。ALTER TABLE 在开始和结束阶段都需要获取 MDL_EXCLUSIVE 锁,该锁会阻塞所有后续对该表的读写操作。如果表上存在未提交的事务或长查询,ALTER TABLE 将一直等待这些事务释放 MDL_SHARED 锁,而新进入的查询又会被 ALTER 阻塞,形成"等待风暴"。
5.2.5 修正方案
绝不直接在线上执行阻塞式 DDL 。使用 pt-online-schema-change 或 gh-ost:
bash
pt-online-schema-change --alter "MODIFY COLUMN order_no VARCHAR(64)" D=order_db,t=orders --execute
这些工具通过创建影子表、触发器和增量数据拷贝,仅在最后切换表名时短暂锁定(通常毫秒级)。
5.2.6 最佳实践
- DDL 规范:生产环境所有 DDL 必须通过 OSC 工具执行,并经过测试验证。
- 锁等待超时 :设置
lock_wait_timeout=5,避免无限等待。 - 变更窗口:即使使用 OSC,也尽量在业务低峰期执行。
5.3 运行时反模式案例 2:VARCHAR 过大导致行格式溢出页过多
5.3.1 错误示例
sql
CREATE TABLE posts (
id BIGINT PRIMARY KEY,
content VARCHAR(16383), -- 实际内容经常超过 5000 字节
...
) ENGINE=InnoDB ROW_FORMAT=COMPACT;
5.3.2 现象描述
SELECT content时 IO 等待极高,sys.io_global_by_file_by_bytes显示大量读取。SHOW TABLE STATUS中Avg_row_length很大,但整体数据量并不是特别大。- 表空间膨胀迅速,
DATA_LENGTH中有大量溢出页(off-page)开销。
5.3.3 排查思路
- 检查行格式 :
SHOW TABLE STATUS WHERE Name='posts'查看ROW_FORMAT。 - 估算实际长度 :
SELECT MAX(LENGTH(content)), AVG(LENGTH(content)) FROM posts; - 分析溢出页 :通过
py_innodb_page_info或 innodb_ruby 工具分析表空间文件,发现大量BLOB指针页。 - 对比拆分方案:将大字段垂直拆分到关联表,测试查询性能变化。
5.3.4 根因分析
根因详见第 2 篇行格式与溢出页(Off-page)存储 。在 COMPACT 或 DYNAMIC 行格式下,当可变长列超过 768 字节或行总大小超过页大小的约一半时,InnoDB 会将超出的数据存储在独立的溢出页中,仅在聚簇索引记录中保留 20 字节的指针。频繁读取 content 列会导致额外的随机 I/O,严重影响性能。而且溢出页占用额外的表空间,造成空间浪费。
5.3.5 修正方案
- 垂直拆分 :将大字段独立到
post_content(post_id, content)表,主表只存储元数据。 - 适当压缩 :使用
ROW_FORMAT=COMPRESSED结合KEY_BLOCK_SIZE=8来压缩存储,但会带来 CPU 开销。 - 数据类型优化 :如果实际长度可控,将
VARCHAR长度调整到合理范围(如VARCHAR(255))。
5.3.6 最佳实践
- 字段长度精确设置 :避免无脑
VARCHAR(65535),按实际最大需求预留 20% 余量即可。 - 大对象分离:对于超过 1KB 的字段,考虑垂直拆分或使用对象存储(OSS)。
- 监控页分裂 :
INNODB_METRICS中的index_page_splits可以反映页分裂频率,间接说明行过大。
6. 连接与配置反模式
连接池配置与 MySQL 服务端参数的协调是确保应用稳定性的基石,任何细微的不匹配都可能导致连接断开或耗尽。
6.1 设计反模式案例 1:max_connections 未考虑应用实例增长导致总连接超限
6.1.1 错误示例
yaml
# 应用连接池配置
spring.datasource.hikari.maximumPoolSize: 30
# 计划部署实例数:10 个,总计 300 个连接
sql
-- MySQL 配置
max_connections = 200
6.1.2 现象描述
- 业务高峰期频繁出现
Too many connections错误。 SHOW STATUS LIKE 'Threads_connected'经常达到 200 上限。Connection_errors_max_connections状态值持续增长,说明大量连接被拒绝。- 监控工具和管理员也无法连接,因为没有预留管理通道。
6.1.3 排查思路
- 核实总连接数需求 :
应用实例数 × maximumPoolSize = 300,超出max_connections。 - 当前连接分布 :
SELECT SUBSTRING_INDEX(HOST,':',1) AS app_host, COUNT(*) FROM information_schema.PROCESSLIST GROUP BY app_host;查看各实例实际连接数。 - 评估连接使用率 :
(Threads_connected / max_connections) * 100 > 80%则存在风险。 - 检查连接泄漏:是否存在未正确关闭的连接导致连接数持续增长。
6.1.4 根因分析
根因详见第 9 篇连接管理与最大连接数计算模型 。max_connections 是 MySQL 实例级别的全局连接上限,包含所有应用连接、管理连接和复制连接。在设计阶段必须全局估算:max_connections >= SUM(各应用实例 × pool_max) + 复制线程 + 管理预留。本例中 200 < 300,必然导致超限。
6.1.5 修正方案
sql
SET GLOBAL max_connections = 500;
-- 永久配置:my.cnf 中 max_connections=500
或降低应用端连接池上限至 20,确保 10×20=200 < max_connections。同时配置 admin_port 为 DBA 预留紧急通道。
6.1.6 最佳实践
- 全局协调不等式 :
max_connections ≥ SUM(实例数 × pool_max) + 30 (管理预留)。 - 启用管理端口 :MySQL 8.0.14+ 的
admin_address和admin_port可绕过max_connections限制。 - 监控连接使用率:设置 80% 告警阈值。
6.2 运行时反模式案例 1:maxLifetime > wait_timeout 导致连接被静默断开
6.2.1 错误示例
yaml
hikari:
maxLifetime: 600000 # 10 分钟
mysql:
wait_timeout: 300 # 5 分钟
6.2.2 现象描述
- 应用日志抛出
Communications link failure或Connection reset by peer,集中在业务低峰期。 - MySQL 错误日志出现
Aborted connection ... (Got an error reading communication packets)。 - 连接池监控显示有规律地出现连接获取失败。
6.2.3 排查思路
- 检查 MySQL 超时 :
SHOW VARIABLES LIKE '%timeout%';确认wait_timeout。 - 核对连接池配置 :查看 HikariCP 的
maxLifetime和idleTimeout。 - 验证不等式 :
maxLifetime(600s) > wait_timeout(300s),违反协调原则。 - 抓取断开时间线 :应用日志时间戳与 MySQL 错误日志的
Aborted connection时间对比,呈强相关。
6.2.4 根因分析
根因详见第 9 篇连接超时机制与连接池生命周期管理 。MySQL 对空闲连接超过 wait_timeout 秒无活动时会主动关闭。连接池以为该连接仍然有效,将其分配给应用,应用尝试使用已断开的连接时触发异常。必须确保连接池在 MySQL 关闭连接之前就主动废弃并刷新连接,即严格遵守 maxLifetime < wait_timeout。
6.2.5 修正方案
yaml
hikari:
maxLifetime: 240000 # 4 分钟 (< 5 分钟)
idleTimeout: 180000 # 3 分钟
keepaliveTime: 60000 # 1 分钟保活
6.2.6 最佳实践
- 强制不等式 :
idleTimeout < maxLifetime < wait_timeout。 - 启用 keepalive :定期发送
SELECT 1保持连接活跃。 - 启动时校验:在应用启动时检查并记录这些参数,若违反则告警。
6.3 运行时反模式案例 2:idleTimeout > wait_timeout 导致 Sleep 连接堆积
6.3.1 错误示例
yaml
hikari.idleTimeout: 600000 # 10 分钟
mysql.wait_timeout: 300 # 5 分钟
6.3.2 现象描述
SHOW PROCESSLIST中大量Command=Sleep且Time > 300的连接,总数接近连接池最大值。- 这些连接实际上已被 MySQL 关闭,但连接池尚未回收,造成"僵尸连接"堆积。
- 数据库
Threads_connected居高不下,浪费内存和文件句柄。
6.3.3 排查思路
- 查看 Sleep 连接 :
SELECT * FROM sys.processlist WHERE command='Sleep' AND time > 300; - 连接池状态 :通过
/actuator/prometheus或 JMX 查看连接池IdleConnections,发现大量"空闲"连接。 - 对比回收时间 :
idleTimeout设定为 10 分钟,而wait_timeout是 5 分钟,连接在 5 分钟时已被 MySQL 杀掉,但连接池还在等 10 分钟才回收,期间一直作为空闲连接存在。
6.3.4 根因分析
连接池根据 idleTimeout 来回收空闲连接。如果 idleTimeout > wait_timeout,在 MySQL 端已经断开的连接无法被连接池感知,它们会作为"空闲连接"继续留在池中,直到超时回收。这期间这些连接早已不可用,白白占用资源。
6.3.5 修正方案
确保 idleTimeout < wait_timeout,例如 idleTimeout=240000(4 分钟)。
6.3.6 最佳实践
- 严格遵循不等式 :
idleTimeout < maxLifetime < wait_timeout。 - 监控 Sleep 连接趋势:异常增多时检查连接池配置。
- 合理设置 minimumIdle:避免保留过多空闲连接,但也不能过小导致频繁创建。
6.4 运行时反模式案例 3:connect_timeout 与连接池 connectionTimeout 不协调
6.4.1 错误示例
yaml
hikari.connectionTimeout: 30000 # 30 秒
mysql.connect_timeout: 10 # 10 秒
6.4.2 现象描述
- 数据库负载高时,应用端频繁抛出
HikariPool-1 - Connection is not available, request timed out after 30000ms。 - 但 MySQL 的
SHOW PROCESSLIST却能看到很多unauthenticated user的连接,说明 TCP 握手和认证其实已经完成或正在进行,但连接池等不及就超时了。 - 连接获取失败率上升,造成资源浪费。
6.4.3 排查思路
- 对比超时参数 :检查 MySQL
connect_timeout和连接池connectionTimeout的数值。 - 观察等待队列 :连接池的
pendingConnects指标上升。 - 模拟网络延迟:在测试环境增加网络延迟,观察连接池是否在 MySQL 拒绝前就提前超时。
- 调整参数 :将
connectionTimeout设置为略小于或匹配connect_timeout的倍数,观察失败率变化。
6.4.4 根因分析
connect_timeout 控制 MySQL 等待 TCP 连接完成握手和认证的最大时间。如果连接池的 connectionTimeout 远大于 connect_timeout,连接池会等待更长时间才宣告失败,但实际底层可能早已超时或被拒绝;如果 connectionTimeout 小于 connect_timeout,连接池可能在 MySQL 有机会建立连接前就放弃,导致不必要的失败。协调不等式:connectionTimeout 应略小于 connect_timeout 的合理倍数(通常 1:1 或 1:2)。
6.4.5 修正方案
yaml
hikari.connectionTimeout: 12000 # 12 秒
mysql.connect_timeout: 10
6.4.6 最佳实践
- 协调配置 :
hikari.connectionTimeout设置为 MySQLconnect_timeout的 1.2~2 倍,既不会过早放弃,也能及时失败。 - 监控连接获取超时 :连接池 Metrics 中
connectionTimeout事件的频率应纳入告警。 - 网络优化:应用与数据库间网络延迟应尽可能低,避免不必要的超时重试。
连接池与 MySQL 协调的全局排查链路图
或 Too many connections"] --> B{"错误类型判断"} B -- "连接断开类" --> C["检查 MySQL wait_timeout"] B -- "连接数耗尽类" --> D["检查 MySQL Threads_connected"] C --> C1["SHOW VARIABLES LIKE 'wait_timeout'"] C1 --> C2["检查连接池 maxLifetime"] C2 --> C3{"maxLifetime < wait_timeout?"} C3 -- 否 --> C4["修正:调整不等式
maxLifetime < wait_timeout"] C3 -- 是 --> C5["检查连接池 idleTimeout"] C5 --> C6{"idleTimeout < maxLifetime?"} C6 -- 否 --> C7["修正:idleTimeout < maxLifetime"] C6 -- 是 --> C8["检查 keepaliveTime 配置"] C8 --> C9["启用 keepalive
定期保活连接"] D --> D1["SHOW STATUS LIKE 'Threads_connected'"] D1 --> D2["SHOW VARIABLES LIKE 'max_connections'"] D2 --> D3{"使用率 > 80%?"} D3 -- 是 --> D4["检查连接池 total max"] D4 --> D5["实例数 × maximumPoolSize
> max_connections?"] D5 -- 是 --> D6["修正:扩容 max_connections
或降低 pool size"] D5 -- 否 --> D7["检查 Sleep 连接堆积"] D7 --> D8["SHOW PROCESSLIST
查看 Sleep 连接数"] D8 --> D9{"大量 Sleep 连接且时间>wait_timeout?"} D9 -- 是 --> D10["idleTimeout > wait_timeout
或未正确回收"] D9 -- 否 --> D11["检查是否有连接泄漏"] D11 --> D12["使用 Arthas trace 追踪
getConnection/release 配对"] D12 --> D13["定位未释放连接的代码
添加 try-with-resources"] D10 --> D14["修正:调整 idleTimeout
确保 idleTimeout < wait_timeout"] D14 --> C6 C4 --> E["全局协调不等式验证"] C7 --> E C9 --> E D6 --> E D13 --> E E --> F["不等式清单:
1. idleTimeout < maxLifetime < wait_timeout
2. connectionTimeout 与 connect_timeout 协调
3. SUM(pool_max × instances) < max_connections
4. maxLifetime < net_read_timeout(慢查询下)"] F --> G["配置验证通过
持续监控连接池 Metrics"] classDef decision fill:#fff4e6,stroke:#ff9800,stroke-width:2px,color:#333 classDef process fill:#f4f4f4,stroke:#333,stroke-width:1px classDef final fill:#e6f7e6,stroke:#4caf50,stroke-width:2px,color:#1e4620 class B,C3,C6,D3,D5,D9 decision class A,C,C1,C2,C4,C5,C7,C8,C9,D,D1,D2,D4,D6,D7,D8,D10,D11,D12,D13,D14,E process class F,G final
图说明:
- 两大入口(连接断开/连接耗尽)分别导向 MySQL 侧参数检查和连接池配置核对。
- 连接断开类主要排查超时参数不等式,连接耗尽类则从连接池总量、Sleep 堆积、连接泄漏多个维度排查。
- 最终汇总为全局协调不等式清单,验证通过后进入持续监控状态。
7. 诊断工具集与工具→现象映射表
7.1 MySQL 端全工具链速查
| 工具 | 作用域 | 关键命令/视图 | 诊断重点 |
|---|---|---|---|
SHOW ENGINE INNODB STATUS |
实时事务/锁/死锁/缓冲池 | SHOW ENGINE INNODB STATUS\G |
死锁日志、长事务、History list length、锁等待 |
sys.schema_* 视图 |
索引、锁、查询分析 | schema_unused_indexes、schema_redundant_indexes、innodb_lock_waits、statements_with_full_table_scans |
索引清理、锁等待链、全表扫描定位 |
performance_schema |
SQL执行、等待事件、锁、内存 | events_statements_summary_by_digest、data_locks、metadata_locks、table_io_waits_summary_by_index_usage |
SQL 耗时聚合、锁快照、元数据锁等待、索引 IO 开销 |
pt-query-digest |
慢查询聚合分析 | pt-query-digest slow.log |
查询指纹、响应时间占比、执行频次、Rows_examined 分析 |
EXPLAIN ANALYZE |
实际执行计划与耗时 | EXPLAIN ANALYZE SELECT ... |
估算行数 vs 实际行数对比、各步骤实际耗时 |
pt-heartbeat |
主从延迟精确监控 | pt-heartbeat --monitor |
亚秒级延迟监控,辅助判断复制链路健康度 |
| PMM (Percona Monitoring) | 全面监控与告警 | Grafana 仪表盘 | QPS、连接数、缓冲池、复制延迟、Purge 活动、锁趋势 |
7.2 JDBC 端工具链整合(与 JDBC 系列第 10 篇联动)
| 工具 | 作用域 | 关键用法 | 诊断重点 |
|---|---|---|---|
| Arthas | 应用端方法追踪、监控 | trace、watch、monitor |
定位事务耗时、连接获取/释放匹配、SQL 参数类型 |
| HikariCP Metrics | 连接池状态 | /actuator/prometheus |
hikaricp_connections_active、pending、timeout、creation |
| Druid SQL 监控 | SQL 统计与连接池 | Druid Admin 界面 | 慢 SQL 列表、连接池活跃连接、SQL 执行时间分布 |
| 应用日志 | 异常堆栈 | Connection reset、CommunicationsException、DeadlockLoserDataAccessException |
连接断开、死锁捕获 |
7.3 工具→反模式现象映射表(14 行)
| 序号 | 典型现象 | 推荐工具 | 关键检查命令/视图 | 常见根因 |
|---|---|---|---|---|
| 1 | 查询变慢,EXPLAIN 显示 type=ALL 但索引存在 |
SHOW WARNINGS + optimizer_trace |
EXPLAIN 后 SHOW WARNINGS |
隐式类型转换 |
| 2 | 全表扫描增多 | sys.statements_with_full_table_scans |
EXPLAIN FORMAT=JSON 查看 key |
索引失效或缺少索引 |
| 3 | 锁等待超时大面积爆发 | sys.innodb_lock_waits |
SELECT * FROM sys.innodb_lock_waits\G |
无索引 UPDATE/DELETE、长事务 |
| 4 | 死锁频繁,日志出现 Deadlock found |
SHOW ENGINE INNODB STATUS |
LATEST DETECTED DEADLOCK 段 |
资源访问顺序不一致 |
| 5 | 主从延迟持续增大 | PMM + pt-heartbeat |
SHOW SLAVE STATUS\G 的 Seconds_Behind_Master |
大事务、从库负载高、网络抖动 |
| 6 | 磁盘空间异常增长,Undo 文件变大 | INNODB_METRICS |
trx_rseg_history_len、innodb_undo 文件大小 |
长事务阻塞 Purge |
| 7 | Too many connections 错误 |
sys.processlist |
Threads_connected 与 max_connections 比例 |
连接池配置过大、连接泄漏 |
| 8 | 应用频繁 Communications link failure |
应用日志 + SHOW VARIABLES |
对比 wait_timeout 与 maxLifetime |
maxLifetime > wait_timeout |
| 9 | Sleep 连接堆积 (>100) | sys.processlist |
Command=Sleep AND Time > wait_timeout |
idleTimeout 配置不当 |
| 10 | Using temporary; Using filesort 高频出现 |
sys.statements_with_temp_tables |
EXPLAIN Extra 列 |
缺少覆盖索引或排序优化 |
| 11 | 深度分页导致性能线性下降 | pt-query-digest |
提取 LIMIT offset 偏移量 |
未使用游标分页 |
| 12 | 元数据锁等待风暴 | sys.schema_table_lock_waits |
performance_schema.metadata_locks |
直接执行 DDL 或在 DDL 前有长事务 |
| 13 | 优化器选错索引,rows 估算严重偏差 |
EXPLAIN ANALYZE + innodb_index_stats |
比较估算 rows 与 actual rows |
统计信息过时 |
| 14 | 连接获取超时但数据库负载低 | 连接池 Metrics | hikaricp_connections_timeout_total |
connectionTimeout 与 connect_timeout 不协调 |
诊断工具全景图
INNODB STATUS] A2[sys schema
视图集] A3[performance_schema] A4[pt-query-digest] A5[EXPLAIN ANALYZE] A6[PMM 监控] end subgraph JDBC_端 B1[Arthas
方法追踪] B2[连接池 Metrics
HikariCP/Druid] B3[应用日志
异常堆栈] end A1 --> C1[事务/锁/死锁分析] A2 --> C1 A2 --> C2[索引分析] A3 --> C3[锁快照/元数据锁] A3 --> C4[SQL 等待事件统计] A4 --> C5[慢查询指纹聚合] A5 --> C6[实际执行计划校验] A6 --> C7[监控告警/趋势] B1 --> C8[事务边界/参数追踪] B2 --> C9[连接池状态/超时] B3 --> C10[连接断开/死锁捕获]
8. 多层级标准化排查决策树
8.1 慢查询突增决策分支
每个节点附带的诊断命令:
B:EXPLAIN FORMAT=JSON SELECT ...D1a:EXPLAIN ... ; SHOW WARNINGS;D2:SELECT * FROM mysql.innodb_index_stats WHERE table_name='...';EXPLAIN ANALYZE对比实际行数D3:查看索引定义SHOW INDEX FROM ...,计算key_len应该 = 类型宽度总和
8.2 锁等待/死锁激增决策分支
Kill 或优化事务边界"] C -- 否 --> C2{"是否有死锁记录"} C2 -- 是 --> D2["死锁分析"] D2 --> E2["提取竞争资源,统一访问顺序"] C2 -- 否 --> C3{"检查 sys.innodb_lock_waits"} C3 --> D3["阻塞链源头通常是
无索引UPDATE或大范围锁"] D3 --> E3["添加索引或分批DML"] classDef decision fill:#fff4e6,stroke:#ff9800,stroke-width:2px,color:#333 classDef process fill:#f4f4f4,stroke:#333,stroke-width:1px class C,C2,C3 decision class A,B,D1,E1,D2,E2,D3,E3 process
8.3 主从延迟决策分支
8.4 连接数耗尽决策分支
第一层:连接数耗尽是最紧急的数据库故障之一,可能导致服务完全不可用。
第二层 :SHOW PROCESSLIST 是连接分析的第一步,观察连接状态分布。
第三层:四种主要分支:
- Sleep 连接堆积:连接池配置问题,
idleTimeout与wait_timeout不等式错误 - 执行中查询堆积:慢查询或锁等待导致连接持有时间延长
- 认证中连接:DNS 解析慢或
connect_timeout配置问题 - 连接总数超限:连接池总量超过
max_connections
8.5多层级标准化排查决策树总图
更新监控] VERIFY -->|问题未解决| CLASSIFY style START fill:#f96,stroke:#333,stroke-width:2px style CLASSIFY fill:#ff9,stroke:#333,stroke-width:3px style RESOLVE fill:#9f9,stroke:#333,stroke-width:2px style FIX fill:#6cf,stroke:#333,stroke-width:2px style DOC fill:#ccc,stroke:#333
决策树总图说明
第一层(故障触发) :从告警出发,首先对故障现象进行分类。四大标准故障类型各有独立决策路径。
第二层(分支处理) :每个分支的第一级诊断工具已明确:慢查询用 EXPLAIN,锁等待用 INNODB STATUS,主从延迟用 SLAVE STATUS,连接数用 PROCESSLIST。
第三层(深度分析) :根据第一级诊断结果进入更细粒度的分析,如索引失效的具体原因(类型转换/函数/LIKE)、死锁的事务分析等。
第四层(闭环) :根因定位后执行修正,验证效果。如果问题未解决,重新进入决策树,考虑多因素叠加的可能性。
9. 面试高频故障排查专题
说明:本专题聚焦真实线上故障排查场景,与前 9 篇原理面试题形成互补。建议对照复习,以构建完整的 MySQL 知识体系与排障能力。每题均包含详细故障场景、排查命令及输出解读、根因分析和修复最佳实践。
Q1:线上一条简单查询突然从毫秒级变为秒级,EXPLAIN 显示 type=ALL 但索引依然存在,如何排查?
场景 :用户登录后查询订单列表,SQL 为 SELECT * FROM orders WHERE mobile = '13800138000',mobile 列有索引 idx_mobile。原来 5ms 的查询变成 3 秒。
排查步骤:
-
EXPLAIN SELECT * FROM orders WHERE mobile = 13800138000;(注意参数未加引号,模拟应用传入数字的情况)yamltype: ALL, key: NULL, rows: 5000000 -
SHOW WARNINGS;显示/* select#1 */ ... where (cast(orders.mobileas signed) = 13800138000)。 -
对比
WHERE mobile = '13800138000',EXPLAIN显示type: ref, key: idx_mobile, rows: 1。 -
应用代码审查发现 Java 实体中
mobile为Long类型,MyBatis 未加引号处理,传入数值导致隐式转换。 -
根因:索引列上发生隐式类型转换,等价于函数操作,破坏索引有序性(第 2 篇最左前缀原则、第 5 篇优化器类型转换规则)。
-
修复 :将 Java 类型改为
String。短期修复可使用CAST(13800138000 AS CHAR)或虚拟列索引。
Q2:数据库 CPU 飙高但 QPS 平稳,SHOW PROCESSLIST 显示大量 Sending data 状态,如何定位?
场景:CPU 使用率从 30% 飙升到 95%,QPS 无明显变化。
排查步骤:
SHOW FULL PROCESSLIST;发现大量线程执行SELECT * FROM products WHERE status=1 ORDER BY RAND() LIMIT 10;EXPLAIN显示type=ALL, Using temporary; Using filesort。SHOW STATUS LIKE 'Created_tmp_disk_tables';值高速增长。pt-query-digest确认该 SQL 消耗 80% 总响应时间。- 根因 :
ORDER BY RAND()无法利用索引,需全表扫描并生成随机值排序,消耗大量 CPU 和临时表(第 5 篇排序与临时表机制)。 - 修复:改用应用层随机生成 ID 列表,或预创建随机池表。
Q3:业务低峰期频繁出现死锁日志,如何分析并解决?
场景:凌晨结算任务期间,MySQL 错误日志出现多次死锁。
排查步骤:
SHOW ENGINE INNODB STATUS\G查看LATEST DETECTED DEADLOCK。- 日志显示两个事务相互等待对方持有的行锁:
prod_id=100和prod_id=200。 - 代码审查发现批量更新库存时,多个 worker 线程未对产品 ID 排序,导致交叉加锁。
- 根因:资源访问顺序不一致导致死锁(第 4 篇死锁检测机制)。
- 修复 :在应用层对要更新的产品 ID 列表执行
sorted(),保证所有事务以相同顺序加锁。
Q4:主从延迟从 0 秒突然增长到 300 秒,如何紧急处理并追溯根因?
场景 :读写分离架构,从库 Seconds_Behind_Master 飙升至 300 秒,用户投诉数据不一致。
排查步骤:
- 主库
SELECT * FROM information_schema.INNODB_TRX WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 100;发现大事务正在执行UPDATE orders SET status=2 WHERE create_time < '2023-01-01'影响 800 万行。 - 从库
SHOW SLAVE STATUS\G显示 SQL 线程正在回放该大事务。 - 紧急将读请求切回主库。
- 根因:大事务主库瞬间提交,从库回放缓慢(第 6 篇复制原理)。
- 修复 :拆分大事务为小批量(每批 1000 行),开启并行复制
slave_parallel_workers=4。
Q5:应用频繁抛出 Communications link failure,如何从 MySQL 端和连接池端双向排查?
场景 :每日凌晨 3-5 点应用日志出现大量 Connection reset 异常。
排查步骤:
- MySQL:
SHOW VARIABLES LIKE 'wait_timeout';→ 300 秒。 - 连接池配置:
maxLifetime=600000(10分钟),idleTimeout=300000。 - 验证不等式:
maxLifetime(600s) > wait_timeout(300s)违反。 - MySQL 错误日志:
Aborted connection ... (Got an error reading communication packets)。 - 根因:MySQL 在空闲 5 分钟后主动杀连接,连接池仍在 10 分钟时才淘汰,中间取到的连接已死(第 9 篇连接超时与连接池生命周期)。
- 修复 :
maxLifetime=240000,idleTimeout=180000,keepaliveTime=60000。
Q6:一条 UPDATE 在测试环境正常,上生产后锁等待严重,如何分析?
场景 :UPDATE orders SET status=4 WHERE status=3 在测试环境(1 万行)秒级完成,生产环境(500 万行)严重锁等待。
排查步骤:
EXPLAIN UPDATE orders SET status=4 WHERE status=3;→type=ALL, rows=5000000。SHOW INDEX FROM orders;发现status无索引。sys.innodb_lock_waits显示大量被该 UPDATE 阻塞的线程。- 根因:无索引导致全表扫描,对扫描的所有行加 X 锁,即使不满足条件(第 4 篇加锁规则)。
- 修复 :添加
INDEX idx_status (status),或分批更新LIMIT 1000。
Q7:information_schema.INNODB_TRX 中有一个事务运行超过 1 小时,如何处理?
场景 :磁盘告警,History list length 超过 20000。
排查步骤:
SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) sec, trx_query FROM information_schema.INNODB_TRX WHERE sec > 3600;找到空闲事务,trx_query为 NULL。- 关联
PROCESSLIST发现线程处于Sleep状态 1 小时。 - 应用侧 Arthas trace 发现该线程阻塞在支付回调上,事务未提交。
- 根因:事务包含外部网络调用,导致长事务和 Undo 堆积(第 3 篇 Undo Purge 与长事务)。
- 修复:Kill 该线程;将外部调用移出事务,设置事务超时。
Q8:sys.schema_unused_indexes 显示大量未使用索引,如何安全清理?
场景:核心表有 12 个索引,8 个从未使用。
排查步骤:
- 确认监控周期覆盖所有业务高峰(至少 7 天)。
- 查看索引 IO 统计
sys.schema_index_statistics确认读写次数为 0。 - 在从库或测试环境测试删除,确保无性能退化。
- 生产环境使用
pt-online-schema-change或直接用ALTER TABLE ... DROP INDEX ..., ALGORITHM=INPLACE, LOCK=NONE逐个删除。 - 根因:历史迭代遗留,未定期清理。
- 最佳实践:每月清理一次,并纳入 CI 检查。
Q9:分库分表后,跨分片的分页查询性能极差,如何优化?
场景 :订单表 16 分片,SELECT * FROM order ORDER BY create_time DESC LIMIT 100000,20 需要从每个分片取 100020 条数据,在内存中合并排序,极其缓慢。
排查步骤:
- 分析分片键
order_id与查询条件不包含该键,导致全分片扫描。 - 确认使用了传统的偏移分页,
offset巨大。 - 根因:分片架构下的全局排序分页需要归并大量数据(第 7 篇分片查询策略)。
- 修复 :改为游标分页,基于
create_time和order_id传递上一页最后一条的游标,每个分片只取limit行。或者将搜索场景迁移至 ES。
Q10:Too many connections 突然出现,如何紧急恢复并分析根因?
场景 :生产环境所有服务报 Too many connections,数据库无法连接。
排查步骤:
- 紧急恢复 :使用
admin_port(如果已配置)登录,或者gdb动态修改max_connections变量。 SHOW PROCESSLIST发现大量 Sleep 连接,来自某个新发布的应用实例。- 检查连接池配置,发现该实例
maximumPoolSize=100,且idleTimeout设置过大。 - 根因:新实例连接池配置错误,加上可能存在的连接泄漏,导致连接数超限(第 9 篇连接管理)。
- 修复 :调整连接池配置,Kill 僵尸连接,配置
max_connections并预留管理端口。
Q11:慢查询日志中 Rows_examined 极大但 Rows_sent 很小,如何优化?
场景 :慢查询日志中 Rows_examined=5000000, Rows_sent=10。
排查步骤:
- 提取 SQL:
SELECT * FROM orders WHERE user_id=123 ORDER BY create_time DESC LIMIT 10; EXPLAIN显示type=ref, key=idx_user_id, rows=500, Extra=Using where; Using filesort。- 缺乏覆盖索引,且
ORDER BY字段不在索引中。 - 根因:MySQL 扫描了所有 user_id=123 的行(500 行),回表后排序,虽最终只发送 10 行,但扫描了全部(第 2 篇回表与排序)。
- 修复 :创建
INDEX(user_id, create_time)覆盖索引。
Q12:一条 SELECT 语句在 EXPLAIN 中显示 Using temporary; Using filesort,如何优化?
场景 :SELECT DISTINCT product_name FROM products ORDER BY create_time;
排查步骤:
EXPLAIN确认 Extra。- 分析
DISTINCT按product_name去重,ORDER BY按create_time排序,两列无法用同一索引满足。 - 根因 :同时使用
DISTINCT与ORDER BY且列不同,MySQL 必须创建临时表去重后再排序。 - 修复 :若业务允许,改为
GROUP BY product_name并调整排序为ORDER BY product_name,或创建复合索引(product_name, create_time)。
Q13:故障模拟设计题:设计一套针对"索引统计信息不准确导致优化器选错索引"的混沌工程实验方案。
方案:
- 目标:验证监控系统能否发现优化器异常,并自动化或手动恢复。
- 注入方式 :大幅降低采样页数
SET GLOBAL innodb_stats_persistent_sample_pages=1;,然后ANALYZE TABLE critical_table;故意生成失真统计信息。或者直接修改mysql.innodb_index_stats表,将某个索引的stat_value调低 100 倍。 - 监控触发 :执行典型查询,观察
EXPLAIN ANALYZE估算rows与实际actual rows的偏差是否超过预设阈值(如 >10 倍),应触发 PMM 或自定义脚本告警。 - 恢复措施 :自动执行
ANALYZE TABLE并恢复采样页数,或切换至备用数据库。 - 回滚:恢复原始配置,重新采集统计信息。
Q14:系统设计题:基于决策树思想,为一套核心交易系统规划 MySQL 数据库层的故障应急预案与全链路监控体系。
方案要点:
- 监控分层 :
- 基础设施层:CPU、IO、网络(Prometheus + Grafana)。
- MySQL 层:PMM(QPS、连接数、锁等待、复制延迟、History list、Undo 使用)。
- 应用层:JDBC Metrics(连接池状态、慢查询追踪)、Arthas 实时诊断接口。
- 告警分级 :
- P0(紧急):
Too many connections、主从延迟 > 30s、死锁频率 > 10/min、数据校验不一致。 - P1(严重):慢查询率 > 5%、连接使用率 > 80%、
History list length> 1000。 - P2(关注):未使用索引、统计信息过期、磁盘使用率 > 70%。
- P0(紧急):
- 应急预案 Runbook 集成决策树:将本文四大决策树转化为文字步骤,针对每种故障现象列出排查命令序列。
- 全链路追踪:使用 TraceID 将业务请求与 SQL 关联,快速定位瓶颈。
- 定期演练:每季度进行混沌工程演练,验证监控告警和应急响应的有效性。
Q15:线上出现大量 Waiting for table metadata lock,业务不可用,如何快速定位阻塞源并恢复?
场景:DDL 或长事务阻塞元数据锁。
排查 :SELECT * FROM sys.schema_table_lock_waits; 找到阻塞源头线程 ID。如果是 DDL,评估是否可 Kill;如果是长事务未提交,找出该事务并 Kill 或强制回滚。设置 lock_wait_timeout 防止无限等待。
Q16:从库复制中断,错误 HA_ERR_KEY_NOT_FOUND,如何处理?
场景:ROW 格式下,从库回放更新时找不到目标行。
排查 :SHOW SLAVE STATUS\G 查看具体 binlog 位置和错误表。使用 pt-table-checksum 验证差异,pt-table-sync --execute 修复从库数据。检查是否有直接操作从库写入或主库不安全的操作。
Q17:LOAD DATA 导入时导致性能骤降和复制延迟,如何优化?
方案 :分批导入、调整 innodb_buffer_pool_size 预留更多空间、在从库执行导入时通过 sql_log_bin=0 忽略复制(需先确保数据一致性),或使用 pt-fifo-split 分割文件。
Q18:SHOW ENGINE INNODB STATUS 中 Semaphore wait 很高,如何分析?
场景 :SEMAPHORES 段显示大量 OS WAIT ARRAY INFO 或 Mutex 等待。
排查 :多为 Buffer Pool 争用或 AHI(自适应哈希索引)锁竞争。可尝试关闭 AHI (innodb_adaptive_hash_index=OFF),增加 innodb_buffer_pool_instances 分担压力。
附录 A:MySQL 排障工具速查表
(表格与正文第 7 章内容一致,完整展示)
附录 B:全局协调不等式速查表
| 不等式 | 违反现象 | 排查路径 | 关联篇章 |
|---|---|---|---|
idleTimeout < maxLifetime < wait_timeout |
Communications link failure |
应用日志 → SHOW VARIABLES → HikariCP 配置 |
第 9 篇、JDBC 第 10 篇 |
SUM(pool_max × instances) < max_connections |
Too many connections |
Threads_connected → 连接池配置总计 |
第 9 篇 |
connectionTimeout < connect_timeout × 2 |
连接获取超时 | HikariCP Metrics → MySQL connect_timeout |
第 9 篇、JDBC 第 10 篇 |
maxLifetime < net_read_timeout |
慢查询导致断连 | 慢查询日志 → net_read_timeout |
第 9 篇 |
| 索引列类型 = 参数类型 | 索引失效 | SHOW WARNINGS → EXPLAIN |
第 2 篇、第 5 篇 |
延伸阅读
- 《高性能 MySQL》第 4 版------第 6-8 章查询优化、第 11 章复制
- 《MySQL 技术内幕:InnoDB 存储引擎》第 2 版------第 6 章锁、第 7 章事务
- Percona Toolkit 官方文档(www.percona.com/doc/percona...
- MySQL 8.0 官方文档:诊断章节(dev.mysql.com/doc/refman/...
- JDBC 系列第 10 篇《JDBC 反模式与排查宝典》------连接池、Arthas 应用侧排查
本文作为 MySQL 性能优化与架构设计系列的收官之作,与 JDBC 系列第 10 篇共同构建了从应用到数据库的完整故障排查体系。掌握六步诊断法、设计/运行时双视角分析范式、多层级标准化排查决策树以及全链路工具链,将帮助每一位工程师在面对线上故障时保持冷静,精准定位根因,高效实施修复。