RocksDB做为一个开源的存储引擎支持事务的ACID特性,而要支持ACID中的I(Isolation),并发控制这块是少不了的,本文主要讨论RocksDB的锁机制实现,细节会涉及到源码分析,但愿经过本文读者能够深刻了解RocksDB并发控制原理。文章主要从如下4方面展开,首先会介绍RocksDB锁的基本结构,而后我会介绍RocksDB行锁数据结构设计下,锁空间开销,接着我会介绍几种典型场景的上锁流程,最后会介绍锁机制中必不可少的死锁检测机制。html
1.行锁数据结构
RocksDB锁粒度最小是行,对于KV存储而言,锁对象就是key,每个key对应一个LockInfo结构。全部key经过hash表管理,查找锁时,直接经过hash表定位便可肯定这个key是否已经被上锁。但若是全局只有一个hash表,会致使这个访问这个hash表的冲突不少,影响并发性能。RocksDB首先按Columnfamily进行拆分,每一个Columnfamily中的锁经过一个LockMap管理,而每一个LockMap再拆分红若干个分片,每一个分片经过LockMapStripe管理,而hash表(std::unordered_map<std::string, LockInfo>)则存在于Stripe结构中,Stripe结构中还包含一个mutex和condition_variable,这个主要做用是,互斥访问hash表,当出现锁冲突时,将线程挂起,解锁后,唤醒挂起的线程。这种设计很简单但也带来一个显而易见的问题,就是多个不相关的锁公用一个condition_variable,致使锁释放时,没必要要的唤醒一批线程,而这些线程重试后,发现仍然须要等待,形成了无效的上下文切换。对比咱们以前讨论的InnoDB锁机制,咱们发现InnoDB是一个page里面的记录复用一把锁,并且复用是有条件的,同一个事务对一个page的若干条记录加锁才能复用;并且锁等待队列是精确等待,精确到记录级别,不会致使的无效的唤醒。虽然RocksDB锁设计比较粗糙,但也作了必定的优化,好比在管理LockMaps时,经过在每一个线程本地缓存一份拷贝lock_maps_cache_,经过全局链表将每一个线程的cache链起来,当LockMaps变动时(删除columnfamily),则全局将每一个线程的copy清空,因为columnfamily改动不多,因此大部分访问LockMaps操做都是不须要加锁的,提升了并发效率。
相关数据结构以下:mysql
struct LockInfo { bool exclusive; //排它锁或是共享锁 autovector<TransactionID> txn_ids; //事务列表,对于共享锁而言,同一个key能够对应多个事务 // Transaction locks are not valid after this time in us uint64_t expiration_time; } struct LockMapStripe { // Mutex must be held before modifying keys map std::shared_ptr<TransactionDBMutex> stripe_mutex; // Condition Variable per stripe for waiting on a lock std::shared_ptr<TransactionDBCondVar> stripe_cv; // Locked keys mapped to the info about the transactions that locked them. std::unordered_map<std::string, LockInfo> keys; } struct LockMap { const size_t num_stripes_; //分片个数 std::atomic<int64_t> lock_cnt{0}; //锁数目 std::vector<LockMapStripe*> lock_map_stripes_; //锁分片 } class TransactionLockMgr { using LockMaps = std::unordered_map<uint32_t, std::shared_ptr<LockMap>>; LockMaps lock_maps_; // Thread-local cache of entries in lock_maps_. This is an optimization // to avoid acquiring a mutex in order to look up a LockMap std::unique_ptr<ThreadLocalPtr> lock_maps_cache_; }
2.行锁空间代价
因为锁信息是常驻内存,咱们简单分析下RocksDB锁占用的内存。每一个锁其实是unordered_map中的一个元素,则锁占用的内存为key_length+8+8+1,假设key为bigint,占8个字节,则100w行记录,须要消耗大约22M内存。可是因为内存与key_length正相关,致使RocksDB的内存消耗不可控。咱们能够简单算算RocksDB做为MySQL存储引擎时,key_length的范围。对于单列索引,最大值为2048个字节,具体能够参考max_supported_key_part_length实现;对于复合索引,索引最大长度为3072个字节,具体能够参考max_supported_key_length实现。假设最坏的状况,key_length=3072,则100w行记录,须要消耗3G内存,若是是锁1亿行记录,则须要消耗300G内存,这种状况下内存会有撑爆的风险。所以RocksDB提供参数配置max_row_locks,确保内存可控,默认RDB_MAX_ROW_LOCKS设置为1G,对于大部分key为bigint场景,极端状况下,也须要消耗22G内存。而在这方面,InnoDB则比较友好,hash表的key是(space_id, page_no),因此不管key有多大,key部分的内存消耗都是恒定的。前面我也提到了InnoDB在一个事务须要锁大量记录场景下是有优化的,多个记录能够公用一把锁,这样也间接能够减小内存。git
3.上锁流程分析
前面简单了解了RocksDB锁数据结构的设计以及锁对内存资源的消耗。这节主要介绍几种典型场景下,RocksDB是如何加锁的。与InnoDB同样,RocksDB也支持MVCC,读不上锁,为了方便,下面的讨论基于RocksDB做为MySQL的一个引擎来展开,主要包括三类,基于主键的更新,基于二级索引的更新,基于主键的范围更新等。在展开讨论以前,有一点须要说明的是,RocksDB与InnoDB不一样,RocksDB的更新也是基于快照的,而InnoDB的更新基于当前读,这种差别也使得在实际应用中,相同隔离级别下,表现有所不同。对于RocksDB而言,在RC隔离级别下,每一个语句开始都会从新获取一次快照;在RR隔离级别下,整个事务中只在第一个语句开始时获取一次快照,全部语句共用这个快照,直到事务结束。github
3.1.基于主键的更新
这里主要接口是TransactionBaseImpl::GetForUpdate
1).尝试对key加锁,若是锁被其它事务持有,则须要等待
2).建立snapshot
3).调用ValidateSnapshot,Get key,经过比较Sequence判断key是否被更新过
4).因为是加锁后,再获取snapshot,因此检查必定成功。
5).执行更新操做
这里有一个延迟获取快照的机制,实际上在语句开始时,须要调用acquire_snapshot获取快照,但为了不冲突致使的重试,在对key加锁后,再获取snapshot,这就保证了在基于主键更新的场景下,不会存在ValidateSnapshot失败的场景。算法
堆栈以下:sql
1-myrocks::ha_rocksdb::get_row_by_rowid 2-myrocks::ha_rocksdb::get_for_update 3-myrocks::Rdb_transaction_impl::get_for_update 4-rocksdb::TransactionBaseImpl::GetForUpdate { //加锁 5-rocksdb::TransactionImpl::TryLock 6-rocksdb::TransactionDBImpl::TryLock 7-rocksdb::TransactionLockMgr::TryLock //延迟获取快照,与acquire_snapshot配合使用 6-SetSnapshotIfNeeded() //检查key对应快照是否过时 6-ValidateSnapshot 7-rocksdb::TransactionUtil::CheckKeyForConflict 8-rocksdb::TransactionUtil::CheckKey 9-rocksdb::DBImpl::GetLatestSequenceForKey //第一次读取 //读取key 5-rocksdb::TransactionBaseImpl::Get 6-rocksdb::WriteBatchWithIndex::GetFromBatchAndDB 7-rocksdb::DB::Get 8-rocksdb::DBImpl::Get 9-rocksdb::DBImpl::GetImpl //第二次读取 }
3.2.基于主键的范围更新
1).建立Snapshot,基于迭代器扫描主键
2).经过get_row_by_rowid,尝试对key加锁
3).调用ValidateSnapshot,Get key,经过比较Sequence判断key是否被更新过
4).若是key被其它事务更新过(key对应的SequenceNumber比Snapshot要新),触发重试
5).重试状况下,会释放老的快照并释放锁,经过tx->acquire_snapshot(false),延迟获取快照(加锁后,再拿snapshot)
5).再次调用get_for_update,因为此时key已经被加锁,重试必定能够成功。
6).执行更新操做
7).跳转到1,继续执行,直到主键不符合条件时,则结束。缓存
3.3.基于二级索引的更新
这种场景与3.2相似,只不过多一步从二级索引定位主键过程。
1).建立Snapshot,基于迭代器扫描二级索引
2).根据二级索引反向找到主键,实际上也是调用get_row_by_rowid,这个过程就会尝试对key加锁
3).继续根据二级索引遍历下一个主键,尝试加锁
4).当返回的二级索引不符合条件时,则结束数据结构
3.4 与InnoDB加锁的区别
前面咱们说到了RocksDB与InnoDB的一点区别是,对于更新场景,RocksDB仍然是快照读,而InnoDB是当前读,致使行为上的差别。好比在RC隔离级别下的范围更新场景,好比一个事务要更新1000条记录,因为是边扫描边加锁,可能在扫描到第999条记录时,发现这个key的Sequence大于扫描的快照(这个key被其它事务更新了),这个时候会触发从新获取快照,而后基于这个快照拿到最新的key值。InnoDB则没有这个问题,经过当前读,扫描过程当中,若是第999条记录被更新了,InnoDB能够直接看到最新的记录。这种状况下,RocksDB和InnoDB看到的结果是同样的。在另一种状况下,假设也是扫描的范围中,新插入了key,这key的Sequence毫无疑问会比扫描的Snapshot要大,所以在Scan过程当中这个key会被过滤掉,也就不存在所谓的冲突检测了,这个key不会被找到。更新过程当中,插入了id为1和900的两条记录,最后第900条记录因为不可见,因此更新不到。而对于InnoDB而言,因为是当前读,新插入的id为900的记录能够被看到并更新,因此这里是与InnoDB有区别的地方。
除了更新基于快照这个区别之外,RocksDB在加锁上也更简洁,全部加锁只涉及惟一索引,具体而言,在更新过程当中,只对主键加锁;更新列涉及惟一约束时,须要加锁;而普通二级索引,则不用加锁,这个目的是为了不惟一约束冲突。这里面,若是更新了惟一约束(主键,或者惟一索引),都须要加锁。而InnoDB则是须要对每一个索引加锁,好比基于二级索引定位更新,则二级索引也须要加锁。之因此有这个区别是,是由于InnoDB为了实现RR隔离级别。这里稍微讲下隔离级别,实际上MySQL中定义的RR隔离级别与SQL标准定义的隔离级别有点不同。SQL标准定义RR隔离级别解决不可重复读的问题,Serializable隔离级别解决幻读问题。不可重复读侧重讲同一条记录值不会修改;而幻读则侧重讲两次读返回的记录条数是固定的,不会增长或减小记录数目。MySQL定义RR隔离级别同时解决了不可重复读和幻读问题,而InnoDB中RR隔离级别的实现就是依赖于GAP锁。而RocksDB不支持GAP锁(仅仅支持惟一约束检查,对不存在的key加锁),由于基于快照的机制能够有效过滤掉新插入的记录,而InnoDB因为当前读,致使须要经过间隙锁禁止其它插入,因此二级索引也须要加锁,主要是为了锁间隙,不然两次当前读的结果可能不同。固然,对RC割裂级别,InnoDB普通二级索引也是没有必要加锁的。并发
4.死锁检测算法
死锁检测采用BFS((Breadth First Search,宽度优先算法),基本思路根据加入等待关系,继续查找被等待者的等待关系,若是发现成环,则认为发生了死锁。须要说明的是InnoDB的死锁检测采用的DFS(Deepth First Search,深度优先算法),逻辑都是相似的,目的是为了找环。自己BFS和DFS两种图搜索算法时间复杂度也相同O(V+E),V和E分别表示节点数目和边的数目。主要实现区别在于,BFS通常与队列配合使用,先进先出;DFS通常与栈配合使用,先进后出。固然在大并发系统下,锁等待关系很是复杂,为了将死锁检测带来的资源消耗控制在必定范围,能够经过设置deadlock_detect_depth来控制死锁检测搜索的深度,或者在特定业务场景下,认为必定不会发生死锁,则关闭死锁检测,这样在必定程度上有利于系统并发的提高。须要说明的是,若是关闭死锁,最好配套将锁等待超时时间设置较小,避免系统真发生死锁时,事务长时间hang住。死锁检测基本流程以下:app
1.定位到具体某个分片,获取mutex
2.调用AcquireLocked尝试加锁
3.若上锁失败,则触发进行死锁检测
4.调用IncrementWaiters增长一个等待者
5.若是等待者不在被等待者map里面,则确定不会存在死锁,返回
6.对于被等待者,沿着wait_txn_map_向下检查等待关系(一次加入全部等待的事务列表,而后逐个分析),看看是否成环
7.若发现成环,则将调用DecrementWaitersImpl将新加入的等待关系解除,并报死锁错误。
相关的数据结构:
class TransactionLockMgr { // Must be held when modifying wait_txn_map_ and rev_wait_txn_map_. std::mutex wait_txn_map_mutex_; // Maps from waitee -> number of waiters. HashMap<TransactionID, int> rev_wait_txn_map_; // Maps from waiter -> waitee. HashMap<TransactionID, autovector<TransactionID>> wait_txn_map_; DecrementWaiters // IncrementWaiters // } struct TransactionOptions { bool deadlock_detect = false; //是否检测死锁 int64_t deadlock_detect_depth = 50; //死锁检测的深度 int64_t lock_timeout = -1; //等待锁时间,线上通常设置为5s int64_t expiration = -1; //持有锁时间, }
参考文档
https://github.com/mdcallag/mytools/wiki/Cursor-Isolation
https://www.postgresql.org/docs/9.4/static/transaction-iso.html
https://github.com/facebook/mysql-5.6/issues/340
http://www.cnblogs.com/digdeep/p/4947694.html
http://www.cnblogs.com/digdeep/archive/2015/11/16/4968453.html