1.隔離等級
#(1)讀取不提交(Read Uncommited,RU)
這種隔離等級下,事務間完全不隔離,會產生髒讀,可以讀取未提交的記錄,實際情況下不會使用。
(2)讀取提交(Read commited,RC)
#只能讀取到已提交的記錄,這種隔離等級下,會存在幻讀現象,所謂幻讀是指在同一個事務中,多次執行同一個查詢,傳回的記錄不完全相同的現象。幻讀產生的根本原因是,在RC隔離等級下,每個語句都會讀取已提交交易的更新,若兩次查詢之間有其他事務提交,則會導致兩次查詢結果不一致。雖然如此,讀取提交隔離等級在生產環境中使用很廣泛。
(3)可重複讀取(Repeatable Read, RR)
#可重複讀取隔離等級解決了不可重複讀取的問題,但依然沒有解決幻讀的問題。那麼不可重複讀與幻讀有什麼差別呢?不可重複讀重點在修改,即讀取過的數據,兩次讀的值不一樣;而幻讀則側重於記錄數目變化【插入和刪除】。一般教科書上告訴我們只有到串列化隔離等級才解決幻讀問題,但mysql的innodb比較特殊,RR即解決了幻讀問題,主要透過GAP鎖定來實現。另外,不是所有的資料庫都實作了該隔離級別,後面會簡單介紹下mysql是如何實作可重複讀隔離級別的。
(4)串列化(Serializable)
#在串列化隔離模式下,消除了髒讀,幻象,但事務並發度急劇下降,事務的隔離等級與事務的並發度成反比,隔離等級越高,事務的並發度越低。在實際生產環境下,dba會在並發和滿足業務需求之間進行權衡,選擇合適的隔離等級。
2.並發調度方式
與隔離等級緊密聯繫的另一個東西是並發調度,透過並發調度實現隔離等級。對於同時調度,不同的資料庫廠商有不同的實作機制,但基本原理類似,都是透過加鎖來保護資料物件不同時會被多個交易修改。多版本的並發控制(MVCC)相對於傳統的基於鎖的並發控制主要特點是讀不上鎖,這種特性對於讀取多寫少的場景,大大提高了系統的並發度,因此大部分關係型資料庫都實現了MVCC。
3.兩階段鎖定協定
兩階段鎖定協定的意義是,事務分為兩個階段,第一個階段是獲得封鎖,第二個階段是釋放封鎖。兩階段封鎖保證並發調度的正確性。兩階段封鎖相對於一階段封鎖(一次性獲得事務所需的所有鎖),提高了並發度,但同時也帶來了死鎖的可能。
4.死鎖
所謂死鎖是指兩個或多個事務,各自佔有對方的期望獲得的資源,形成的循環等待,彼此無法繼續執行的一種狀態。
5.鎖定類型
根據鎖的型別分,可以分為共享鎖,排他鎖,意向共享鎖和意向排他鎖。根據鎖的粒徑分,又可以分為行鎖,表鎖。對於mysql而言,事務機制更多是靠底層的儲存引擎來實現,因此,mysql層面只有表鎖,而支援事務的innodb儲存引擎則實現了行鎖(記錄鎖),gap鎖,next-key鎖。 Mysql的記錄鎖實質是索引記錄的鎖,因為innodb是索引組織表;gap鎖是索引記錄間隙的鎖,這種鎖只在RR隔離等級下有效;next-key鎖是記錄鎖加上記錄之前gap鎖的組合。 mysql透過gap鎖定和next-key鎖定實現RR隔離等級。
說明:
對於更新作業(讀不上鎖),只有走索引才可能上行鎖定;否則會對叢集索引的每一行上寫鎖,實際上等同於對錶上寫鎖。
若多個實體記錄對應同一個索引,若同時訪問,也會出現鎖定衝突;
##當表有多個索引時,不同事務可以用不同的索引鎖住不同的行,另外innodb會同時用行鎖對資料記錄(叢集索引)加鎖。
MVCC並發控制機制下,任何操作都不會阻塞讀取操作,讀取操作也不會阻塞任何操作,只因為讀不上鎖。
RocksDB作為一個開源的儲存引擎支援事務的ACID特性,而要支援ACID中的I(Isolation),並發控制這塊是少不了的,本文主要討論RocksDB的鎖機制實現,細節會涉及到原始碼分析,希望透過本文讀者可以深入了解RocksDB並發控制原理。文章主要從以下4方面展開,首先會介紹RocksDB鎖的基本結構,然後我會介紹RocksDB行鎖資料結構設計下,鎖空間開銷,接著我會介紹幾種典型場景的上鎖流程,最後會介紹鎖機制中不可或缺的死鎖偵測機制。
1.行鎖定資料結構
# RocksDB鎖定尺寸最小是行,對於KV儲存而言,鎖定物件就是key ,每一個key對應一個LockInfo結構。所有key透過hash表管理,找出鎖定時,直接透過hash表定位即可確定這個key是否已經被上鎖。但如果全域只有一個hash表,會導致這個存取這個hash表的衝突很多,影響並發效能。 RocksDB先按Columnfamily進行拆分,每個Columnfamily中的鎖定透過一個LockMap管理,而每個LockMap再拆分成若干個分片,每個分片透過LockMapStripe管理,而hash表(std::unordered_map
相關資料結構如下:
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在一个事务需要锁大量记录场景下是有优化的,多个记录可以公用一把锁,这样也间接可以减少内存。
3.上锁流程分析
前面简单了解了RocksDB锁数据结构的设计以及锁对内存资源的消耗。这节主要介绍几种典型场景下,RocksDB是如何加锁的。与InnoDB一样,RocksDB也支持MVCC,读不上锁,为了方便,下面的讨论基于RocksDB作为MySQL的一个引擎来展开,主要包括三类,基于主键的更新,基于二级索引的更新,基于主键的范围更新等。在展开讨论之前,有一点需要说明的是,RocksDB与InnoDB不同,RocksDB的更新也是基于快照的,而InnoDB的更新基于当前读,这种差异也使得在实际应用中,相同隔离级别下,表现有所不一样。对于RocksDB而言,在RC隔离级别下,每个语句开始都会重新获取一次快照;在RR隔离级别下,整个事务中只在第一个语句开始时获取一次快照,所有语句共用这个快照,直到事务结束。
3.1.基于主键的更新
这里主要接口是TransactionBaseImpl::GetForUpdate
1).尝试对key加锁,如果锁被其它事务持有,则需要等待
2).创建snapshot
3).调用ValidateSnapshot,Get key,通过比较Sequence判断key是否被更新过
4).由于是加锁后,再获取snapshot,所以检查一定成功。
5).执行更新操作
这里有一个延迟获取快照的机制,实际上在语句开始时,需要调用acquire_snapshot获取快照,但为了避免冲突导致的重试,在对key加锁后,再获取snapshot,这就保证了在基于主键更新的场景下,不会存在ValidateSnapshot失败的场景。
堆栈如下:
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).当返回的二级索引不符合条件时,则结束
4.死锁检测算法
死锁检测采用DFS((Depth First Search,深度优先算法),基本思路根据加入等待关系,继续查找被等待者的等待关系,如果发现成环,则认为发生了死锁,当然在大并发系统下,锁等待关系非常复杂,为了将死锁检测带来的资源消耗控制在一定范围,可以通过设置deadlock_detect_depth来控制死锁检测搜索的深度,或者在特定业务场景下,认为一定不会发生死锁,则关闭死锁检测,这样在一定程度上有利于系统并发的提升。需要说明的是,如果关闭死锁,最好配套将锁等待超时时间设置较小,避免系统真发生死锁时,事务长时间hang住。死锁检测基本流程如下:
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; //持有锁时间, }
以上是mysql鎖機制的詳細介紹的詳細內容。更多資訊請關注PHP中文網其他相關文章!