OceanBase 1.0 的分布式事务

OceanBase 1.0 的分布式事务

数据库的功能强大而繁杂,其中,“事务(Transaction)”是使用者不自觉就会用到的功能。作为开发数据库的工程师,我们是倾注了大量的精力和时间在事务这个功能上,并且深知数据库系统实现事务是付出了很大代价的。这代价不仅包括数据库软件开发的工作,而且还包括数据库运行过程中的代价。换句话说,在其他情况不变的时候,如果数据库放弃事务功能,能获得更好的性能。在数据库软件刚出现时,并没有事务这个功能,但这种情况下,使用数据库开发软件很多时候无法保证数据的正确性和一致性,或者把软件搞得很复杂。所以,数据库支持了事务功能,提供给使用者一个将许多数据库的操作打包在一起的功能,例如 Atomic(原子性),保证事务内对数据的多次修改操作,要么全部完成要么全部回滚。虽然付出了很多代价,但是用户的应用程序会因此大大简化。而且,用户操作数据的流程越来越复杂,如果没有事务特性的保证,使用者更加无法确切知道数据在数据库中是否保持正确。

事务重要的 4 个特性 ACID 分别代表 Atomicity(原子性)、Consistency(一致性)、Isolation(隔离性)、Durability(持久性)。

  • Atomicity(原子性)表示事务中的多个操作要么全部完成,要么全部没有生效,不会出现中间状态;
  • Consistency(一致性)表示事务操作不会违反数据库的一致性约束;
  • Isolation(隔离性)表示多个并发事务之间不互相影响;
  • Durability(持久性)表示事务一旦成功就不会丢失。

数据库系统有很多工程化方法来实现这些事务特性。其中,保证所有数据都存储在持久化设备中,就可以保证数据的 Durability(持久性)。常用的持久化设备有磁盘和 SSD,这种设备保证在断电的时候数据都不会丢失。事务的一致性是数据库系统对于数据的约束,常见的约束有数据库的外键(Foreign Key),数据库系统保证事务执行后这些约束都不会被破环。这两个功能点,在有原子性(Atomicity)和隔离性(Isolation)的基础上,系统实现起来相对简单。所以,在这篇文章中,我们只详细描述原子性(Atomicity)和隔离性(Isolation)两个特性。

原子性(Atomicity)

原子性(Atomicity)是事务中最重要的特性,如果一切操作正常,保证原子性(Atomicity)并不复杂。保证事务原子性(Atomicity)的复杂性多出自异常处理,例如宕机恢复、主备切换等。以一个事务修改两行数据为例,如果在修改完第一行之后、修改第二行之前机器宕机了,如果没有其他机制保证,待机器重新恢复服务,就只有第一行的修改存留在系统中,违反了原子性(Atomicity)的要求。

什么是原子性(Atomicity)

实现原子性(Atomicity)的一种方法是将多个操作的生效时机放在一个原子操作上。计算机系统在内存操作上可以做到原子性的操作有给一个内存变量赋值、CAS 操作等。硬盘的原子性操作和硬件本身有关,磁盘一般是一个 512 字节的块写入是原子的,SSD 一般是 4KB 的块写入是原子的。用一个数据结构的例子说明这种原子性实现机制。

struct Balance {
    int accountA;
    int accountB;
};
Balance * x = new Balance();

上面是 C++ 的结构体,表示了两个账户 A 和 B 的余额。如果从 A 转账 5 元给 B,对应的 C++ 代码如下:

x->accountA -= 5;
x->accountB += 5;

上面的代码不是原子的,在两条语句中间,如果另一个线程读取 accountA 和 accountB 的值,会发现转账操作只执行了一半。如果使用下面的代码,就可以保证无论什么时候读取,都不会读到转账执行了一半的情况:

Balance * tmp = new Balance();
tmp->accountA = x->accountA - 5;
tmp->accountB = x->accountB + 5;
x = tmp;

操作的生效时间是x = tmp;语句,单一变量的赋值操作是原子的,保证了整个转账操作是原子的。

使用日志实现原子性(Atomicity)

上面基于原子性操作的实现方法在数据结构中很常用,但是现在数据库系统都是使用日志技术(log)实现原子性(Atomicity),因为日志技术解决原子性的方法更灵活,还能同时解决了事务的持久性(Durability)需求,而且比直接持久化数据有更好的性能,所以几乎所有的数据库系统都采用了日志技术(log),甚至还包括文件系统、队列系统等。

使用 log 时,先把整个事务的操作编码成连续的日志信息,再把日志信息写入持久化设备,当日志成功写入后,就是保证事务原子性成功的时机。以上面的转账操作为例,编码出的日志信息如下:

<accountA, balance, -5>, <accountB, balance, +5>

当上面的信息持久化成功后,数据库系统再会修改两个帐户的数据,而且帐户数据本身持久化到硬盘的时机可以延后到任意时刻,因为即使数据没有持久化前就宕机了,系统重启后还是可以从 log 中将上面的转账操作恢复出来。所以,转账事务的原子性(Atomicity)成功的时机就是 log 持久化成功的时间点。

对于一次数据库事务,如果只有这么一条日志并且长度小于硬盘的一次原子写入操作,例如磁盘的 512 字节,那么原子性很容易保证,这次写入成功了,事务就成功了;如果写入失败了,那么事务就没有完成。如果事务只需要写一条日志但是日志长度大于一次原子写入的大小,就需要附加的手段来保证 log 写入的原子性。一种常见方法是用长度加上校验值(checksum),在这条日志的头部包含整条日志的长度和校验值(checksum)。从日志文件里读取日志时,首先读到日志头部,获取日志长度和校验值(checksum),然后根据日志长度将整条日志内容读取出,并且和校验值(checksum)比对,如果一致,那么既认为日志时完整的,对应的事务也是成功提交的;如果校验值(checksum)不一致,那么认为事务失败。如果事务的信息会分成多次写入硬盘,即事务包含多条日志。在这多条日志中,每条日志的处理方法都与前面描述的一样,但是事务最终是否保证了原子性需要依赖这多条日志都持久化成功,所以数据库系统会在所有日志都持久化成功后再写最后一条确认日志,只有最后一条日志写成功了,事务才算成功,否则,事务还是会被回滚。

分布式事务原理

上面这种方法适用的前提是事务的所有日志都在一个日志序列中。当数据库是由多台机器组成时,每台机器都会有自己的日志,一个事务如果涉及多台机器,那么就会将日志写到多台机器上,我们称这种事务为分布式事务。理论上,即使事务的日志分散在多台机器上,事务提交时,如果所有机器都持久化日志成功,那么事务就算提交成功,并且可以保证原子性(Atomicity)。比较麻烦的是,如果机器重启,从日志中恢复事务状态时,同样需要询问所有机器来确认是否事务的所有日志都持久化成功了。但是实际的系统中无法承受每次需要恢复事务状态时,都要对每个事务进行多机通讯。所以,分布式事务采用两阶段提交的方式,对于单机写日志的流程进行扩展,来适应分布式事务。

典型的分布式事务流程如下:

典型分布式事务状态机

左侧 (a) 是协调者状态机,右侧 (b) 是参与者状态机。协调者是驱动整个事务提交流程的主体,实现中它可以在任意机器上,当然也可以和其中一个参与者在一台机器上。参与者是真正做事务操作的执行者。协调者首先通知所有的参与者持久化(图中的 prepare 命令),当参与者将事务的日志持久化成功后会回复 prepare ok,当所有参与者都回复 prepare ok 后,意味着整个事务完成了,然后协调者会写下事务 commit 的日志,并且发送 commit 给所有参与者,如果其中任何一个参与者返回失败(即 abort ok),那么协调者就会认为事务是失败的,记下事务回滚的日志,并且发送 abort 给所有参与者。

在这个流程下,分布式事务提交的延迟是 2 次写日志(参与者写 prepare 日志 + 协调者写 commit 日志)的延迟和 2 次通讯(协调者通知参与者 prepare + 协调者通知参与者 commit)的延迟。

OceanBase 对于分布式事务的优化

OceanBase 的两阶段提交协议对这个流程做了优化,事务是否提交本质上取决于是否事务的所有日志都持久化到硬盘中,不依赖协调者的日志,日志全部持久化的状态也是确定的。所以,OceanBase 的两阶段提交流程取消了协调者写日志的过程,将协调者变成一个无持久化状态的状态机,状态机如下:

OceanBase分布式事务协调者状态机

看起来很复杂,是因为实际的工程项目中还需要处理各种异常情况,还有各种优化。参与者状态机如下:

OceanBase分布式事务状态机

在上面的协调者和参与者的状态机中,与经典状态机除了多了很多异常和优化的处理,还有一个最大的不同是多了一个 CLEAR 阶段,理论上协调者完成 commit 操作后整个事务流程就结束了,但是在实际实现中,虽然事务状态确定了,但是协调者和参与者之间还可能因为网络丢包或者机器异常等导致信息传输不确定的状况,需要互相查询状态或者重试,这时 CLEAR 状态的意义就是保证所有状态机都达到确定状态了,才对状态机对应的数据结构进行清理。

OceanBase 对于协调者的优化

前文描述过,在单机事务的场景中,如果一个事务需要写多条日志,在确认所有日志写成功后,会写下最后一条日志表示整个事务确认完成提交。这最后一条日志的信息是可以和正常事务日志的最后一条合并在一起的。但是在分布式事务中,两阶段提交的协调者在确认所有参与者上的日志都写成功后写下的 commit 日志是无法从工程上与任何参与者的日志合并在一起的。但是,事务是否提交本身是在所有参与者的日志都成功持久化的时候就确定了的。所以,OceanBase 做的一个重大优化就是省去协调者的 commit 日志。调者接收到所有参与者的 prepare ok 消息后,不写本地的 commit 日志,直接给所有参与者发送 commit 请求。当参与者收到 commit 请求后会写下 commit 日志,这条日志是原始的协议中也会有的操作。

在这个模式下,极端的情况是当所有参与者都成功写下 prepare 日志,但是在协调者发送 commit 消息之前,系统出现宕机,如何恢复这个本应该成功的事务。宕机重启后,参与者发现事务处于 PREPARED 状态,则会询问协调者事务的状态。因为协调者也是宕机重启了并且协调者不持久化任何信息,所以并没有任何协调者的状态。但是协调者会在收到参与者的询问请求后,构建出协调者状态机,并询问其他所有参与者的事务状态,当发现所有参与者都是 PREPARED 状态,就能确定这个事务是最终成功 commit 的,然后会给所有参与者发送 commit 请求,状态机就正常运转下去了。

OceanBase 对于单机多 partition 事务的优化

OceanBase 的部署是以 partition 为单位,一台机器上的多个 partition 分别有各自独立的 paxos group,所以即使是一台机器上的事务,如果涉及多个 partition,也是分布式事务。多机的分布式事务应答用户的时机是在所有参与者应答协调者 commit ok 之后,OceanBase 使用了 MVCC 机制进行并发控制,参与者在事务提交时需要进行修改全局版本号的操作(下面章节会描述),所以参与者在收到 commit 请求时,可以先修改全局版本号并且回复协调者,再持久化 commit 日志。但是 commit 操作在网络上的 round trip 时间还是需要消耗的。

对于单机多 partition 事务,多个 partition 需要修改的全局版本号其实是一个,所以协调者在收到所有参与者回复的 prepare ok 请求后,认为事务可以提交时,就可以帮助参与者修改全局版本号,因为单机多 partition 事务的协调者也一定是在这同一台机器上。所以,协调者在收到参与者 prepare ok 时即可应答用户事务提交成功,减少了一次 commit round trip 的消耗。

隔离性(Isolation)

数据库系统不能只服务一个用户,需要允许多个用户同时访问数据。所以在保证事务原子性的同时,还需要保证当多用户同时访问时数据的正确性,这是非常挑战的事情。数据库使用一种并发控制技术(Concurrency Control)来控制和协调同时在数据库系统中操作的事务。常见的并发控制算法有 Lock-based Concurrency Control 和 Multiple version Concurrency Control。

Lock-based Concurrency Control

这种方法类似于数据结构设计中常用的锁机制。数据库系统对用户在事务过程中操作的每一行数据进行加锁操作,如果是读操作就加上读锁,如果是写操作就加上写锁。如果两个不同的事务试图修改同一行数据,第一个对这个数据加上写锁的事务是正常执行。第二个事务为了保证数据的正确性,会等待第一个事务执行。待第一个事务执行结束后释放行锁,第二个事务才恢复继续执行。

A 100
B 200

还以转账操作为例,上面标示 A B 两个帐户分别有 100 块和 200 块。如果事务 1 执行从 A 帐户转 10 块到 B 帐户的操作,那么在事务一执行过程中,A B 两行数据都会被加上写锁。如果第二个事务执行另一次转账操作,从 A 帐户转 50 块到 B 帐户,那么给行 A 加写锁的时候会加不上,事务二会一直等待直到锁加上。

在上面的场景中,如果在事务一、二执行的过程中,另一个事务三查询 A B 两个帐户的信息,目的是统计 A B 两个帐户的余额总和。那么需要读取 A B 两行,读取之前需要先加读锁,但是在事务一、二操作时,A B 两行都是持有写锁的,所以事务三需要等待事务一、二都结束了才能进行操作。

Multiple Version Concurrency Control

在上面的例子中,一个读取帐户余额总和的操作无论事务一、二是否执行完,其结果都是确定的,这里的结果一定是 300 块。实际系统中,并行执行的事务是可以由数据库系统来安排其先后顺序的,所以出现了 Multiple Version Concurrency Control(MVCC) 的方法,对于数据库的数据的每次修改都留存版本,这样读取操作可以不受修改操作的影响直接在历史版本上执行。

继续上面事务三的例子。在 MVCC 方法下,每一个事务都会有一个提交版本,假定事务一是 100,事务二是 101。那么修改都完成后,数据库中的数据状态如下:

101: A 40   -->  100: A 90   -->  98: A 100
101: B 260  -->  100: B 210  -->  98: B 200

数据的初始版本是 98。每次数据修改的记录都会被串联起来。另有一个全局变量 Global Committed Version(GCV) 标示全局最后提交的版本号。在事务一执行之前 GCV 是 98,事务一提交之后 GCV 变成 100,事务二提交之后 GCV 变成 101。所以,当事务三开始执行时,先获取 GCV,然后根据这个版本号去读相应的数据。

MVCC 的修改操作依然会依赖上面提到的锁机制,所以写操作之间的冲突依然需要等待。但是 MVCC 最大的优势就是读操作与写操作完全隔离开,互相不影响。对于数据库的性能和并发能力提升非常有益。

OceanBase 的方案

使用 MVCC 方案时 GCV 的修改需要依次递增,如果 GCV 被修改成了 101,表示 101 及之前的所有事务都提交了。OceanBase 使用了分布式事务后,这个保证变得困难,例如,版本号为 100 的事务可能是分布式事务,101 是单机事务,分布式事务的提交过程要显著长于单机事务。结果,版本号 101 的事务先完成,GCV 就被修改成了 101。但是此时 100 版本号的事务还在提交过程中,并不能被读取。

所以,OceanBase 采用了 MVCC 和 Lock-based 结合的方式,读取操作先获取 GCV,然后按照这个版本号读取每一行数据。如果这行数据上没有修改,或者有修改但是修改的版本号会大于 GCV,那么这行数据可以直接按照版本号读取。如果这行数据上有个正在提交中事务,并且版本号有可能小于读取使用的版本号,那么读取操作就需要等待这个事务结束,就好像 Lock-based 方案中读锁等待写锁一样。但是,这个发生的概率极低,所以整体的性能还是像 MVCC 一样好。

总结

这篇文章只是从原子性(Atomicity)和隔离性(Isolation)两个角度解释了从普通实现方案到 OceanBase 的分布式环境下的取舍和优化,OceanBase 实现的更多优化都在细节中,欢迎有兴趣的同学多多交流。

赞 (0) 评论 分享 ()