本文目录导读:

Java中解决事务一致性,核心在于确保一组数据库操作要么全部成功,要么全部失败回滚,根据应用的复杂度、性能要求和分布式程度,解决方案可以分为几个层次。
以下是详细的解决方案和案例:
单体应用:使用声明式事务(最常用)
在单服务、单数据库模式下,Spring的@Transactional注解是最简单、最可靠的方案。
核心原理: Spring AOP 拦截方法,在方法执行前开启事务,执行后提交;如果抛出运行时异常(RuntimeException)则回滚。
案例:银行转账
@Service
public class TransferService {
@Autowired
private AccountDao accountDao;
// 核心:添加 @Transactional 注解
@Transactional(rollbackFor = Exception.class) // 回滚所有异常,包括 checked exception
public void transfer(String from, String to, BigDecimal amount) {
// 1. 扣减转出账户
accountDao.decreaseBalance(from, amount);
// 2. 增加转入账户
// 假设这里发生了异常(如:数据库宕机、除零错误、网络超时)
accountDao.increaseBalance(to, amount);
// 3. 记录交易日志(可选)
// ...
}
}
要点:
rollbackFor:默认只回滚RuntimeException和Error,对于SQLException等(需要显式声明rollbackFor = Exception.class,除非它们是RuntimeException的子类)。propagation:事务传播行为,最常用的是REQUIRED(默认):如果没有事务就新建一个,如果有就加入当前事务。isolation:隔离级别,解决脏读、不可重复读、幻读,通常使用READ_COMMITTED。- 注意事项:
@Transactional默认只对public方法生效,在同一个类中,一个非@Transactional方法调用另一个@Transactional方法,事务会失效(类内部调用不走代理)。
微服务/分布式系统:使用分布式事务方案
在跨服务、跨数据库的场景下,单体事务无法保证一致性,需要使用分布式事务方案。
方案1:可靠消息最终一致性(最常用)
适用于非实时、高可用的场景(如:用户下单 -> 扣库存 -> 发积分)。
核心原理: 将本地事务与消息发送绑定,利用消息队列(如RocketMQ、Kafka)确保“业务操作”和“消息投递”要么同时成功,要么同时失败。
案例:下单扣库存
// Service A:订单服务
@Service
public class OrderServiceImpl implements OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private TransactionMsgProducer msgProducer; // 消息生产者
@Override
@Transactional
public void createOrder(Order order) {
// 1. 本地事务:插入订单记录
orderMapper.insert(order);
// 2. 发送预备消息(半消息),消息体包含"扣减库存"指令
// 如果这里发送失败,本地事务回滚
msgProducer.sendHalfMessage(new StockDeductionMessage(order.getProductId(), order.getQuantity()));
// 3. 本地事务提交(或回滚)
// 注意:如果本地事务提交成功,消息队列必须能自动将“预备消息”转为“正式消息”,供下游消费
// 这是通过消息队列的事务回查机制实现的
}
}
关键点:
- 消息回查:消息队列(RocketMQ)会定期回查业务系统,确认本地事务是否执行成功,如果成功,才投递消息。
- 幂等性:下游服务(库存服务)必须实现幂等(使用唯一键、版本号等),防止消息重复消费导致库存重复扣减。
方案2:TCC(Try-Confirm-Cancel)
适用于强一致性、短事务的场景(如:金融交易、分布式锁)。
核心原理: 将业务逻辑分为 Try(预留资源)、Confirm(确认执行)、Cancel(取消回滚)三个阶段。
案例:跨行转账(A银行转给B银行)
// 转账服务(协调者)
public class TransferCoordinator {
@Autowired
private BankAService bankA;
@Autowired
private BankBService bankB;
public void transfer(String from, String to, BigDecimal amount) {
try {
// 1. Try:尝试预留资源
boolean tryA = bankA.tryDeduce(from, amount); // 冻结金额
boolean tryB = bankB.tryAdd(to, amount); // 尝试增加金额(通常直接增加,预留不适用)
// 2. Confirm:所有Try成功,执行确认
if (tryA && tryB) {
bankA.confirmDeduce(from, amount); // 确认扣减
bankB.confirmAdd(to, amount); // 确认增加
} else {
// 3. Cancel:任何Try失败,执行回滚
if (tryA) bankA.cancelDeduce(from, amount);
if (tryB) bankB.cancelAdd(to, amount);
throw new BusinessException("转账失败");
}
} catch (Exception e) {
// 网络超时或异常时,需要重试或保证幂等
log.error("转账失败,需人工介入或定时任务补偿", e);
}
}
}
注意事项:
- 空回滚:如果Try阶段失败,但Cancel方法被调用了,需要能正确处理(资源不存在时直接成功)。
- 幂等:Confirm和Cancel方法必须支持幂等调用。
- 防悬挂:避免Cancel在Try之前执行(通常通过事务ID和状态表实现)。
方案3:Seata AT 模式
核心原理: 为开发者屏蔽复杂性,性能略高于TCC,AT模式通过数据源代理和全局锁实现,对代码侵入性较小。
- 原理:在业务SQL执行前,Seata记录
before image;执行后,记录after image,如果全局事务失败,使用after image回滚回before image。 - 适用场景:适合大部分基于ACID的分布式事务场景,对已有代码侵入小。
- 缺点:对SQL类型有要求(不支持复杂的批量操作),且需要全局锁,高并发下性能下降。
代码设计层面的注意事项
无论使用哪种方案,代码中都容易踩坑,以下是3个经常导致事务一致性失效的场景:
事务方法内部 try-catch 了异常
@Transactional
public void transfer() {
try {
accountDao.decreaseBalance(...);
// 发生异常
accountDao.increaseBalance(...);
} catch (Exception e) {
// 错误做法:捕获异常后什么都不做,或者只是打印日志
log.error("转账失败", e);
// 没有抛出异常,Spring认为事务正常,会提交!
}
}
解决: 在catch块中抛出RuntimeException异常,或者手动调用TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。
多线程事务
@Transactional
public void process() {
// 主线程操作
// ...
new Thread(() -> {
// 子线程操作数据库
// 子线程有自己的独立事务!无法与主线程合并
// 主线程回滚,子线程不会回滚
}).start();
}
解决: 谨慎使用多线程事务,如果非要使用,考虑分布式事务方案(如TCC、Seata)或使用TransactionTemplate在子线程中手动控制。
事务方法不是 public
@Transactional注解默认只对public方法生效,如果方法声明为private或protected,事务不会生效。
总结与选择建议
| 方案 | 复杂度 | 性能 | 一致性强度 | 适用场景 |
|---|---|---|---|---|
| @Transactional | 低 | 高 | 强(单体) | 单数据库、单服务 |
| 可靠消息最终一致性 | 中 | 高 | 最终 | 非实时、高可用、允许短暂不一致 |
| TCC | 高 | 中 | 强( | 跨服务、短事务、对一致性要求高 |
| Seata AT | 中 | 中 | 强 | 跨服务、对一致性要求高、性能适中 |
建议:
- 能
@Transactional解决的,绝对不用分布式事务。 - 跨服务时,优先考虑可靠消息最终一致性(用MQ把事务拆成异步),如果业务能接受短暂的不一致(订单创建1秒后通知库存扣减),这是最稳定且性能最好的方案。
- 如果必须强一致(绝对不允许账户金额多扣或少扣),则考虑TCC或Seata AT,TCC虽然复杂,但更容易控制资源隔离。
无论哪种方案,幂等性是保障最终一致性的基石,在没有幂等的情况下,任何重试机制都可能导致数据不一致。