分布式事务
想聊聊分布式事务。
看了网上的一些说法,仔细思考之后感觉都不大统一,有些就是不对。本文加入了一些自己的思考,讨论了一些实现中的细节,如果不对欢迎指正。
先说说两阶段和三阶段提交吧。
两阶段提交
我这里说的两阶段提交,区别于网络上某些文章里提到的显然不实用的两阶段实现,是考虑到超时、异常恢复的两阶段提交。
前提
各系统的所有操作应当保证幂等。
流程
具体的流程图不再画(网上随便搜搜就有),简单描述一下就是两步:
- 发起事务:事务的发起者提出一个请求(比如用户下单购买某个商品),要求其依赖服务(也就是事务的执行者)响应请求(比如通知优惠券业务锁定使用的优惠券、通知支付业务冻结付款金额、通知仓储服务冻结库存等等)
当所有依赖方都回复确认之后,事务的准备阶段完毕。 - 所有依赖服务的成功确认后,事务的发起者通知所有执行者确认(confirm)事务;如果第一步中只要有一个执行者返回失败,则取消(cancel)事务。
对于第二步,有些文章中的简单的二阶段提交是不需要执行者回复的,个人认为这意味着发起者无法确认第二步事务(无论是 confirm 还是 cancel 操作)有没有成功执行,所以个人认为需要确认。下文按有确认进行讨论。
更进一步,对于执行者而言,因为第一步的锁定返回了成功,所以第二步的确认只能是成功,不允许失败。执行者应想办法重试并保证成功。如果失败则意味着出现了系统的数据不一致。
超时处理
以上的流程在是理想情况下。
当考虑到网络异常等情况,会存在三个问题:
- 对执行者而言:如果没有收到第二步事务,该如何处理?此时的执行者会一直锁定资源等待第二步事务。
- 对发起者而言:如果第一步中没有收到回复,该如果处理?此时的发起者无法得知是否所有执行者都成功锁定了资源。
- 对发起者而言:如果第二步中没有收到回复,该如果处理?此时的发起者无法得知是否所有执行者都成功确认了事务。
执行者没有收到第一步事务,对执行者而言是无感知的。所以没有这个问题。
依次回答这三个问题:
- 执行者没有收到第二步事务,有三种处理方案:第一种是一直锁定资源等待,第二种是超时 confirm 事务,第三种是超时 cancel 事务。对于两阶段提交而言,其他执行者在第一步是有可能返回失败的,所以显然强行 confirm 会有风险,第三种更为合理。此处有“应当 confirm 但因为网络或其他问题而没有收到,最终执行了超时 cancel”的风险,会导致数据不一致。
- 发起者第一步中没有收到回复,也存在两种策略:要么超时重试(再次提出事务),要么超时后当做返回失败处理。这两种可以组合使用,即多次超时重试后仍无回复则当做返回失败处理。
- 发起者第二步中没有收到回复,和问题 2 的处理策略类似,多次超时重试后仍无返回说明出现了异常,但不同的是这个异常是一个无法回滚的异常,意味着系统中可能出现了数据不一致,可能需要其他(很可能是人工)方式修复数据。
对于问题 3 ,因为在问题 1 的回答中我们默认执行者超时会 cancel 事务,所以当发起者第二步提出的是 cancel 时不会有什么问题。换句话说,当发起者在第二步提出 confirm 而没有收到回复时可能会出现数据不一致。
异常重试
当执行过程中发生异常(比如宕机),事务应当可以重试。
- 对发起者而言:如果在第一步发生异常:部分执行者锁定了资源,而另一部分从未收到过事务请求。由于执行者会默认超时 cancel,所以发起者发起 cancel 后(或不处理,直接等待超时)重新发起新事务即可。
- 对发起者而言:如果在第二步发生异常:如果执行的是 cancel,则无需重试,当做成功即可(当然也可以重试)。如果执行的是 confirm,则可能发生部分机器成功 confirm,部分机器由于没有收到 confirm,默认超时 cancel 请求,从而数据不一致的风险。
- 对执行者而言:如果在第一步发生异常:尽量返回失败即可,超时发起者会重试/cancel 请求。不会有什么风险。
- 对执行者而言:如果在第二步发生异常:尽量重试并保证成功。如果执行的是 confirm,说明第一步的锁定返回了成功,所以第二步的确认只能是成功。如果是 cancel,则更应当自行重试保证资源释放。
问题
在思考前两章问题的过程中,我们意识到了这个流程所存在的问题:
- 最严重的风险,如果发起者在第二步 confirm 的过程中出现了异常、或由于网络问题部分执行者没有收到 confirm,那么会出现数据不一致的问题。
- 第一步操作会锁定资源,然而有可能操作不成功,需要释放资源。这种反复的“锁定-释放”降低了并发。
三阶段提交
相对两阶段提交的改进
名词上的修改没太多意义(cancel -> rollback,confirm -> doCommit)
三阶段提交在两阶段提交上的改进就是在之前多了一步:
在锁定资源之前先进行查询,确认是否可提交。我们姑且将其称之为第 0 步。
好处是什么?
还是以之前“用户下单购买某个商品”为例。对于这个场景,第 0 步会检查向优惠券服务检查优惠券是否可用、向支付业务检查账户余额是否足够、向仓储服务检查库存是否足够等。
只有当第 0 步全部返回成功时,才会执行第一步的锁定资源。这时的第一步也几乎可以全部返回成功(只有并发情况下会失败)。
因此,对于执行者而言,如果一直没有收到第二步(实际上的第三步)的事务,超时可以默认执行 confirm 操作。大多数情况下都会成功避免数据不一致。(只有并发竞争情况下有可能失败)
简而言之,三阶段提交相比两阶段提交多了第 0 步检查是否可提交。执行者的默认超时行为从 cancel 改为 confirm。
总结三阶段提交
我们可以看到,三阶段提交的确成功提高了并发,降低了反复的“锁定-释放”的可能。
然而他并没有完全解决二阶段提交数据不一致的问题,只是极大概率避免了数据不一致的可能性。在极端情况下:由于高并发,多个请求同时通过了第 0 步检查,部分却在第 1 步锁定失败,本应 cancel 却因为网络或其他问题导致部分(或全部)执行者没有收到 cancel 命令默认 confirm 了事务,导致了数据不一致。
接下来聊聊 TCC。
TCC 框架
简述
个人认为,TCC 是一种实现,Github 上有诸多具体的实现,例如 https://github.com/changmingxie/tcc-transaction 。
TCC 指的就是 Try、Confirm、Cancel 三个操作,基本类似两阶段提交。由事务管理方发起向所有参与者发起 try 请求,根据 try 请求的结果决定全部 confirm 或是全部 cancel。
TCC 框架一般需要使用数据库持久化记录事务数据,跟踪整个事务的执行状态,并在事务失败后补偿重试。具有一定的容灾能力。
TCC 不仅可以认为是两阶段事务的实现,在之前加上资源检查的步骤(也就是上文所说的第 0 步),也同样可以认为是三阶段事务的实现。
TCC 框架对参与者的要求
幂等性
在上一篇也提到过,所有操作 Try、Confirm、Cancel 三个方法均需满足保证幂等。一旦发生网络波动重试、或事务补偿执行,不幂等的接口重复执行后便会有数据正确性的风险。
二阶段设计
在 TCC 框架内,所有的参与者的业务逻辑都需按照二阶段设计。一阶段锁定和预备资源、二阶段执行提交(confirm)或释放资源(cancel)。
锁定事务不隔离
仍然以用户购买商品举例,假设用户账户余额为 10 元,同时下了价值 2 和 3 元的两单。一阶段的锁定应当是分别进行的,也就是说,如果两单同时执行一阶段,一共会有 5 元成为冻结金额。(与数据库的事务隔离不同,所以称之为事务不隔离)
允许“空取消”
考虑一种情况,如果执行者由于网络问题并未收到过阶段 1 的 Try 请求,却收到了阶段 2 的 Cancel 请求(不可能是 Confirm,想想为什么)。这种情况下就是我们所说的“空取消”。应当跳过执行。
防悬挂
这种情况比较少见。在某些极端情况下,Cancel 可能比 Try 先到达(或先被处理)。由于先到达的 Cancel 请求被当做了“空取消”处理了,所以只要在“空取消”时短暂记录事务,对后到的 try 拒绝处理就可以了。
事务恢复
以 tcc-transaction 为例。
事务恢复配置
包含几个关键配置项:
- maxRetryCount 最大重试次数,如果重试完依然失败,tcc-transaction 的做法是打印错误日志,交给人工处理。默认 30 次
- recoverDuration 单个事务恢复重试的间隔时间,默认 120s
- cronExpression 定时任务 cron 表达式,默认
0 */1 * * * ?
,每分钟执行一次。 - delayCancelExceptions 延迟取消异常类的集合,默认包含
OptimisticLockException.class
和SocketTimeoutException.class
事务恢复流程
由 Quartz 调度事务恢复定时任务,并禁止并发。
事务在重试时会乐观锁更新,同时只有一个应用节点能更新成功。
优化空间
立即返回
在需要极致响应的情况下,一阶段结束后可以立刻返回,将二阶段交给线程池或其他异步方式执行。因为一阶段收到所有返回后,就已经可以确认事务能否执行,接下来交给异步任务、失败重试和事务恢复机制即可。