1
分布式事务背景
随着分布式数据库技术的发展越来越成熟, 业内对于分布式数据库的要求也由曾经只用满足解决海量数据的存储和读取这类边缘业务向核心交易业务转变. 分布式数据库如果要满足核心账务类交易需求, 则其需要完善分布式事务, 向传统关系型数据库看齐. 即分布式事务的实现也需要像传统关系型数据库的事务一样满足事务的标准要求及定义, 即 ACID 特征.
分布式数据库的数据是进行多机器多节点分散存储的, 这样的存储架构为实现分布式事务带来了极大的难度. 数据事务操作时, 事务操作会结合数据分布情况, 到不同的存储位置上去执行, 而这个存储位置位于网络中的不同机器的不同磁盘上.
2
事务基本概念
2.1 事务使用场景
银行应用是一个经典案例, 可以解释事务应用的必要性. 假设银行数据库有两张表, 支票账户表 (check) 和存款账户表(save). 现在要从 LiLei 的支票账户里转账 200 元到她的存款账户, 那么需要至少完成 3 步操作:
检查支票存款账户的余额是否大于 200 元;
从支票存款账户余额中减去 200 元;
在存款账户余额中增加 200 元;
所有的操作被打包在一个事务里执行, 如果某一步失败, 就回滚所有已完成步骤. 事务操作一般用? START TRANSACTION? 语句开始一个事务, 用? COMMIT? 语句提交整个事务, 永久地修改数据, 或者用? ROLLBACK? 语句回滚整个事务, 取消已做的修改. 事务 SQL 操作样例如下:
- START?TRANSACTION;
- SELECT?balance?FROM?check?WHERE?customer_id?=?10233276?;?
- UPDATE?check?SET?balance?=?balance?-?200.00?WHERE?customer_id?=?10233276;?
- UPDATE?save?SET?balance?=?balance?+?200.00?WHERE?customer_id?=?10233276;?
- COMMIT;
此为银行对于转账类的交易所必须使用的事务操作场景, 而在实际的生产环境中, 事务操作的复杂度比这复杂得多.
2.2 事务概念和特性
事务是访问及操作数据库各类数据项的操作序列集合, 如各类增删改查 SQL 操作组合. 它通常由 begin transaction 和 end transaction 语句来界定.
数据库系统的事务需包含以下特性:
原子性(Atomicity): 事务的所有操作在数据库中要么全部执行成功, 要么全部执行失败.
一致性(Correspondence): 事务操作前后, 数据的完整性必须保持一致.
隔离性(Isolation): 多个用户并发访问数据库时, 数据库为每个用户开启事务, 不能被其他事务的操作数据所干扰. 即每个事务都感觉不到系统中有其他事务在并发地执行.
持久性(Durability): 一个事务成功完成后, 它对数据库的改变必须是永久的, 即使出现系统故障也不会对事务有影响.
事务隔离级别
针对事务隔离, SQL 标准定义了 4 类隔离级别, 包括了一些具体规则, 用来限定事务内外的哪些改变是可见的, 哪些是不可见的. 下面介绍四种隔离级:
READ UNCOMMITTED(读取未提交内容)
在 READ UNCOMMITTED 隔离级别, 所有事务都可以 "看到" 未提交事务的执行结果. 读取未提交数据, 也被称之为 "脏读".
READ COMMITTED(读取提交内容)
大多数数据库系统的默认隔离级是 read committed. 它满足了隔离的早先单定义: 一个事务在开始时, 只能 "看见" 已经提交事务所做的改变, 一个事务从开始到提交前, 所做的任何数据改变都是不可见的, 除非已经提交. 此隔离级别不支持 "可重复读" 的操作. 这意味着用户运行同一语句两次, 看到的结果是不同的.
REPEATABLE READ (可重读)
REPEATABLE READ 隔离级解决了 READ UNCOMMITTED 隔离级导致的问题. 它确保同一事务的多个实例在并发读取数据时, 会 "看到同样的" 数据行. 不过理论上, 这会导致另一个棘手问题: 幻读 (Phantom Read). 简单来说, 幻读指当用户读取某一范围的数据行时, 另一个事务又在该范围内插入了新行, 当用户再读取该范围的数据行时, 会发现有新的 "幻影" 行. 数据库存储引擎可以通过多版本并发控制 (Multiversion Concurrency Control) 机制解决了幻读问题, 如 MySQL 的 InnoDB 和 Falcon.
SERIALIZABLE (可串行化)
SERIALIZABLE 是最高级别的隔离级, 它通过强制事务排序, 使之不可能相互冲突, 从而解决幻读问题. 简言之, SERIALIZABLE 是在每个读的数据行上加锁. 在这个级别, 可能导致大量的超时现象和锁竞争现象. 数据库应用中很少看到有用户选择这种隔离级. 但如果用户的应用为了数据的稳定性, 需要强制减少并发的话, 也可以选择这种隔离级.
3
分布式事务
分布式事务的实现需要保证事务的原子性, 一致性, 隔离性和持久性, 而实现此 ACID 属性的基本技术思路有:
通过 "两阶段提交(Two-phase Commit,2PC)" 协议实现事务的原子性, 一致性和持久性等属性;
隔离性级别的实现通常使用多版本并发控制机制来保证. 实现多版本并发控制常用的方式是 "快照隔离(Snapshot Isolation)" 技术;
下面先分别介绍一下这两个概念.
3.1 两阶段提交
两阶段提交 (Two-phase Commit,2PC) 是为了使基于分布式系统架构下的所有节点在进行事务提交时保持一致性而设计的一种协议.
两阶段提交算法的成立基于以下假设:
该分布式系统中, 存在一个节点作为事务协调器, 其他节点作为事务管理器, 且节点之间可以进行网络通信.
所有节点都采用预写式日志(Write Ahead Log), 且日志被写入后即被保持在可靠的存储设备上, 即使节点损坏不会导致日志数据的消失.
所有节点不会永久性损坏, 即使损坏后仍然可以恢复.
以下对二阶段提交算法分阶段进行说明.
第一阶段(提交请求阶段)
事务协调器节点向所有事务管理器节点询问是否可以执行提交操作, 并开始等待各事务管理器节点的响应. 事务管理器节点执行询问发起为止的所有事务操作, 并将 Undo 信息和 Redo 信息写入日志.
各事务管理器节点响应事务协调器节点发起的询问. 如果事务管理器节点的事务操作实际执行成功, 则它返回一个 "同意" 消息; 如果事务管理器节点的事务操作实际执行失败, 则它返回一个 "中止" 消息. 有时候, 第一阶段也被称作投票阶段, 即各事务管理器投票是否要继续接下来的提交操作.
第二阶段(提交执行阶段)
成功的情况
当事务协调器节点从所有事务管理器节点获得的相应消息都为 "同意" 时:
事务协调器节点向所有事务管理器节点发出 "正式提交" 的请求.
事务管理器节点正式完成操作, 并释放在整个事务期间内占用的资源.
事务管理器节点向事务协调器节点发送 "完成" 消息.
事务协调器节点受到所有事务管理器节点反馈的 "完成" 消息后, 完成事务.
失败的情况
如果任一事务管理器节点在第一阶段返回的响应消息为 "中止", 或者事务协调器节点在第一阶段的询问超时之前无法获取所有事务管理器节点的响应消息时:
事务协调器节点向所有事务管理器节点发出 "回滚操作" 的请求.
事务管理器节点利用之前写入的 Undo 信息执行回滚, 并释放在整个事务期间内占用的资源.
事务管理器节点向事务协调器节点发送 "回滚完成" 消息.
事务协调器节点受到所有事务管理器节点反馈的 "回滚完成" 消息后, 取消事务.
有时第二阶段也被称作完成阶段, 因为无论结果怎样, 事务协调器都必须在此阶段结束当前事务.
事务协调器和事务管理器之间的通信流程的示意图:
?
两阶段提交算法的最大缺点就在于: 它的执行过程中间, 节点都处于阻塞状态. 即节点之间在等待对方的相应消息时, 它什么也做不了. 特别是, 当一个节点在已经占有了某项资源的情况下, 为了等待其他节点的响应消息而陷入阻塞状态时, 当第三个节点尝试访问该节点占有的资源时, 这个节点也将连带陷入阻塞状态.
另外, 事务协调器节点指示事务管理器节点进行提交等操作时, 如有事务管理器节点出现了崩溃等情况而导致事务协调器始终无法获取所有事务管理器的响应信息, 这时事务协调器将只能依赖事务协调器自身的超时机制来生效. 但往往超时机制生效时, 事务协调器都会指示事务管理器进行回滚操作. 这样的策略显得比较保守.
3.2? 快照隔离
快照隔离 (Snapshot Isolation) 技术是实现多版本并发控制的技术之一. 此技术策略的前提就是每条数据都要支持版本化, 事务对数据的每次写操作成功提交后 (更新, 插入, 删除) 都会生成该数据的一个新版本. 这里有一个概念, 就是写操作成功提交之后, 才会生成数据的新版本. 在写操作没有成功提交之前, 对数据的任何修改, 都不算生效的.
什么是快照 Snapshot 呢? 简单来说就是在某个特定时刻 T1, 数据库里面所有数据最新版本的集合. 举个例子, 比如数据库里面只有 3 条记录, 它们在时间戳 T1 的时候, 状态如下:
?
即 row1[version 10],row2[version = 1], row3[version=19] 就形成了数据库在 T1 时刻的快照. 过了几分钟之后, 到了时间 T2, 如果在 T1 和 T2 之间没有写操作成功提交, 那么数据库的状态没有变化, 即 T1 时候的快照和 T2 时候的快照是相等的. 再过了几分钟之后, 到了时间 T3, 在 T2 和 T3 之间, 有一条对 row2 的更新操作, 一条对 row3 的删除操作, 一条 row4 的插入操作成功提交了, 数据库中的数据状态变成了:?
?
即 row1[version 10],row 2[version = 2], row3[version=20],row4[version=1] 就构成了数据库在 T3 时间的快照. 因为每条记录的版本变更是不一样的, 所以需要注意数据版本的变更情况.
另外, 请注意在数据多版本的要求下, 删除操作并不是真的删掉 row3, 而是生成了一个 row3 的新版本. 在实际实现中, 数据库不一定是按上面示例的一样把值赋值成 null, 也可能用一个特殊的标志位标识这是一个 "删除" 的版本.
快照永远和特定的时间相关, 脱离时间谈论快照是没有意义的. 如果在一段时间内, 数据库没有任何写操作成功提交, 那么这段时间内, 数据库在任意时间的快照都是相等的. 所以, 我们可以认为, 每一个有包含写操作的事务成功提交, 都会形成数据库的一个不同的快照. 在很多数据库实现中, version 直接使用时间戳, 而不是上面例子中的数字.
每个事务在启动时, 都会记录当时的时间作为启动时间戳 Start-Timestamp. 该事务只能读取启动时间戳那个时刻的数据快照. 然后每个事务在提交时, 会记录当时时间作为提交时间戳 Commit-Timestamp, 当该事务成功提交后, 会形成一个 Commit-Timestamp 的数据快照. 后续启动的事务才能看到该事务写的数据(如果该事务有写操作).
?
?
上图中, 三条横线代表三个事务. 事务 T2 是看不到事务 T1 写的任何数据的, 因为事务 T2 启动时, 事务 T1 还没有提交. 而事务 T3 可以看到事务 T1 和事务 T2 写的数据, 因为它启动的时候, 事务 T1 和事务 T2 都提交了.
快照隔离 (Snapshot Isolation) 需要通过锁机制来防止写冲突, 对于读操作, 不加锁. 如果多个事务同时写一个数据, 锁机制保证最多只有一个事务能提交成功. 由于对读操作不加锁, Snapshot Isolation 的性能会显著提高.
4
SequoiaDB 分布式事务实现
4.1 基本概念和定义
为了实现分布式事务, 巨杉数据库通过采用全局时间来实现全局事务对跨数据分片的事务的协调和管理. 基于此需求, 为了确定全局时间, 巨杉数据库定义了时间戳的相关概念与定义, 引入了时间戳管理机制. 具体的定义如下:
LLT(Local Logical Timestamp): 每个节点 (CATALOG,COORD,DATA) 维护自己的本地逻辑时间(最小单位: microsecond)
ULT(Universal Logical Timestamp): 定义 CATALOG 主节点的本地时间为全局逻辑时间(最小单位: microsecond)
LRT(Local Real Timestamp): 本地 UTC 时间
为了保证整个集群全局时间的一致与准确, 协调节点 (COORD) 和数据节点 (DATA) 需要定时与编目节点 (CATALOG) 的主节点进行时间同步. 而同步时间定义了以下规则:
1.?CATALOG 主节点的 LLT(即 ULT)通过所有机器的 CPU Tick 计算
2.? 其它节点的 LLT 通过与 CATALOG 主节点进行同步 ULT 来维护
同步的间隔为 ULTSyncInterval(默认: 60 秒)
同步结果需要使用差小于全局容忍误差 ULTTolerance(默认:?1ms)
5.?ULTTolerance 根据时间差同步, 网络状态进行动态调整
全局时间的定义及规则确认之后, 则可以将其用于分布式事务的实现当中. 分布式事务采用二段提交机制实现, 结合二段提交的原理, 定义了以下几类事务时间:
TBT(Transaction Begin Timestamp): 事务开始时间
TPCT(Transaction Pre-Commit Timestamp): 事务的预提交 (precommit) 时间
TCP(Transaction Commit Timestamp): 事务的提交时间
其中, 同一个事务的 TBT 和 TPCT 之间需要有一个事务时间间隔, 此间隔取当时 ULTTolerance. 事务时间间隔也可以定义为不同节点发起的事务时间之间的最小可以容忍的误差. 即如果两个不同节点的事务时间之间相关小于事务时间间隔, 即认为这两个事务时间有误差的情况下相等.
4.2 二段提交实现
巨杉数据库对于分布式事务采用的是经典二段提交 (2PC) 方式实现的. 其采用全局时间来实现全局事务的统一协调管理, 使分布式集群中的不同节点进行事务的统一操作. 在整个事务操作过程中, 客户端发起的事务分为三个部分:
第一部分: 事务开始. 在这一部分的操作中, 客户端向数据库服务器发起 "事务开始" 的请求. 数据库服务器结合其本地逻辑时间生成一个事务开始时间, 并记录在案.
第二部分: 事务的增删改查操作. 此部分是整个事务原子包的系列操作, 它包含增删查改四类基本数据操作. 在执行事务原子包里面第一条 SQL 语句时, 分布式集群需要判断和校验协调节点和数据节点之间的时间差值. 如果此差值大于延时容忍值, 则要求 COORD 节点, DATA 节点向 CATALOG 主节点发起时间同步, 然后再重新发起 SQL 操作. 如果时间差在容忍范围内, 则直接执行. 第一条事务操作执行成功后, 说明时间比对成功, 接下来的操作则直接执行.
第三部分: 事务完成. 此部分为事务的结束部分. 在此部分中, 整个事务执行完成, 开始发起事务提交的操作. 此操作进入事务的二段提交阶段, 即先预提交, 预提交成功之后再提交一次, 整个提交流程才完成.
巨杉数据库事务实现的具体流程如下图:
?
4.3? 并发控制技术
巨杉数据库对于多版本控制 (MVCC) 技术是通过采用事务锁, 内存老版本以及磁盘回滚段重建老版本的设计来实现. 此架构设计的理论基础是通过对内存结构的合理利用, 存储数据和索引的老版本信息, 从而实现数据的快速的并发访问.
此架构的基本原则是: 充分利用内存结构缓存老版本以提高读的访问速度, 同时结合事务可视性条件和 MVCC 来满足全局事务的不同隔离级别 (RC/RR) 的访问要求. 在 MVCC 的实现中, 巨杉数据库也平衡兼顾运行时的效率和多版本存储空间的使用, 以及回收的开销.
在多版本控制技术的事务锁实现中, RR(可重复读)配置下的读操作可以在使用完记录之后立即释放锁, 不需要一直持有, 直到事务提交或者回滚. 但是写事务操作则需要一直持有插入, 更改和删除的锁, 直到事务完成提交或者回滚. 巨杉数据库锁的实现是采用悲观锁机制, 与传统关系型数据库的采用的主流锁机制类似.
在多版控制技术的实现中, 除了引入悲观锁的机制以外, 巨杉数据库还采用了内存老版本机制提升数据库并发访问及操作的能力. 内存老版本是通过在记录锁上附加有一个存储原版本数据和索引相关的结构, 于内存中存储了老版本的数据.
所有事务写操作 (修改, 删除, 插入) 会在该结构中保存一个事务开始前的记录的拷贝, 还包含所有改动过索引的原始版本. 当读者试图获取记录锁时, 如果记录正在被修改, 读者取锁失败时将通过回调函数获得该锁的老版本结构, 从而获取上次提交后的数据. 在事务提交时, 释放记录锁之后异步回收存储老版本记录和索引的空间, 用户可以选择打开异步删除涉及到的待删除数据. 同时在该锁或记录被下一个写操作用到时, 他们都会被同步回收. 其中老版本的结构如下:
?
?
巨杉数据库在实现多版本并发控制技术时, 除了采用事务锁和内存老版本机制外, 还采用了磁盘回滚段对并发控制策略进行了完善与补充. 众所周知, 内存是高速存储设备, 但是其存在存储空间比较小以及断电数据丢失的问题. 针对此问题, 磁盘回滚段机制通过将内存中的 "老版本数据" 持久化到磁盘上, 保证数据库在掉电等异常情况下不会影响事务的正常操作.
回滚段使用系统集合空间, 名为 "SYSRBS". 另外, 其内部会使用 1 个集合, 命名格式为 "SYSRBSXXXX", 其中 XXXX 为循环编号, 范围为 0~4096. 同时, 回滚段使用第一个集合 (即: SYSRBS0000) 存储 RBS 的元数据, 包括当前 RBS 集合和最后空闲 RBS 集合. 巨杉数据库会在启动时检查是否支持 MVCC, 如果支持, 则会检查 "SYSRBS" 集合空间是否存在, 不存在的话则会创建此集合空间, 同时创建 SYSRBSCL0000 和 SYSRBSCL0001 集合. 如果回滚段的集合空间和集合均存在, 则会从 SYSRBSCL0000 中读取元数据信息, 根据当前 RBS 集合和最后空闲 RBS 集合信息创建下一下 SYSRBSCLXXXX.
为了更进一步提高读取速度, 巨杉数据库将磁盘回滚段与内存老版本相结合, 最新的老版本还是挂在记录锁的 oldversionContainer 上, 其它更老的版本放磁盘上. 这样满足大多数据短事务只用读内存的老版本, 无需再读磁盘, 从而提供了读取速度. 考虑到主节点异常的情况, 多版本控制需要将记录老版本数据的回滚段也同步至备节点, 当备节点升为主节点后, 可以通过回滚段重建老版本.
当事务 ID 小于全局最小事务 ID(lowTranID)时, 数据库后台的异步线程负责回收老版本记录和索引节点内存. 内存老版本清理时要将其保存的老版本写入 RBS. 而磁盘老版本的清理则是从最后空闲集合 (lastFreeCL) 开始, 逐个对比表的最大事务 ID(MaxGTID), 如果小于全局最小事务 ID, 则可以删除这个表(即 SYSRBSCLXXXX).
5
总结
巨杉数据库通过采用事务锁, 内存老版本以及磁盘回滚段重建老版本的设计来实现了多版本并发控制技术. 此设计通过对内存结构的合理利用, 存储数据和索引的老版本信息, 从而实现多版本数据的快速的并发访问.
?