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 的理由

  1. 一致性快照:事务内多次读取结果一致,简化了应用逻辑。例如,一个复杂的报表查询涉及多个表的多次查询,RR 保证这些查询看到的数据是同一时刻的快照,不会出现”表 A 看到了新数据,表 B 还是旧数据”的不一致。
  2. 与早期 Binlog STATEMENT 格式兼容:虽然现代 MySQL 推荐 ROW 格式,但一些老系统仍在使用 STATEMENT 格式。

3.2 选择 RC 的理由

  1. 更少的锁冲突:RC 不使用间隙锁,加锁范围更小,死锁概率更低。对于高并发写入的 OLTP 系统,这是一个显著的优势。
  2. Undo Log 回收更快:RC 的 ReadView 是语句级别的,每条 SELECT 结束后 ReadView 就不再需要了,对应的 Undo Log 可以更早被 Purge 线程回收。RR 的 ReadView 要到事务结束才释放,如果事务持续时间长,Undo Log 会持续膨胀。
  3. 半一致性读(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 异常(如 IOExceptionSQLException),事务不会回滚。

@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 章 小结

本文的核心知识链条是:

  1. 事务的 ACID 属性中,隔离性是最复杂的——它是一个”隔离到什么程度”的连续光谱
  2. RC 与 RR 的本质区别只有一个:ReadView 的创建时机。RC 每条 SELECT 创建新的,RR 整个事务只创建一次。由此衍生出一致性读、加锁范围、Undo Purge 效率等一系列行为差异
  3. RR 级别下的幻读防护是”不完整的”:快照读通过 MVCC 防止了幻读,但当前读场景下仍可能出现逻辑上的幻读。完全防止需要 SELECT ... FOR UPDATE 加间隙锁
  4. 生产选择 RC 还是 RR取决于业务特征:高并发 OLTP 倾向 RC(减少锁冲突),需要一致性快照的场景倾向 RR
  5. 长事务是性能杀手:Undo 膨胀、锁持有时间长、连接占用、主从延迟放大、崩溃恢复变慢、死锁风险增加
  6. Spring @Transactional 有四个常见陷阱:自调用失效、checked 异常不回滚、事务中包含外部调用、传播行为不理解
  7. 事务边界的设计原则:只将必须保证原子性的操作放在事务中,其他操作移出;编程式事务在复杂场景下比声明式事务更可控

思考题

  1. information_schema.INNODB_TRX 显示了当前活跃的事务。trx_started 列显示事务开始时间——如果一个事务持续了几小时,它可能持有锁并阻止 Undo Log 回收。你如何设置监控来检测长事务?kill <thread_id> 终止长事务后 InnoDB 需要回滚该事务——回滚大事务需要多长时间?
  2. SHOW ENGINE INNODB STATUS 中的 TRANSACTIONS 部分显示了锁等待信息。innodb_lock_wait_timeout(默认 50 秒)控制锁等待的最大时间——超时后事务回滚。在某些场景下 50 秒太长(如 Web 请求通常 30 秒超时),某些场景下太短(如批量数据导入)。你如何为不同类型的事务设置不同的锁等待超时?
  3. 隐式锁(Implicit Lock)是 InnoDB 为新插入的行自动添加的锁——不显式存储在锁表中。当其他事务需要对该行加锁时,InnoDB 通过检查行的 trx_id 判断是否存在隐式锁。隐式锁转化为显式锁的过程对并发插入性能有什么影响?自增 ID 的’AUTO-INC Lock’在高并发插入时是否成为瓶颈?