 上的昂贵互斥锁)
RocksDB 无锁读取路径剖析如何用线程本地存储消除 Get() 上的昂贵互斥锁【免费下载链接】rocksdbA library that provides an embeddable, persistent key-value store for fast storage.项目地址: https://gitcode.com/gh_mirrors/ro/rocksdb导读本文围绕 RocksDB 读取路径上的一次经典优化展开在Get()返回数据前RocksDB 需要获取当前数据库版本SuperVersion的引用而早期实现中这一操作受全局互斥锁DB mutex保护导致内存型负载下读吞吐在 8 核之后就无法继续扩展。本文详细讲解 RocksDB 如何利用线程本地存储Thread Local Storage, TLS缓存 SuperVersion 引用、以全局版本号比对替代高频加锁、并借助CASCompare-And-Swap无锁清扫回收过期版本资源最终将读性能扩展到 32 核。读完本文你将理解 SuperVersion 的引用计数与生命周期管理机制掌握GetThreadLocalSuperVersion/ReturnThreadLocalSuperVersion/ResetThreadLocalSuperVersions这一套先取后用、用完归还、后台清扫的无锁协议并能从当前仓库源码中印证其全部实现细节。一、背景MVCC 与 SuperVersion 的引入RocksDB 采用多版本并发控制MVCC策略。SST 文件一旦写入便不可变memtable 则是支持单写多读的无锁数据结构因此读操作面临的核心问题并非数据本身而是如何安全地找到并引用当前活着的一批 memtable 与 SST 文件集合——这个集合在刷盘flush、压缩compaction、新建 memtable 等事件发生时会整体更替。早期实现中每次读操作都要在互斥锁内逐个增加每个 memtable 与每个 SST 文件的引用计数。单次操作本身不昂贵但对高吞吐读服务而言锁竞争会成为瓶颈尤其是全部数据驻留内存的场景。为此 RocksDB 引入了元数据之上的元数据结构——SuperVersion它持有对当前 memtable、不可变 memtable 列表与版本Version的统一引用计数读线程只需对这一个结构增加引用计数即可保证整组资源不被回收。前一篇博客 《Reducing Lock Contention in RocksDB》 已系统介绍了这一系列降低锁竞争的措施。在当前仓库中SuperVersion的定义位于 db/column_family.h其注释明确写着holds references to memtable, all immutable memtables and version持有 memtable、所有不可变 memtable 及版本的引用关键成员包括ColumnFamilyData* cfd所属列族ReadOnlyMemTable* mem当前可写 memtableMemTableListVersion* imm不可变 memtable 列表版本Version* current当前 LSM 版本对应一组 SST 文件uint64_t version_numberSuperVersion 的版本号正是无锁比对的核心字段私有成员std::atomicuint32_t refs原子引用计数。引用计数的增删在 db/column_family.cc 中实现SuperVersion* SuperVersion::Ref() { refs.fetch_add(1, std::memory_order_relaxed); return this; } bool SuperVersion::Unref() { // fetch_sub returns the previous value of ref uint32_t previous_refs refs.fetch_sub(1); assert(previous_refs 0); return previous_refs 1; }Ref()只是原子地把引用计数加一并返回thisUnref()返回true表示这是最后一次引用释放调用方必须在持有互斥锁的前提下执行Cleanup()见 db/column_family.cc依次对imm、mem、current和cfd做反向引用释放并回收待删除的 memtable。二、问题Get() 路径上的互斥锁成为扩展性瓶颈有了 SuperVersion 之后读路径仍需获取最新版本并增加引用。在GetImpl()的开头早期代码是mutex_.Lock(); auto* s super_version_-Ref(); mutex_.Unlock();这把锁是必要的super_version_指针可能被更新若Ref()进行的同时旧 SuperVersion 恰好被删除就会产生悬垂指针。然而这种每次读都加锁的做法给纯内存负载带来了严峻挑战——它使 RocksDB 的读吞吐无法扩展到 8 核以上。作者在文中给出的数据是在 32 核 CPU 上运行 32 个读线程会导致70% 的系统 CPU 占用大量时间消耗在内核态锁操作与上下文切换上。对于以内存访问为主的 Get 请求而言这显然是难以接受的。三、核心思路线程本地缓存 全局版本号比对优化思路建立在两个观察之上版本变更flush/compaction 产生新 SuperVersion是低频事件相对数百万次读请求几乎可以忽略每个线程不必每次都向全局获取引用——版本没变时本线程上次缓存的那个 SuperVersion 依然有效。因此方案是在每个线程第一次执行 Get 时支付一次互斥锁成本取得新 SuperVersion 的引用后不释放而是缓存在线程本地存储中同时用一个原子变量维护全局 SuperVersion 版本号。后续读操作只需读出线程本地的 SuperVersion 及其版本号与全局版本号做一次原子比较相同则直接使用缓存引用零锁、零原子开销之外的成本不同则加锁更新本地引用。互斥锁的成本被摊薄到百万次读取之间从而变得可以忽略。原文档给出的核心伪代码慢路径中旧版本清理已省略如下SuperVersion* s thread_local_-Get(); if (s-version_number ! super_version_number_.load()) { // slow path, cleanup of current super version is omitted mutex_.Lock(); s super_version_-Ref(); mutex_.Unlock(); }快路径只剩一次线程本地读取加一次原子 load完全不触碰互斥锁只有检测到版本号变化才进入加锁的慢路径。源码印证无锁协议的现代形态这一设计在今天的仓库中依然健在只是用更精细的原子操作强化了线程本地指针的独占语义。核心实现在ColumnFamilyData的两个方法中db/column_family.ccSuperVersion* ColumnFamilyData::GetThreadLocalSuperVersion(DBImpl* db) { // 使用原子 Swap() 将本地指针换成特殊哨兵 kSVInUse独占取走缓存引用 void* ptr local_sv_-Swap(SuperVersion::kSVInUse); assert(ptr ! SuperVersion::kSVInUse); SuperVersion* sv static_castSuperVersion*(ptr); if (sv SuperVersion::kSVObsolete) { // 慢路径本地缓存已被清扫标记为废弃需加锁获取最新 SuperVersion RecordTick(ioptions_.stats, NUMBER_SUPERVERSION_ACQUIRES); db-mutex()-Lock(); sv super_version_-Ref(); db-mutex()-Unlock(); } assert(sv ! nullptr); return sv; } bool ColumnFamilyData::ReturnThreadLocalSuperVersion(SuperVersion* sv) { void* expected SuperVersion::kSVInUse; if (local_sv_-CompareAndSwap(static_castvoid*(sv), expected)) { // 本地槽位仍是 kSVInUse说明期间没有发生清扫缓存引用可原样归还 return true; } else { // 本线程持有 SuperVersion 期间发生了清扫本地已被置为 kSVObsolete assert(expected SuperVersion::kSVObsolete); } return false; }两个特殊哨兵在 db/column_family.cc 中定义其设计意图见 db/column_family.h 的注释kSVInUse取SuperVersion::dummy静态变量的地址保证它与任何真实 SuperVersion 对象地址都不冲突且跨平台可移植kSVObsolete为nullptr用作已清扫废弃标记。在此基础上DBImpl层面封装了成对的取/还 APIdb/db_impl/db_impl.ccGetAndRefSuperVersion(cfd)调用cfd-GetThreadLocalSuperVersion(this)返回已缓存或新获取的 SuperVersionReturnAndCleanupSuperVersion(cfd, sv)先尝试ReturnThreadLocalSuperVersion(sv)把引用归还 TLS若返回false即期间被清扫过则调用CleanupSuperVersion(sv)走昂贵的完整清理路径CleanupSuperVersion(sv)当Unref()显示这是最后引用时加锁执行Cleanup()并根据avoid_unnecessary_blocking_io选项决定是立即delete还是延迟回收AddSuperVersionsToFreeQueueSchedulePurge同时记录NUMBER_SUPERVERSION_CLEANUPS统计。GetReferencedSuperVersiondb/column_family.cc则组合了取 显式加一次引用 归还供调用方在函数调用边界之外安全持有引用是Get()主路径之外如TablesRangeTombstoneSummary、外部文件摄取等场景的通用获取入口。四、无锁清扫回收每个线程本地缓存的过期 SuperVersion引入 TLS 缓存后带来一个新问题一个线程可能只访问一次GetImpl()就再也不回来。此时该 SuperVersion 已被引用并缓存于其线程本地存储中所有属于该版本的资源memtable、SST 文件都被冻结无法释放。需要一个监督者在不加锁的前提下遍历各线程本地存储并回收其资源。原文档给出了用 CAS 实现的**无锁清扫lockless sweep**三步协议读线程用 CAS 从本地存储取走 SuperVersion并放入特殊标志SuperVersion::kSVInUse表示该缓存正被使用中GetImpl()结束时读线程用 CAS 把 SuperVersion 放回本地存储并期望看到kSVInUse。若看到的不再是kSVInUse说明期间已被清扫过则读线程自己负责清理昂贵但不会在热路径上频繁发生任何一次 flush/compaction 之后后台线程对所有线程的本地存储执行一次清扫CAS 遍历释放遇到的 SuperVersion被清扫线程下次访问时必须重新获取新的 SuperVersion 引用。对照当前源码InstallSuperVersion()新 SuperVersion 安装点db/column_family.cc在把旧 SuperVersion 换下之前会先调用ResetThreadLocalSuperVersions()其实现db/column_family.cc正是后台清扫的落点void ColumnFamilyData::ResetThreadLocalSuperVersions() { autovectorvoid* sv_ptrs; local_sv_-Scrape(sv_ptrs, SuperVersion::kSVObsolete); for (auto ptr : sv_ptrs) { assert(ptr); if (ptr SuperVersion::kSVInUse) { continue; // 该线程正在使用缓存跳过由该线程自己负责清理 } auto sv static_castSuperVersion*(ptr); bool was_last_ref __attribute__((__unused__)); was_last_ref sv-Unref(); assert(!was_last_ref); } }Scrape原子地把每个线程本地存储中的 SuperVersion 全部取出并统一置为kSVObsolete对处于kSVInUse状态的槽位读线程正在使用缓存跳过——对应原文档第 3 步由读线程负责回收的分工对空闲缓存执行Unref()递减引用计数使旧 SuperVersion 随引用归零而被回收。配合 db/column_family.cc 中Swap(kSVInUse)与kSVObsolete检查可以验证三条不变量清扫总是安装kSVObsolete取用时总是安装kSVInUse若取用与归还之间发生清扫归还时的 CAS 必然失败并返回false。这套协议保证了无论清扫线程与读线程如何交错同一 SuperVersion 的引用都不会被错误双重释放。为什么不用互斥锁直接保护Daryl Grove 曾对 mutex 与 atomic 做过经典对比但真实的成本差异远超汇编指令数。互斥锁会让线程在 CPU 上自旋等待甚至触发上下文切换导致所有读线程在临界区入口互相竞争而本方案让线程各自去比对一个变化频率极低的全局版本号几乎不产生共享写操作因而对 CPU 缓存cache line极为友好——这正是它能将大部分 CPU 时间留在用户态的关键。简单地说原子比较是各自看一张很少更新的公告牌互斥锁则是所有人都要挤过同一扇门。五、效果与统计指标原文档给出的结果是相当惊人的RocksDB 可以很好地扩展到 32 核且大部分 CPU 时间都花费在用户态user land而非此前的 70% 系统态 CPU 占用。这一无锁路径在仓库中是可观测、可度量的。include/rocksdb/statistics.hinclude/rocksdb/statistics.h定义了三个相关统计项可帮助用户验证快/慢路径的分布NUMBER_SUPERVERSION_ACQUIRES慢路径中加锁重新获取 SuperVersion 的次数对应GetThreadLocalSuperVersion中检测到kSVObsolete的分支NUMBER_SUPERVERSION_RELEASESSuperVersion 引用释放次数NUMBER_SUPERVERSION_CLEANUPS需要完整执行Cleanup()的回收次数。在DBImpl::CleanupSuperVersion中可看到后两者的记录点db/db_impl/db_impl.cc。当这些计数远低于读请求总数时说明绝大多数读请求都命中了线程本地缓存的快路径无锁设计正在发挥作用。六、从 2014 到今天的演进脉络需要说明的是原文档2014 年 6 月发布描述的是当时的首次实现版本号比对 TLS 缓存 后台清扫。如今的代码在协议骨架上一脉相承但细节更严密早期通过全局版本号相等即直接用缓存来判断现代实现则直接用Swap(kSVInUse)CompareAndSwap建立槽位独占语义彻底避免取用与归还之间本地指针被并发改写的窗口local_sv_是基于ThreadLocalPtr的线程本地存储各 DB 实例/列族均有独立槽位并在 db/column_family.cc 的慢路径中统计NUMBER_SUPERVERSION_ACQUIRES回收时机从flush/compaction 后泛化为InstallSuperVersion()统一执行无论是刷盘、压缩、SetOptions还是full_history_ts_low收拢历史导致的新版本安装。若读者希望亲手验证这套机制可以在内存型负载下开启Statistics观察NUMBER_SUPERVERSION_ACQUIRES与读请求数的比值也可以阅读 db/db_impl/db_impl.cc 附近GetImpl()主路径对GetReferencedSuperVersion的调用确认读路径上取-用-还三阶段正是本文所讲协议的完整落地。正是这次为了一次读锁住整个世界的优化奠定了 RocksDB 在高并发内存型读负载下保持线性扩展的基石。【免费下载链接】rocksdbA library that provides an embeddable, persistent key-value store for fast storage.项目地址: https://gitcode.com/gh_mirrors/ro/rocksdb创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考