分布式事务实现

事务大家都比较熟悉,主流的框架都做了很好的封装以至于我们都不用太关心其实现细节。日常开发中我们会经常使用,但这多为单机版本的事务,而本节我们要关注的是分布式事务,在讲分布式事务前我们先看下传统关系数据库中事务的[ACID](https://en.wikipedia.org/wiki/ACID_(computer_science))特性:
  • 原子性(Atomicity) 原子性要求事务内的所有操作要么都成功,要么都回滚,如果事务的一部分失败,则整个事务失败,数据库状态保持不变
  • 一致性(Consistency) 一致性要求确保任何事务将数据库从一个有效状态转移到另一个有效状态。写入数据库的任何数据必须遵守所有定义的规则,包括但不限于约束,级联,触发器及其任何组合
  • 隔离性(Isolation) 隔离性要求一个事务不能看到另外一个还未完成的事务产生的结果,即每个事务彼此独立互不干扰
  • 持久性(Durability) 持久性要求确保一旦事务被提交,即使在断电,崩溃或错误等情况下,它也会保持这种状态
这四个特性为传统关系型数据库事务处理的核心要素,其中有几点比较重要简要说明如下:
  • 读未提交(Read uncommitted) 所有事务都可以看到其他未提交事务的执行结果,实际场景中很少使用,对应的这会引发脏读(Dirty Read)
  • 读已提交(Read committed) 每个事务只能看见已经提交事务所做的改变,这会引发不可重复读(Nonrepeatable Read),所谓不可重复读是指在同一个事务中执行完全相同的查询语句时可能看到不一样的结果,比如事务T1执行查询用户A的信息后事务T2修改了A信息并提交,这时事务T1还未完成并且又执行了一次查询A信息,这次结果会是T2修改后的数据,与第一次查询不同
  • 可重复读(Repeatable Read) 每个事务在执行期间只会看到相同的数据,即其它事务对数据的变更不会体现在尚在执行的事务中,这可能导致幻读(Phantom Read),幻读的典型示例如事务T1执行查询用户表的所有记录,结果为空,此时事务2插入了一条用户记录并提交,但在事务1中此记录不可见,事务1再次查询用户记录还是为空,事务1尝试插入一条用户记录时就可能会出现一致性错误(如主键重复)
  • 串行化(Serializable) 强制对事务排序,串行执行,最高的隔离级别,但对性能比较大
    解决不可重复读与幻读本质的区别在于资源锁的粒度,前者只要锁定查询结果行,即保证在事务1未提交前查询到的结果集不可被其它事务修改(更新或删除),后者需要表锁,因为在事务1未提交前要禁止插入记录。当然表锁严重影响性能,所以主流的数据库都会使用乐观锁解决,如MySQL的多版本并发控制 MVCC
怎么理解一致性?很多文章都以转账来解释一致性:
张三给李四转账100元。事务要做的是从张三账户上减掉100元,李四账户上加上100元。
解释一:如果张三少了100元,那李四没有加上100元,那就破坏了一致性。可这难道不是原子性要保证的吗?
解释二:一致性的含义是其他事务要么看到张三还没有给李四转账的状态,要么张三已经成功转账给李四的状态,而对于张三少了100元,李四还没加上100元这个中间状态是不可见的。这不正是隔离性的要求吗?
解释三:在多事务情况下,事务A要给张三加100元,先读取张三的余额,然后这时事务B也给张三加了100元并提交,事务A执行余额+100,最终导致张三只多了100,而实际上应该是200,违反了一致性。但这个更应该是通过锁来控制并发事务,与ACID中的一致性没有太大关系。
所以,在ACID中所谓的一致性很单纯的针对于数据库规则的约束:是否违反外键约束?是否使用了不存在的主键?是否存在级联更新……
上面是对ACID的解释,但在分布式环境下我们必须假定网络是不可靠,它可能中断、超时,消息可能乱序、丢包,所以分布式事务就是基于这个前提下设计的。常见的分布式事务有以下几种:

2PC(Two-phase commit protocol),二阶段提交

这可能是最基础的分布式事务方案,几乎所有数据库都支持的XA规范就基于此实现。它将事务分两个阶段:提交请求(commit-request,也有叫voting)、提交(commit),另外它还定义了两个角色:协调器(coordinator),参与者(cohorts)。核心逻辑是:
  1. 1.
    提交请求阶段:
    1. 1.
      协调器向所有参与者发送事务提交请求命令,并等待所有参与者的答复
    2. 2.
      每个参与者执行收到的事务提交请求
    3. 3.
      每个参与者执行事务成功则返回同意(agreement),反之返回中止(abort)
  2. 2.
    提交阶段:
    1. 1.
      如果协调器收到的都是同意,那么它:
      1. 1.
        向所有参与者发送提交(commit)命令
      2. 2.
        每个参与者执行提交操作,释放上一步打开的本地锁和资源
      3. 3.
        每个参与者返回确认(acknowledgment)
      4. 4.
        协调器在收到所有确认后完成事务
    2. 2.
      如果协调器收到有包含中止命令,那么它:
      1. 1.
        向所有参与者发送回滚(rollback)命令
      2. 2.
        每个参与者执行回滚操作,释放上一步打开的本地锁和资源
      3. 3.
        每个参与者返回确认(acknowledgment)
      4. 4.
        协调器在收到所有确认后回滚事务
2PC是CP(CAP定理,详见后续章节)设计,这是强一致性的,所以势必会损失可用性,它的问题在于:
  • 同步阻塞,执行中所有参与者的事务都会阻塞,所占的锁及资源不会释放
  • 数据不一致,在提交阶段如果出现网络故障部分参与者可能会收不到提交命令从而导致数据不一致
  • 单点故障,作为事务处理重要组成的协调器存在单点问题
为了解决2PC的问题,出现了3PC,后者加入了超时处理并新增了一个"Prepared to commit"阶段,由于3PC并没有解决2PC的同步性能问题,并且并没有真正解决数据不一致的问题(只是降低了概率),所以使用并不广泛,有兴趣的读者可参考此文 。

补偿型事务

在类似2PC的提交和回滚机制不可用或不适用时,通常使用补偿事务来撤销失败的事务并将系统恢复到先前的状态。这之中TCC的方案人气很高,TCC是Try-Confirm-Cancel的简写,Try对应于2PC的提交请求阶段,Confirm对应的提交阶段的成功处理,Cancel对应的是提交阶段的回滚处理,但与2PC本质的区别在于:2PC是两个阶段只有一个事务,而TCC分别对应了三个事务操作。
TCC的逻辑是:
  1. 1.
    Try阶段完成业务检查及资源预处理,以订单支付为例,用户发起订单支付后对应冻结操作:
    1. 1.
      资金服务在本地事务下冻结支付金额(UPDATE account SET balance_freeze = balance_freeze+<支付金额> WHERE id = <当前账户>)
    2. 2.
      优惠券服务在本地事务下冻结使用的优惠券(UPDATE coupon SET status = 'FREEZE' WHERE id = <使用的优惠券>)
    3. 3.
      积分服务在本地事务下冻结支付成功后奖励的积分(UPDATE account SET points_freeze = points_freeze+<奖励积分> WHERE id = <当前账户>)
  2. 2.
    Confirm阶段确认并执行业务,执行只涉及Try阶段预处理的资源:
    1. 1.
      资金服务在本地事务下解冻支付金额并完成实际扣款(UPDATE account SET balance_freeze = balance_freeze-<支付金额> , balance = balance+<支付金额> WHERE id = <当前账户>)
    2. 2.
      优惠券服务在本地事务下解冻使用的优惠券并完成优惠券的使用(UPDATE coupon SET status = USED WHERE id = <使用的优惠券>)
    3. 3.
      积分服务在本地事务下解冻积分并完成积分奖励(UPDATE account SET points_freeze = points_freeze-<奖励积分> , points = points+<奖励积分> WHERE id = <当前账户>)
  3. 3.
    Cancel阶段为取消执行的业务,释放Try阶段预留的资源,如果出现余额不足、优惠券不可用等情况则执行回滚操作,执行Try的逆向操作,使最终结果看上去没有发生过一样,如对应的:
    1. 1.
      积分服务在本地事务下解冻积分(UPDATE account SET points_freeze = points_freeze-<奖励积分> WHERE id = <当前账户>)
    2. 2.
      优惠券服务在本地事务下解冻使用的优惠券(UPDATE coupon SET status = UNUSED WHERE id = <使用的优惠券>)
    3. 3.
      资金服务在本地事务下解冻支付金额(UPDATE account SET balance_freeze = balance_freeze-<支付金额> WHERE id = <当前账户>)
基于事务补偿的事务相比2PC而言更为灵活,没有严格的规范约束,基于TCC方案的不同产品有不同的实现,比如TCC也需要类似2PC的事务协调器,但有些产品需要使用独立的服务,有些产品则直接使用Zookeeper+本地逻辑实现。
在2PC方案中所有请求提交阶段占用的资源都在等待提交阶段释放,两个阶段之间需要等待所有参与者响应,所以花费的时间会比较久,但TCC不存在全局长事务,它将一个大事务分成三个阶段,每个阶段的每个实例都有一个个独立的本地事务,每个本地事务都各自提交,只在需要的时候回滚,所以TCC有着比2PC更高的性能。
但事务补偿对业务的侵入比较大,一次事务需要涉及3个阶段的代码编写,一方面提高了开发维护的成本,另一方面它也不适用于无法自己主导的工程,比如与三方服务之间的事务处理。
TCC本质上与2PC一样都有两个步骤:先做资源预处理再提交或回滚,Saga 则是1PC方案,同TCC类似,它的做法是将分布式事务拆分成一个个本地事务,但本地事务没有预处理步骤,而是直接提交,出错时由Saga发布回滚指令各服务执行相应的回滚流程,如一个分布式事务的执行流程是:服务A -> 服务B -> 服务C,则Saga要求服务A先执行本地事务,成功后通知服务B,服务B执行本地事务,成功后再通知服务C,如果服务C本地事务也执行成功则整个事务执行成功,如果中间有错误则执行返向回滚,回滚的顺序与正常执行的顺序相反。Saga与TCC相比少了预处理步骤提升了性能,简化了开发,但这也限制了其使用的场景,比如转账TCC在真正处理成功前对金额会做冻结,但Saga则会直接变更金额,如果有步骤执行失败在回滚之前就存在应撤销的金额被消费的可能。

通知型事务

通知型事务正是解决2PC性能问题及TCC业务侵入问题而设计的,将事务看作消息,多使用MQ来传递,之中又可分为可靠通知和最大努力通知。
最大努力通知比较好理解,它的前提是服务存在依赖,上游服务事务成功后下游服务事务业务要求必定成功,如果失败会有一定的策略重试,如果重试策略还失败,上游服务需要提供一个查询的接口以便获取上游服务事务成功后的数据。这一方案最直接的使用场景是跨系统间的数据处理,比如业务系统与支付网关,在支付网关支付成功后意味着钱已经流转,网关会通知(异步回调)作为下游的业务系统,一次失败后再会重试几次,如果再失败就需要业务系统主动来网关查询处理结果,而这过程中要求下游回调接口必须幂等(幂等的介绍见下一小节)。
可靠通知可以理解为支持回滚的最大努通知,在重试策略也失败后可靠通知会执行事务回滚,这样一来就没有服务依赖及必须成功的约束,反之服务需要提供事务回滚逻辑,对业务有少许侵入。
无论是哪种分布式事务,都需要类似有事务协调器这一服务,都需要确保以下几个内容:
  • 事务协调器与各参与者(业务服务)内的调用必须幂等,即重试不会导致数据异常
  • 事务协调器与各参与者(业务服务)内的调用必须有超时时间,不能无限或长时间地等待
  • 事务必须能确保发送到事务协调器,这是大前提,对于MQ而言可使用AMQP规范的实现,如RabbitMQ,开启AMQP的事务机制
我们回看分布式事务下的ACID保证,原子性(Atomicity)和持久性(Durability)与传统事务无异,但一致性(Consistency)与隔离性(Isolation)上除了2PC、3PC完全满足外不同实现的补偿型与通知型事务都有或多或少的缺失,它们都强调最终一致性,即允许在一段可接受的时间内各节点数据不一致,由于它们多半是将大事务分解成一个个本地小事务,所以在一段时间也存在隔离性问题,这块后续章节会进一步讨论。
分布式事务的引入会增加架构的复杂度也会导致性能的下降,正如前面章节所言,应该通过合理的服务划分尽可能地避免分布式事务的使用。另外分布式事务所面对的场景注定其无法做到像本地事务一样健壮,所以设计时需要考虑人工补偿、定时校对等流程。