RocksDB上锁机制的实例详解

      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)则存在于Stripe结构中,Stripe结构中还包含一个mutex和condition_variable,这个主要作用是,互斥访问hash表,当出现锁冲突时,将线程挂起,解锁后,唤醒挂起的线程。这种设计很简单但也带来一个显而易见的问题,就是多个不相关的锁公用一个condition_variable,导致锁释放时,不必要的唤醒一批线程,而这些线程重试后,发现仍然需要等待,造成了无效的上下文切换。对比我们之前讨论的InnoDB锁机制,我们发现InnoDB是一个page里面的记录复用一把锁,而且复用是有条件的,同一个事务对一个page的若干条记录加锁才能复用;而且锁等待队列是精确等待,精确到记录级别,不会导致的无效的唤醒。虽然RocksDB锁设计比较粗糙,但也做了一定的优化,比如在管理LockMaps时,通过在每个线程本地缓存一份拷贝lock_maps_cache_,通过全局链表将每个线程的cache链起来,当LockMaps变更时(删除columnfamily),则全局将每个线程的copy清空,由于columnfamily改动很少,所以大部分访问LockMaps操作都是不需要加锁的,提高了并发效率。
相关数据结构如下:

struct LockInfo {bool exclusive; //排它锁或是共享锁autovector txn_ids; //事务列表,对于共享锁而言,同一个key可以对应多个事务// Transaction locks are not valid after this time in usuint64_t expiration_time;}struct LockMapStripe { // Mutex must be held before modifying keys mapstd::shared_ptr stripe_mutex;// Condition Variable per stripe for waiting on a lockstd::shared_ptr stripe_cv;// Locked keys mapped to the info about the transactions that locked them.std::unordered_map keys;}struct LockMap {const size_t num_stripes_; //分片个数std::atomic lock_cnt{0}; //锁数目std::vector lock_map_stripes_; //锁分片}class TransactionLockMgr {using LockMaps = std::unordered_map>;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 LockMapstd::unique_ptr 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_rowid2-myrocks::ha_rocksdb::get_for_update3-myrocks::Rdb_transaction_impl::get_for_update4-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 //第一次读取//读取key5-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.死锁检测算法
      死锁检测采用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 rev_wait_txn_map_;// Maps from waiter -> waitee.HashMap> wait_txn_map_;DecrementWaiters //IncrementWaiters //}struct TransactionOptions {bool deadlock_detect = false; //是否检测死锁int64_t deadlock_detect_depth = 50; //死锁检测的深度int64_t lock_timeout = -1; //等待锁时间,线上一般设置为5sint64_t expiration = -1; //持有锁时间,}

登录后复制

以上就是RocksDB上锁机制的实例详解的详细内容,更多请关注【创想鸟】其它相关文章!

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至253000106@qq.com举报,一经查实,本站将立刻删除。

发布者:PHP中文网,转转请注明出处:https://www.chuangxiangniao.com/p/1619232.html

(0)
上一篇 2025年2月19日 00:45:33
下一篇 2025年2月19日 00:45:45

AD推荐 黄金广告位招租... 更多推荐

相关推荐

  • 如何在Go中使用错误处理机制?

    go语言作为一门强类型、高效、现代化的编程语言,在现代软件开发中得到了越来越广泛的应用。其中,错误处理机制是go语言值得关注的一个方面,而go语言的错误处理机制相比于其他编程语言也有很大的不同和优越性。本文将介绍go语言的错误处理机制的基本…

    编程技术 2025年3月2日
    500
  • Go语言中的运行时调度机制

    随着并发编程的普及,越来越多的编程语言开始提供原生的并发支持。而在这些支持中,有一种运行时调度机制被广泛使用——协程调度。这篇文章将会探讨go语言中的协程调度机制。 Go语言是一种快速的、静态类型的编程语言,由Google开发,具有强大的并…

    编程技术 2025年3月2日
    400
  • Go语言中的错误处理机制

    go语言作为一门现代化编程语言,对于错误处理机制有着很好的支持,其错误处理的思路清晰简单,不仅提供了标准的错误处理方式,还支持自定义错误类型,方便开发者进行更加灵活的错误处理。 一、错误处理概述 在Go语言中,错误处理被视为一个普通的返回值…

    编程技术 2025年3月2日
    300
  • Go 语言中的内存管理机制是怎样的?

    go 语言是一门广泛用于系统级编程的高效编程语言,其主要优势之一是其内存管理机制。go 语言内建的垃圾回收机制(garbage collection,简称 gc)使得程序员不必亲自进行内存分配和释放操作,提高了开发效率和代码质量。本文将对 …

    编程技术 2025年3月2日
    300
  • Go 语言中的反射机制是怎样实现的?

    在计算机科学领域,反射(reflection)是指在运行时(runtime)对程序进行检查和修改的能力,通俗来讲就是程序在运行时能够 “自己检查自己”。在 go 语言中,反射机制是一项强大的特性,它为我们提供了一种机制,可以在运行时检查任意…

    编程技术 2025年3月2日
    300
  • 深度探索Golang中的错误处理机制

    深入理解Golang中的错误处理机制 Golang作为一门高效、并发性强的编程语言,其错误处理机制在编写程序时非常重要。在Golang中,错误被视为普通的返回值,而不是像其他语言一样通过异常来处理。本文将深入探讨Golang中的错误处理机制…

    2025年3月1日
    300
  • 探究Golang中除法运算的机制

    深入理解Golang中的除法运算机制,需要具体代码示例 在Golang中,除法运算是程序员在日常编码中经常使用到的操作之一。然而,对于不同类型的数据进行除法运算可能会产生不同的结果,这是由Golang的除法运算机制决定的。在本文中,我们将深…

    2025年3月1日
    300
  • 深入探讨Golang变量的存储位置和机制

    标题:深入探讨Golang变量的存储位置和机制 随着Go语言(Golang)在云计算、大数据和人工智能领域的应用逐渐增多,深入了解Golang变量的存储位置和机制变得尤为重要。在本文中,我们将详细探讨Golang中变量的内存分配、存储位置以…

    2025年3月1日
    300
  • 深入探讨Go语言同步机制的原理与实现

    Go语言作为一种面向并发编程的语言,在其同步机制设计中引入了goroutine、channel以及select语句等特性,使得并发编程变得更加容易和高效。本文将深入探讨Go语言同步机制的原理与实现,并结合具体的代码示例进行讲解。 1. Go…

    2025年3月1日
    300
  • Golang编译器工作机制揭秘

    Golang编译器工作机制揭秘 一、引言随着Golang语言在近年来的风靡,越来越多的开发者开始关注其编译器工作原理。Golang编译器是一种特殊的编译器,它采用了一系列独特的优化技术来提高编译效率和运行性能。本文将深入探讨Golang编译…

    2025年3月1日
    300

发表回复

登录后才能评论