背景
工作这么多年,我一直以为编码规范是人人皆知的常识,其实不然。今天来说说事务,规范中要求事务尽可能细粒度,避免大事务。
场景:
一个业务方法中可能先执行多次查询,最后执行两次插入。假设业务要求是:两次插入必须同时成功或同时失败,但前面的查询不需要与插入共享同一个事务。
这时,把 @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 及所使用的框架、驱动可能据此进行优化或施加限制。
- JDBC 层:给连接设置只读状态
- ORM 层:减少不必要的持久化工作
- 表明方法的事务语义
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 只包住两个插入,可以是精准的设计,而不是多余的设计。但它成立有两个前提:
- 那些查询确实不需要与写入共享事务来保证业务正确性;
- 调用链上不存在使
TransactionTemplate加入其中的无关外层事务。
最重要的原则不是“事务只包写操作”,而是:
让事务覆盖保证业务正确性所必需的操作,并尽量缩短从开始到提交的时间。