如何保证多个数据库操作的原子性?
很简单,用事务。具体到代码,在方法上加一个@Transactional,这样方法内部的entity操作都能在一个数据事务内。
abc三个entity,要么全部修改,要么一个不改。
@Transactional
public void someOperation() {
aRepo.save(a);
bRepo.save(b);
cRepo.save(c);
}
那如果有个entity的修改是在另外一个服务上呢?
假设 b
这个数据属于另一个微服务,我们通过 FeignClient 远程调用它:
@Transactional
public void someOperation() {
aRepo.save(a);
bFeignClient.save(b);
cRepo.save(c);
}
那么@Transactional就只能保证a,c两个修改的原子性,b的修改不受控制。
分布式事务的解决方案
分布式事务通常使用两阶段提交(2PC, Two-Pahse Commit)进行处理。
1.第一阶段(Prepare,准备阶段)
- 事务协调器(TC,Transaction Coordinator)通知所有参与者(分支事务)执行事务操作,但不提交
- 每个参与者执行本地事务并记录undo log(或锁定资源),然后向TC报告准备成功或失败
- 使用行级锁(SELECT ... FOR UPDATE)锁定一行数据,无法被其他线程修改。
2.第二阶段(Commit/Rollback,提交或回滚阶段)
- 如果所有参与者都准备成功,TC通知所有分支事务提交
- 如果有任何一个失败,TC通知所有分支事务回滚。
就代码来讲,假设不使用XA命令。
一个分支事务会依次执行这5句SQL,一般执行完④成功后,就停住了,不再发新SQL给数据库。
分支事务会等待协调器TC的命令,如果可以执行,就继续执行COMMIT,否则就执行ROLLBACK。
-- 1. 开启事务
START TRANSACTION;
-- 2. 先锁定目标数据,确保后续不会有其他事务并发修改
SELECT * FROM table_b WHERE id = '12345' FOR UPDATE;
-- 3. 记录旧数据到 `undo_log`
INSERT INTO undo_log (table_name, record_id, old_value)
SELECT 'table_b', id, name FROM table_b WHERE id = '12345';
-- 4. 执行更新操作
UPDATE table_b SET name='www' WHERE id= '12345';
-- 5. 等待 TC 指令:
-- ✅ 如果 TC 说"可以提交",则执行:
COMMIT;
-- ❌ 如果 TC 说"回滚",则执行:
ROLLBACK;
如果分支事务COMMIT后,其他分支事务失败,则可以通过undo_log表来回滚数据。
分布式事务框架------Seata
高性能的AT模式
- 在第一阶段,Seata直接修改数据库(和2PC不同,它不会锁定资源)
- Seata会拦截SQL并记录undo log(修改前的数据),用于回滚
- 在第二阶段:
- 提交时:直接提交,无额外操作
- 回滚时:用undo log恢复数据
相比于传统的2PC,它避免了长时间锁定资源,提高了性能。
案例代码:
@GlobalTransactional
public void someOperation() {
aRepo.save(a);
bFeignClient.save(b);
cRepo.save(c);
}
有了Seata这种高性能框架,分布式事务为何还是不常见?
1. 业务通常不需要强一致性,仅需最终一致性
大多数业务场景对数据的一致性要求没有那么严格,只要能在一段时间内完成最终一致性,就足够了。
案例:用户余额充值与优惠券发放
假设你在一个电商平台充值 100 元,并且平台规定:首次充值 100 元以上,会赠送 10 元优惠券。
假设充值和优惠卷发放是在两个独立的服务上,完全可以在充值完成后,写入MQ,然后优惠卷服务再处理消息。
只要最终结果一致就行。
2. 事务本地化
拆分微服务时,事务操作通常划归到一个服务内,不会跨服务。
比如一个系统,它的支付相关的操作,都在一个支付服务内。
3.Seata 仍然存在额外开销
比如额外的SQL解析;undo log表的维护,额外的数据库写入;额外的网络通信。