Go语言进阶(十四)分布式事务

分布式事务

讲到事务,又得搬出经典的转账问题了:

  • 支付宝账户表:A (id, user_id, amount)
  • 余额宝账户表:B (id, user_id, amount)

用户的 user_id = 1,从支付宝转帐1万快到余额宝分为两个步骤:

  • 1.支付宝表扣除1万:UPDATE A SET amount = amount - 10000 WHERE user_id = 1;
  • 1.余额宝表增加1万:UPDATE B SET amount = amount + 10000 WHERE user_id = 1;

image-20211222141421906

单个数据库,我们保证 ACID 使用 数据库事务。多数据库我们如何保证数据的一致性?

随着我们系统变大,我们进行了微服务架构的改造,因为每个微服务独占了一个数据库实例,从 user_id = 1 发起的转帐动作,跨越了两个微服务:pay 和 balance 服务。
我们需要保证,跨多个服务的步骤数据一致性:

  • 微服务 pay 的支付宝表扣除1万;
  • 微服务 balance 的余额宝表增加1万;

image-20211222141829235

每个系统都对应一个独立的数据源,且可能位于不同机房,同时调用多个系统的服务很难保证同时成功,这就是跨服务分布式事务问题。

我们系统应该能保证每个服务自身的 ACID,基于这个假设,我们事务消息解决分布式事务问题。

事务消息

当支付宝账户扣除1万后,我们只要生成一个凭证(消息)即可,这个凭证(消息)上写着“让余额宝账户增加 1万”,只要这个凭证(消息)能可靠保存,我们最终是可以拿着这个凭证(消息)让余额宝账户增加1万的,即我们能依靠这个凭证(消息)完成最终一致性

image-20211222141954179

如何可靠的保存消息凭证?

要解决消息可靠存储,我们实际上需要解决的问题是,本地的 mysql 存储和 message 存储的一致性问题。

  • Transactional outbox
  • Polling publisher
  • Transaction log tailing
  • 2PC Message Queue

事务消息一旦被可靠的持久化,我们整个分布式事务,变为了最终一致性,消息的消费才能保障最终业务数据的完整性,所以我们要尽最大努力,把消息送达到下游的业务消费方,称为:Best Effort。只有消息被消费,整个交易才能算是完整完结。

Best Effort(最大努力)

即尽最大努力交付,主要用于在这样一种场景:不同的服务平台之间的事务性保证。

比如我们在电商购物,使用支付宝支付;又比如玩网游的时候,通过 App Store 充值。

拿购物为例,电商平台与支付平台是相互独立的,隶属于不同的公司,即使是同一个公司也很可能是独立的部门。

image-20211222142636872

  • 做过支付宝交易接口的同学都知道,我们一般会在支付宝的回调页面和接口里,解密参数,然后调用系统中更新交易状态相关的服务,将订单更新为付款成功。
  • 同时,只有当我们回调页面中输出了 success 字样或者标识业务处理成功相应状态码时,支付宝才会停止回调请求。否则,支付宝会每间隔一段时间后,再向客户方发起回调请求,直到输出成功标识为止。

Transactional outbox

Transactional outbox,支付宝在完成扣款的同时,同时记录消息数据,这个消息数据与业务数据保存在同一数据库实例里(消息记录表表名为 msg)。

上述事务能保证只要支付宝账户里被扣了钱,消息一定能保存下来。当上述事务提交成功后,我们想办法将此消息通知余额宝,余额宝处理成功后发送回复成功消息,支付宝收到回复后删除该条消息数据。

Polling publisher

Polling publisher,我们定时的轮训 msg 表,把 status = 1 的消息统统拿出来消费,可以按照自增 id 排序,保证顺序消费。在这里我们独立了一个 pay_task 服务,把拖出来的消息 publish 给我们消息队列,balance 服务自己来消费队列,或者直接 rpc 发送给 balance 服务。

image-20211222143048943

实际我们第一个版本的 archive-service 在使用 CQRS 时,就用的这个模型,Pull 的模型,从延迟来说不够好,Pull 太猛对 Database 有一定压力,Pull 频次低了,延迟比较高。

Transaction log tailing

Transaction log tailing,上述保存消息的方式使得消息数据和业务数据紧耦合在一起,从架构上看不够优雅,而且容易诱发其他问题。

有一些业务场景,可以直接使用主表被 canal 订阅使用,有一些业务场景自带这类 message 表,比如订单或者交易流水,可以直接使用这类流水表作为 message 表使用。

image-20211222143630116

使用 canal 订阅以后,是实时流式消费数据,在消费者 balance 或者 balance-job 必须努力送达到。

我们发现,所有努力送达的模型,必须是先预扣(预占资源)的做法。

幂等

还有一个很严重的问题就是消息重复投递,如果相同的消息被重复投递两次,那么我们余额宝账户将会增加2万而不是1万了。

为什么相同的消息会被重复投递?比如余额宝处理完消息 msg 后,发送了处理成功的消息给支付宝,正常情况下支付宝应该要删除消息msg,但如果支付宝这时候悲剧的挂了,重启后一看消息 msg 还在,就会继续发送消息 msg。

  • 全局唯一 ID+ 去重表

在余额宝这边增加消息应用状态表 msg_apply,通俗来说就是个账本,用于记录消息的消费情况,每次来一个消息,在真正执行之前,先去消息应用状态表中查询一遍,如果找到说明是重复消息,丢弃即可,如果没找到才执行,同时插入到消息应用状态表(同一事务)。

  • 版本号

2PC(Two Phase Commitment 两阶段提交)

两阶段提交协议中,涉及到两种角色:

  • 一个事务协调者(coordinator):负责协调多个参与者进行事务投票及提交(回滚)
  • 多个事务参与者(participants):即本地事务执行者

总共处理步骤有两个:

  • 投票阶段(voting phase):协调者将通知事务参与者准备提交或取消事务,然后进入表决过程。参与者将告知协调者自己的决策:同意(事务参与者本地事务执行成功,但未提交)或取消(本地事务执行故障);
  • 提交阶段(commit phase):收到参与者的通知后,协调者再向参与者发出通知,根据反馈情况决定各参与者是否要提交还是回滚;

2PC Message Queue

image-20211222144232151

Seata 2PC

Seata 实现 2PC 与传统 2PC 的差别:

  • 架构层次方面:传统 2PC 方案的 RM 实际上是在数据库层,RM 本质上就是数据库自身,通过 XA 协议实现,而 Seata 的 RM 是以 jar 包的形式作为中间件层部署在应用程序这一侧的。
  • 两阶段提交方面:传统 2PC无论第二阶段的决议是 commit 还是 rollback ,事务性资源的锁都要保持到 Phase2 完成才释放。而 Seata 的做法是在 Phase1 就将本地事务提交,这样就可以省去 Phase2 持锁的时间,整体提高效率。

image-20211222144438894

TCC

TCC 是 Try、Confirm、Cancel 三个词语的缩写,TCC 要求每个分支事务实现三个操作:预处理 Try、确认 Confirm、撤销 Cancel。

  • Try 操作做业务检查及资源预留,
  • Confirm 做业务确认操作,
  • Cancel 实现一个与 Try 相反的操作即回滚操作。

TM 首先发起所有的分支事务的 Try 操作,任何一个分支事务的 Try 操作执行失败,TM 将会发起所有分支事务的 Cancel 操作,若 Try 操作全部成功,TM 将会发起所有分支事务的 Confirm 操作,其中 Confirm/Cancel 操作若执行失败,TM 会进行重试。

需要注意:

  • 空回滚
  • 防悬挂

image-20211222144709050

微服务

Event Sourcing(事件溯源模式)

Event Sourcing 模式,即事件溯源模式。该模式使用只可追加的存储来记录对数据所进行的所有操作,而不是存储领域数据的当前状态。其中,存储 Event 记录的介质称作 Event Store

在实践中,通常将 Event Sourcing 模式与 CQRS 模式相配合。通过命令接口将事件持久化存储,通过查询接口读取 Materialized View(物化视图)。物化视图可以由事件处理器实时或定期生成。

基于消息的系统

整个系统是基于消息驱动的。事件就是消息,命令接口可以视为消息的生产者,事件处理器就是消息的消费者。Event Store 是持久化存储介质,能保证数据不会丢失。命令接口只要确保事件进入 Event Store 就能返回,而事件处理器在后台运行,为查询接口生成物化视图。整个系统没有阻塞操作,十分高效。

缺点是,查询接口获得的数据并非实时的,系统只能保证数据的最终一致性。不过这点对于普通应用来说都不是问题,毕竟在网页或 APP 中显示的信息,本质上都属于“历史数据”。

更多查看reference: https://www.fournoas.com/posts/CQRS-and-event-sourcing-pattern-in-practice/

Saga

相关概念

1987年普林斯顿大学的Hector Garcia-Molina和Kenneth Salem发表了一篇Paper Sagas,讲述的是如何处理long lived transaction(长活事务)。Saga是一个长活事务可被分解成可以交错运行的子事务集合。其中每个子事务都是一个保持数据库一致性的真实事务。
论文地址:sagas

Saga的组成

  • 每个Saga由一系列sub-transaction Ti 组成
  • 每个Ti 都有对应的补偿动作Ci,补偿动作用于撤销Ti造成的结果

可以看到,和TCC相比,Saga没有“预留”动作,它的Ti就是直接提交到库。

Saga的执行顺序有两种:

  • T1, T2, T3, …, Tn
  • T1, T2, …, Tj, Cj,…, C2, C1,其中0 < j < n

Saga定义了两种恢复策略:

  • backward recovery,向后恢复,补偿所有已完成的事务,如果任一子事务失败。即上面提到的第二种执行顺序,其中j是发生错误的sub-transaction,这种做法的效果是撤销掉之前所有成功的sub-transation,使得整个Saga的执行结果撤销。
  • forward recovery,向前恢复,重试失败的事务,假设每个子事务最终都会成功。适用于必须要成功的场景,执行顺序是类似于这样的:T1, T2, …, Tj(失败), Tj(重试),…, Tn,其中j是发生错误的sub-transaction。该情况下不需要Ci。

显然,向前恢复没有必要提供补偿事务,如果你的业务中,子事务(最终)总会成功,或补偿事务难以定义或不可能,向前恢复更符合你的需求。

理论上补偿事务永不失败,然而,在分布式世界中,服务器可能会宕机,网络可能会失败,甚至数据中心也可能会停电。在这种情况下我们能做些什么? 最后的手段是提供回退措施,比如人工干预。

更多查看reference: https://www.jianshu.com/p/e4b662407c66

reference

  • https://www.jianshu.com/p/e4b662407c66
  • https://iambowen.gitbooks.io/cloud-design-pattern/content/patterns/event-sourcing.html
  • https://www.fournoas.com/posts/CQRS-and-event-sourcing-pattern-in-practice/