内容范围 :索引与 B+Tree、数据页与 Buffer Pool、MySQL 查询执行链路、
my.cnf性能参数与作用域、MVCC 与 InnoDB 锁、Spring Boot@Transactional原理与传播行为、NOT_SUPPORTED专题、排障与实践。适用基线:MySQL 8.0 / 8.4、InnoDB、Spring Boot 3.x、Spring Framework 6.x、Java 21、MyBatis / MyBatis-Plus。部分行为与特定小版本、事务管理器、驱动、SQL 执行计划有关,应结合实际环境验证。
定位:由技术讨论整理的独立学习文档。SQL / Java 片段以原理与可操作示例为主;在生产数据库执行 DDL、加锁操作或调整参数前,应先在测试环境验证并评估影响。
图表说明:本文包含 16 张可渲染的 Mermaid 流程图、时序图和结构图。建议使用支持 Mermaid 的 Markdown 编辑器(如 VS Code + Mermaid 预览扩展、Obsidian 或 GitHub)查看。纯文本查看器会显示图表源代码。
目录
- 总览:从一次请求到数据持久化
- [InnoDB 索引本质与 B+Tree 原理](#InnoDB 索引本质与 B+Tree 原理 "#2-innodb-%E7%B4%A2%E5%BC%95%E6%9C%AC%E8%B4%A8%E4%B8%8E-btree-%E5%8E%9F%E7%90%86")
- [InnoDB 数据页是否越大越好](#InnoDB 数据页是否越大越好 "#3-innodb-%E6%95%B0%E6%8D%AE%E9%A1%B5%E6%98%AF%E5%90%A6%E8%B6%8A%E5%A4%A7%E8%B6%8A%E5%A5%BD")
- [MySQL 一条 SELECT 的完整执行过程](#MySQL 一条 SELECT 的完整执行过程 "#4-mysql-%E4%B8%80%E6%9D%A1-select-%E7%9A%84%E5%AE%8C%E6%95%B4%E6%89%A7%E8%A1%8C%E8%BF%87%E7%A8%8B")
- [
my.cnf中影响性能的关键参数](#my.cnf 中影响性能的关键参数 "#5-mycnf-%E4%B8%AD%E5%BD%B1%E5%93%8D%E6%80%A7%E8%83%BD%E7%9A%84%E5%85%B3%E9%94%AE%E5%8F%82%E6%95%B0") - [GLOBAL、SESSION、SQL 与 TABLE 的配置范围](#GLOBAL、SESSION、SQL 与 TABLE 的配置范围 "#6-globalsessionsql-%E4%B8%8E-table-%E7%9A%84%E9%85%8D%E7%BD%AE%E8%8C%83%E5%9B%B4")
- [MVCC:版本链、Undo Log 与 Read View](#MVCC:版本链、Undo Log 与 Read View "#7-mvcc%E7%89%88%E6%9C%AC%E9%93%BEundo-log-%E4%B8%8E-read-view")
- [InnoDB 锁:S/X、记录锁、间隙锁、临键锁与死锁](#InnoDB 锁:S/X、记录锁、间隙锁、临键锁与死锁 "#8-innodb-%E9%94%81sx%E8%AE%B0%E5%BD%95%E9%94%81%E9%97%B4%E9%9A%99%E9%94%81%E4%B8%B4%E9%94%AE%E9%94%81%E4%B8%8E%E6%AD%BB%E9%94%81")
- [Spring Boot
@Transactional的工作原理](#Spring Boot @Transactional 的工作原理 "#9-spring-boot-transactional-%E7%9A%84%E5%B7%A5%E4%BD%9C%E5%8E%9F%E7%90%86") - 七种事务传播行为及对照
NOT_SUPPORTED:外部事务"挂起"到底是什么- 事务回滚、隔离级别与常见失效问题
- [生产排查 SQL 与 Spring 配置](#生产排查 SQL 与 Spring 配置 "#13-%E7%94%9F%E4%BA%A7%E6%8E%92%E6%9F%A5-sql-%E4%B8%8E-spring-%E9%85%8D%E7%BD%AE")
- 最佳实践、常见误区与学习路线
- 官方参考资料
1. 总览:从一次请求到数据持久化
图 1|从 HTTP 请求到 InnoDB 的完整链路
图中为逻辑链路:查询过程中 Executor 与 InnoDB 通常会多次交互,不是仅访问一次数据页。
在 Spring Boot + MySQL 系统中,一次数据库操作可概括为:
text
HTTP 请求
│
▼
Spring Controller / Service
│
├─ Spring AOP 事务代理(若 @Transactional 生效)
│ └─ 事务管理器:获取连接、设定事务边界、提交/回滚
▼
MyBatis / JDBC / Connector/J
│
▼
MySQL Server 层
├─ 连接、鉴权、解析、语义检查
├─ 优化器生成查询计划
└─ 执行器执行查询计划
│
▼
InnoDB 存储引擎
├─ B+Tree 索引查找 / 回表
├─ Buffer Pool / 数据页 / 磁盘 I/O
├─ MVCC / Undo Log / Read View
├─ Record / Gap / Next-Key Locks
└─ Redo Log / 事务持久化
│
▼
返回结果或提交/回滚
职责划分:Spring 负责"何时开启、参与、挂起、提交、回滚事务";InnoDB 负责"如何读写数据、锁定数据、判断版本可见性以及确保崩溃恢复"。二者不是同一个层面。
2. InnoDB 索引本质与 B+Tree 原理
2.1 为什么需要索引
假设表有 1000 万条记录:
sql
CREATE TABLE users (
id BIGINT PRIMARY KEY,
username VARCHAR(100) NOT NULL,
age INT,
email VARCHAR(255)
) ENGINE=InnoDB;
SELECT * FROM users WHERE username = 'zhangsan';
如果 username 没有合适索引,可能需要扫描大量行并逐行判断。全表扫描工作量通常随数据量增长,近似为 O(N)。
创建索引:
sql
CREATE INDEX idx_users_username ON users(username);
InnoDB 为索引键建立 B+Tree。搜索时按有序键从根节点一路缩小范围,而不是逐行检查所有记录。
2.2 B+Tree 的关键结构
图 2|B+Tree 三层索引结构
B+Tree 叶子页按索引键有序组织,页间有相邻页链接,方便范围扫描。图中的字母范围只用于展示分布,并非 InnoDB 的真实页格式。
text
[根页:分隔键]
/ \
[内部索引页] [内部索引页]
/ \ / \
[叶子页] [叶子页] [叶子页] ...
↔ ↔ ↔
有序的叶子页(支持范围扫描)
- 内部节点:分隔键 + 子页面指针,负责导航。
- 叶子节点:保存索引记录。
- 大扇出:一个内部页可以导航到很多子页,因此树的高度通常较低。
- 有序结构:对范围查询、按索引顺序读取、某些排序查询有优势。
理论上,B+Tree 的搜索复杂度近似为 O(log_F N) ,其中 F 是扇出。但数据库里真正重要的是:索引能否显著减少访问的数据页以及随机 I/O。
2.3 聚簇索引与二级索引
图 3|二级索引查询、回表和覆盖索引分支
sql
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
tenant_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
amount DECIMAL(12,2) NOT NULL,
created_at DATETIME NOT NULL,
KEY idx_tenant (tenant_id)
) ENGINE=InnoDB;
| 类型 | InnoDB 叶子记录包含什么 | 查询特点 |
|---|---|---|
| 聚簇索引(通常为主键) | 完整行记录 | 主键定位后可获得该行所有列 |
| 二级索引 | 二级索引键以及相应主键列等信息 | 若请求列不在索引中,需要进一步访问聚簇索引 |
二级索引查询示意:
text
WHERE tenant_id = 1001
│
▼
idx_tenant B+Tree 叶子页
│ 取得主键 id
▼
PRIMARY B+Tree 叶子页
│ 获取完整行
▼
返回需要的列
第二步叫回表 。若需要的列全部能从二级索引记录取得,则属于覆盖索引,通常可避免回表。
sql
-- idx_tenant(tenant_id) 的叶子记录包含主键 id
SELECT id, tenant_id
FROM orders
WHERE tenant_id = 1001; -- 具备覆盖索引条件
SELECT id, user_id, amount
FROM orders
WHERE tenant_id = 1001; -- 通常还需回表
2.4 联合索引与最左前缀
sql
CREATE INDEX idx_tenant_user_time
ON orders(tenant_id, user_id, created_at);
这个索引按 (tenant_id, user_id, created_at) 的组合顺序排列:先租户、再用户、再时间。
| 条件 | 常见索引利用情况 |
|---|---|
tenant_id = ? |
可高效定位前缀 |
tenant_id = ? AND user_id = ? |
可使用更长的等值前缀 |
前两列等值 + created_at 范围 |
可定位更窄的有序范围 |
仅 user_id = ? |
通常不能直接利用最左前缀定位,优化器可能选择其他方法 |
tenant_id = ? AND created_at >= ? |
可按 tenant_id 定位,但不能默认第三列都参与范围缩减 |
最左前缀并非"跳过首列后索引绝对无用"。索引覆盖、索引扫描、优化器选择的特殊访问方式需要区别看待。
2.5 索引未必提高速度
以下情况更可能失去索引优势:
LIKE '%abc'这种前导通配符条件。- 对普通索引列施加函数或运算,如
YEAR(created_at)=2026。 - 返回整表大量数据、低选择性条件、过多回表。
- 与
ORDER BY或 JOIN 条件不匹配的索引设计。 - 为每列单独建索引,却没有考虑真正的高频组合查询。
原则:先观察 SQL 的谓词、返回列、排序与访问频率,再设计索引。
3. InnoDB 数据页是否越大越好
图 4|查询访问数据页时的 Buffer Pool 判定
页越大,一次缓存未命中需要搬运的数据通常越多;但相邻记录可能被一起缓存,从而提升局部性,因此不存在"越大越好"的统一结论。
InnoDB 将表和索引组织在数据库页(Page)中,默认页大小为 16KB;可选 4KB、8KB、16KB、32KB、64KB。
sql
SHOW VARIABLES LIKE 'innodb_page_size';
3.1 小页与大页的取舍
| 因素 | 较小数据页 | 较大数据页 |
|---|---|---|
| 单页容量 | 更少 | 更多 |
| B+Tree 扇出 | 可能较小 | 可能较大 |
| B+Tree 高度 | 可能较高 | 可能较低 |
| 随机读取一条小记录 | 可能读取较少无关数据 | 可能读取较多无关数据 |
| 大范围扫描 | 可能需访问更多页 | 可能需访问更少页 |
| Buffer Pool 缓存粒度 | 更细 | 更粗 |
| 频繁写入 | 取决于布局、存储与负载 | 取决于布局、存储与负载 |
关键问题不是"页是否最大",而是:
- 每条 SQL 访问多少个不同的数据页?
- 这些页是否已存在于 Buffer Pool?
- 查询是否具有空间/时间局部性?
- 读写并发、设备块大小、写放大是否更重要?
推荐:多数常规 OLTP 业务保留 16KB,除非基准测试说明其他页大小明显更好。
重要限制 :innodb_page_size 只能在初始化 MySQL 实例之前配置,已初始化实例不能直接动态改成别的页大小;不同页大小的实例不能直接混用原有 InnoDB 数据文件。
4. MySQL 一条 SELECT 的完整执行过程
图 5|SELECT 处理流水线与主要性能瓶颈
这里把执行算子画为教学流程;优化器可以调整物理执行顺序,某些过滤/排序在存储访问过程中便可完成或被索引消除。
例如:
sql
SELECT id, user_id, amount
FROM orders
WHERE tenant_id = 1001
ORDER BY created_at DESC
LIMIT 10;
典型流程:
| 阶段 | 工作 | 主要性能关注点 |
|---|---|---|
| 1. 连接管理 | 应用通过 JDBC / 连接池发送 SQL | 获取连接等待、网络往返 |
| 2. 解析 | 词法、语法解析,生成内部结构 | SQL 复杂度与频繁解析 |
| 3. 语义与权限 | 解析表列、检查权限、类型等 | 解析及字典开销 |
| 4. 优化器 | 选择索引、访问顺序、JOIN、排序策略 | 行数估算、统计信息、执行计划 |
| 5. 执行器 | 按计划获取、过滤、聚合或排序记录 | 无效扫描、排序、临时表 |
| 6. InnoDB | B+Tree、Buffer Pool、MVCC、锁 | 回表、页面未命中、长版本链 |
| 7. 返回 | 通过协议输出结果给客户端 | 结果集大小、传输、ORM 映射 |
不是固定的"先过滤全部行,再排序全部行":优化器可能利用索引消除排序,也可能需要临时表或额外排序。
sql
EXPLAIN
SELECT id, user_id, amount
FROM orders
WHERE tenant_id = 1001
ORDER BY created_at DESC
LIMIT 10;
EXPLAIN ANALYZE
SELECT id, user_id, amount
FROM orders
WHERE tenant_id = 1001
ORDER BY created_at DESC
LIMIT 10;
EXPLAIN展示优化器的估算与计划。EXPLAIN ANALYZE会实际执行查询,并提供运行时耗时、实际行数、循环次数等信息。- 重点看:
key、type、rows、Extra、是否排序与回表、实际返回行数及耗时。 - 如果 SQL 在数据库里只需 20ms,HTTP 响应却需数百毫秒,还要排查连接池排队、Java 映射、JSON 序列化和网络传输。
MySQL 8.0 不再有旧式 SQL Query Cache:Buffer Pool 缓存的是数据/索引页,而非任意 SELECT 的完整结果集。
5. my.cnf 中影响性能的关键参数
5.1 参数分类
| 参数 | 影响 | 主要调优原则 |
|---|---|---|
innodb_buffer_pool_size |
InnoDB 数据/索引页缓存 | 优先看真实读盘与内存余量 |
tmp_table_size |
内部内存临时表大小限制 | 结合磁盘临时表与查询形态 |
temptable_max_ram |
TempTable 引擎全局 RAM 使用限额 | 关注并发复杂查询的总内存 |
sort_buffer_size |
部分排序的会话缓冲 | 不能代替合适索引,不宜全局设过大 |
join_buffer_size |
部分 JOIN 操作的缓冲 | 优先保证连接键索引 |
innodb_io_capacity / _max |
后台刷脏页等 I/O 预算 | 根据设备能力与实际刷盘瓶颈测试 |
innodb_flush_method |
InnoDB 文件 I/O 方式 | 随版本/系统而异,不盲目改 |
innodb_redo_log_capacity |
Redo 日志容量及检查点压力 | 写入密集场景结合检查点指标 |
innodb_flush_log_at_trx_commit |
事务提交日志持久化 | 优先保证数据安全 |
sync_binlog |
Binlog 持久化 | 根据复制和持久性要求设置 |
max_connections |
最大连接数 | 与 HikariCP 总连接池规模匹配 |
thread_cache_size |
线程复用 | 对频繁连接建立有效,优先考虑连接池 |
table_open_cache |
打开表的缓存 | 根据表数量、Opened_tables 等观察 |
slow_query_log / long_query_time |
慢 SQL 诊断 | 建议按负载开启和设置阈值 |
5.2 Buffer Pool 是重点,但不是越大越好
- 可缓存聚簇索引页、二级索引页和脏页等。
- 命中后不必再次从磁盘加载对应页。
- 独立数据库服务器可从内存的较大比例评估,但一定预留连接、临时表、排序、系统缓存及操作系统空间;非独立 MySQL 服务器须额外为其他应用留内存。
- 索引失配造成的大扫描,即使完全命中 Buffer Pool,仍可能浪费大量 CPU。
示例:16GB 内存、独立数据库、经过容量评估后,先测试 10GB Buffer Pool。
ini
[mysqld]
innodb_buffer_pool_size = 10G
5.3 不要把排序/连接缓冲全部调大
ini
[mysqld]
sort_buffer_size = 256K
join_buffer_size = 256K
这类内存可能按连接或执行操作分配;在高并发下,调得很大可能造成明显内存压力。优先寻找可支持过滤、JOIN、ORDER BY 的索引。
5.4 写入安全相关参数
ini
[mysqld]
innodb_flush_log_at_trx_commit = 1
sync_binlog = 1
对于账号、订单、库存、支付等重要数据,通常优先重视崩溃恢复及复制可靠性。为了更高写入吞吐随意降低持久性级别可能增加故障时的数据丢失风险。
5.5 一个保守的示例(不是通用最优配置)
假设:MySQL 8.4、8 核、16GB、独立数据库服务器、SSD/NVMe、OLTP 读写混合负载。
ini
[mysqld]
# 内存:必须结合机器真实余量核对
innodb_buffer_pool_size = 10G
# 事务可靠性
innodb_flush_log_at_trx_commit = 1
sync_binlog = 1
# 供测试的写入参数
innodb_redo_log_capacity = 1G
# 连接上限应结合应用实例与 HikariCP 总容量设计
max_connections = 200
# 不盲目调大按连接/操作使用的缓冲
sort_buffer_size = 256K
join_buffer_size = 256K
# 慢 SQL 诊断
slow_query_log = ON
long_query_time = 1
log_output = FILE
# 暂保留默认,测量后按需调整:
# innodb_io_capacity / innodb_io_capacity_max
# tmp_table_size / temptable_max_ram
# table_open_cache / thread_cache_size
参数具体默认值、是否动态和允许值随 MySQL 小版本可能不同,实际修改前用
SHOW VARIABLES和对应版本官方手册核对。
6. GLOBAL、SESSION、SQL 与 TABLE 的配置范围
图 6|参数作用域如何决定实际生效值
这是作用域判定示意,不是所有参数都支持四级覆盖;例如
innodb_buffer_pool_size不能通过 SESSION 或 TABLE 调整。
误区:"全局一定优先,会覆盖表或者会话配置"。实际上不同变量支持不同作用域,只有支持相应作用域的设置才存在覆盖关系。
| 作用域 | 配置方式 | 举例 |
|---|---|---|
| 实例 / 全局 | my.cnf、SET GLOBAL、SET PERSIST |
innodb_buffer_pool_size、max_connections |
| 当前连接 / 会话 | SET SESSION |
sort_buffer_size、join_buffer_size |
| 当前单条 SQL | /*+ SET_VAR(...) */,仅适用允许该提示的参数 |
对个别排序 SQL 提高 sort_buffer_size |
| 数据表 | ALTER TABLE 中支持的表选项 |
STATS_PERSISTENT、STATS_SAMPLE_PAGES、ROW_FORMAT |
6.1 GLOBAL 与 SESSION
sql
SHOW GLOBAL VARIABLES LIKE 'sort_buffer_size';
SHOW SESSION VARIABLES LIKE 'sort_buffer_size';
-- 更改当前连接,不影响其他会话
SET SESSION sort_buffer_size = 4194304;
-- 修改新建连接的全局默认值,不会自动替换既有会话值
SET GLOBAL sort_buffer_size = 1048576;
只对支持动态全局修改的变量可用 SET GLOBAL;持久化需要编辑配置或对允许的变量使用 SET PERSIST。
6.2 对单条 SQL 设置
sql
SELECT /*+ SET_VAR(sort_buffer_size = 4M) */ *
FROM orders
WHERE tenant_id = 1001
ORDER BY created_at DESC;
SET_VAR 是优化器提示,只有支持的变量才生效。通过 SHOW WARNINGS 检查提示是否被忽略。
6.3 表级统计信息设置
sql
ALTER TABLE orders
STATS_PERSISTENT = 1,
STATS_AUTO_RECALC = 1,
STATS_SAMPLE_PAGES = 64;
ANALYZE TABLE orders;
表选项可覆盖相应的全局统计信息默认设置;更多统计采样不一定带来更快查询,主要改善优化器估算,可能增加采样开销。
6.4 不支持给一张表单独分配 Buffer Pool
以下写法是不存在的,不要照抄:
ini
# 错误示意
# orders.innodb_buffer_pool_size = 8G
Buffer Pool 是 InnoDB 实例级共享缓存,不能按表划出固定专属容量。若某张表需要更好性能,优先改进索引、查询形态与热点访问;强隔离需求通常考虑拆实例。
7. MVCC:版本链、Undo Log 与 Read View
MVCC(Multi-Version Concurrency Control) 让多个事务能够在并发写入时,通过版本可见性读取符合自身快照的数据,降低读写之间的直接阻塞。
7.1 一次并发读写示例
初始:
sql
CREATE TABLE account (
id BIGINT PRIMARY KEY,
balance DECIMAL(12,2) NOT NULL
) ENGINE=InnoDB;
INSERT INTO account VALUES (1, 1000.00);
事务 A:
sql
START TRANSACTION;
UPDATE account SET balance = 800 WHERE id = 1;
-- 暂不 COMMIT
事务 B 在 RC/RR 下执行普通 SELECT:
sql
SELECT balance FROM account WHERE id = 1;
B 通常读到可见的旧版本 1000.00,而不是等待 A 或读到未提交的 800。这就是一致性快照读的典型表现。
7.2 InnoDB 隐藏列与 Undo
图 7|当前记录与 Undo 版本链
示意为逻辑版本链,不是表中同时存在三条完整行;Undo 提供回滚和构造旧版本所需的信息。
聚簇索引记录的隐藏元数据通常包括:
| 隐藏字段 | 用途 |
|---|---|
DB_TRX_ID |
最后修改该记录的事务 ID |
DB_ROLL_PTR |
指向相关 Undo 记录,支持回滚/重建旧版本 |
DB_ROW_ID |
需要 InnoDB 自动生成聚簇索引时使用的隐藏行 ID,不是每张表都需要 |
text
当前行 V3 (TRX=300, balance=600)
│ Undo 关联
▼
旧版本 V2 (TRX=200, balance=800)
│
▼
更早版本 V1 (TRX=100, balance=1000)
这是一条逻辑版本链;不意味着每个旧版本都在业务表中保存一条完整物理行。Undo Log 提供的是恢复或重建历史版本所需的信息。
7.3 Read View 决定哪些版本可见
图 8|Read View 版本可见性判断(简化)
这是 Read View 核心规则的教学模型;真实实现还有事务状态、版本链遍历、删除标志等细节。
Read View 保存创建快照时的活跃事务相关信息和事务 ID 边界。读取某个版本时,InnoDB 要判断修改该版本的事务是否对当前读可见;若不可见,沿 Undo 相关历史信息查找更旧的可见版本。
重要区别:
- READ COMMITTED(RC):普通一致性读一般每条语句获取新的 Read View,因此同一事务两次 SELECT 可能看到不同已提交结果。
- REPEATABLE READ(RR):普通一致性读通常复用事务中首次一致性读的快照,因此两次快照读的可见结果一致。
- 默认 RR 中,
BEGIN本身通常不意味着 Read View 已立即创建;一般在首次一致性读时创建。
7.4 快照读与当前读
图 9|同一事务内的快照读与当前读
这是简化时间线,用于区分 RR 快照读与当前读;真实锁定读结果还受事务自身修改和其他并发事务影响。
| 操作 | 机制 |
|---|---|
SELECT ...(普通查询) |
一致性快照读,依据 Read View 判断可见性 |
SELECT ... FOR UPDATE |
锁定读,获取适当的排他锁 |
SELECT ... FOR SHARE |
锁定读,获取适当的共享锁 |
UPDATE / DELETE |
根据当前可更新状态访问并锁定记录 |
容易误解的一点 :RR 下普通 SELECT 仍可能看到旧快照,但随后的 FOR UPDATE 可以看到较新的已提交版本;不能说"RR 内所有 SQL 都只见到同一个数据版本"。
7.5 长事务的代价
如果某个事务持续保持较老的 Read View,InnoDB 的 Purge 清理可能无法及时移除仍可能被读取的历史版本,导致 Undo 历史积累。即使是只读长事务,也不能认为毫无成本。
8. InnoDB 锁:S/X、记录锁、间隙锁、临键锁与死锁
MVCC 不能解决所有并发问题,修改冲突、锁定读与防止插入幻影记录仍需要锁。
8.1 锁分类
| 锁类型 | 作用 |
|---|---|
| S(共享锁) | 多事务可共享,但与同一资源的 X 锁冲突 |
| X(排他锁) | 保护写入/独占访问 |
| Record Lock | 锁定索引记录 |
| Gap Lock | 阻止其他事务在受保护的索引间隙插入 |
| Next-Key Lock | Record Lock + 前面的 Gap Lock |
| Insert Intention Lock | 插入过程的间隙插入意向协调 |
| IS / IX | 表级意向锁,标识事务准备获取行级 S/X 锁 |
| Metadata Lock(MDL) | 协调表结构访问和 DDL |
| AUTO-INC Lock | 部分自增分配场景的同步机制 |
S/X 兼容矩阵:
| 已持有 / 新请求 | S | X |
|---|---|---|
| S | 兼容 | 冲突 |
| X | 冲突 | 冲突 |
普通 MVCC 快照读通常不需要给每条读取记录加 S 锁。
8.2 Record、Gap、Next-Key 的差别
图 10|记录锁、间隙锁、临键锁的覆盖对象
索引键:10, 20, 30, 40。
text
Record(20) :锁索引记录 20
Gap(20,30) :阻止在 20 与 30 之间插入
Next-Key(20,30] :阻止该间隙插入,并锁记录 30
- 对已存在主键做唯一索引等值命中,往往只需记录锁。
- RR 下的范围锁定读可能锁定扫描到的索引记录及相关间隙,以保护范围免于幻影插入。
- 精确锁定范围取决于实际访问路径、索引、SQL 和隔离级别,不能直接把 WHERE 条件当成锁区间。
- Gap Lock 主要阻止插入;不同事务的 Gap Lock 不必相互冲突。
8.3 可复现实验:范围锁等待
在测试库准备:
sql
CREATE TABLE lock_demo (
id INT PRIMARY KEY,
amount INT NOT NULL
) ENGINE=InnoDB;
INSERT INTO lock_demo VALUES (10, 100), (20, 200), (30, 300);
连接 A:
sql
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT * FROM lock_demo
WHERE id BETWEEN 10 AND 20
FOR UPDATE;
-- 暂不提交
连接 B:
sql
START TRANSACTION;
INSERT INTO lock_demo VALUES (15, 150);
-- 可能因 A 的锁定范围而等待
连接 A COMMIT 后,B 才可能继续。实验完记得提交或回滚两个连接。
8.4 为什么索引影响锁范围
sql
UPDATE orders SET amount = amount + 1 WHERE tenant_id = 1001;
如果没有 tenant_id 合适索引,可能需要扫描大量索引记录,带来更大的锁竞争和更长的执行时间。建立合适索引不仅减少扫描量,还可能减少锁涉及的记录和范围。
8.5 死锁与锁等待
图 11|两个事务的循环锁等待
text
事务 A:锁住 id=10 → 尝试 id=20 → 等待 B
事务 B:锁住 id=20 → 尝试 id=10 → 等待 A
↓
形成死锁
| 异常 | 常见 MySQL 错误码 | 默认含义 |
|---|---|---|
| 死锁 | 1213 | InnoDB 检测到循环等待,通常回滚一个事务 |
| 锁等待超时 | 1205 | 超过锁等待阈值;默认通常只回滚当前语句 |
生产建议:统一多行更新顺序、缩短事务、避免事务里做慢 HTTP 调用、合理设计索引、在确认事务已回滚后重试整个事务,而非只重试其中某条 SQL。
9. Spring Boot @Transactional 的工作原理
9.1 它并不是 MySQL 事务实现本身
图 12|@Transactional 的 AOP 代理与数据库事务边界
对于加入外部事务的方法,
commit()并不等于此处就提交整个物理事务;最终由外层物理事务边界负责。
典型同步 JDBC 调用链:
text
Controller
↓
Spring AOP Proxy
↓
TransactionInterceptor
↓
PlatformTransactionManager
├─ 创建/参与/挂起事务
├─ JDBC Connection 绑定到当前线程
└─ commit / rollback / 清理
↓
OrderService 真正的方法
↓
MyBatis / JDBC SQL
↓
InnoDB
一般通过事务管理器拿到 Connection,控制自动提交和事务边界。InnoDB 继续负责 MVCC、锁、Undo、Redo。
9.2 一个典型的业务事务
java
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderMapper orderMapper;
private final InventoryMapper inventoryMapper;
@Transactional(rollbackFor = Exception.class)
public void createOrder(Order order) {
orderMapper.insert(order);
int affected = inventoryMapper.decreaseStock(
order.getProductId(), order.getQuantity());
if (affected != 1) {
throw new IllegalStateException("库存不足");
}
}
}
对应的原子条件扣减:
sql
UPDATE inventory
SET stock = stock - :qty
WHERE product_id = :productId
AND stock >= :qty;
:qty/:productId是概念性绑定参数占位符;MyBatis XML 中请使用#{qty}/#{productId}等对应名称。affected = 1表示扣减成功,否则应触发适当的业务失败处理。
9.3 重要注解参数
java
@Transactional(
propagation = Propagation.REQUIRED,
isolation = Isolation.READ_COMMITTED,
timeout = 10,
readOnly = false,
rollbackFor = Exception.class
)
public void execute() { }
| 参数 | 作用 | 注意 |
|---|---|---|
propagation |
调用嵌套时采用何种事务传播行为 | 默认 REQUIRED |
isolation |
新事务的隔离级别 | 加入现有事务时通常沿用现有级别 |
timeout |
事务超时设置 | 不等于强制中断全部 Java 代码 |
readOnly |
只读提示 | 不应视为绝对写入禁令 |
rollbackFor |
额外异常回滚规则 | 可覆盖默认不回滚 checked exception 的规则 |
noRollbackFor |
指定不因某类异常触发回滚 | 不能强行提交已标记 rollback-only 的事务 |
transactionManager |
指定使用哪个事务管理器 | 多数据源系统尤其重要 |
10. 七种事务传播行为及对照
图 13|传播行为选择逻辑
NESTED需要事务管理器支持保存点;已有事务时采用NESTED不意味着开启独立事务。
传播行为回答:"调用内部事务方法时,如果当前已经有 Spring 事务,该怎么办?"
| 传播行为 | 当前有事务 | 当前无事务 |
|---|---|---|
REQUIRED |
加入现有物理事务 | 创建事务 |
REQUIRES_NEW |
挂起原事务,创建独立物理事务 | 创建事务 |
NESTED |
在同一物理事务里建立保存点(需支持) | 创建事务 |
SUPPORTS |
加入 | 不开启事务 |
NOT_SUPPORTED |
挂起原事务,非事务执行 | 非事务执行 |
MANDATORY |
必须加入 | 抛异常 |
NEVER |
抛异常 | 非事务执行 |
10.1 REQUIRED:最常用
text
OrderService @Transactional(REQUIRED)
└─ InventoryService @Transactional(REQUIRED)
↓
同一个物理事务 T1
若内部方法按回滚规则把共享事务标记为 rollback-only,外层即便捕获异常,也无法正常提交;外层提交时可能得到 UnexpectedRollbackException。
10.2 REQUIRES_NEW:独立物理事务
text
外部 T1 ──[挂起]──► 内部 T2(新连接/独立提交或回滚)
◄─[恢复]──
内部事务提交后,外部后续回滚不会自动撤销内部已提交的数据。但 T2 与 T1 是两个事务,仍可能发生锁冲突:T1 持有的锁不会因为它被挂起而释放。通常还要考虑额外连接导致连接池耗尽。
10.3 NESTED:保存点
sql
START TRANSACTION;
INSERT INTO orders ...;
SAVEPOINT item_point;
UPDATE inventory ...;
ROLLBACK TO SAVEPOINT item_point;
COMMIT;
允许在同一物理事务中对内部工作回滚到保存点。NESTED 不能独立提交;外层回滚会一起撤销内部工作。是否支持取决于事务管理器和 JDBC Savepoint 能力。
11. NOT_SUPPORTED:外部事务"挂起"到底是什么
11.1 "外部事务"究竟指什么?
外部事务 是进入被调用方法时,当前线程已经存在的 Spring 事务。它常常由外层方法上的 @Transactional 创建,但不等于外层那个注解本身;也可能来自更上层事务方法或编程式事务。
例如:
java
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderMapper orderMapper;
private final LogService logService;
@Transactional
public void createOrder() {
orderMapper.insertOrder(); // 属于 T1
logService.saveLog(); // T1 暂时挂起
orderMapper.updateOrder(); // 恢复 T1
}
}
java
@Service
@RequiredArgsConstructor
public class LogService {
private final LogMapper logMapper;
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void saveLog() {
logMapper.insertLog(); // 不加入 T1
}
}
为突出事务概念,示例中的 Mapper 方法简写;项目中使用实际对应的 Mapper 签名和参数。
11.2 时间线
图 14|NOT_SUPPORTED 挂起与恢复事务的时序图
上图刻意突出"挂起 ≠ 提交/回滚":外层事务 T1 在内层执行期间仍存在,只是不作为当前内层调用的事务上下文。
text
T0: 进入 createOrder(),事务管理器创建 T1
T1: insertOrder() 在 T1 中执行,尚未提交
T2: 调用 saveLog(),NOT_SUPPORTED 使 Spring 挂起 T1
T3: saveLog() 不参与 T1,数据库单条 DML 通常自动提交
T4: saveLog() 返回,Spring 恢复 T1 的事务上下文
T5: updateOrder() 继续在 T1 中执行
T6: createOrder() 结束,提交或回滚 T1
"挂起"不是 COMMIT,也不是 ROLLBACK。 它只是暂时解绑外部事务的线程上下文/资源,让被调用方法在非 Spring 事务环境中执行;之后恢复原事务。外部事务原先持有的 InnoDB 锁一般仍然保持到提交或回滚。
补充 :对典型 JDBC + MyBatis 场景,saveLog() 中的 SQL 通常使用与 T1 不同的连接、处于数据库 autocommit=1 的单语句事务行为;它不是"数据库层面完全不存在事务"。
11.3 外部回滚时会发生什么?
| 操作 | 执行上下文 | 若操作已成功,后续 T1 回滚的影响 |
|---|---|---|
insertOrder() |
T1 | 回滚 |
saveLog() 中的单条 INSERT |
非 Spring 事务,通常自动提交 | 通常不会被 T1 回滚 |
updateOrder() |
T1 | 回滚 |
11.4 NOT_SUPPORTED 与 REQUIRES_NEW 不同
| 维度 | NOT_SUPPORTED | REQUIRES_NEW |
|---|---|---|
| 遇到 T1 | 挂起 T1 | 挂起 T1 |
| 是否开启内部 Spring 事务 | 否 | 是(T2) |
| 内部两条 SQL 是否原子提交 | 默认不保证 | 按内部事务规则统一提交/回滚 |
| 外部 T1 回滚 | 不撤销内部已自动提交操作 | 不撤销内部已提交的 T2 |
| 适合的场景 | 允许非原子执行、不需要业务事务的工作 | 必须独立原子提交的一组数据库操作 |
如果没有外部事务,NOT_SUPPORTED 就直接以非事务方式执行,不需要挂起任何东西。
11.5 最易忽略的前提:调用必须经过 Spring 代理
java
@Service
public class OrderService {
@Transactional
public void createOrder() {
saveLog(); // this.saveLog():自调用,绕过事务代理
}
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void saveLog() {
// NOT_SUPPORTED 不会因这次自调用而生效
}
}
此时不是"外部 T1 被成功挂起",而是内部方法注解根本没有被代理拦截。请通过另一个 Spring Bean 的代理方法调用,或使用明确设计的编程式事务。
12. 事务回滚、隔离级别与常见失效问题
12.1 回滚规则
图 15|Spring 事务方法返回时的回滚决策
如果内部仅参与已有物理事务,"提交/回滚当前作用域"可能体现为标记状态而非立刻提交整个数据库事务。
Spring 常见默认行为:
RuntimeException与Error:默认触发回滚。- 普通 checked exception(如
IOException):默认不因该异常回滚。 @Transactional(rollbackFor = Exception.class):扩大回滚异常类型。noRollbackFor:排除指定异常,但不保证已标记 rollback-only 的事务能提交。
Spring Framework 6.2 可以配置不同的全局默认回滚策略,具体以项目版本和配置为准。
12.2 捕获异常不等于回滚
java
@Transactional(rollbackFor = Exception.class)
public void createOrder() {
orderMapper.insert(...);
try {
inventoryMapper.decrease(...);
} catch (Exception ex) {
log.error("failed", ex);
// 若异常被吞掉,且事务未被标记 rollback-only,外层可能继续提交
}
}
如果是内层经过事务代理的 REQUIRED 方法已经标记共享事务 rollback-only,则外层即使捕获异常,也可能在提交时得到 UnexpectedRollbackException。
12.3 常见事务失效/误用
| 情况 | 原因与建议 |
|---|---|
同类 this.method() 调用 |
绕过事务代理,独立传播设置不生效;拆分到不同 Bean |
| 非 Spring 管理对象 | new 出的对象没有 Spring 事务代理 |
private / CGLIB final 方法 |
不能按普通类代理机制正常增强;优先 public 业务方法 |
新线程、CompletableFuture |
同步 JDBC 事务一般靠线程绑定,不自动跨线程传播 |
| 不同 DataSource | 事务管理器与 Mapper 使用资源不一致,不能默认共用事务 |
readOnly=true |
是只读提示,不是通用绝对禁写保障 |
timeout=10 |
不是自动在第 10 秒杀掉任意 Java 代码 |
| 在事务中调用外部 HTTP | 持锁时间长,而且 HTTP 操作不会因 MySQL 回滚自动撤销 |
| DDL 与 DML 混用 | 某些 MySQL DDL 可能隐式提交,不能假设随本地 DML 事务统一回滚 |
12.4 Spring 隔离设置与 InnoDB
java
@Transactional(isolation = Isolation.READ_COMMITTED)
public void process() { }
如果这个方法创建了新物理事务 ,事务管理器可以尝试用 RC 运行;但如果它通过 REQUIRED 加入现有 RR 事务,通常使用已有事务的隔离级别,不能在同一物理事务内部随意切换。可以用 validateExistingTransaction 帮助发现配置冲突。
12.5 从业务角度防止并发覆盖
避免"先读库存,再计算并覆盖"的竞态:
sql
-- 推荐:在数据库内一次完成条件判断与更新
UPDATE inventory
SET stock = stock - 1
WHERE product_id = 1001 AND stock > 0;
检查受影响行数,决定后续业务是否成功。若涉及多个表的一致性,由 Service 层事务统一处理;若是跨服务一致性,考虑 Outbox、幂等与补偿,不要指望一个 JDBC 事务覆盖外部系统。
13. 生产排查 SQL 与 Spring 配置
图 16|事务慢、锁等待、慢 SQL 的初步定位路径
13.1 查看关键参数
sql
SHOW VARIABLES WHERE Variable_name IN (
'version',
'transaction_isolation',
'innodb_page_size',
'innodb_buffer_pool_size',
'innodb_redo_log_capacity',
'innodb_io_capacity',
'innodb_io_capacity_max',
'sort_buffer_size',
'join_buffer_size',
'tmp_table_size',
'temptable_max_ram',
'max_connections'
);
13.2 Buffer Pool 读取、临时表和连接情况
sql
SHOW GLOBAL STATUS WHERE Variable_name IN (
'Innodb_buffer_pool_read_requests',
'Innodb_buffer_pool_reads',
'Innodb_buffer_pool_pages_free',
'Created_tmp_tables',
'Created_tmp_disk_tables',
'Sort_merge_passes',
'Threads_connected',
'Threads_running',
'Max_used_connections'
);
逻辑读命中率可以按某个时间窗口估算:
text
HitRate ≈ 1 - (窗口内 Innodb_buffer_pool_reads 的增量
/ 窗口内 Innodb_buffer_pool_read_requests 的增量)
不能只看实例启动以来的累计总值,且高命中率不代表 SQL 没有扫描浪费。
13.3 查找耗时 SQL
sql
SELECT
SCHEMA_NAME,
DIGEST_TEXT,
COUNT_STAR,
ROUND(AVG_TIMER_WAIT / 1000000000, 2) AS avg_ms,
ROUND(SUM_TIMER_WAIT / 1000000000000, 2) AS total_seconds
FROM performance_schema.events_statements_summary_by_digest
WHERE DIGEST_TEXT IS NOT NULL
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 10;
依赖 Performance Schema 对应采集功能已启用。
13.4 查事务与锁
sql
SELECT
trx_id, trx_state, trx_started,
trx_wait_started, trx_mysql_thread_id, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;
SELECT
OBJECT_SCHEMA, OBJECT_NAME, INDEX_NAME,
LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks;
SELECT
waiting_pid, waiting_query,
blocking_pid, blocking_query,
wait_age_secs, locked_table, locked_index
FROM sys.innodb_lock_waits;
SHOW ENGINE INNODB STATUS;
SHOW ENGINE INNODB STATUS 中重点查看 LATEST DETECTED DEADLOCK 和 History list length。锁信息是动态快照,抓取时机很关键。
13.5 观察 Spring 事务日志
yaml
logging:
level:
org.springframework.transaction: TRACE
org.springframework.jdbc.datasource: DEBUG
org.mybatis.spring: DEBUG
仅在需要诊断时开启较详细日志,避免长期运行导致大量日志。
可以检查当前线程的事务上下文:
java
boolean active = TransactionSynchronizationManager
.isActualTransactionActive();
boolean readOnly = TransactionSynchronizationManager
.isCurrentTransactionReadOnly();
这只能证明当前存在活跃 Spring 事务上下文;不能单独证明所有 Mapper 使用的数据库连接都正确加入该事务。
14. 最佳实践、常见误区与学习路线
14.1 推荐调优顺序
- 确认 SQL 是否真正耗时,或慢在连接池、网络、ORM 映射。
- 看
EXPLAIN ANALYZE:扫描行数、索引、排序、回表、估计误差。 - 优化联合索引、覆盖索引、分页、返回列。
- 观察 Buffer Pool 命中和实际存储 I/O。
- 观察锁等待、长事务、死锁、Undo 历史。
- 最后针对指标审慎调整
my.cnf;一次只调整少量变量,并用压测确认收益。
14.2 高并发场景的事务原则
- 事务短:尽量不要在事务中做慢网络请求、等待或复杂计算。
- 更新顺序一致:降低交叉加锁导致的死锁风险。
- 读写方式明确 :普通 SELECT 是 MVCC 一致性读,
FOR UPDATE是锁定读。 - 一组数据库写入统一事务 :常用
REQUIRED。 - 独立写入用 REQUIRES_NEW 前先评估:额外连接、锁冲突、数据一致性。
- NOT_SUPPORTED 不等于"开一个新事务":只是让内部方法不参与已有事务。
- 多租户数据源先确定租户再开始事务:事务绑定连接后再切换租户上下文,通常不能改变已有连接。
- 审计/消息可靠性:只有数据库内同一个事务的操作才天然同提交或回滚;跨系统要单独设计一致性保障。
14.3 常见误区速查
| 误区 | 正确认知 |
|---|---|
| 索引是一个额外的数据副本 | B+Tree 是有序的查找结构;二级索引记录并非完整表行 |
| 数据页越大越快 | 大页减少页数量,但可能增加随机访问和缓存浪费 |
| 每条索引查找必有多次磁盘 I/O | 根页/内部页/叶子页可能已经缓存在 Buffer Pool |
| 全局变量一定覆盖会话 | 支持 SESSION 的变量可覆盖当前连接的 GLOBAL 默认值 |
| 每张表都能单独设置 Buffer Pool | Buffer Pool 是 InnoDB 实例级共享缓存 |
| RR 事务中任何查询都只能见到同一个版本 | 普通快照读与锁定读语义不同 |
| @Transactional 意味着方法里的所有动作可回滚 | 本地 DB 事务不自动回滚 HTTP、邮件、文件和其他服务 |
| 内层 REQUIRED 捕获异常后外层必然提交 | 内部可能已将共享事务标记为 rollback-only |
| REQUIRES_NEW 与 NESTED 等价 | 前者新物理事务,后者同一物理事务保存点 |
| NOT_SUPPORTED 会结束外部事务 | 它只是暂时挂起并恢复,外部事务不因此提交或回滚 |
| 对内部方法直接 this 调用也能触发事务传播 | 默认 Spring AOP 代理模式下自调用会绕过事务拦截 |
14.4 建议继续深入的知识路线
text
索引与 B+Tree
↓
InnoDB Page / Buffer Pool / I/O
↓
Optimizer / EXPLAIN ANALYZE
↓
MVCC / Undo / Read View
↓
Record / Gap / Next-Key / Deadlock
↓
Spring AOP / TransactionInterceptor
↓
Propagation / 隔离 / 回滚 / DataSource
↓
多租户、异步消息、Outbox、高并发事务实战
15. 官方参考资料
以下均为官方技术资料,便于进一步查证具体 MySQL / Spring 版本的细节:
MySQL
- MySQL 8.4:InnoDB 参数
- MySQL 8.4:InnoDB Page Size 与实例初始化
- MySQL 8.4:InnoDB 聚簇索引与二级索引
- MySQL 8.4:Consistent Nonlocking Reads
- MySQL 8.4:InnoDB 锁
- MySQL 8.4:Next-Key Lock 与幻读
- MySQL 8.4:持久化优化器统计信息
- MySQL 8.4:EXPLAIN
Spring 与 MyBatis
- Spring Framework 6.2:
@Transactional注解 - Spring Framework 6.2:事务传播行为
- Spring Framework 6.2:事务回滚规则
- MyBatis-Spring:事务支持
一句话总览 :MySQL 用 B+Tree 与数据页决定"数据怎么找",用 Buffer Pool 决定"能否少读磁盘",用 MVCC 与锁决定"并发时看哪个版本、谁需要等待",而 Spring @Transactional 决定"这些数据库操作如何组成一个可提交或回滚的业务事务"。