03 事务与隔离级别的正确打开方式
摘要: “用事务”和”用对事务”是两回事。很多开发者知道在 Spring 中加上 @Transactional 就算”用了事务”,但对事务的隔离级别选择、长事务的危害、事务边界的合理拆分缺乏深入理解。本文从”事务到底在保护什么”这个问题出发,详细剖析四种隔离级别在 InnoDB 中的真实行为差异——尤其是 READ COMMITTED 与 REPEATABLE READ 这两个生产中最常用级别的微妙区别,以及 RR 级别下”幻读到底防住了没有”这个经典争议。然后聚焦长事务的六宗罪和 Spring @Transactional 的常见陷阱,最后给出事务边界拆分的工程实践。
第 1 章 事务到底在保护什么
1.1 从一个转账场景说起
假设用户 A 向用户 B 转账 100 元。这个操作至少涉及两条 SQL:
UPDATE accounts SET balance = balance - 100 WHERE user_id = 'A';
UPDATE accounts SET balance = balance + 100 WHERE user_id = 'B';如果第一条执行成功(A 扣了 100 元),第二条执行失败(B 没加上 100 元),系统中凭空少了 100 元。这种”部分成功”的状态对业务来说是灾难性的。
事务(Transaction)就是为了解决这个问题而存在的:将多个操作打包成一个不可分割的工作单元,要么全部成功(COMMIT),要么全部失败(ROLLBACK),不存在中间状态。
1.2 ACID:事务的四个保证
事务提供的保证可以用 ACID 来概括:
| 属性 | 含义 | InnoDB 的实现手段 |
|---|---|---|
| Atomicity(原子性) | 事务中的所有操作要么全部完成,要么全部不做 | Undo Log:记录修改前的旧值,ROLLBACK 时用于恢复 |
| Consistency(一致性) | 事务执行前后,数据库从一个一致状态转换到另一个一致状态 | 由原子性 + 隔离性 + 持久性共同保证,加上应用层的约束(如外键、CHECK) |
| Isolation(隔离性) | 并发执行的多个事务之间互不干扰 | MVCC(多版本并发控制)+ 锁机制 |
| Durability(持久性) | 事务一旦提交,其修改永久保存,即使系统崩溃也不会丢失 | Redo Log + WAL 协议 |
这四个属性中,隔离性是最复杂的——因为它不是一个”有或没有”的问题,而是一个”隔离到什么程度”的问题。隔离程度越高,并发性能越低;隔离程度越低,并发异常的风险越高。四种隔离级别就是在这个光谱上的四个刻度。
1.3 没有隔离性会怎样
在没有任何隔离措施的情况下,并发事务之间可能出现以下异常:
脏读(Dirty Read):事务 A 读到了事务 B 尚未提交的修改。如果事务 B 随后回滚,事务 A 读到的就是一个从未存在过的”幽灵值”。
事务 A 事务 B
UPDATE balance SET val=900 WHERE id=1 (原值1000)
SELECT val FROM balance
WHERE id=1
→ 读到 900(脏数据!)
ROLLBACK(B 回滚了,val 恢复为 1000)
事务 A 基于 900 做后续逻辑...(但 900 这个值从未被正式写入过)
不可重复读(Non-Repeatable Read):事务 A 在两次读取同一行数据时,得到了不同的结果——因为在两次读之间,事务 B 修改并提交了这行数据。
事务 A 事务 B
SELECT val FROM balance
WHERE id=1
→ 读到 1000
UPDATE balance SET val=900 WHERE id=1
COMMIT
SELECT val FROM balance
WHERE id=1
→ 读到 900(同一个事务内,两次读结果不同!)
幻读(Phantom Read):事务 A 在两次执行相同的范围查询时,得到了不同的行集——因为在两次查询之间,事务 B 插入了新行(或删除了已有行)。
事务 A 事务 B
SELECT * FROM orders
WHERE status=1
→ 返回 10 行
INSERT INTO orders (..., status=1, ...)
COMMIT
SELECT * FROM orders
WHERE status=1
→ 返回 11 行(多了一行"幻影行"!)
核心概念
脏读、不可重复读、幻读这三种异常的严重程度是递减的。脏读最危险(读到了从未提交的数据),不可重复读次之(读到了已提交但变化的数据),幻读最轻(行集变化但每一行的数据本身是正确的)。隔离级别就是选择”容忍哪些异常、禁止哪些异常”。
第 2 章 四种隔离级别在 InnoDB 中的真实行为
2.1 隔离级别速查表
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | InnoDB 默认 |
|---|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 | 否 |
| READ COMMITTED (RC) | 不可能 | 可能 | 可能 | 否 |
| REPEATABLE READ (RR) | 不可能 | 不可能 | 部分防护 | 是 |
| SERIALIZABLE | 不可能 | 不可能 | 不可能 | 否 |
InnoDB 的默认隔离级别是 REPEATABLE READ,这与大多数其他数据库(Oracle、PostgreSQL、SQL Server 默认 READ COMMITTED)不同。这个选择有历史原因——MySQL 早期的 Binlog 在 STATEMENT 格式下,RC 级别可能导致主从数据不一致,RR 级别通过间隙锁避免了这个问题。但在现代 MySQL(ROW 格式 Binlog + GTID 复制)中,这个历史原因已不再成立。
2.2 READ UNCOMMITTED:几乎没有隔离
这个级别允许读取其他事务未提交的修改(脏读)。在生产环境中几乎没有任何合理的使用场景——如果你能容忍脏读,通常意味着你不需要事务。
唯一可能的用途是”调试目的”:在排查某个长事务的中间状态时,可以临时将会话隔离级别设为 RU 来观察未提交的数据。但即便如此,也有更好的替代方案(如 SHOW ENGINE INNODB STATUS)。
2.3 READ COMMITTED (RC):每次读取都看到最新已提交的数据
RC 的语义很直观:每次 SELECT 都能看到在该 SELECT 执行时刻之前已经提交的所有修改。事务内的两次 SELECT 之间,如果其他事务提交了修改,第二次 SELECT 就能看到这些修改。
RC 在 InnoDB 中的实现方式是:每次执行 SELECT 语句时,都创建一个新的 ReadView。
ReadView 是 MVCC 的核心数据结构,它记录了”在这一瞬间,哪些事务是活跃的(未提交的)“。当 InnoDB 读取一行数据时,会检查这行数据的 trx_id(最后修改这行数据的事务 ID):
- 如果
trx_id对应的事务已经提交了(不在 ReadView 的活跃事务列表中),说明这个修改是”已提交的”,当前事务可以看到 - 如果
trx_id对应的事务还没提交(在活跃事务列表中),说明这个修改是”未提交的”,当前事务需要沿着 Undo Log 的版本链往回找,找到一个已提交的旧版本
因为 RC 每次 SELECT 都创建新的 ReadView,所以两次 SELECT 之间如果有其他事务提交了修改,新的 ReadView 会将这个事务排除出”活跃列表”,从而看到它的修改——这就是”不可重复读”的来源。
2.3.1 RC 的加锁行为
RC 级别下,InnoDB 对写操作的加锁行为相比 RR 有一个重要差异:RC 不使用间隙锁(Gap Lock)。
这意味着在 RC 级别下:
UPDATE ... WHERE ...只锁定匹配的行(记录锁),不锁定行之间的”间隙”INSERT不会因为间隙锁而阻塞(除非与唯一索引冲突)- 锁的范围更小,死锁的概率更低,并发性能更高
但代价是:RC 无法防止幻读——新行可以被其他事务插入到你的查询范围中。
2.4 REPEATABLE READ (RR):事务内看到的数据始终一致
RR 的语义是:事务内的所有 SELECT 看到的数据快照是一致的——以事务中第一次 SELECT 执行时刻为准。无论其他事务在此期间提交了什么修改,当前事务的 SELECT 结果都不会变。
RR 在 InnoDB 中的实现方式是:事务中第一次执行 SELECT 时创建 ReadView,之后复用同一个 ReadView。
这与 RC “每次 SELECT 都创建新 ReadView”形成了核心差异:
事务 A (RR 级别) 事务 B
BEGIN;
SELECT val FROM t WHERE id=1;
→ 读到 1000
(此时创建 ReadView,记录活跃事务列表)
UPDATE t SET val=900 WHERE id=1;
COMMIT;
SELECT val FROM t WHERE id=1;
→ 仍然读到 1000!
(复用同一个 ReadView,事务 B 的提交时间在 ReadView 创建之后,
所以事务 B 的修改对事务 A 不可见)
COMMIT;
设计哲学
RC 和 RR 在 MVCC 层面的唯一区别就是 ReadView 的创建时机:RC 每次 SELECT 都创建新的,RR 整个事务只创建一次。底层的版本链遍历逻辑、Undo Log 读取逻辑完全相同。理解了这一点,两个隔离级别的所有行为差异都能自然推导出来。
2.4.1 RR 级别下的”幻读”争议
根据 SQL 标准,RR 级别不保证防止幻读。但 InnoDB 的 RR 实现在快照读(Snapshot Read)场景下确实防止了幻读:
事务 A (RR 级别) 事务 B
BEGIN;
SELECT * FROM orders WHERE status=1;
→ 返回 10 行(创建 ReadView)
INSERT INTO orders (..., status=1, ...);
COMMIT;
SELECT * FROM orders WHERE status=1;
→ 仍然返回 10 行!
(ReadView 没变,新插入的行对事务 A 不可见)
快照读不会看到新行——因为 ReadView 是固定的,新插入的行的 trx_id 在 ReadView 创建之后,被判定为”不可见”。
但当前读(Current Read)场景下,幻读仍然可能发生:
事务 A (RR 级别) 事务 B
BEGIN;
SELECT * FROM orders WHERE status=1;
→ 返回 10 行
INSERT INTO orders (id=999, status=1, ...);
COMMIT;
UPDATE orders SET remark='test' WHERE status=1;
→ 影响了 11 行!(当前读看到了事务 B 插入的新行)
SELECT * FROM orders WHERE status=1;
→ 返回 11 行!
(因为事务 A 自己修改了 id=999 这行,它的 trx_id 变成了事务 A 自己的 ID,
所以对事务 A 的 ReadView 可见了)
这个场景揭示了一个微妙的事实:InnoDB 的 RR 级别通过 MVCC 快照读防止了大部分幻读场景,但在快照读与当前读混合使用时,仍然存在”逻辑上的幻读”。
要完全防止这种情况,需要在第一次 SELECT 时使用 SELECT ... FOR UPDATE(当前读 + 加锁),让 InnoDB 对查询范围加间隙锁,阻止其他事务在这个范围内插入新行。
2.4.2 快照读与当前读的区别
| 读取方式 | SQL 形式 | 行为 | 加锁 |
|---|---|---|---|
| 快照读 | 普通 SELECT | 读取 ReadView 对应的历史版本 | 不加锁 |
| 当前读 | SELECT ... FOR UPDATE | 读取最新已提交的版本 | 加排他锁 |
| 当前读 | SELECT ... LOCK IN SHARE MODE | 读取最新已提交的版本 | 加共享锁 |
| 当前读 | INSERT / UPDATE / DELETE | 读取最新已提交的版本 | 加排他锁 |
所有的写操作(INSERT / UPDATE / DELETE)都是当前读——它们必须基于最新的数据来执行,否则会丢失其他已提交事务的修改。这就是为什么写操作不走 MVCC 快照,而是加锁读取最新版本。
2.5 SERIALIZABLE:完全串行化
SERIALIZABLE 级别下,InnoDB 将所有的普通 SELECT 自动转换为 SELECT ... LOCK IN SHARE MODE(即加共享锁的当前读)。这意味着:
- 读-读不冲突(共享锁兼容)
- 读-写冲突(共享锁与排他锁不兼容)
- 写-写冲突(排他锁不兼容)
任何并发的读写操作都会相互阻塞,效果等同于事务串行执行。这彻底消除了所有并发异常,但并发性能极差——在 OLTP 系统中几乎不可用。
第 3 章 生产中 RC 还是 RR
3.1 选择 RR 的理由
- 一致性快照:事务内多次读取结果一致,简化了应用逻辑。例如,一个复杂的报表查询涉及多个表的多次查询,RR 保证这些查询看到的数据是同一时刻的快照,不会出现”表 A 看到了新数据,表 B 还是旧数据”的不一致。
- 与早期 Binlog STATEMENT 格式兼容:虽然现代 MySQL 推荐 ROW 格式,但一些老系统仍在使用 STATEMENT 格式。
3.2 选择 RC 的理由
- 更少的锁冲突:RC 不使用间隙锁,加锁范围更小,死锁概率更低。对于高并发写入的 OLTP 系统,这是一个显著的优势。
- Undo Log 回收更快:RC 的 ReadView 是语句级别的,每条 SELECT 结束后 ReadView 就不再需要了,对应的 Undo Log 可以更早被 Purge 线程回收。RR 的 ReadView 要到事务结束才释放,如果事务持续时间长,Undo Log 会持续膨胀。
- 半一致性读(Semi-Consistent Read):RC 级别下,
UPDATE语句如果遇到一行被其他事务锁定的记录,InnoDB 会用 MVCC 读取该行的最新已提交版本来判断是否满足 WHERE 条件——如果不满足,直接跳过(不等待锁释放)。这在高并发 UPDATE 场景下能显著减少锁等待时间。RR 级别下没有这个优化。
3.3 实践建议
| 场景 | 推荐隔离级别 | 原因 |
|---|---|---|
| 高并发 OLTP(电商、支付) | RC | 减少锁冲突和死锁,提高吞吐量 |
| 报表/分析查询 | RR | 需要一致性快照,避免读到”半新半旧”的数据 |
| 默认/不确定 | RR(MySQL 默认) | 更强的隔离保证,减少意外的并发问题 |
| 从 Oracle/PostgreSQL 迁移 | RC | 行为更接近源数据库的默认级别 |
互联网公司的一个常见实践是:全局设为 RC,少数需要强一致性的业务场景通过 SELECT ... FOR UPDATE 手动加锁。这样既享受了 RC 的高并发性能,又在必要时通过悲观锁保证了关键操作的正确性。
-- 修改全局隔离级别
SET GLOBAL transaction_isolation = 'READ-COMMITTED';
-- 修改当前会话的隔离级别
SET SESSION transaction_isolation = 'READ-COMMITTED';生产避坑
修改全局隔离级别时,务必同步修改
my.cnf配置文件(transaction_isolation = READ-COMMITTED),否则 MySQL 重启后会恢复默认值。同时确认 Binlog 格式为 ROW(binlog_format = ROW),因为 RC + STATEMENT 格式在某些场景下会导致主从数据不一致。
第 4 章 长事务的六宗罪
4.1 什么是长事务
“长事务”没有精确的定义,但一般指持续时间超过几秒到几分钟的事务。在高并发的 OLTP 系统中,一个事务持续超过 1 秒就应该引起警觉,超过 10 秒就是需要立刻处理的问题。
长事务的产生通常有以下原因:
- 事务中包含了耗时的外部调用(如 RPC、HTTP 请求)
- 事务中包含了大量的数据处理逻辑
- 忘记提交或回滚(代码中缺少 COMMIT/ROLLBACK,或异常处理不完善)
- 开启了事务但长时间没有操作(如开发者在 MySQL 客户端中
BEGIN后去开会了)
4.2 六宗罪
第一罪:Undo Log 膨胀
在 RR 隔离级别下,长事务的 ReadView 会一直存在,导致 InnoDB 无法清理该 ReadView 之后产生的所有 Undo Log 版本。即使其他事务已经提交,它们的旧版本数据仍然需要保留(因为长事务可能还需要读取这些旧版本)。随着时间推移,Undo Log 占用的空间不断增长,可能从几 MB 膨胀到几十 GB。
-- 查看当前 Undo Log 的 History List 长度
SHOW ENGINE INNODB STATUS\G
-- 在 TRANSACTIONS 部分查看 History list length
-- 如果这个值持续增长且超过几十万,说明有长事务阻止了 Undo Purge第二罪:锁持有时间过长
事务中获取的行锁要到事务提交或回滚时才释放。长事务意味着锁被持有的时间更长,其他需要修改相同行的事务会被阻塞更久,甚至超时失败。
第三罪:连接资源占用
每个活跃事务占用一个数据库连接。长事务长时间占着连接不放,在连接池大小有限的情况下,可能导致连接耗尽,新的请求无法获取连接。
第四罪:主从延迟放大
如果长事务涉及大量的写操作,这些操作在主库上是一个事务(原子性),在从库上也必须作为一个事务来重放。从库的 SQL 线程在重放这个大事务期间,无法处理后续的 Binlog 事件,导致主从延迟被放大。
第五罪:崩溃恢复时间延长
如果 MySQL 在长事务执行期间崩溃,重启后需要通过 Redo Log 前滚(redo)和 Undo Log 回滚(undo)来恢复数据一致性。长事务意味着更多的 Redo Log 需要重放、更多的未提交修改需要回滚,恢复时间更长。
第六罪:死锁风险增加
事务持续时间越长,持有的锁越多,与其他事务产生锁冲突和死锁的概率就越高。而且长事务回滚的代价也更大——需要撤销更多的修改。
4.3 如何发现和杀掉长事务
-- 查找持续时间超过 60 秒的事务
SELECT
trx_id,
trx_state,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec,
trx_rows_locked,
trx_rows_modified,
trx_query
FROM information_schema.INNODB_TRX
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60
ORDER BY duration_sec DESC;找到长事务后,可以通过 KILL <thread_id> 来强制终止对应的连接(事务会自动回滚)。但这是”治标”——根本解决方案是从应用代码层面避免长事务的产生。
生产避坑
建议在监控系统中配置长事务告警:当
information_schema.INNODB_TRX中出现持续时间超过阈值(如 30 秒)的事务时,自动触发告警。同时可以设置 MySQL 参数innodb_rollback_on_timeout = ON(默认 OFF),让超时的事务自动回滚整个事务(而不是默认的只回滚最后一条语句)。
第 5 章 Spring @Transactional 的常见陷阱
5.1 陷阱一:自调用导致事务失效
Spring 的 @Transactional 基于 AOP 代理实现。当你在同一个类内部调用一个带有 @Transactional 的方法时,调用不会经过代理对象,事务注解不会生效。
@Service
public class OrderService {
public void createOrder(Order order) {
// ... 业务逻辑
this.saveOrder(order); // ❌ 自调用,事务不生效!
}
@Transactional
public void saveOrder(Order order) {
orderDao.insert(order);
// 如果这里抛异常,不会回滚——因为事务根本没开启
}
}原因:Spring AOP 默认使用 JDK 动态代理或 CGLIB 代理。调用 this.saveOrder() 是通过 this(原始对象)直接调用,绕过了代理对象。代理对象拦截不到这次调用,自然无法开启事务。
解决方案:
- 将被调用的方法拆到另一个 Bean 中
- 注入自身代理(
@Autowired private OrderService self;,然后调用self.saveOrder()) - 使用
AopContext.currentProxy()获取当前代理对象
5.2 陷阱二:异常类型不匹配导致不回滚
@Transactional 默认只在抛出 unchecked 异常(RuntimeException 及其子类)和 Error 时回滚。如果抛出的是 checked 异常(如 IOException、SQLException),事务不会回滚。
@Transactional
public void processFile(String path) throws IOException {
orderDao.insert(order);
Files.readAllBytes(Paths.get(path)); // 抛出 IOException
// 事务不会回滚!因为 IOException 是 checked 异常
}解决方案:显式指定回滚的异常类型:
@Transactional(rollbackFor = Exception.class)
public void processFile(String path) throws IOException {
// 现在任何异常都会触发回滚
}生产避坑
建议在团队规范中强制要求所有
@Transactional注解都加上rollbackFor = Exception.class。默认的”只回滚 RuntimeException”行为是 Java 历史遗留的设计缺陷,在实际开发中经常导致数据不一致的 bug——而且这种 bug 非常隐蔽,因为代码不会报错,只是数据”悄悄地”不一致了。
5.3 陷阱三:事务中包含耗时的外部调用
@Transactional
public void createOrderWithNotify(Order order) {
orderDao.insert(order); // 数据库写入(几毫秒)
inventoryService.deduct(order); // RPC 调用扣减库存(可能几百毫秒甚至超时)
emailService.send(order); // HTTP 调用发送邮件通知(可能几秒)
}这个事务的持续时间取决于最慢的那个外部调用。如果邮件服务超时(比如 30 秒),这个事务就会变成一个 30 秒的长事务,期间数据库连接被占用、行锁被持有。
解决方案:将非核心的外部调用移出事务边界:
@Transactional
public void createOrder(Order order) {
orderDao.insert(order); // 核心数据库操作
inventoryService.deduct(order); // 核心业务(需要事务保护)
}
// 事务提交后,异步发送通知
public void createOrderWithNotify(Order order) {
createOrder(order);
// 事务已提交,下面的操作不在事务中
asyncNotifyService.sendEmail(order); // 异步发送,失败可重试
}5.4 陷阱四:事务传播行为不理解
Spring 的事务传播行为(Propagation)控制了”在已有事务的上下文中调用另一个事务方法时,如何处理事务边界”。最常用的两种:
| 传播行为 | 含义 | 适用场景 |
|---|---|---|
REQUIRED(默认) | 如果当前有事务就加入,没有就创建新事务 | 大多数场景 |
REQUIRES_NEW | 无论当前是否有事务,都创建新事务(当前事务挂起) | 需要独立提交/回滚的操作(如审计日志) |
常见错误:
@Transactional
public void outerMethod() {
try {
innerService.innerMethod(); // innerMethod 也有 @Transactional
} catch (Exception e) {
log.error("inner failed", e);
// 吞掉了异常,希望外层事务继续提交
}
// 但如果 innerMethod 标记了事务为 rollback-only,
// 外层事务提交时会抛出 UnexpectedRollbackException!
}当 innerMethod 抛出异常时,Spring 会将当前事务标记为 rollback-only。即使外层方法 catch 了异常,事务在提交时仍然会被强制回滚——因为事务已经被”判了死刑”。如果需要 innerMethod 的失败不影响外层事务,应该将 innerMethod 的传播行为设为 REQUIRES_NEW。
第 6 章 事务边界拆分的工程实践
6.1 核心原则:事务越短越好
事务边界设计的核心原则是:只将必须保证原子性的操作放在同一个事务中,其他操作移出事务边界。
一个典型的”大事务”:
@Transactional
public void createOrder(OrderRequest request) {
// 1. 参数校验(不需要事务)
validateRequest(request);
// 2. 查询商品信息(只读,不需要事务保护)
Product product = productService.getById(request.getProductId());
// 3. 计算价格(纯计算,不需要事务)
BigDecimal totalPrice = calculatePrice(product, request.getQuantity());
// 4. 创建订单(需要事务)
Order order = orderDao.insert(buildOrder(request, totalPrice));
// 5. 扣减库存(需要事务,且需要与创建订单保持原子性)
inventoryDao.deduct(request.getProductId(), request.getQuantity());
// 6. 发送消息通知(不需要事务,可以异步)
messageService.send(buildOrderMessage(order));
// 7. 记录操作日志(不需要与订单创建保持原子性)
auditLogService.log("CREATE_ORDER", order.getId());
}优化后:
public void createOrder(OrderRequest request) {
// 1-3:事务外完成
validateRequest(request);
Product product = productService.getById(request.getProductId());
BigDecimal totalPrice = calculatePrice(product, request.getQuantity());
// 4-5:最小事务边界
Order order = doCreateOrder(request, totalPrice);
// 6-7:事务外完成
asyncMessageService.send(buildOrderMessage(order));
auditLogService.log("CREATE_ORDER", order.getId());
}
@Transactional(rollbackFor = Exception.class)
public Order doCreateOrder(OrderRequest request, BigDecimal totalPrice) {
Order order = orderDao.insert(buildOrder(request, totalPrice));
inventoryDao.deduct(request.getProductId(), request.getQuantity());
return order;
}优化前的事务可能持续数百毫秒(包含外部查询和消息发送),优化后的事务只包含两次数据库写入,通常在几毫秒内完成。
6.2 编程式事务 vs 声明式事务
对于需要精细控制事务边界的场景,编程式事务(TransactionTemplate)比声明式事务(@Transactional)更灵活:
@Autowired
private TransactionTemplate transactionTemplate;
public void createOrder(OrderRequest request) {
// 事务外的准备工作
validateRequest(request);
Product product = productService.getById(request.getProductId());
BigDecimal totalPrice = calculatePrice(product, request.getQuantity());
// 精确的事务边界
Order order = transactionTemplate.execute(status -> {
Order o = orderDao.insert(buildOrder(request, totalPrice));
inventoryDao.deduct(request.getProductId(), request.getQuantity());
return o;
});
// 事务外的后续操作
asyncMessageService.send(buildOrderMessage(order));
}编程式事务的优势在于:事务边界在代码中是可见的(不像 @Transactional 需要理解 AOP 代理的行为),不受自调用问题的影响,也更容易做精确的异常处理。
第 7 章 小结
本文的核心知识链条是:
- 事务的 ACID 属性中,隔离性是最复杂的——它是一个”隔离到什么程度”的连续光谱
- RC 与 RR 的本质区别只有一个:ReadView 的创建时机。RC 每条 SELECT 创建新的,RR 整个事务只创建一次。由此衍生出一致性读、加锁范围、Undo Purge 效率等一系列行为差异
- RR 级别下的幻读防护是”不完整的”:快照读通过 MVCC 防止了幻读,但当前读场景下仍可能出现逻辑上的幻读。完全防止需要
SELECT ... FOR UPDATE加间隙锁 - 生产选择 RC 还是 RR取决于业务特征:高并发 OLTP 倾向 RC(减少锁冲突),需要一致性快照的场景倾向 RR
- 长事务是性能杀手:Undo 膨胀、锁持有时间长、连接占用、主从延迟放大、崩溃恢复变慢、死锁风险增加
- Spring @Transactional 有四个常见陷阱:自调用失效、checked 异常不回滚、事务中包含外部调用、传播行为不理解
- 事务边界的设计原则:只将必须保证原子性的操作放在事务中,其他操作移出;编程式事务在复杂场景下比声明式事务更可控
思考题
information_schema.INNODB_TRX显示了当前活跃的事务。trx_started列显示事务开始时间——如果一个事务持续了几小时,它可能持有锁并阻止 Undo Log 回收。你如何设置监控来检测长事务?kill <thread_id>终止长事务后 InnoDB 需要回滚该事务——回滚大事务需要多长时间?SHOW ENGINE INNODB STATUS中的TRANSACTIONS部分显示了锁等待信息。innodb_lock_wait_timeout(默认 50 秒)控制锁等待的最大时间——超时后事务回滚。在某些场景下 50 秒太长(如 Web 请求通常 30 秒超时),某些场景下太短(如批量数据导入)。你如何为不同类型的事务设置不同的锁等待超时?- 隐式锁(Implicit Lock)是 InnoDB 为新插入的行自动添加的锁——不显式存储在锁表中。当其他事务需要对该行加锁时,InnoDB 通过检查行的
trx_id判断是否存在隐式锁。隐式锁转化为显式锁的过程对并发插入性能有什么影响?自增 ID 的’AUTO-INC Lock’在高并发插入时是否成为瓶颈?