Spring 事务与 MySQL 事务底层解析:为什么只把必要的写操作放进事务

背景

工作这么多年,我一直以为编码规范是人人皆知的常识,其实不然。今天来说说事务,规范中要求事务尽可能细粒度,避免大事务。
场景:
一个业务方法中可能先执行多次查询,最后执行两次插入。假设业务要求是:两次插入必须同时成功或同时失败,但前面的查询不需要与插入共享同一个事务。
这时,把 @Transactional 标在整个方法上,和用 TransactionTemplate 只包住两次插入,在数据库眼中是不一样的:

  • 业务事务边界:哪些操作必须一起成功或失败。
  • Spring 事务边界:Spring 从哪里开始管理连接,在哪里提交或回滚。
  • MySQL 事务实现:InnoDB 如何通过 MVCC、undo、redo 和锁实现事务。

一、事务应该包住什么?

假设有一个创建订单的方法:

查询商品展示信息
查询用户的一些辅助信息
计算订单备注
插入订单
插入订单事件

业务要求只有:插入订单 + 插入订单事件 必须原子化。

那么理想的事务边界是:

查询商品展示信息
查询用户辅助信息
计算订单备注

BEGIN
    插入订单
    插入订单事件
COMMIT

而不是:

BEGIN
    查询商品展示信息
    查询用户辅助信息
    计算订单备注
    插入订单
    插入订单事件
COMMIT

后者不一定错误,但它扩大了事务范围。如果查询耗时较长,或者中间还有远程调用、复杂计算,事务会被无谓地拉长。
不过,不能只看 SQL 类型来决定边界:查询不等于可以放到事务外
如果某次查询的结果决定后续写入是否正确,例如“查库存后扣库存”,那么把查询移出去可能引入并发问题。事务边界首先由业务一致性要求决定,其次才是性能优化。

二、MySQL 的事务底层:一条 SQL 到底发生了什么?

2.1 autocommit:没有显式事务,也不是“完全没有事务”

MySQL 通常默认开启:SELECT @@autocommit;
当 autocommit = 1 时,如果应用没有显式开启事务,每条独立 SQL 通常按自己的语句级事务执行:

SELECT * FROM product WHERE id = 1;
INSERT INTO orders (...) VALUES (...);

这两条 SQL 不会自动组成一个事务。
因此:

  • SELECT 执行结束后,它的语句级事务也结束;
  • INSERT 有自己的提交或失败处理;
  • 两条独立 INSERT 如果第二条失败,第一条可能已经提交。

所以,两次插入需要原子性时,必须显式建立事务边界:

START TRANSACTION;

INSERT INTO orders (...) VALUES (...);
INSERT INTO order_event (...) VALUES (...);

COMMIT;

失败则执行:ROLLBACK;
Spring 的核心工作之一,就是替应用代码管理这样的事务边界。

2.2 InnoDB 的普通 SELECT:一致性非锁定读

InnoDB 在常见隔离级别下,普通查询一般是一致性非锁定读:
SELECT * FROM product WHERE id = 1;
它通常不会像下面这样锁住读取的行:
SELECT * FROM product WHERE id = 1 FOR UPDATE;

InnoDB 通过 MVCC 为查询提供符合隔离级别要求的记录版本。历史版本与 undo 记录有关。
但这里要注意三个限定:

  • 讨论的是 InnoDB 的普通 SELECT,不是 FOR UPDATE、FOR SHARE 等锁定读。
  • “不加行锁”不等于“没有任何数据库资源消耗”。
  • MVCC 行为与事务隔离级别有关。

在 MySQL InnoDB 默认的 REPEATABLE READ 下,同一事务中第一次一致性读建立的 read view,通常会被后续一致性读复用;在 READ COMMITTED 下,通常每条一致性读都会使用新的 read view。

2.3 为什么长时间的查询事务可能影响 undo 清理

InnoDB 更新记录时,需要让仍在运行的旧事务有机会读到符合其快照的旧版本。

因此,如果一个事务持续很久,并且持有较老的读视图,InnoDB 可能需要保留相关历史版本,不能立即由 purge 清理。

这会带来潜在问题:

  • undo 历史记录积累;
  • purge 落后;
  • 历史版本链变长;
  • 存储与查询成本上升。

但这里不能理解成:只要事务里执行一个 SELECT,undo 就一定显著膨胀。

实际影响取决于事务隔离级别、读视图何时建立、事务持续时间,以及同时发生了多少更新。真正需要警惕的是长期存活的事务和旧快照。

2.4 写操作不只是“多执行一条 SQL”

执行 INSERT、UPDATE、DELETE 时,InnoDB 需要处理一系列事务工作:

  • 修改数据页;
  • 生成用于回滚和 MVCC 的相关信息;
  • 记录 redo,以支持崩溃恢复;
  • 维护索引;
  • 根据操作获取相应的锁;
  • 在提交时完成持久化相关流程。

不同写入语句获取的锁并不完全相同。比如 INSERT、更新已有记录、唯一键冲突检查、外键检查,各自的锁行为可能不同。

因此,不能简单概括为“插入只锁新插入的行”。分析锁等待时,要结合索引、唯一约束、外键和具体 SQL。

2.5 “先读后写”不会让数据库自动拆分事务

考虑:

START TRANSACTION;
SELECT * FROM product WHERE id = 1;
INSERT INTO orders (...) VALUES (...);
COMMIT;

InnoDB 可以对前面的普通 SELECT 使用一致性读;写操作发生时,也会进入相应的读写处理流程。
但无论内部如何管理事务 ID、读视图、回滚资源,都不意味着SELECT 被数据库“抽取”到事务之外。
事务边界仍然是 START TRANSACTION 到 COMMIT。
引擎内部的读写状态变化,和应用要求的提交/回滚边界,是两个不同层次的概念。

三、Spring 事务的底层:它是怎样控制 MySQL 事务的?

3.1 Spring 本身不实现 MySQL 的 ACID

Spring 不负责实现 InnoDB 的 MVCC、undo、redo 或行锁。
Spring 事务管理主要负责:

  • 确定事务何时开始;
  • 获取并管理数据库连接;
  • 将连接关联到当前执行上下文;
  • 控制提交与回滚;
  • 处理传播行为、隔离级别、超时等配置。

最终真正执行事务的是 JDBC 驱动和数据库。
可以把调用链简化为:

业务代码
   ↓
Spring 事务管理
   ↓
JDBC Connection
   ↓
MySQL 驱动
   ↓
MySQL / InnoDB

3.2 DataSourceTransactionManager 做了什么?

在典型的 JDBC 场景中,常见的事务管理器是 DataSourceTransactionManager。
一次新事务的大致过程是:

获取 Connection
    ↓
记录原有连接状态
    ↓
按需设置隔离级别、readOnly 等属性
    ↓
关闭该连接的 autoCommit
    ↓
把 ConnectionHolder 绑定到当前线程
    ↓
执行事务内 SQL
    ↓
成功则 commit,失败则 rollback
    ↓
恢复连接状态
    ↓
解绑并释放连接

连接关联主要由 Spring 的 TransactionSynchronizationManager 管理。
对于同步执行的常见 JDBC 代码,可以近似理解为:事务期间,Spring 把对应数据源的连接绑定到当前线程,使同一事务中的数据库访问取得同一个事务连接
因此:

@Transactional
public void create() {
    jdbcTemplate.queryForObject(...);
    jdbcTemplate.update(...);
}

只要事务确实生效,且都使用同一个受该事务管理器管理的数据源,两条 SQL 就会参与同一个事务。
一个重要细节:事务开始不一定等于 MySQL 已立即执行了业务 SQL:
通常的 DataSourceTransactionManager 会在事务开始阶段获取连接并准备连接状态。
但如果使用了 LazyConnectionDataSourceProxy 等延迟获取机制,物理连接的获取和实际数据库操作可能进一步延后。
所以,“Spring 事务方法一进入,数据库就一定已执行 BEGIN 并建立快照”不是一个可以不加限定地使用的结论。
特别是在 InnoDB 中,读视图何时建立还与隔离级别和实际执行的一致性读有关。

3.3 同一个连接,不等于同一个事务

一个物理连接可以先后执行多个事务:

连接 A:
    SELECT ...        -- 独立语句级事务
    SELECT ...        -- 另一个独立语句级事务

    BEGIN
    INSERT ...
    INSERT ...
    COMMIT

    SELECT ...        -- 又一个独立语句级事务

判断两条 SQL 是否在同一事务,不能只看它们是否复用了同一条物理连接。还要看执行时的事务边界和连接状态。
反过来,在一个正常生效的 JDBC 本地事务中,事务内 SQL 通常需要使用同一条受 Spring 管理的连接。
如果代码绕开 Spring 管理,自己直接从别的数据源获取连接,就可能出现“方法标了事务,但某条 SQL 没参与进去”的问题。

四、声明式事务:@Transactional 底层如何生效?

4.1 本质是代理拦截

最常见的声明式事务写法:

@Transactional
public void createOrder() {
    // 业务逻辑
}

Spring 通常通过 AOP 代理处理这个方法:

调用方
   ↓
Spring 代理对象
   ↓
事务拦截器
   ↓
开启或加入事务
   ↓
执行目标方法
   ↓
提交或回滚

重点:调用必须经过代理。

常见的“自调用失效”问题:

@Service
public class OrderService {

    public void outer() {
        inner(); // 同类内部直接调用,通常不会经过事务代理
    }

    @Transactional
    public void inner() {
        // 这里的事务注解可能不会按预期生效
    }
}

另外,还需要注意 Spring 代理模式、方法可见性、Bean 是否受 Spring 管理等条件。不能只看代码上有没有注解。

4.2 声明式事务的边界通常是整个方法

@Transactional
public void createOrder() {
    queryA();
    queryB();
    insertOrder();
    insertEvent();
}

如果这是一个新事务,且上述访问都参与该事务,那么事务覆盖的是整个方法中的这些数据库操作:

事务开始
    queryA
    queryB
    insertOrder
    insertEvent
事务提交

MySQL 不会因为 queryA()、queryB() 是查询,就自动将它们移出事务。
如果只希望两个插入处于事务中,声明式事务通常可以通过拆分方法到另一个 Spring Bean实现。

@Service
public class OrderFacade {

    private final ProductRepository productRepository;
    private final OrderWriteService orderWriteService;

    public OrderFacade(
            ProductRepository productRepository,
            OrderWriteService orderWriteService) {
        this.productRepository = productRepository;
        this.orderWriteService = orderWriteService;
    }

    public String createOrder(long productId, long customerId) {
        // 不在下面这个新事务方法的边界内
        ProductSnapshot product = productRepository.findSnapshot(productId);

        // 跨 Bean 调用,经过 Spring 代理
        return orderWriteService.insertOrderAndEvent(
                product,
                customerId);
    }
}
@Service
public class OrderWriteService {

    private final JdbcTemplate jdbcTemplate;

    public OrderWriteService(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    @Transactional
    public String insertOrderAndEvent(
            ProductSnapshot product,
            long customerId) {

        String orderId = UUID.randomUUID().toString();
        String eventId = UUID.randomUUID().toString();

        jdbcTemplate.update(
                """
                INSERT INTO orders(id, customer_id, product_id, product_name)
                VALUES (?, ?, ?, ?)
                """,
                orderId,
                customerId,
                product.id(),
                product.name()
        );

        jdbcTemplate.update(
                """
                INSERT INTO order_event(id, order_id, event_type)
                VALUES (?, ?, ?)
                """,
                eventId,
                orderId,
                "ORDER_CREATED"
        );

        return orderId;
    }
}

这仍然是声明式事务,但边界已经缩小为 insertOrderAndEvent()。

4.3 传播行为:REQUIRED 不一定会“新开一个小事务”

@Transactional 默认传播行为是 REQUIRED。

含义是:

  • 当前没有事务:创建新事务;
  • 当前已有事务:加入现有事务。

例如:

@Transactional
public void outer() {
    queryManyThings();
    orderWriteService.insertOrderAndEvent(...);
}

即使insertOrderAndEvent() 自己也标了 @Transactional,它默认还是加入 outer() 的事务。

结果仍然是大事务:

outer 开始事务
    多次查询
    两次插入
outer 提交事务

因此把写操作拆到另一个方法,不足以保证事务变小;还必须确认调用它时不存在不需要的外层事务。
常见传播行为还包括:

传播行为 核心含义
REQUIRED 有事务就加入,没有则创建
REQUIRES_NEW 挂起当前事务,创建独立新事务
NESTED 在现有事务内建立保存点,具体支持取决于事务管理器等条件
SUPPORTS 有事务就参与,没有则非事务运行
NOT_SUPPORTED 挂起当前事务,以非事务方式运行

REQUIRES_NEW 不是“免费获得小事务”:外层事务仍然存在且被挂起;使用连接池时,它还可能需要另一条连接。大量并发使用时,应关注连接池容量。

4.4 回滚规则:异常是否抛出非常重要

声明式事务默认通常对:RuntimeException,Error 执行回滚。
受检异常默认不一定回滚,可以显式指定:

@Transactional(rollbackFor = Exception.class)
public void createOrder() throws Exception {
    // ...
}

另一个常见问题是吞掉异常:

@Transactional
public void createOrder() {
    try {
        insertOrder();
        insertEvent();
    } catch (Exception ex) {
        log.error("插入失败", ex);
        // 方法正常返回,事务可能提交
    }
}

如果希望回滚,就不能只记录日志然后正常返回。通常应该继续抛出异常,或者明确把事务标记为仅回滚。

五、编程式事务:TransactionTemplate 如何控制边界

5.1 核心思想
TransactionTemplate 的回调块,就是代码显式指定的事务执行范围:

transactionTemplate.execute(status -> {
    insertOrder();
    insertEvent();
    return orderId;
});

相比:

@Transactional
public void wholeMethod() {
    queryA();
    queryB();
    insertOrder();
    insertEvent();
}

它可以在同一个方法内部做到:

public String wholeMethod() {
    queryA();
    queryB();

    return transactionTemplate.execute(status -> {
        insertOrder();
        insertEvent();
        return orderId;
    });
}

查询在回调之外;两次插入在回调之内。
这不是 MySQL 自动优化的结果,而是代码明确改变了事务边界。

5.2 编程式事务仍然受传播行为影响

@Transactional
public void outer() {
    orderService.createOrder(...);
}

如果 createOrder() 内的 TransactionTemplate 使用默认的 REQUIRED,它通常会加入外层事务。
此时:

  • 前置查询仍在外层事务里;
  • 回调中的两次插入也在外层事务里;
  • execute() 返回不等于数据库已经独立提交。

所以:
TransactionTemplate.execute() 的代码块看起来很小,不代表它一定对应一个独立的小事务。
要真正缩短事务范围,应优先从调用链上消除不必要的外层事务,而不是只在内部增加一个模板。

六、事务不能盲目缩小:先查询、后写入的并发问题

这是“查询放在事务外”最容易被误用的地方。
假设业务逻辑是:

int stock = queryStock(productId);

if (stock > 0) {
    insertOrder();
    reduceStock();
}

如果 queryStock() 在事务外,两个并发请求可能都看到库存为 1,随后都认为自己可以下单。
即使把查询放在普通事务里,也不意味着自动解决问题:普通一致性读不会自动锁住库存行。
方案一:条件更新
通常可以把“检查库存并扣减”合并为一条具有原子条件的 SQL:

UPDATE product_stock
SET stock = stock - 1
WHERE product_id = ?
  AND stock > 0;

方案二:锁定读

SELECT stock
FROM product_stock
WHERE product_id = ?
FOR UPDATE;

七、readOnly、超时与连接占用:几个常见误区

7.1 @Transactional(readOnly = true)

告诉 Spring 和底层数据访问组件:“这个事务只打算读取数据,请按只读事务处理。”

@Transactional(readOnly = true)
public ProductSnapshot getProduct(long id) {
    return repository.findById(id);
}

readOnly = true 描述的是这个事务的预期用途。Spring 及所使用的框架、驱动可能据此进行优化或施加限制。

    1. JDBC 层:给连接设置只读状态
    1. ORM 层:减少不必要的持久化工作
    1. 表明方法的事务语义

7.2 事务超时不等于任意代码运行到点就立刻被杀死

@Transactional(timeout = 5)
public void process() {
    queryA();
    remoteCall();
    insertA();
    insertB();
}

Spring 的事务超时从事务创建开始计算,但实际执行效果依赖所用的数据访问框架是否将剩余超时传给 JDBC 语句,以及数据库、驱动的行为。
它不是一个可以保证“第 5 秒强制中断整个 Java 方法”的通用计时器。
即便如此,前面的查询和耗时操作仍可能消耗事务的时间预算。因此把无关的耗时操作移出事务,是有价值的。

7.3 长事务与长时间占用连接有关,但要看实际配置

在常见的 DataSourceTransactionManager 使用方式下,事务会持有其连接,直到完成清理。事务持续越久,该连接通常越久不能归还连接池。
但如果使用延迟连接代理、不同数据访问技术或特殊连接管理策略,连接实际获取时机可能不同。
准确表达应当是:大事务范围通常会延长事务资源的存活时间;在常见 JDBC 配置下,也会延长数据库连接的占用时间。

7.4 “事务开始得早”与“写锁持有得久”不是完全同一回事

BEGIN
耗时查询 5 秒
INSERT A
INSERT B
COMMIT

写锁不会因为事务一开始就提前 5 秒获取;它通常是在相关写操作执行时获取,然后持续到事务结束。
因此不能笼统地说:“前置查询越多,插入的锁一定持有得越久。”
真正影响锁持有时间的,主要是获取锁之后到提交之前的时间。
但前置查询仍可能扩大事务总时长、占用连接、维持快照,并推迟写入开始。不同成本要分开分析。

八、声明式事务与编程式事务怎么选

维度 声明式 @Transactional 编程式 TransactionTemplate
边界表达 通常以方法为单位 以代码块为单位
业务代码简洁度 较高 较低
同一方法中只包部分逻辑 通常需要拆分 Bean / 方法 可以直接圈定回调
是否依赖代理调用 常见代理模式下依赖 模板本身不依赖事务 AOP 代理
是否受传播行为影响 是 是
异常处理 依赖回滚规则与异常是否向外抛出 依赖回调异常、回滚标记等
适用场景 大多数方法级事务 方法内需要精确控制事务块

优先使用声明式事务的情况

  • 整个业务方法就是一个自然的事务单元;
  • 方法短,几乎没有无关耗时操作;
  • 可以通过拆分服务方法清晰表达边界;
  • 希望业务代码保持简洁。

考虑编程式事务的情况

  • 一个方法只有一小段逻辑需要事务;
  • 方法中夹杂大量不需要参与事务的查询或计算;
  • 希望在代码中直接展示事务边界;
  • 不想仅为事务边界额外拆分 Bean。

但如果同一个类里到处都是 TransactionTemplate.execute(),也可能说明业务结构需要重新整理,而不是继续增加编程式事务。

九、总结

Spring 事务解决的是事务边界和连接管理;MySQL InnoDB 解决的是事务的实际执行与隔离。数据库会优化事务中的 SQL,但不会擅自把事务里的查询移到事务之外。

对于“很多查询,只有两个插入需要原子性”的场景,使用 TransactionTemplate 只包住两个插入,可以是精准的设计,而不是多余的设计。但它成立有两个前提:

  1. 那些查询确实不需要与写入共享事务来保证业务正确性;
  2. 调用链上不存在使 TransactionTemplate 加入其中的无关外层事务。

最重要的原则不是“事务只包写操作”,而是:

让事务覆盖保证业务正确性所必需的操作,并尽量缩短从开始到提交的时间。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容