分布式之Distributed Transactions

0. 前言

一台服务器的读写性能有限,当数据量过大时,我们需要将数据分割到不同的服务器上,从而使系统能够并行处理客户请求,提高系统整体性能。但这又引出新的问题:分布式事务(Distributed Transactions)

举个例子:一家银行将北京用户的数据存放在北京的服务器中,将上海用户的数据存储在上海的服务器中。此时,一个北京的用户A向上海的用户B汇款100元。按照正常流程,需要同时进行2个操作:到北京服务器中将用户A的余额-100,到上海服务器中将用户B的余额+100。但是,分布式网络充满复杂性,如果北京服务器操作完之后,上海服务器宕机了,那用户A的余额-100,而用户B的余额并没有+100。或者,北京服务器执行操作出错了,因为用户A余额并不够100,而上海服务器执行操作成功,用户B余额+100。不管是哪种结果,这肯定是不符合预期的,系统应该保证2个操作都成功或都失败。而这就是分布式事务所要解决的问题之一。

1. 事务概述

事务可以看作一种由底层数据库提供给上层应用的抽象,一些常见的数据库都会提供事务的接口,比如MySQL。使用者只需标明事务的起点和终点,并在事务中包含读写等一系列相关操作。

以下是事务的例子,假设有两个银行账户x和y,它们的余额都是10元,现在有2个事务如下:

  • T1:x向y转账1元。

    1
    2
    3
    4
    5
    BEGIN-X      // 事务开始
    add(x, -1) // x余额-1
    add(y, 1) // y余额+1
    END-X // 事务结束

  • T2:查看x和y的余额。

    1
    2
    3
    4
    5
    BEGIN-X            // 事务开始
    tmp1 = get(x) // 读取x余额
    tmp2 = get(y) // 读取y余额
    print tmp1, tmp2 // 打印x和y的余额
    END-X // 事务结束

数据库会保证事务满足以下特性:

  • Atomicity(原子性):一个事务中的所有操作,要么全部成功,要么全部失败。如果事务在执行过程中发生错误,数据会被恢复到事务开始前的状态,就像这个事务从来没有执行过一样。以上述事务T1为例,如果第3步add(y, 1)执行出错,那add(x, -1)的操作会被撤销,不会出现,x余额为9,y余额依旧为10的情况。原子性通常由日志实现。

  • Consistency(一致性):在事务开始前、进行中、结束后,数据库的完整性没有被破坏。完整性包括外键约束、应用定义的约束不会被破坏,比如银行中所有账户余额总和需等于一个固定值。一致性由其它3个特性共同保证。

  • Isolation(隔离性):多个事务并发执行时,它们之间不会相互干扰,也看不到对方事务的中间值,所有事务就像按序进行的一样(serializable)。以上述T1和T2为例,两个事务并发执行,T2可能先于T1执行打印”10,10”,也可能在T1之后执行打印”9,11”,但T2不会看到T1事务执行过程中x余额为9、y余额为10的中间状态(幻读)。隔离性通常由实现。

  • Durability(持久性):事务结束后,对数据的修改就是永久的,即便系统故障也不会丢失。持久性需要确保事务成功时,能够写入磁盘

2. 分布式事务

分布式事务是一种特殊的事务,它需要在分布式的情况下,保证ACID四大特性。不同于单机数据库,在分布式情况下,数据分布在不同机器上,比如上述示例中x和y的账户数据可能分布在银行的不同服务器上,甚至分布在不同银行中。因此,事务执行还需要考虑网络拥塞、机器崩溃等等问题。

分布式事务有两大重要部分:

  1. 并发控制(concurrency control),保证隔离性(Before-or-After)

  2. 原子提交(atomic commit),保证原子性(All-or-Nothing)

并发控制——2PL

并发控制分为两大类:

  • 悲观(pessimistic) 。在使用数据时加锁,发生冲突会产生延迟。

  • 乐观(optimistic) 。使用数据时不加锁,但在事务提交时,检查读写是否满足序列化(比如检查版本号),若发生冲突则会中断并重试。

如果冲突经常发生,悲观并发控制会快于乐观;如果冲突不经常发生,乐观并发控制会快于悲观(因为锁的开销)。

接下来将主要介绍悲观并发控制。

在悲观并发控制中,锁可以有很多粒度,比如:数据库中针对表的表锁,针对一行记录的行锁。同时,锁也有不同类型,比如:排它锁(X锁)和共享锁(S锁)。

两段锁(Two-phase locking, 2PL) 是悲观并发控制的一种规则,它的定义如下:

  1. 在对数据进行读写之前,事务需要获取该数据对应的锁

  2. 事务只有在提交或终止之后,才能释放它所获得的锁。为什么在事务结束后才释放锁,而不是在数据读写完之后?以上述事务T1和T2为例,假设T1先拿到x的锁,add(x, -1)之后释放x锁,此时T2拿到x锁执行get(x),再抢先一步拿到y锁执行get(y),那T2将看到一个中间状态”9, 10”,这显然不满足串行化(serializable),即不满足隔离性。

虽然两段锁规则能保证隔离性,但会发生死锁的情况。比如,有T3和T4两个事务,它们同时分别拿到x锁和y锁,接下来T3等待y锁释放,T4等待x锁释放,发生死锁。

1
2
3
T3      T4
get(x) get(y)
get(y) get(x)

通常,数据库会通过一些方法来避免和检测死锁,比如:通过银行家算法来避免死锁,通过跟踪周期或超时机制来检测死锁等等。

以上是标准数据库的并发控制,单机数据库是这样,分布式数据库也是这样,只是会稍微有趣些。

原子提交——2PC

对于单机数据库,可以通过日志REDO/UNDO机制来实现原子性。但在分布式下,则更为复杂。

继续以上述事务T1、T2为例,假设现在x存储在服务器S1上,而y存储在服务器S2上。在事务T1中,需要先让S1执行add(x, -1),再让S2执行add(y, 1)。当S1执行完后,由于网络堵塞、机器故障等各种原因,S2未能成功执行。此时,事务只执行了一半,为了保证正确性,我们应让S1的x值回滚。这正是原子性所要保证的,事务中的所有操作,要么全部执行成功,要么全部不执行(All-or-Nothing)。

在分布式下,保证一系列操作原子性的算法,被称为原子提交协议(Atomic Commit Protocols) 。其中较为常见的一种原子提交协议,是两阶段提交(Two-Phase Commit, 2PC)

首先,为了方便,我们需要指定一台服务器来管理事务(类似leader),它被称为事务协调者(Transaction Coordinator, TC),而负责执行操作的服务器被称为参与者,由TC来通知参与者执行相关操作。同一时刻,可能有多个事务并发执行,为了分辨这些事务,TC会给每个事务分配唯一ID,。

依旧假设x存储在参与者A上,而y存储在参与者B上,现在有一个分布式事务先get(x)再put(y)。按照正常流程,TC会先向A请求get(x),得到成功回复后,再向B请求put(x),并得到成功回复。

1

2PC协议过程如下:

  1. TC会先向所有参与者发送Prepare消息。

  2. 参与者收到Prepare消息后,会判断自己能否成功执行操作,并向TC回复Yes/No。在回复Yes/No之前,参与者需持久化保存事务状态

  3. TC会等待所有参与者的回复。若都回复Yes,TC则会向所有参与者发送Commit消息;若其中一个参与者回复No,或者未收到所有参与者的回复,TC将会发送Abort消息给所有参与者,让它们回滚相关操作。在发送Commit/Abort消息之前,TC需持久化保存事务状态

  4. 参与者会一直等待TC的Commit/Abort消息(阻塞),期间会向TC主动询问。当收到Commit/Abort消息后,参与者会提交/回滚操作,并返回ACK。返回ACK之后,参与者可以删除事务相关记录

  5. TC回向客户返回事务结果(不需要等待所有ACK)。若未到某个参与者的ACK,TC将重新发送Commit/Abort消息。当TC收到所有参与者的ACK后,可以删除事务相关记录

1

值得一提,为了遵循两段锁规则,参与者会在Prepare时对数据加锁,在完成Commit或Abort时解锁。参与者会自己维护一张lock表,记录锁与对应事务操作的数据对象。

接下来考虑参与者发生故障的情况:

  1. 参与者B在收到Prepare消息之后、回复Yes/No之前崩溃了。TC未收到B的回复,将不会进行Commit,系统其它参与者也不会提交事务。这种情况下,参与者不需要保存事务状态,重启后就当任何事情都未发生一样。

  2. 参与者B在回复Yes之后崩溃了。假设TC收到所有参与者的Yes回复,将会发送Commit消息,A收到Commit后,会实际执行操作,持久化结果,并释放锁。为了保证原子性,B重启后需要继续完成操作并提交。因此,B在回复Prepare之前,需要持久化保存事务的状态、操作的内容和事务相关的锁等等。当B重启后,会读取日志中的数据,恢复相关事务的状态。之后,若收到Commit/Abort消息,B将继续完成事务;若一直未收到Commit/Abort消息,B将向TC询问。

  3. 参与者B在完成Commit之后崩溃了,数据已经持久化,事务已完成。

考虑TC发生故障的情况:

  1. 在发送Commit消息之前崩溃,这种情况相当于事务中止。当各参与者发现事务一直未收到Commit消息,会向重启后的TC询问,TC发现自己并不知道这个事务,参与者则会中止这个事务。

  2. 在发送一个或多个Commit消息之后崩溃,这种情况TC不能忘记事务。因此,TC需要在发送Commit之前,将事务状态保存到磁盘中。当TC重启后,会从磁盘中恢复相关事务。对于未完成的事务,TC会向所有的参与者重发Commit/Abort消息。

考虑网络数据包丢失的情况:

  1. TC发送Prepare消息后,未收到所有参与者的Yes/No回复。TC可以重新发送一轮的Prepare消息,并继续等待。但当Prepare过程超时后,Prepare将单方面中止事务,并发送Abort消息。

  2. 参与者B对Prepare消息并回复Yes后,未收到TC的Commit/Abort消息,参与者B将会一直等待,不能单方面中止事务(阻塞)。因为其它参与者可能已收到Commit并完成持久化,而B由于网络原因未能收到,此时B若独自中止事务,那将破坏事务的原子性(有人Commit有人Abort)。

  3. 当TC没有收到Commit/Abort的ACK时,会重复发送Commit/Abort消息。参与者B收到TC发来的Commit/Abort消息时,它会进行判断:若对应的事务未完成(还在内存中),参与者会继续完成事务;若对应事务不存在,则说明事务已完成,参与者会简单回复一个ACK

对于故障情况,不论TC还是参与者,一旦进入Commit/Abort阶段,就必须保证事务原子性地完成

  • 因此,TC需要在发送Commit/Abort之前持久化事务状态,在收到所有参与者的ACK之后,才能删除事务状态。

  • 因此,参与者需要在对Prepare回复Yes之前持久化事务状态,在完成Commit/Abort操作之后,才能删除事务状态。

  • 因此,当参与者回复Yes之后,必须无限等待Commit/Abort消息。

2PC的缺点:

  • 慢:网络通信轮次较多;TC必须等待所有参与者的回复;大量的写磁盘操作。

  • 阻塞:参与者回复Yes之后,必须无限等待Commit/Abort消息。

3. 总结

最后,我们需要区分:

  • 分布式一致性算法。由于单机一旦出错将难以恢复,为了容错/高可用,需要将同一份数据备份到多台服务器上,分布式一致性算法是为了保证这些备份数据的一致。在分布式一致性算法中,所有服务器都做相同操作,因此只需要超半数的服务器成功即可。

  • 分布式事务。由于单机存储、读写性能有限,为了提高性能,需要将一份数据分割存储到不同服务器上,分布式事务则是为了保证同时涉及多台服务器上不同数据的一系列操作能够顺利完成。在分布式事务中,如果事务涉及多个服务器,不同服务器将做不同操作,因此需要所有服务器都成功。