「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!」html
下面是摘自《Principles of Distributed Database Systems, 3rd Edition》中关于事务的一段描述,讲述了事务实现所依赖的组件:前端
事务是对数据库进行一致、可靠访问的基本单元,做为一个比较大的原子操做,负责将数据库从一个状态转移到另外一个状态。为了知足一致性,须要对数据完整性限制进行定义,而且须要并发控制算法来协调多个事务的执行。并发控制也会处理隔离性的问题,事务的持久性和原子性须要可靠性的支持。持久性是由不一样的提交协议和提交管理方法实现的;另外为了知足原子性,还须要开发恰当的恢复协议。ios
事务须要知足ACID属性,即:原子性、一致性、隔离性、持久性。本篇主要分析隔离性的实现,对其余三个属性仅略做说明。算法
隔离级别的定义是从数据库并发访问所出现问题引发的,下面是来自postgres针对隔离级别的解释说明:sql
下表列出了每种隔离级别的含义,主要是描述该级别语义下,上述问题是否容许出现:数据库
下面解释了这两种隔离级别一般的实现手段。后端
snapshot isolation缓存
mvcc:经过版本号来实现快照。markdown
锁:经过锁解决write-write冲突。这里有悲观和乐观两种处理冲突的方式。
serializable
大多数状况下,都将这两种隔离级别归类为同一种隔离级别,可是两种隔离级别仍是有细微差异。不过目前主流的数据库都采用SI,下面是两种隔离级别分别存在的问题。
这种隔离级别的实现方式是经过锁来实现的。当T1要按照某个条件查询数据时,若是查出了10行,就会对这10行数据加锁。后来的事务若是想改这10行数据,就会申请锁失败,所以T1再次读这10行数据时,内容不会发生变化,这就是repeatable read语义。
可是若是另一个事务T2插入了一行新的数据,这行数据知足T1的过滤条件,这行数据在T1执行时是没法加锁的(由于尚未这行数据),T2提交以后,T1若是再次执行一样的查询,会查出11行数据,这就是幻读问题。
SI没有幻读问题。SI隔离级别经过快照方式进行数据隔离,所以对于上面的例子,T1执行时的快照不会受T2的影响,T2插入的数据在T1的生命周期中是不会被查询到的,所以SI隔离级别没有幻读问题。
可是SI有Write Skew问题,关于Write Skew问题的例子以下图,两个事务:T1要把全部的白球染成黑色,T2要把全部的黑球染成白色。若是是serializable隔离级别,则要么最终全为白色,要么最终全为黑色,根据事务执行的前后顺序而定;若是是SI隔离级别,T1和T2基于同一个快照来执行本身的事务,因为两个事务修改的数据不一样,因此两个事务不会产生冲突,会分别成功,致使最终的结果与指望不一样(T1指望全黑,T2指望全白)。也就是说SI隔离级别下,两个快照会产生遮挡效果。
而repeatable read反而没有Write Skew问题,由于T1提交前,为了保证repeatable read的语义,是会经过读锁的约束,不容许T2修改数据库中已有球的颜色的。若是在SI上经过锁来解决Write Skew的问题,会致使纯读事务堵塞写事务,丧失了SI的读不堵塞写的优点。
上述例子转换成一个可验证性较强的SQL示例以下:
T1: blacknum = select count(*) from balls where color=black;
T1: if blacknum > 0 update balls set color=black where color=white;
T2: whitenum = select count(*) from balls where color= white ;
T2: if whitenum > 0 update balls set color=black where color=black;
T1: commit;
T2: commit;
复制代码
另一个在yugabyte上测试过的例子:
yugabyte=# select * from users;
username | password | age
-----------+----------+-----
zhangsan3 | 111111 | 12
zhangsan1 | 111111 | 11
/* SI 级别测试 */
T1: begin;
T1: select count(*) from users where age = 12;
T1: update users set age=12 where age=11;
T2: begin;
T2: select count(*) from users where age = 11;
T2: update users set age=11 where age =12;
T1: commit;
T2: commit;
yugabyte=# select * from users;
username | password | age
-----------+----------+-----
zhangsan3 | 111111 | 11
zhangsan1 | 111111 | 12
/* serializable 级别测试 */
T1: begin; set transaction isolation level serializable;
T1: select count(*) from users where age = 12;
T1: update users set age=12 where age=11;
T2: begin; set transaction isolation level serializable;
T2: select count(*) from users where age = 11;
T2: update users set age=11 where age =12;
T1: commit;
T2: commit; ERROR: Error during commit: Operation expired: Transaction expired or aborted by a conflict: 40001
yugabyte=# select * from users;
username | password | age
-----------+----------+-----
zhangsan3 | 111111 | 12
zhangsan1 | 111111 | 12
复制代码
并发控制是实现隔离级别的手段。好比要实现serializable隔离级别,并发控制算法就须要将全部并发事务严格的排序,而后串行调度执行。
两阶段提交并发控制已经在理论上证实是能够实现serializable隔离级别的并发控制算法。可是2PL有2个比较典型的问题:
这里之因此排序,主要是要实现serializable隔离级别。这种算法处理冲突的方式是给失败的事务分配一个新的时间戳,而后重启该事务,所以TOCC算法不存在死锁场景,代价是事务会重启不少次。
基于时间的并发控制算法中,时间戳的生成是最核心的工做。要实现分布式的全局惟一单调递增的时间戳是很是困难的工做,目前没有这方面的解决方案。所以目前的时间戳生成都是每一个节点生成本身的时间戳,这带来的问题是节点间的时间戳可能不一致,某些节点分配的时间戳落后于其余节点,这会致使由该节点生成的事务一般会被拒绝而后重启。
为了尽可能让节点间的时间戳保持同步,一个优化方式是节点间经过rpc来互相更新时间戳,两两之间选大的那个时间戳。
改进:TO最大的问题是会致使事务过多的重启,一种改进方式是调度器对要执行的事务进行缓存,而不是当即执行,直到调度器确认不会接受到时间戳更小的请求,此时再对事务进行排序执行。这样会在必定程度上缓解事务冲突的几率(时间戳小的会被先执行),从而减小事务重启的几率。不过缓存的方式可能引入死锁。
多版本TO是另一种用来避免重启事务的算法。基本的思想是:更新操做不修改数据库,而是建立一个新版本;每一个版本都标记了与该版本相关的事务的时间戳信息(可能包含事务的start和commit时间戳)。
事务管理程序为每一个事务分配一个时间戳,事务的读操做会被转换为对某个版本的读(根据事务的当前时间戳来肯定)。对于写操做,只有一种状况会被拒绝:已经存在一个时间戳更大(意味着更晚到来,也就是更新)的事务在目标数据上进行读,这种状况才会拒绝这个时间戳较老的事务的执行。
mvcc不对事务进行严格排序执行,此种并发控制一般用于实现snapshot isolation级别。SI是目前商业数据库采用比较多的隔离级别。SI相对serializable,存在一个问题:Write Skew;在大多数场景下SI已经足够好。
这是针对并发控制算法所采起的机制的一种表述,主要是从性能角度考虑问题。当咱们假设事务之间的冲突时比较频繁的时候,一般会采起悲观算法;反之会采起乐观算法。
悲观算法不容许两个事务对同一个数据项进行冲突访问,所以悲观算法的执行步骤以下:
有效性验证 -> 读 -> 计算 -> 写
乐观算法将有效性验证移到写以前执行:
读 -> 计算 -> 有效性验证 -> 写
乐观算法不会堵塞事务的执行,所以具备更高的并发能力。可是若是并发事务之间冲突比较多,可能会致使事务发生较多的重启,从而影响性能。乐观算法适合冲突较少发生的并发场景。
一种实现乐观锁的方式是在事务有效性验证的时候分配时间戳,这样的分配方式产生的结果就是哪一个事务先commit,哪一个事务会得到较早的时间戳;对于乐观锁并发控制来讲,过早分配时间戳反而会致使没必要要的冲突断定发生。
对于并发控制实现的有效性,一般都有比较严格的形式化证实。这里没有深刻了解。
yugabyte的隔离级别是基于mvcc+锁类组合实现的(固然从更高的层面来看待这个问题的话,也能够认为锁是实现mvcc的一部分,这里分红两部分来看待有助于更清楚的理解),这里具体解释mvcc和锁在yugabyte的隔离级别实现中分别解决什么问题:
mvcc
mvcc主要是解决并发访问问题。传统的基于锁的并发访问控制性能比较差,缘由是传统的读锁会堵塞全部的写操做,只有读-读操做之间不会堵塞。mvcc的提出专门解决这个问题,用户的读操做是对特定版本的数据的访问,写操做不会修改该版本的数据,所以不会堵塞写。另外事务的原子性要求在一个事务内提交的全部数据的版本号必须相同,这保证了读操做不会读取到事务执行一半后的结果。
若是系统可以保证一个事务在开始阶段获取的数据库视图,在整个事务执行过程当中保持不变(除了事务本身产生的变动),那么系统就完成了多版本(mv)这一层面的工做。可是只有多版本还不能彻底解决问题,缘由是当多个用户基于同一个版本的数据进行修改时,仍是会有冲突产生的,解决并发写冲突问题仍是须要锁的参与(或者其余无锁数据结构)。
锁
当冲突发生时,有不少识别冲突的机制。加锁是用来规避冲突的一种方式,实现起来比较简单直观。好比T1修改row1的column1的值时,会加锁;此时T2若是想作一样的操做经过检查锁的存在与否就能够提早检测到冲突。
另外,yugabyte的不一样隔离级别的控制也是经过锁来实现的。yugabyte定义了很是细粒度的锁类型,不一样隔离级别根据其语义要求,在数据访问时加不一样的锁。好比:两个SI级别的写锁在语义上是冲突的,即:在SI隔离级别下,两个事务对同一个对象申请read-for-update的写锁是不被支持的,从而不容许此类事务并发执行(或者在申请时不作检查,最终提交时检测冲突时让一个失败,根据悲观仍是乐观控制方法不一样而不一样)。而对于serializable隔离级别的写锁,语义上是不冲突的,因为在该隔离级别下事务是串行执行,多个事务同时更新同一个目标数据是被容许并发执行的,最终的结果是latest hybrid timestamp wins。值得指出的是,yugabyte的SI级别的纯写锁(区别于read-for-update锁)与serializable隔离基本的写锁采用的是同一种锁: strong serializable write lock 。关于锁的更具体的定义接下来会详细讲解。
对于冲突事务的处理,若是是在commit阶段检查冲突(乐观锁),一般的处理方式是First-Committer-Wins(FCW),后提交的直接失败,从而规避Lost Update问题;若是是在事务开始阶段检查冲突(悲观锁),则一般采用 First-write-wins(FWW)机制,后启动的事务直接失败。 yugabyte没有采用FWW,而是采用优先级的方式,优先级高的事务能够继续执行,优先级低的直接失败。yugabyte的优先级分配是随机数,两个事务的优先级高低是随机的。不过悲观锁和乐观锁的优先级处于不一样的区间,悲观锁的优先级区间的全部值都高于乐观锁。所以一个乐观锁事务若是与一个悲观锁事务冲突,则乐观锁的事务必定会被取消。
总结:以上就是yugabyte隔离级别实现的所有考虑,剩下的就是基于这些考虑的具体实现。
数据表示
DocDB的数据在磁盘上是以Tablet为单位管理的,每一个Tablet对应一个rocksdb文件数据库实例。 rocksdb是一个kv系统,对于一个特定的Key-Value对,yugabyte经过将事务的时间戳编码到Key中来标记数据的版本号。
版本管理
yugabyte事务中的数据访问操做最终都会转换成对一个或多个Tablet的operation,DocDB接收到的每一个请求都是独立到某个Tablet的,不存着一个请求操做多个Tablet的状况,所以一个事务若是须要访问(修改)多个Tablet的数据,在YQL层面会转换成多个DocDB的rpc请求,而多个Tablet是可能分布在不一样节点上的,这里就须要YQL经过分布式事务的方式来管理事务,关于分布式事务的实现这里不展开讨论,有专门的一篇文章来讲明。这里主要是为了说明一点:到达DocDB的请求必定是具体到某个Tablet了,因此DocDB的版本管理也是在Tablet内部完成的,不是全局管理。
DocDB经过 operation来抽象全部对Tablet的写操做,operation能够看作是DocDB内部的事务。 每一个Tablet内部都有一个MvccManager,MvccManager主要负责时间戳管理,他提供以下特性:
为了支持SNAPSHOT和SERIALIZABLE两种隔离级别,yugabyte对锁进行了以下划分 :
能够看到SI read并不加锁,所以SI隔离级别的read不会堵塞任何其余事务。 下面是这三种锁的冲突矩阵:
Fine grained locking:
为了在更细粒度上减小锁的冲突,yugabyte按照数据读写的特色对锁进行了更近一步的划分,好比当修改一行中的某个列的value时,在行上加WeakLock,在要修改的列上加StrongLock;这样当另外一个事务修改的是同一行的另外一个列的value时,WeakLock之间不冲突, StrongLock也不冲突(由于锁的是不一样对象), 因此这个事务能够并发执行,下面是更细粒度的锁冲突矩阵:
SharedLockManager实现了DocDB的fine-grained locking,每一个Tablet维护一个 SharedLockManager实例,负责当前Tablet的锁控制。
yugabyte的锁类型定义:
YB_DEFINE_ENUM(IntentType,
((kWeakRead, kWeakIntentFlag | kReadIntentFlag))
((kWeakWrite, kWeakIntentFlag | kWriteIntentFlag))
((kStrongRead, kStrongIntentFlag | kReadIntentFlag))
((kStrongWrite, kStrongIntentFlag | kWriteIntentFlag))
);
复制代码
本节将详细分析事务的执行流程中涉及并发控制部分的工做机制,在这个流程分析过程当中,咱们将重点关注以下一些问题:
Operation
Operation是对写操做的事务具体操做的抽象,根据操做类型不一样又派生出具体的Operation对象,如:
Operation主要封装了以下接口:
OperationDriver
OperationDriver是对一个事务执行流程的抽象,全部事务的执行都由OperationDriver来管理,事务的执行流程抽象步骤:
Init() :事务开始前会先建立一个OperationDriver对象来管理该事务的执行流程。
ExecuteAsync():将OperationDriver提交到Preparer线程并当即返回,后续的执行由Preparer线程控制。
PrepareAndStartTask():调用Prepare() and Start()。
ReplicationFinished():raft完成复制后调用改回调函数,该回调函数是在Init阶段赋值给raft的。
HybridTime
用来多版本控制的混合时间戳实现。
MvccManager
MvccManager负责维护每一个tablet的mvcc控制流程。MvccManager维护了一个deque队列,用来跟踪全部operation的时间戳, MvccManager要求全部operation的 replicated顺序必须与该operation的时间戳的入队顺序保持一致。也就是对同一个tablet上的全部的operation必须按照时间戳申请的前后顺序完成。
Tablet
用户的一个表中的数据会按partition进行分区管理,每一个分区在yugabyte中称做一个Tablet,raft复制是以tablet为单位管理的。 每一个tablet维护本身的MvccManager实例,用以管理当前tablet的混合时间戳分配。
SharedLockManager
并发控制的锁实现。
TransactionCoordinator
负责分布式事务状态管理,即:status tablet的读写。同时协调TransactionParticipant完成数据最终写入到normal_db
TransactionParticipant
负责处理APPLYING和CLEANUP请求,这两个请求一个是commit成功后将数据从intent_db写入到normal_db,完成最终的数据提交;另外一个是对abort的事务进行rollback操做,清理垃圾数据(从intent_db中删除)。
该阶段是申请事务,会分别在client端和server端建立YBTransaction实例(metadata相同)。
心跳信息,TakeTransaction以后会当即执行。通知status tablet更新状态,并维护心跳。
这一步骤是进行数据的读写,client端构造doc operation,经过rpc发送给DocDB(TabletService)。事务的隔离级别和锁的控制都在这里处理。
申请内存锁阶段(内存锁只在tablet leader上持有)
这里针对要修改的目标对象申请锁的类型进行总结(其父路径所有申请相应的weak锁):
1)若是是Serializable,读操做走写流程,会申请StrongRead锁
2)若是是snapshot isolation,读操做不申请任何锁。
3)若是是Serializable,UpSert操做申请StrongWrite锁,其余写操 做 (insert/update/delete)申请StrongRead + StrongWrite锁
4)若是是snapshot isolation,申请 StrongRead + StrongWrite锁
5)全部父路径都申请对应的weak锁。
目前看来,yugabyte把不一样隔离级别的事务冲突语义所有在该阶段完成,保证只要这里放行的事务,后续的并发执行不会出现问题,全部可能引发数据正确性不符合隔离级别的事务要么在该阶段被取消,要么把其余正在执行的事务取消,让本身得以执行。
数据复制阶段\
这一阶段经过raft将对数据库的修改操做复制到全部副本所在节点上,当获得大多数节点完成复制的响应后,Leader会将数据写入到rocksdb(对于分布式事务,写intent_db,记录中包含锁),而后leader会释放内存锁。
// 收到commit消息后,会检查事务是否被其余事务给取消了
// raft将TransactionStatus::COMMITTED消息复制到全部副本,复制完成后,更新 commit_time_
commit_time_ = data.hybrid_time; // 这个时间戳是 COMMITTED这rpc请求对应的operation在start阶段从MvccManager申请的
// 而后调用StartApply将 APPLYING请求入队,该请求的参数以下:
void StartApply() {
if (context_.leader()) {
for (const auto& tablet : involved_tablets_) { // 通知当前事务的全部涉及到的tablet,执行APPLY操做
context_.NotifyApplying({
.tablet = tablet.first,
.transaction = id_,
.commit_time = commit_time_, // commit_time就是 COMMITTED消息被raft复制完成后的时间戳
.sealed = status_ == TransactionStatus::SEALED});
}
}
}
复制代码
// COMMITTED 消息处理完后,对发起事务的客户端来讲,事务的流程已经走完了,数据已经可见了。而对yugabyte来讲,
// 数据还停留在intent_db中,须要移动到normal_db,并设置LocalCommitedTime,供其余并发事务检测冲突使用。
// 该消息是由TransactionParticipant处理的,一样的会先将该消息经过raft复制到tablet的全部副本,raft复制完该消息后,
// 执行ProcessApply,将数据从intent_db 迁移到 normal_db,而后注册异步任务删除intent_db中的数据
HybridTime commit_time(data.state.commit_hybrid_time()); // 这是 COMMITTED的时间戳
TransactionApplyData apply_data = {
data.leader_term,
id, // 事务ID
data.op_id, // operation id
commit_time, // COMMITTED的时间戳
data.hybrid_time, // APPLYING的时间戳
data.sealed,
data.state.tablets(0) };
ProcessApply(apply_data);
// 每一个tablet上的 ProcessApply 工做流程(目前只有局部tablet视角)
CHECKED_STATUS ProcessApply(const TransactionApplyData& data) {
{ // 一顿操做,主要是设置 Local Commit Time
// It is our last chance to load transaction metadata, if missing.
// Because it will be deleted when intents are applied.
// We are not trying to cleanup intents here because we don't know whether this transaction
// has intents of not.
auto lock_and_iterator = LockAndFind(
data.transaction_id, "pre apply"s, TransactionLoadFlags{TransactionLoadFlag::kMustExist});
if (!lock_and_iterator.found()) {
// This situation is normal and could be caused by 2 scenarios:
// 1) Write batch failed, but originator doesn't know that.
// 2) Failed to notify status tablet that we applied transaction.
LOG_WITH_PREFIX(WARNING) << Format("Apply of unknown transaction: $0", data);
NotifyApplied(data);
CHECK(!FLAGS_fail_in_apply_if_no_metadata);
return Status::OK();
}
lock_and_iterator.transaction().SetLocalCommitTime(data.commit_ht);
LOG_IF_WITH_PREFIX(DFATAL, data.log_ht < last_safe_time_)
<< "Apply transaction before last safe time " << data.transaction_id
<< ": " << data.log_ht << " vs " << last_safe_time_;
}
// 经过事务反向索引,找到全部的intent,而后apply到normal_db
CHECK_OK(applier_.ApplyIntents(data));
{// 这里发起异步删除 intent 任务(删除intent_db中的数据)
MinRunningNotifier min_running_notifier(&applier_);
// We are not trying to cleanup intents here because we don't know whether this transaction
// has intents or not.
auto lock_and_iterator = LockAndFind(
data.transaction_id, "apply"s, TransactionLoadFlags{TransactionLoadFlag::kMustExist});
if (lock_and_iterator.found()) {
RemoveUnlocked(lock_and_iterator.iterator, "applied"s, &min_running_notifier);
}
}
NotifyApplied(data);
return Status::OK();
}
复制代码
事务由于某些缘由(发生错误或用户主动abort)走到abort流程后,须要分别由coordinator变动status tablet状态,由participate清理垃圾数据。