MVCC多版本是一个解决并发问题的模型,或者说是一种设计思路。
why MVCC?
如果有一份数据,无论它是存储在内存里还是磁盘上,当我们读取数据时可能有写操作正在修改它。传统思路就是将数据用一把锁保护起来:
- 读之前加读锁,这样就不会有写操作。
- 写之前加写锁,这样就不会有读操作和其他写操作。
如果数据是内存里的一个哈希表,那么锁的代价还可以忍受,几乎总是瞬间可以完成。
然而存储系统一般都要和磁盘打交道,读请求是从磁盘上读取一个文件的某个偏移量
写请求可能需要删除旧的文件并创建一个修改过的新文件(一般存储不会覆盖修改数据,因为异常时可能写坏数据)。
如果仍为了保护数据而上锁,那么磁盘的缓慢操作将导致锁占用时间很长,从而直接降低了系统吞吐。
如果不上锁,那么极有可能正在读某个文件的同时,文件被删除了,导致严重错误。
这就是MVCC多版本技术出现的背景环境。
how mvcc?
假设一个场景:读操作会去磁盘扫描sst文件查找对应的数据,而策略job可能恰好正在合并遍历中的某sst文件,同时向合并后的sst文件输出中,最后还会删除掉旧的sst文件。
这意味着,正在读的sst文件可能被删除,或者正在输出中(不完整)。
按照我们悲观锁的思路,直接加一把大锁,那么读请求期间写请求无法进行,漫长的写期间读请求不能进行,基本这个leveldb就没法用了。
leveldb采用MVCC避免大锁,这里引入了几个概念:
1.version edit:记录sst文件集合的变化。当合并N个sst生成1个大sst,被合并的sst不再有意义,合并生成的新sst包含了它们的数据,version edit记录的就是:合并了哪些sst,新增了哪个sst。
2.version:代表一个版本,记录了数据库由哪些sst文件组成。当磁盘合并发生后,对应的version edit施加到最新的version上,从而产生新的version,它记录了当前最新的数据库sst集合。
3.version_set:指向最新的version。
version set全局唯一,其中current_指针指向最新的version:
VersionSet {
// 实际的 Env
Env* const env_;
// db 的数据路径
const std::string dbname_;
// 正在服务的 Version 链表
Version dummy_versions_;
// 当前最新的的 Version
Version* current_;
这是version,files_保存了该版本数据库的所有sst文件。
class Version {
......
// 属于的 VersionSet
VersionSet* vset_;
// 链表指针
Version* next_;
Version* prev_;
// 引用计数
int refs_;
// 每个 level 的所有 sstable 元信息。
// files_[i]中的 FileMetaData 按照 FileMetaData::smallest 排序
std::vector<FileMetaData*> files_[config::kNumLevels];
next_,prev_是链表,说明多个version是串在一起的。这里就容易产生几个好奇:
1.为什么会有多个version?
2.version是怎么产生的?
回想合并的过程,N个sst文件merge到1个sst文件后,虽然N个sst文件已经没有意义,但是leveldb并不会立即删除它们,这是为什么呢?这就涉及到读请求了。
某个时刻,读请求会根据version_set里的current_找到最新version,并在这个version里保存的sst文件集合中寻找目标数据。
此后假设发生了合并并产生了新的version,新version将更新到version_set的current_字段成为新的链表头。如果此时立即删除被合并的sst文件,那么正在进行中的读请求就会出错
所以删除sst文件的动作并不是立即发生的。同时,因为读请求依据的是旧版本的version(sst文件集合),所以新合并生成的sst并不会被该读请求扫描到。
在实现上来说,读请求引用了旧版本的version,而写请求需要设置新版本的version,那么旧版本的version何时释放内存呢?
这里就采用了引用计数机制,最新的version默认是1个引用计数并保存在version_set的链表头部(current_),当读请求到来后会对version_set的current_增加1个引用计数。此后发生合并生成新version替换current_时,先释放旧version的1个引用计数(还剩余1个由读请求持有),然后替换current_为新的version对象。
当读请求结束后,会释放旧version的最后1个引用计数,此时引用计数降低为0,旧版本version进入析构函数。这里涉及另外一个重要设计,我们知道之前写请求(合并sst)完成时并没有删除被合并的sst文件,其目的是因为之前的读请求可能正在访问旧sst文件,特意延迟了删除操作。
删除应该延迟到什么时候呢?其实只要涉及到这种资源持有问题一般都是采用引用计数,在version中的files中保存的FileMetaData也是基于引用计数的共享内存。
当合并旧version+version edit生成新version时,旧version中没有被合并的sst文件对应的FileMetaData的引用计数将+1并保存到新version中
而被合并的sst文件的FileMetaData的引用计数保持不变, 但不保存到新version中。
当替换current_为新version时候,会首先释放旧version的1个引用计数,从而进入旧version析构。
在旧version析构中,会释放它的每个FileMetaData的1个引用计数,对于那些在新version中已经被合并的sst文件来说,引用计数减少为0将完成最终删除。
可见,leveldb的MVCC实际上由几个重要部分组成:
- sst文件的不变性:只合并生成新sst而不覆盖旧的,保障多版本都可以读到属于自己的数据。
- version引用计数:保证某版本所有sst文件的资源有效性。
- FileMetaData引用计数:保证单个sst文件在多version下的资源有效性。