1. 分離レベル
(1) 非コミット読み取り (RU)
この分離レベルでは、トランザクションはまったく分離されず、ダーティ リードが発生し、コミットされていないレコードを読み取ることができます。実際の場面では使われません。
(2) コミットされた読み取り (RC)
は、この分離レベルでは、送信されたレコードのみを読み取ることができます。いわゆるファントム読み取りは、同様のファントム読み取りの現象を指します。トランザクション内で同じクエリが複数回実行されると、返されるレコードはまったく同じではなくなります。ファントム読み取りの根本的な原因は、RC 分離レベルの下で、各ステートメントが送信されたトランザクションの更新を読み取るため、2 つのクエリの間に他のトランザクションが送信されると、2 つのクエリの結果が矛盾することになります。それにもかかわらず、読み取りコミット分離レベルは実稼働環境で広く使用されています。
(3) リピータブルリード (RR)
リピータブルリード分離レベルは、非リピータブルリードの問題を解決しますが、ファントムリードの問題はまだ解決しません。それでは、反復不可能な読書と幻の読書の違いは何でしょうか?非反復読み取りは変更、つまり 2 回読み取られた値が異なることに焦点を当てますが、ファントム読み取りはレコード数の変化 (挿入と削除) に焦点を当てます。一般に、教科書には、ファントム リードの問題はシリアル化分離レベルに達した場合にのみ解決できると記載されていますが、MySQL の innodb は、主に GAP ロックを通じてファントム リードの問題を解決します。さらに、すべてのデータベースがこの分離レベルを実装しているわけではありません。後で、mysql が反復読み取り分離レベルを実装する方法を簡単に紹介します。
(4) シリアル化可能
シリアル化された分離モードでは、ダーティ リードとファントムは排除されますが、トランザクションの同時実行性は急激に低下し、トランザクションの分離レベルはトランザクションの同時実行性に反比例し、トランザクションの分離レベルが高くなるほど、分離レベルが高くなるほど、トランザクションの同時実行性は低くなります。実際の運用環境では、DBA は同時実行性とビジネス ニーズを満たすこととの間でトレードオフを行い、適切な分離レベルを選択します。
2. 同時スケジューリング方法
分離レベルに密接に関係するもう 1 つの方法は、分離レベルを達成するための同時スケジューリングです。同時スケジューリングの実装メカニズムはデータベース ベンダーごとに異なりますが、基本原則はすべてロックを使用して、複数のトランザクションによって同時にデータ オブジェクトが変更されないように保護します。従来のロックベースの同時実行制御と比較して、マルチバージョン同時実行制御 (MVCC) の主な特徴は、読み取りが多く書き込みが少ないシナリオでのシステムの同時実行性が大幅に向上することです。そのため、ほとんどのリレーショナル データベースはすべて MVCC を実装しています。
3. 2 フェーズ ロック プロトコル
2 フェーズ ロック プロトコルの意味は、トランザクションが 2 つの段階に分かれており、第 1 段階はブロックを取得することであり、第 2 段階はブロックを解除することです。封鎖。 2 段階のブロッキングにより、同時スケジューリングの正確性が保証されます。 1 段階ロック (トランザクションに必要なすべてのロックを一度に取得する) と比較して、2 段階ロックは同時実行性を向上させますが、デッドロックが発生する可能性もあります。
4. デッドロック
いわゆるデッドロックとは、相手が取得する予定のリソースを複数のトランザクションがそれぞれ占有し、待ちのループが形成され、相互にトランザクションを続行できない状態を指します。実行する。
5. ロックの種類
ロックの種類に応じて、共有ロック、排他ロック、意図共有ロック、意図排他ロックに分けることができます。ロックの粒度に応じて、行ロックと表ロックに分けることができます。 mysql の場合、トランザクション メカニズムは基盤となるストレージ エンジンによって実装されるため、mysql レベルにはテーブル ロックのみがあり、トランザクションをサポートする innodb ストレージ エンジンには行ロック (レコード ロック)、ギャップ ロック、ネクスト キー ロックが実装されます。 。 innodb はインデックス構成テーブルであるため、Mysql のレコード ロックは本質的にインデックス レコードのロックであり、ギャップ ロックはネクスト キー ロックの下でのみ有効です。レコード ロックとレコード ロックの組み合わせの前のギャップ。 mysql は、ギャップ ロックとネクスト キー ロックを通じて RR 分離レベルを実装します。
説明:
更新操作 (読み取りはロックされない) の場合、アップリンク ロックはインデックスを介してのみ可能です。それ以外の場合は、クラスター化インデックスの各行に書き込みロックが設定されます。これは実際には次と同等です。テーブルの書き込みロック。
複数の物理レコードが同じインデックスに対応し、同時にアクセスされる場合、ロックの競合も発生します。
テーブルに複数のインデックスがある場合、異なるトランザクションが異なる行をロックする可能性があります。さらに、Innodb は行ロックを使用してデータ レコード (クラスター化インデックス) をロックします。
MVCC 同時実行制御メカニズムでは、読み取り操作がロックされていないという理由だけで、読み取り操作がブロックされる操作はなく、読み取り操作が操作をブロックすることもありません。
RocksDB は、オープンソースのストレージ エンジンとして、トランザクションの ACID 特性をサポートします。ACID の I (Isolation) をサポートするには、同時実行制御が不可欠です。この記事では主に、RocksDB のロック メカニズムの実装について説明します。この記事を通じて、読者が RocksDB 同時実行制御の原則を深く理解できることを願っています。この記事は主に次の 4 つの側面から始まります。まず、RocksDB のロックの基本構造を紹介します。次に、RocksDB の行ロック データ構造の設計におけるロック スペースのオーバーヘッドを紹介します。次に、いくつかの代表的なロック プロセスを紹介します。最後に、メカニズムの中で重要なデッドロック検出メカニズムを紹介します。
1. 行ロックのデータ構造
RocksDB の最小ロック粒度はキーであり、各キーは LockInfo 構造に対応します。すべてのキーはハッシュ テーブルを通じて管理され、ロックを検索する場合は、ハッシュ テーブルを通じてキーを直接見つけて、キーがロックされているかどうかを判断できます。ただし、グローバルにハッシュ テーブルが 1 つしかない場合、このハッシュ テーブルにアクセスするときに多くの競合が発生し、同時実行パフォーマンスに影響します。 RocksDB はまず Columnfamily によって分割され、各 Columnfamily のロックは LockMap によって管理され、各 LockMap は LockMapStripe とハッシュ テーブル (std::unowned_mapstruct 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 中国語 Web サイトの他の関連記事を参照してください。