初探分布式事务

概括

最近在做的项目设计到一些金钱交易方面的事情。涉及到一些分布式事务的逻辑。

参考了一些资料,做一些总结。

  • 一句话概括分布式事务:

一部分资源的数据更新以后,怎么保证另外一部分资源的数据也必须更新成功。

资源的概念可以是mysql,也可以是redis,mongodb这样的Nosql,甚至是消息队列,文件系统等等

  • 一句话总结分布式事务的核心思想:

将分布式事务分拆为本地事务

所以当资源无法完成本地事务时,分布式事务就无法成立。

  • 分布式事务的3种方案:

    两阶段提交

    ebay的本地消息表

    事务消息

2阶段提交

两阶段提交作为一种强一致性协议,在1994年就有了标准分布式事务规范:

DTP/XA规范(Distributed Transaction Processing)

这个规范中定义的XA protocol作为标准2阶段提交协议已经有了各种实现。

通用实现

角色分为(全局)事务管理器TM和(局部)资源管理器RM,以及上层业务

1 业务发送update 1和update 2给TM

TM失败或超时,事务失败

2 TM生成事务ID

TM向1所在的RM1发送update指令

TM向2所在的RM2发送update指令

RM1和RM2暂存(可失缓冲区)但不执行

TM,RM失败或超时,事务失败

3 业务发送commit给TM

TM失败或超时,事务失败

TM向RM1发送prepare指令,RM1检查update语句是否报错后,RM1将update语句和prepare状态持久化后返回成功

TM向RM2发送prepare指令,RM2检查update语句是否报错后,RM2将update语句和prepare状态持久化后返回成功

任何一个RM失败,事务失败,此时的失败需要进行持久化记录

4 TM将事务ID和是否成功持久化

这里的持久化标志着事务是否真正成功

5 TM向业务返回成功或失败

TM返回信息未被业务成功接受,业务超时,事务的是否成功取决于第4阶段

6 事务成功:

TM向RM1发送commit指令

TM向RM2发送commit指令

事务失败:

TM向RM1发送rollback指令

TM向RM2发送rollback指令

每隔一段时间,TM向RM1和RM2查询处于prepare状态的事务ID

TM挂掉以后重启,TM立即向RM1和RM2查询处于prepare状态的事务ID。

对于每个事务ID,RM1和RM2的事务ID列表很有可能是不同的(有的prepare成功有的没有,或者有的已经commit或者rollback有的没有)

每个事务ID都要查询自己的持久化记录决定成功或失败重新发送commit或者rollback

如果TM不存在这个事务ID,说明事务ID持久化出错了,那这个事务也是错误的需要rollback事务

mysql的XA协议

协议分为几个关键词

1
2
3
4
5
xa start 'id';
update xxxx;//这是具体执行的事务
xa end 'id';
xa prepare 'id';
xa commit 'id';

当xa prepare成功写入binlog以后,即使TM挂掉,也可以通过xa recover查询处于prepare状态的事务ID

因此可以很方便的实现二阶段提交

mysql在今年的更新修复了一个长达10年的bug,使得prepare状态的XA事务被持久化不再自动rollback

具体看这里

MySQL · 特性分析 · 浅谈 MySQL 5.7 XA 事务改进

二阶段提交虽然是强一致性,但是由于xa commit的不同节点的顺序问题,还是有可能出现脏读的,这里有一些相关讨论

Mysql XA事务脏读

ebay的本地消息表

ebay的一个架构师在Base:An Acid Alternative中提出的方法

例如A给B转账100这种场景。A,B分属RM1和RM2两个模块

1 业务向RM1提交请求,RM1向RM2提交一个消息请求,RM2生成一个消息ID例如Xid,将它持久化以后,返回这个Id

1
insert into message(id, status) values ('Xid','prepare')

此时Xid在RM2中是prepare阶段

2 RM1提交扣款事务,RM1操作的SQL有两张表,一张是用户余额表,一张是消息表

用户余额表进行扣费,消息表包含事务ID,交易双方和交易金额

1
2
3
4
5
Begin transaction
update user set amount=amount-100 where user='A';
insert into message(id, userFrom, userTo, amount, status) values('Xid', 'A', 'B', 100, 'commit');
End transaction
commit;

3 RM1向业务返回成功(延迟到账)

4 RM1根据第2阶段的结果向RM2进行commit或rollback

5 RM2收到请求,执行本地事务

1
2
3
4
5
6
7
Begin transaction
if (select count(1) from message where id = 'Xid' and status = 'prepare') == 0 {
update user set amount=amount+100 where user='B';
update message set status='commit' where id='Xid';
}
End transaction
commit;

RM2需要不断扫描prepare状态的消息,向RM1进行查询。这种prepare状态的事务是因为第4阶段请求失败导致的。

第5阶段的执行保证了事务的幂等性,防止多次执行消息导致RM2的多次执行

事务消息

阿里云RocketMQ文档

分布式开放消息系统(RocketMQ)的原理与实践

1 发送方向 MQ 服务端发送消息;

2 MQ Server 将消息持久化成功之后,向发送方 ACK 确认消息已经发送成功,此时消息为半消息。

3 发送方开始执行本地事务逻辑。

4 发送方根据本地事务执行结果向 MQ Server 提交二次确认(Commit 或是 Rollback),MQ Server 收到 Commit 状态则将半消息标记为可投递,订阅方最终将收到该消息;MQ Server 收到 Rollback 状态则删除半消息,订阅方将不会接受该消息。

5 在断网或者是应用重启的特殊情况下,上述步骤4提交的二次确认最终未到达 MQ Server,经过固定时间后 MQ Server 将对该消息发起消息回查。

6 发送方收到消息回查后,需要检查对应消息的本地事务执行的最终结果。

7 发送方根据检查得到的本地事务的最终状态再次提交二次确认,MQ Server 仍按照步骤4对半消息进行操作。

优缺点总结

讨论了2阶段提交,本地消息表和事务消息三种工程上的分布式事务实现机制

下面分别做优缺点总结

  • 2阶段提交

优点:

一旦向业务返回成功,就很少会失败需要人工干预校正,业务xa start到xa end前会对执行的sql进行校验

缺点:

1 同步阻塞性能差

2 虽然是理论上说是强一致性,但是工程实现上还是最终一致性,因为最终的commit还是基于异步提交,可能出现不一致的情况

  • 本地消息表

优点:

最终一致性实现,对于简单的sql而言下游出错可能性很低。例如转账服务,一般只有A扣款需要余额校验,B加钱很难出错。

缺点:

1 消息耦合在数据库中

2 对于下游是复杂事务逻辑的时候,一旦出错就需要人工校正

  • 事务消息

优点:

消息不耦合,对业务代码上实现友好(java直接封了库,对业务开发是透明的)

缺点:

引入了额外的消息中间件,对小业务或者支持不完善的语言(例如C++)来说不友好(需要额外掌握MQ知识)

参考资料:如何用消息系统避免分布式事务?