在单库环境下,当我们面对一个需要同时操作多个数据表的业务,比如用户下单,如何保证数据操作的原子性、完整性与一致性呢?
答案就是事务。
举个例子:用户下了一个订单,需要修改余额表、订单表、流水表,于是会有类似的伪代码:
start transaction;
CURD table t_account; any Exception rollback;
CURD table t_order; any Exception rollback;
CURD table t_flow; any Exception rollback;
commit;
- 如果对余额表,订单表,流水表的SQL操作全部成功,则全部提交;
- 如果任何一个出现问题,则全部回滚;
数据库事务,正是保证单库内数据完整性及一致性的基石。
多库环境下的事务难题
然而,互联网的业务特点往往是数据量与并发量巨大,经常通过拆库的方式来提升系统的伸缩性与性能。
一旦进行了分库,用户余额、订单、流水这些数据可能就分布在了不同的物理数据库,甚至是不同数据库实例上。此时,数据库原生的单库事务机制就失效了。如何在多个独立的数据库之间保证数据一致性,成为了一个必须面对的技术挑战。
补偿事务:一种业务端的解决思路
在高并发场景下,强一致的分布式事务方案往往落地困难。那么,有没有一种更务实的实践呢?
补偿事务就是一种常见的业务层解决思路。
什么是补偿事务?简单说,就是在业务逻辑层面,为每一个正向事务准备一个逆向操作的事务,以备回滚之用。
举个栗子:
修改余额的正向事务为:
int Do_AccountT(uid, money){
start transaction;
//余额改变money这么多
CURD table t_account with money for uid;
anyException rollback return NO;
commit;
return YES;
}
那么,对应的修改余额的补偿事务就可以是:
int Compensate_AccountT(uid, money){
//做一个money的反向操作
return Do_AccountT(uid, -1*money){
}
同理,订单操作的正向事务是:Do_OrderT,用于新增一个订单;其补偿事务则是:Compensate_OrderT,用于删除一个订单。
那么,为了跨库保证余额与订单的一致性,我们可以这样组织业务逻辑:
// 执行第一个事务
int flag = Do_AccountT();
if(flag=YES){
//第一个事务成功,则执行第二个事务
flag= Do_OrderT();
if(flag=YES){
// 第二个事务成功,则成功
return YES;
}
else{
// 第二个事务失败,执行第一个事务的补偿事务
Compensate_AccountT();
}
}
补偿事务的缺点
虽然补偿事务提供了一种思路,但它也存在明显的局限性:
- 不具备通用性:不同的业务需要编写不同的补偿逻辑,开发成本高。
- 未考虑补偿失败:上述逻辑假设补偿事务本身一定能成功,但实际情况中补偿操作也可能失败。
- 逻辑复杂:如果业务流程涉及多个步骤(比如余额+订单+流水),
if/else的嵌套层级会呈指数级增长(3个步骤就有2^3=8个分支),代码可维护性急剧下降。
后置提交优化:另一种概率性降低不一致的思路
除了在业务逻辑层想办法,我们是否能在事务执行的时序上做些优化呢?后置提交优化就是这样一种实践。
我们先回顾单库大事务:
start transaction;
CURD table t_account; any Exception rollback;
CURD table t_order; any Exception rollback;
CURD table t_flow; any Exception rollback;
commit;
拆分成多库后,这个大事务会变成三个独立的小事务串行执行:
start transaction1;
//第一个库事务执行
CURD table t_account; any Exception rollback;
…
// 第一个库事务提交
commit1;
start transaction2;
//第二个库事务执行
CURD table t_order; any Exception rollback;
…
// 第二个库事务提交
commit2;
start transaction3;
//第三个库事务执行
CURD table t_flow; any Exception rollback;
…
// 第三个库事务提交
commit3;
请注意,这三个事务发生在三个不同的库或数据库实例上。
一个事务通常可以分为执行(CURD)与提交(commit)两个阶段。执行阶段耗时较长(涉及业务逻辑和I/O),而提交阶段通常极快。
于是,上述串行方案的时间轴可能如下所示:

- 第一个事务执行200ms,提交1ms;
- 第二个事务执行120ms,提交1ms;
- 第三个事务执行80ms,提交1ms;
那么,在哪个时间点会出现数据不一致?
答案是:从第一个事务成功提交之后,到最后一个事务成功提交之前的这个时间窗口内,如果发生异常(如服务器重启、数据库故障等),数据就可能处于不一致状态。

如上图所示,在最后202ms内出现异常,前一个或两个库的事务已提交,而后面的未提交,就会导致不一致。
什么是后置提交优化?
后置提交优化,核心是改变事务执行与提交的时序:让所有事务先执行,最后再统一提交。

- 第一个事务执行200ms,第二个事务执行120ms,第三个事务执行80ms;
- 然后,第一个事务提交1ms,第二个事务提交1ms,第三个事务提交1ms;
优化后,不一致的时间窗口发生了什么变化?
问题的本质没变,不一致仍然可能发生在第一个提交开始到最后一个提交完成之间。

如上图,不一致的风险被压缩到了最后2ms内。
两种方案的对比与权衡
我们来对比一下:
- 串行事务方案:总耗时303ms,最后202ms内出现异常都可能导致不一致。
- 后置提交优化方案:总耗时同样是303ms,但最后2ms内出现异常才会导致不一致。
虽然没有彻底解决分布式事务的一致性问题,但不一致出现的概率被大大降低了(在这个例子中降低了100倍)。这是一种典型的用“概率性”换“实现简易性”的权衡。
后置提交优化的不足
这种优化并非没有代价,其主要影响在于数据库连接资源的占用时间变长,从而可能影响系统吞吐量:
- 串行方案:第一个库事务提交后,其数据库连接就可以释放,供其他请求使用。
- 优化方案:所有库的数据库连接,都必须等到所有事务执行完并提交后,才能释放。
这意味着数据库连接被占用的时间增长了,在高并发场景下,可能会成为限制系统整体吞吐量的瓶颈。
总结
面对分布式事务这一复杂课题,本文介绍了两种易于理解和落地的实践方案:
- 补偿事务:在业务层通过逆向操作实现“回滚”,灵活但业务逻辑复杂。
- 后置提交优化:通过调整事务执行时序(先执行,后提交),大幅缩短不一致风险窗口,实现简单但可能牺牲一定吞吐量。
后置提交优化的核心,就是把:
trx1.exec(); trx1.commit();
trx2.exec(); trx2.commit();
trx3.exec(); trx3.commit();
优化为:
trx1.exec(); trx2.exec(); trx3.exec();
trx1.commit(); trx2.commit(); trx3.commit();
这个改动成本极低,虽然不能提供100%的强一致性保证,却能显著降低数据不一致的概率。在实际的System Design与MySQL等数据库技术栈的架构设计中,架构师需要根据业务对一致性和吞吐量的实际要求,谨慎地进行权衡与取舍。毕竟,脱离具体业务场景谈架构设计,是不切实际的。
对于希望深入探讨更多分布式系统难题和实战经验的开发者,欢迎在云栈社区交流分享。思路往往比固定的结论更有价值。