硬核来了!OceanBase事务引擎特性和应用实践分享

硬核来了!OceanBase事务引擎特性和应用实践分享

OB君:好消息!OceanBase现推出分布式数据库产品模块原理简介系列内容,通过完整13篇文章帮助数据库从业者建立更系统完善的数据库知识体系。第七期我们来聊聊分布式数据库中一个非常重要的技术门槛——事务。

Part 1 前言

分布式数据库有两个重要的技术门槛:SQL和事务。不同数据库在支持事务时原理也不尽相同。OceanBase的事务又有什么特点?OceanBase是否只适合小事务?OceanBase的读写分离和复制表方案跟事务有什么关系?本文在为你介绍OceanBase事务的原理过程中会逐步揭开这些问题的谜底。

Part 2 事务的ACID特性

很多人对数据库事务的ACID特性已经习以为常了,可能不会去细想其中的原理。传统关系数据库对ACID的实现原理也不完全相同,在分布式数据库下这个又有新的特点。

原子性(Atomicity)

在多线程开发中,原子性操作意味着其他线程无法看到这个操作的中间结果,只能看到其开始前和结束后的状态,这个在数据库中是通过隔离性(Isolation)体现。数据库事务的原子性指一个事务要么全部成功要么全部失败。这个挑战在于发生故障的时候。比如说进程崩溃,网络连接中断、磁盘变满或者写入异常等。此外,OceanBase是分布式数据库,业务的事务可能会修改多个节点的数据。OceanBase使用两阶段提交协议(2PC)来保证事务的原子性,确保相关多台机器上的事务要么都提交成功,要么都回滚。

一致性(Consistency)

一致性是指业务针对数据定义了某种规则,这个可能体现在数据库的约束上,也可能不会体现。比如说会计业务中,借贷要平衡。业务通过事务实现这个一致性,数据库通过原子性、隔离性和持久性来保证业务实现这个一致性。隔离性(Isolation)隔离性描述的是并发的事务读写相同的数据(即冲突)时的特点。不同的做法会有不同的结果或问题。比如说脏读问题、不可重复读、幻读、丢失更新、写偏序问题等。前面三个业务研发都很好理解,后面两个就很少见。针对这些问题数据库通过不同的隔离级别来应对。最严格的隔离级别是可序列化(Serializable),可序列化是指多个并发执行的事务结果等于这些事务按照某种顺序串行执行的结果。常用的隔离级别是读已提交(Read Committed)和可重复读(Read Repeatable)。OceanBase 1.0版本支持读已提交隔离级别,2.2版本支持可序列化隔离级别。

持久性(Durability)

持久性是一个承诺,一旦事务提交成功,即使发生硬件故障或数据库崩溃,事务修改的任何数据都不会丢失(或能找回来)。关系型数据库在实现这个时候并不是把数据修改落到存储上,而是在每笔修改之前先记录相应的事务日志,然后把事务日志写到可靠的存储上。而数据却依然在数据库缓存里并不立即落盘。Oracle就是这样做的,数据主要是异步定时落盘(也有其他触发机制略去不提)。OceanBase的机制更特别,事务日志是在COMMIT的时候才生成并落盘,数据修改会一直不落盘,每天只落盘一次(后来增加转储机制,在增量内存不足的情况下可以转储到磁盘以释放内存)。因为有事务日志的保护以及三副本的高可用,OceanBase并不担心异常情形下数据丢失以及恢复速度过慢等问题。OceanBase跟传统关系型数据库最大的不同还是在于保证事务日志的可靠性方法上使用了Paxos协议(具体是Multiple-Paxos)。

Part 3 OceanBase事务原理

OceanBase的读写跟传统数据库有很大的一点不同就是OceanBase的写并不是直接在数据块上修改,而是新开辟一块增量内存用于存放数据的变化。同一笔记录多次变化后增量块会以链表形式组织在一起,这些增量修改会一直在内存里不落盘。OceanBase读则是要把最早读入内存的数据块加上后续相关的增量块内容合并读出。这个读写分离的设计决定了OceanBase的事务特点。

OceanBase支持读已提交和可序列化两种隔离级别。在了解其原理之前先看事务实现的一些基本概念。

事务版本号

在数据库中,需要准确区分不同查询和修改的先后顺序。通常可能会认为根据时间判断即可。不过这个并不可靠,即使在单机数据库里,数据库时间也可能随着主机时间跳变。在分布式数据库下,不同节点的时间也不是完全严格的一致。即使是一致的,加上节点间网络通信的时间,依然无法判断不同修改的先后顺序。所以OceanBase实现了一个内部单调递增的时间戳。它跟时间有点关系,但不是时间。然后多个场景下会取这个时间戳。这个时间戳类似于Oracle的SCN。

  • 事务提交版本号(Commit Version):用于确定不同事务提交的先后顺序。在事务COMMIT的时候取时间戳作为事务提交版本。在分布式事务里,多个参与者使用的是同一个事务提交版本号,以确保一个读请求能够读到完整的一致的事务。对于每笔数据,每次事务修改都会留下一个提交版本,不同版本按顺序串联起来关联到该记录上。
  • 读快照版本号(Snapshot Version):事务在修改数据的时候会先读取数据,需要决定读取哪个版本的数据,这个版本就是读快照版本。此后OceanBase只会读所有提交版本小于或等于读快照版本的最新已提交版本的数据,并且在读之后的所有事务提交时的提交版本都会大于这个读快照版本。这个机制保证了单个SQL没有脏读、不可重复读、幻读问题。不同的事务隔离级别,生成的读快照版本号方法不一样。在读已提交隔离级别下,单个SQL在开始执行时取当时节点已提交事务的最大的事务提交版本号作为读快照版本,读的数据是语句级别的一致性版本。在可序列化隔离级别下,SQL执行时取事务开始时的节点最大的事务提交版本号作为读快照版本,读的数据是事务级别的一致性版本。

两阶段提交协议

OceanBase在租户(实例)的内部是支持分布式事务,对于业务而言,不需要知道一个事务是否是分布式事务,只需要开启事务即可。OceanBase在提交的时候会走两阶段提交协议。如果发现事务修改的数据都在同一个节点内部,会优化为单机事务提交流程。两阶段提交协议有两个角色:协调者和参与者。参与者列表维护在事务相关会话所在机器,OceanBase会选取第一个事务的第一个参与者(Px)作为协调者。在COMMIT的时候发送end_trans消息给协调者(Px),并告知有哪些参与者(Px,Py,Pz等)。下面两幅图分别表示协调者和参与者的流程图,内部会维护一个有限状态机。

协调者流程:

  1. Px收到end_trans消息后创建协调者状态机。协调者进入PREPARE状态并向所有参与者发送prepare消息,等候答复。
  2. (a)如果收到所有参与者的prepare ok消息则进入COMMIT状态并向所有参与者发送commit消息。
  3. (b)如果收到一个参与者abort ok消息则进入ABORT状态并向所有参与者发送abort消息。

参与者流程:

  1. 参与者状态机在DML语句执行过程中就创建了。在收到prepare消息后将事务的修改写日志并发起持久化操作。此时需要等三副本多数派持久化事务日志成功。
  2. 参与者事务日志持久化成功后进入PREPARED状态并回复协调者prepare ok消息;如果持久化失败则进入ABORTED状态并回复协调者abort ok消息。
  3. (a)参与者收到协调者的commit消息则写commit日志,进入COMMITTED状态并回复commit ok消息。这个过程里会确定分布式事务的提交版本号并更新Public Version,释放行锁等。
  4. (b)参与者收到协调者的abort消息则写abort日志,进入ABORTED状态并回复abort ok消息。

传统的两阶段提交的弊端在于对参与者的锁粒度太大,会阻塞相应数据的读。此外就是协调者宕机时分布式事务流程会进入不确定状态,应用提交会一直等待。可能有人会担心协调者宕机怎么办?

  • 协调者宕机

协调者本身就是三副本,具有高可用能力。协调者宕机后在15s左右就可以自行恢复。参与者没有收到协调者信息时,参与者会定时重发上一条消息。协调者恢复后虽然没有协调者日志可以读取,但是可以通过参与者回复给协调者的消息里恢复出协调者当前处于的状态。

全局时间戳服务

  • 外部一致性

外部一致性描述的是两个事务T1和T2,如果T1完成后T2才开始提交,则T1的事务版本号一定会小于T2。但是在不同节点上,使用本地时间戳生成提交版本时,不能保证T1的版本号比T2小。从而快照读的时候读出来的数据不符合业务需求。在分布式数据库中间件产品里,如果单SQL访问的数据来自多个节点,中间件节点会分别向不同节点发出SQL读取数据到中心节点聚合。它在每个节点上执行的SQL都是遵循读已提交隔离级别限制。严格来说,这个SQL汇总的数据并不一定满足读快照版本的要求,在OceanBase里严格避免这个问题,除非用户主动开启弱一致性读。

OceanBase默认读规则是强一致性读,即一个SQL读取的数据的提交版本必须是小于读快照版本的最新版本。在读已提交隔离级别下,这是语句级一致性读;在可序列化隔离级别下,这是事务级一致性读。

  • 全局时间戳服务

OceanBase 2.0开始实现了全局时间戳服务(Global Timestamp Service,简称GTS)。每个租户一个GTS服务,服务的架构采用C/S结构。租户的每个节点都会有个GTS Client,服务于节点内部的请求。GTS Server只有一个,依托于表__all_dummy表的Leader副本。同时GTS Server也就有高可用能力了。使用全局时间戳服务获取一致性快照读版本这个又简称为全局一致性快照读。

并发事务控制

当多个事务不是先后顺序关系时,则是并发事务。并发读写相同数据可能会存在一些冲突。

  • 写写并发控制

OceanBase写会先在记录上申请加排它锁(即行锁),如果已经有其他锁存在则需要在队列里等待。行锁保证了每个时刻最多只能有一个事务修改这个记录,行锁释放的时候通知等待队列里的第一个事务。这个队列避免了锁等待的争抢。OceanBase会在行上维护一个链表,记录历史修改和提交版本信息。

OceanBase里这个锁等待不会无限制等待下去,每个SQL执行有个超时机制,由变量ob_query_timeout控制,默认10s。时间到时,DML SQL会报lock wait timeout。这个报错信息是取自MySQL,在MySQL里变量innodb_lock_wait_timeout会控制锁等待超时时间。

  • 读写并发控制

前面说了OceanBase的读会读取记录的快照版本,是不加锁的,所以读不会阻塞写。不过如果用了SELECT...FOR UPDATE语法,则不会是快照读,会尝试加锁(共享锁),直到事务提交或者回滚才释放。这个时候就跟并发写有冲突。OceanBase事务隔离级别详述前面提到了OceanBase支持两种隔离级别:读已提交和可序列化。读已提交的功能和问题大家都非常熟悉了就不重复了。这里再细说一下序列化隔离级别。可序列化的定义是让并发的事务执行的效果跟按某种顺序串行执行效果一样,只有顺序执行才符合这个定义。通常的实现方法就是事务期间访问的数据全程加锁(共享锁或排它锁),以防止事务期间访问的数据被其他事务修改了。这样做的并发太低,所以Oracle在实现可序列化隔离级别的时候实际选用了快照读的策略,整个事务访问的数据是同一个快照版本。这样由于减少了读写并发冲突,整体并发的能力提上去了。

不过Oracle这个隔离级别可能有写偏序(write skew)问题,OceanBase在这点暂时与Oracle保持一致。

Part 4 OceanBase事务相关解决方案

OceanBase读写分离方案

默认情况下OceanBase是三副本架构,每个数据(即分区)都有三个副本,应用通过OBProxy读写主副本,备副本不提供服务。不过使用弱一致性读可以访问备副本,风险就是数据同步可能有延时。备副本通过同步主副本的事务日志(即CLog)来保持数据跟主副本同步。这个同步协议有的分布式数据库使用的是Raft协议,这个协议实现方式简单一些,但有个缺陷是CLog必须连续,否则会有相应的等待,从而制约了COMMIT的性能。OceanBase使用Multi Paxos协议同步CLog,事务日志可以并发发送和在备副本上并发乱序回放,所以备副本的增量链条上的事务版本不一定在每个时刻都是连续的(有空洞)。为了能读到正确的数据,备副本读快照数据需要一个相对安全的位点(事务提交版本),早于这个版本的事务日志都已经回放完毕。同时这个安全的位点也不能太过陈旧,否则业务读到的数据延时太大也没意义。这个允许的最大延时由参数max_stale_time_for_weak_consistency控制,默认是5s。备副本数据延时超出这个选择就不作为候选备副本,两个备副本都超出了那还是读主副本。开启弱一致性读有多种方法。在SQL里加hint或者在会话级别设置。

OceanBase复制表方案

在分布式架构下,总有些业务表是不能做拆分的。这些表多是一些基础数据表。而一些业务大表拆分后还会经常跟这些基础表做表连接。由于业务大表的数据分布在多个节点上,这个SQL执行计划会是一个分布式执行计划,功能虽然满足,性能却不会太好。在分布式数据库中间件里就有这个问题,于是中间件产品推出小表广播的方案,把非拆分表的数据同步到所有分库里。这个同步多是外部同步工具做,所以是异步的。使用小表广播始终有数据延时风险。OceanBase 2.2版本也推出了类似功能,即“复制表”功能。由于OceanBase多副本之间的同步是内在的,所以OceanBase在实现复制表时有天然的优势。只需要增加一种副本类型,让主副本跟这个“复制副本”之间同步使用全同步机制。做到主副本事务的提交会等待所有复制副本接收到事务日志落盘,主副本的COMMIT才会返回(即全同步)。很明显这个主副本的COMMIT性能会下降一些。考虑到使用复制表方案的表数据更新并不频繁,这个性能代价是完全可以接受的(此外OceanBase可以控制复制副本的范围)。反过来也说明不要对一个频繁更新的非分区表设计复制表方案。

OceanBase使用“复制表”的可能异常是如果“复制副本”出现不可用,会导致主副本COMMIT出现等待引起写性能下降。不过“复制副本”如果不可用了会很快被自动拉黑下次就不考虑这个“复制副本”而是依然访问主副本去。这个对业务来说是很好的。同样的,有拉黑逻辑就有赎回逻辑。如果“复制副本”恢复可用了很快就会继续服务。有关复制表使用案例以后有机会再详细介绍。

Part 5 OceanBase事务开发运维实践

OceanBase超时机制

OceanBase里有很多超时保护机制。如SQL执行超时保护,事务持续或闲置时间超时保护。针对SQL超时或事务超时可以调大相应超时参数,这样行为就跟Oracle比较接近(永不超时)。不过这个只是改变了问题的表现形式,并不改变有问题的本质。OceanBase事务建议由于OceanBase的事务日志是在COMMIT的时候才生成并落盘,如果事务很大,会影响COMMIT的性能,同时还占用一定的内存资源。这个事务的大小多大合适并没有严格的说法。这取决于事务的并发数、内存的大小等等。通常建议并发很高的时候,事务大小(影响的记录数)控制在1000以内,并发低的话,放宽到10000笔也可以。这些只是建议,并不是代码逻辑。所以要根据实际情况反复测试再定。用一个事务更新几十万几百万记录的做法在OceanBase里也不建议的,应用写法虽然简单但把成本和风险都转移到数据库不是一个好的设计。

事务异常处理建议

针对异常情况,这里补充一下,异常状况下事务的结果是提交还是回滚完全取决于数据库内部规则(状态机的状态的推动)。这个结果可能跟客户端收到的信息不一致。通常来说如果客户端收到事务提交成功的消息,则数据库端事务一定提交成功了;如果客户端收到事务回滚或者被杀的消息,则数据库端事务一定回滚了;如果是其他异常信息,则数据库的事务状态是一个不确认的状态(unknown)。这是一个通用的结论,并不是OceanBase才会这样,业务研发初次碰到可能会难以理解,误认为OceanBase有什么问题。数据库在工程实现时,有个基本的原则就是:数据库在遇到错误时,尽可能少的撤销已经完成的事情。数据库能保证的是在异常的时候它依然保证ACID特性,怎么让应用从这个异常中恢复是是应用需要考虑的。很多开发者在实现业务逻辑倾向于只考虑乐观的情况,而不考虑异常处理的复杂性。这种做法通常就导致异常会从最底层调用堆栈一直上抛,直到用户看到一个错误信息。中止(ABORT)的意义在于可以安全的重试。尽管重试一个异常的事务是一个简单有效的处理机制,但它并不完美:

  • 如果数据库事务提交成功了,在答复客户端时网络发生故障,客户端会认为事务提交失败。简单重试事务会导致事务被执行两次。如果数据表上有唯一性约束会避免产生业务数据异常。
  • 如果数据库错误是由于节点负载过大(性能瓶颈)导致的,重试事务会使得性能问题更加恶化。此时更好的做法是限制重试次数,单独处理与过载相关的错误。
  • 如果是临时性错误(如死锁、网络抖动、故障切换),重试事务是建议的。如果是永久性错误(如违反约束),重试是无意义的。
  • 如果业务事务还有非数据库操作(如发送电子邮件、写文件等),重试事务还会带来额外的副作用。当然在事务里就不应该包含非数据库操作。如果非要如此,则业务自己得实现两阶段提交机制。

以上异常处理分析适用于所有关系数据库,也包括OceanBase。实际测试也发现,OceanBase在节点负载非常高时(CPU利用率接近100%),分布式事务协调者和参与者之间的消息通信(RPC)可能会出现等待超时等情况,可能导致客户端收到的是一个事务状态未知的报错。尽管通过调整RPC相关参数可以避免报错,但不是根本的解决问题方法。OceanBase可以在线弹性扩容,租户性能瓶颈这个情况应当提前规划好资源,在需要的时候可以立即对租户或集群资源在线扩容。

  • 事务错误码

虽然业务研发很期望数据库返回的事务消息只有成功或失败两种,但是分布式架构有自己的复杂性和不确定性,加上环境的不确定性,实际结果会比期望的要复杂的多。

  1. -6001:ERROR 6001 (25000): OB-6001:Transaction set changed during the execution:事务在执行过程中SET改变。事务回滚。
  2. -6002: ERROR 6002 (40000): OB-6002:transaction is rolled back:事务被回滚。
  3. -6003:ERROR 1205 (HY000): OB-1205:Lock wait timeout exceeded; try restarting transaction:锁等待时间已经超过超时时间,尝试重新启动事务。当前SQL失败,不改变事务状态,需要应用重试或做其他处理。
  4. -6004: ERROR 6004 (HY000): OB-6004:Shared lock conflict:执行INSERT/UPDATE/DELETE时,行锁冲突。
  5. -6005:ERROR 6005 (HY000): OB-6005: Trylock row conflict:获取行锁冲突。
  6. -6210:ERROR (25000): OB-4012:Transaction is timeout:事务超时。需要明确发起回滚才可以继续复用当前连接。
  7. -6211:ERROR 6211 (25000): OB-6002:Transaction is killed:事务被杀。数据库端事务会回滚。
  8. -6213:ERROR 6002 (HY000): OB-6002:Transaction context does not exist:事务内容不存在。数据库端事务会回滚。
  9. -6224: ERROR 6002 (25000): OB-6002:transaction need rollback:事务需要回滚。不需要客户端发起回滚,数据库端事务会回滚。
  10. -6225:ERROR 4012 (25000): OB-4012:Transaction result is unknown:事务结果未知。数据库端事务可能已提交或者回滚。需要业务重试。
  11. -6226: ERROR 1792 (25006): OB-1792:Cannot execute statement in a READ ONLY transaction:不能在只读事务中执行语句。

Part 6 总结

OceanBase通常是三副本架构,每个数据(即分区)有三个副本,其中一个主副本(Leader),两个备副本(Follower),默认只有主副本提供读写服务。OceanBase的写并不是在内存中数据块上直接修改,而是新开辟增量内存用于记录增量变化,同一笔记录多次变化会以链表组织在一起。OceanBase的事务日志只有REDO日志(又叫CLog),没有UNDO日志。CLog日志在主副本上COMMIT的时候生成并发起持久化到三副本请求,使用的Mutil-Paxos协议,只要多数派副本接收了CLog并落盘,主副本的COMMIT就可以继续。

OceanBase副本类型还有只读副本(只包含数据,默认弱一致性读)、复制副本(包含数据和日志,跟主副本保持强同步,强一致性读)。这两个类型可用于做读写分离方案和复制表方案。相比于其他数据库的读写分离和小表广播,OceanBase的方案更加高效、正确。OceanBase支持事务,事务流程默认是按两阶段提交协议走。如果是单机事务流程会有优化。两阶段提交过程中,协调者由其中一个参与者兼任,协调者和参与者都是三副本有高可用机制,无论哪个宕机了都会很快恢复并继续推进分布式事务状态。

OceanBase事务支持两种隔离级别,读已提交和可序列化。这点跟Oracle是保持一致的。OceanBase支持全局时间戳服务,类似于Oracle中的SCN机制,所以OceanBase 2.x版本的读支持全局一致性快照读。

参考文献:《设计数据密集型应用》

<完>

Tips:关注OceanBase公众号回复“产品原理”获取OceanBase产品模块原理简介系列已发布的6篇文章合集(该系列持续更新中)。

加入OceanBase技术交流群:

— 想了解更多OceanBase背后的技术秘密?

— 想与蚂蚁金服OceanBase的技术专家深入交流?

— 扫码进入OceanBase钉钉交流群

最系统!一篇文章读懂OceanBase数据库的产品家族体系

OceanBase SQL 引擎的模块介绍和调优实践分享

OceanBase内存管理原理解析

● OceanBase总控服务到底是啥?一文详解RootService总控服务

如何做到像访问传统数据库一样访问分布式数据库?一文详解OceanBase通信协议层

首发!OceanBase存储引擎的设计哲学和应用实践

发布于 2019-08-16