初探分布式事务

概括

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

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

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

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

资源的概念可以是 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 知识)

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