Spring Boot 事务回滚的边界:删库与删文件的三种处理方式与一致性方案
删除一个商品需要清理两类数据:数据库中的记录,以及对象存储中的图片文件。前者由 @Transactional 管理,后者不在事务范围内。当两类操作写在同一个方法中时,事务回滚只能撤销其中一半,从而产生数据与文件不一致的中间状态。
以下按三种常见处理方式分析各自的行为,并给出保证严格一致性的实现路径。表格中的结果基于 Spring Boot 3.5.5 + H2 2.2.224 + JDK 21 的验证工程:以 JdbcTemplate 操作数据库,以一个内存对象模拟文件存储的删除操作,逐项记录事务回滚后数据行是否保留、文件是否已删除。
一、事务的回滚范围
@Transactional 的实现方式是在方法外层加入拦截:方法执行前从连接池获取连接并关闭自动提交,方法正常返回时提交,抛出需要回滚的异常时回滚。能够被回滚的操作,仅限于通过该连接执行的 SQL。
方法中不经过该连接的动作------删除对象存储文件、调用远程接口、写入 Redis、投递消息------不参与回滚。这些操作一旦执行即生效,事务回滚不会撤销它们。
回滚范围还取决于异常类型。默认配置下只有 RuntimeException 与 Error 触发回滚,受检异常不触发:
| 写法 | 方法内抛出的异常 | 数据行是否删除 |
|---|---|---|
@Transactional |
new Exception(...) |
已删除,未回滚 |
@Transactional(rollbackFor = Exception.class) |
new Exception(...) |
未删除,已回滚 |
两者仅相差 rollbackFor 属性,结果相反。这一条与文件删除叠加后,会出现"事务已回滚但文件已删除"的状态。
二、方式一:删库与删文件写在同一个事务方法内
java
@Transactional
public void deleteDbThenFile(long id, String url) {
repo.delete(id); // 数据库操作,可回滚
fileStore.delete(url); // 文件操作,不可回滚
throw new IllegalStateException("后续步骤失败");
}
方法体内抛出异常,事务回滚,结果如下:
| 观察项 | 结果 |
|---|---|
| 数据库中的数据行 | 仍然存在(已回滚) |
| 文件 | 已删除,无法恢复 |
事务回滚撤销了 repo.delete 执行的 SQL,但文件删除已经生效。结果是数据库中保留了一条指向不存在文件的记录,读取该记录时会得到无效的图片地址。
将异常替换为进程中断,结果相同:文件删除已生效而事务尚未提交,回滚后数据完整、文件缺失。
调整顺序不能规避该问题。若先删除文件再删除数据库记录,文件先被删除,随后数据库操作失败并回滚,数据保留而文件缺失,结果一致。
若顺序为"先删库、后删文件",且事务成功提交后再执行文件删除,则当文件删除失败(对象存储不可用、请求超时)时,数据库记录已删除而文件保留。该文件不再被任何记录引用,不会导致读取错误,仅占用存储空间。
两种失败状态的差别在于可见性:文件删除失败留下的是无引用的冗余文件,数据库记录删除失败留下的是引用不存在文件的记录。由此形成"主体数据先删除、附件数据后删除"的排列原则,目的是让失败落在不产生可见错误的一侧。
该原则只处理了错误的可见性,未解决一致性问题,并且要求文件删除发生在事务提交之后------这一点在单一事务方法内无法满足,因为方法体全部执行于提交之前。
三、方式二:注册 afterCommit,在提交后执行文件删除
TransactionSynchronizationManager 提供事务同步回调:
java
@Transactional
public void deleteWithAfterCommit(long id, String url) {
repo.delete(id);
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
fileStore.delete(url);
}
});
} else {
fileStore.delete(url);
}
}
isSynchronizationActive() 用于判断当前是否存在事务。在无事务环境下调用 registerSynchronization 会抛出 IllegalStateException,因此需要 else 分支直接执行删除;在事务外调用该方法返回 false,代码进入 else 分支。
回调的执行顺序如下:
| 顺序 | 事件 |
|---|---|
| 1 | 方法体执行完毕 |
| 2 | afterCommit |
| 3 | afterCompletion |
afterCommit 在事务提交完成后立即执行,此时方法尚未返回调用方。它不是异步执行,也不存在延迟。
afterCommit 内部的事务状态查询结果如下:
| 方法 | 返回值 |
|---|---|
isActualTransactionActive() |
true |
isSynchronizationActive() |
true |
事务已经提交,但连接资源尚未解绑,因此 API 层面仍报告事务活跃。在该位置直接执行数据库操作不会进入有效的事务边界,需要显式开启新事务。
事务回滚时 afterCommit 不执行,afterCompletion 始终执行:
| 顺序 | 事件 |
|---|---|
| 1 | 方法体执行完毕 |
| 2 | afterCompletion(status = 1,STATUS_ROLLED_BACK) |
回滚路径下文件不会删除,方式一中"回滚后文件缺失"的问题不再出现。
afterCommit 位于提交之后,其自身异常没有回滚机制。在 afterCommit 内使文件删除失败,结果如下:
| 观察项 | 结果 |
|---|---|
| 数据库中的数据行 | 已删除(事务已提交,无法回滚) |
| 文件 | 未删除 |
异常会继续抛给调用方。调用方收到异常后按失败处理,但数据库中的删除已经提交,同时文件未被删除,形成无引用文件。
方式二的作用是确定执行顺序:文件删除严格发生在事务提交之后,回滚路径不再产生文件缺失。一致性未得到保证,不一致的表现形式由"数据引用缺失文件"变为"文件无引用"。
四、方式三:拆分为两个方法
java
@Service
public class TxInnerService {
@Transactional
public void deleteDb(long id) {
repo.delete(id);
}
}
@Service
public class ProductDeleteService {
public void delete(long id, String url) {
inner.deleteDb(id); // 事务在此开始并结束
fileStore.delete(url); // 事务范围之外
}
}
外层方法未标注 @Transactional,内层事务在 inner.deleteDb(id) 返回时已提交。此后文件删除失败不会回滚任何操作:
| 观察项 | 结果 |
|---|---|
| 数据库中的数据行 | 已删除 |
| 文件 | 未删除 |
结果与方式二在提交后失败的情形一致。两者的区别在于代码结构的表达:方式二将"提交之后"写在代码中,方式三的 delete() 方法名将两段性质不同的操作合并为一个整体,容易被当作同一事务处理。
五、三种方式的结果对照
| 处理方式 | 事务回滚时 | 提交后文件删除失败时 | 保证的内容 |
|---|---|---|---|
| 写在事务方法内 | 数据保留、文件已删除 | ------ | 无 |
| 注册 afterCommit | 数据保留、文件未删除 | 数据已删除、文件保留 | 文件删除发生在提交之后 |
| 拆分为两个方法 | 数据保留、文件未删除 | 数据已删除、文件保留 | 同上 |
三种方式均不保证数据与文件的一致,只保证主体数据先于附件数据删除,使失败落在不产生可见错误的一侧,代价是产生无引用的冗余数据。
文件删除、远程调用、消息投递、缓存清理都属于事务范围之外的副作用。上述三种方式针对单一副作用的执行顺序,副作用数量增加时需重新排列。
六、事务注解生效的前提
上述三种方式都要求 @Transactional 实际生效,其条件是调用经过 Spring 生成的代理对象。以下三种情况会使注解失效或需要额外处理。
同类内部调用不经过代理。 在同一个类中通过 this 调用自身的事务方法不经过代理,注解不生效:
| 调用方式 | isActualTransactionActive() |
|---|---|
this.innerTx() |
false |
self.innerTx() |
true |
解决方式是注入自身后调用:
java
@Service
public class SelfInvocationService {
private final SelfInvocationService self; // 注入自身
public SelfInvocationService(@Lazy SelfInvocationService self) {
this.self = self;
}
public void wrong() {
this.innerTx(); // 不经过代理,事务不生效
}
public void right() {
self.innerTx(); // 经过代理,事务生效
}
@Transactional
public void innerTx() {
// ...
}
}
@Lazy 用于避免构造期的循环依赖。另一种方式是将事务方法放入独立的 bean------方式三中的 TxInnerService 即为此类拆分,同时解决了代理问题。
代理对字段访问的处理需要额外注意:CGLIB 代理是目标类的子类,字段各自独立存储。从外部通过代理引用读取 public 字段,读到的是代理对象自身的字段值,只有方法调用会委派给目标对象。因此对外暴露状态应使用方法而非字段。
初始化阶段调用事务方法不生效。 在构造函数、@PostConstruct 等回调中调用自身的事务方法,调用路径为 this,代理尚未参与,事务不会开启。
需要显式控制事务边界时使用 TransactionTemplate。 编程式事务不依赖 AOP 代理:
java
@Service
public class ProductDeleteService {
private final TransactionTemplate transactionTemplate;
private final ProductRepository repo;
public ProductDeleteService(PlatformTransactionManager transactionManager,
ProductRepository repo) {
this.transactionTemplate = new TransactionTemplate(transactionManager);
this.repo = repo;
}
public void delete(long id, String url) {
transactionTemplate.execute(status -> {
repo.delete(id);
return null;
});
fileStore.delete(url); // 事务已结束
}
}
方法体内 isActualTransactionActive() 返回 true,status.setRollbackOnly() 可正常回滚。该方式将事务范围写成显式代码块,不依赖代理是否生效,也不需要 isSynchronizationActive() 判断分支。
七、严格一致性的实现路径
上述方式的共同点是:副作用执行时,数据库侧状态已确定而副作用侧状态未确定,两者之间没有持久化的凭据。补上凭据有三条路径。
编写补偿逻辑。 正向操作时记录撤销方式,失败时反向执行。每个副作用都需要对应的逆操作,且逆操作本身可能失败。删除文件的逆操作是重新上传文件,若原文件已被覆盖,恢复的内容也不再是原文件。
本地消息表加定时任务。 将待执行的清理操作与业务数据写入同一个事务:业务数据删除的同时插入一条待投递记录。两者同时提交或同时回滚,提交成功即表示数据已删除且存在一条待处理记录。提交后由回调执行投递;投递失败或进程中断时,由定时任务重新投递。
java
@Transactional
public void removeSpuInfo(List<Long> spuIds) {
// ... 7 张表的 delete ...
// 该 insert 与上述删除位于同一事务
String messageId = mqMessageService.savePending(
RabbitMQConfig.PRODUCT_EVENT_EXCHANGE,
RabbitMQConfig.PRODUCT_DELETED_ROUTING_KEY,
new ProductDeletedTo(existingSpuIds, skuIds, imageUrls));
// 提交后投递;投递失败时状态保留为待投递,由定时任务重投
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
mqMessageSender.send(messageId);
}
});
} else {
mqMessageSender.send(messageId);
}
}
在该结构中 afterCommit 的职责与方式二不同。方式二中它是唯一一次尝试,失败后只留下无引用文件;此处它仅用于尽快投递,兜底的是已落库的消息记录------进程在提交后中断,记录仍然存在,定时任务会重新投递。
重复投递是该方案的固有代价:broker 已确认但本地状态未及时更新时进程中断,重启后必然重投一次。重复由消费端幂等处理。删除文件类操作天然幂等(删除不存在的 key 同样返回成功),无需额外的去重表。
由消息队列承担投递与重试。 本地消息表与消息队列是同一条路径的两部分:消息表保证记录持久化并可重新投递,消息队列保证投递后有消费方处理、处理失败可重试。消费端将未能删除的地址视为失败而非仅记录日志,通过 nack 进入死信、延迟重投,重试达到上限后进入死信队列等待处理。无法删除的地址(例如管理员填写的外部 URL)经过完整重试后进入死信队列,比保留在存储桶中更易被发现。
@Transactional 的回滚范围限于当前数据库连接上的操作,文件删除、远程调用、消息投递均在其之外。afterCommit 与拆分方法能确定执行顺序,使失败落在不产生可见错误的一侧,但不产生持久化凭据,无法恢复数据与文件的一致性。实现严格一致性需要先将待执行的操作与业务数据写入同一事务,再执行该操作,即把一次性副作用转换为可重试的任务。