在数据库系统中,并发访问是一个常见的场景。多个用户同时读写数据库,如何保证每个人的读写结果都是正确的,这就是并发控制要解决的问题。
考虑一个简单的转账场景,开始的时候 A 账户有 1000 元,要转 800 元给 B 账户。转账过程包括两步:从 A 扣钱,给 B 加钱。恰好在这两步中间,有人查询了 A 和 B 的余额。
如果没有任何并发控制,查询者会看到一个异常现象:A 账户已经被扣除了 800 元,只剩 200 元,B 账户还没收到转账,还是原来的金额!这就是典型的数据不一致问题。为了解决这个问题,数据库系统需要某种并发控制机制。
最直观的解决方案是加锁,当有人在进行写操作(如转账)时,其他人的读操作必须等待。回到前面的问题,只有在转账的两步都完成之后,才能查到正确的账户余额。但是锁机制存在明显的问题,每次只要写相关 key,所有读该 key 的操作都要排队等待,导致并发上不去,性能会比较差。
现代数据库系统普遍采用 MVCC 来控制并发,LevelDB 也不例外,接下来我们结合源码来理解 LevelDB 的 MVCC 实现。
MVCC(Multi-Version Concurrency Control) 是一种并发控制机制,它通过维护数据的多个版本来实现并发访问。简单来说,LevelDB 的 MVCC 实现关键点有下面几个:
这就是 MVCC 的核心思想了。我们通过一个具体的时间操作序列,来理解下 MVCC 是怎么工作的。假设有以下操作序列:
1 | 时间点 T1: sequence=100, 写入 key=A, value=1 |
那么不管 Reader1 和 Reader2 谁先读取,Reader1 读取 key=A 总会得到 value=2(sequence=101),Reader2 读取 key=A 会得到 value=3(sequence=102)。后续如果有新的读取,不带 snapshot 的读取会得到最新的数据。通过下面的时序图更容易理解,mermaid 源码在这里:
MVCC 的整体效果就如上了,还是比较容易理解的。下面看看 LevelDB 中是怎么实现 MVCC 的。
实现 MVCC 的前提是,每个键都保存多个版本。所以要设计一个数据结构,把键和版本号关联起来。LevelDB 设计的 key 格式如下:
[key][sequence<<8|type]
LevelDB 的做法也比较容易理解,在原来的 key 后面拼上版本信息。这里版本信息是一个 64 位的 uint,其中高 56 位存储的是 sequence,低 8 位存储的是操作类型。这里操作类型目前只有两种,对应的分别是写入和删除操作。
1 | // Value types encoded as the last component of internal keys. |
这里序列号只有 56 位,所以最多可以支持 $ 2^{56} $ 次写入。这样实现会不会有问题?如果我想写入更多的 key 那岂不是不支持了?理论上是的,但是咱们从实际使用场景来分析下。假设每秒写入 100W 次,这个已经是很高的写入 QPS 了,那么可以持续写入的时间是:
$$ 2^{56} / 1000000 / 3600 / 24 / 365 = 2284 $$
嗯。。。能写 2000 多年,所以这个序列号是够用的,不用担心耗尽问题了。这里的数据格式设计虽然很简单,但还是有不少好处的:
我们知道 LevelDB 中键是顺序存储的,当要查询单个键时,可以用二分查找快速定位。当需要获取一系列连续键时,可以使用二分查找快速定位范围起点,然后顺序扫描即可。但现在我们给键增加了版本号,那么问题来了,带版本号的键要怎么排序呢?
LevelDB 的做法也是比较简单有效,排序规则如下:
为了实现这里的排序规则,LevelDB 实现了自己的比较器,在 db/dbformat.cc 中,代码如下:
1 | int InternalKeyComparator::Compare(const Slice& akey, const Slice& bkey) const { |
可以看到首先从带版本号的 key 中去掉后 8 位,拿到真实的用户键,之后按照用户键的排序规则进行比较。这里再多说一句,LevelDB 提供了一个默认的用户键比较器 leveldb.BytewiseComparator
,这里是完全按照键值的字节序进行比较。比较器的实现代码在 util/comparator.cc 中,如下:
1 | class BytewiseComparatorImpl : public Comparator { |
这里 Slice 是 LevelDB 中定义的一个字符串类,用于表示一个字符串,它的 compare 就是字节码比较。其实 LevelDB 也支持用户自定义比较器,只需要实现 Comparator 接口即可。这里多说一点,在使用比较器的时候,用 BytewiseComparator 封装了一个单例,代码有点难理解,如下:
1 | const Comparator* BytewiseComparator() { |
我之前专门写了一篇文章来解释 NoDestructor 模板类,感兴趣的可以看下:LevelDB 源码阅读:禁止对象被析构。
这种排序规则的好处也是显而易见的,首先按照用户键升序排列,这样范围查询非常高效。当用户需要获取一系列连续键时,可以使用二分查找快速定位范围起点,然后顺序扫描即可。另外,同一个用户键的多个版本按序列号降序排列,这意味着最新版本在前,便于快速找到当前值。查询时,只需找到第一个序列号小于等于当前快照的版本,不需要完整扫描所有版本。
好了,关于排序就说到这。下面咱们结合代码来看看写入和读取的时候,是怎么拼接 key 的。
LevelDB 写入键值对的步骤比较复杂,可以看我之前的文章:LevelDB 源码阅读:写入键值的工程实现和优化细节。简单说就是先写入 memtable,然后是 immutable memtable,最后不断沉淀(compaction)到不同层次的 SST 文件。整个过程的第一步就是写入 memtable,所以在最开始写入 memtable 的时候,就会给 key 带上版本和类型,组装成前面我们说的带版本的内部 key 格式。
这里组装 Key 的代码在 db/memtable.c 的 MemTable::Add
函数中。这里除了组装 key,还拼接了 value 部分。实现如下:
1 | void MemTable::Add(SequenceNumber s, ValueType type, const Slice& key, |
可以看到这里同一个用户键的多次写入会产生多个版本,每个版本都有唯一的 sequence number。用户键一旦被转换为内部键,后续所有处理过程都基于这个内部键进行。包括 MemTable 转为 Immutable MemTable,SST 文件写入,SST 文件合并等。
这里 Add 函数中,在 internal_key 内部键的前面其实也保存了整个内部键的长度,然后把长度和内部键拼接起来,一起插入到了 MemTable 中。这样的 key 其实是 memtable_key,后续在读取的时候,也是用 memtable_key 来在 memtable 中查找的。
这里为什么要保存长度呢?我们知道 Memtable 中的 SkipList 使用 const char* 指针作为键类型,但这些指针只是指向内存中某个位置的裸指针。当跳表的比较器需要比较两个键时,它需要知道每个键的确切范围,也就是起始位置和结束位置。如果直接使用 internal key,就没有明确的方法知道一个 internal key 在内存中的确切边界。加上长度信息后,就可以快速定位到每个键的边界,从而进行正确的比较。
接下来看看读取键值的过程。在读取键值的时候,会先把用户键转为内部键,然后进行查找。不过这里首先面临一个问题是,序列号要用哪个呢。回答这个问题前,我们先来看读取键常用的的方法,如下:
1 | std::string newValue; |
这里有个 ReadOptions 参数,里面会封装一个 Snapshot 快照对象。这里的快照你可以理解为数据库在某个时间点的状态,里面有这个时间点之前所有的数据,但不会包含这个时间点之后的写入。
其实这里快照的核心实现就是保存某个时间点的最大序列号,读取的时候,会用这个序列号来组装内部键。读的时候,分两种情况,如果没有指定 snapshot,使用当前最新的 sequence number。如果使用了之前保存下来的 snapshot,则会使用 snapshot 的序列号。
之后会根据快照序列号和用户键组装,这里先定义了一个 LookupKey 对象,用来封装查找时候使用内部键的一些常用操作。代码在 db/dbformat.h 中,如下:
1 | // A helper class useful for DBImpl::Get() |
在 LookupKey 的构造函数中,会根据传入的 user_key 和 sequence 来组装内部键,具体代码在 db/dbformat.cc 中。后续在 memtable 中搜索的时候,用的 memtable_key,然后在 SST 中查找的时候,用的 internal_key。这里 memtable_key 就是我们前面说的,在 internal_key 的前面加上了长度信息,方便在 SkipList 中快速定位到每个键的边界。
这里在 memtable 和 immutable memtable 中找不到的话,会去 SST 中查找。SST 的查找就相当复杂一些,涉及多版本数据的管理,后续我会专门写文章来介绍这里的读取过程。
本篇对 MVCC 的讲解还比较浅显,介绍了大概的概念,以及重点讲了下读取和写入过程中如何对序列号进行处理的过程。并没有深入数据多版本管理,以及旧版本数据回收清理的过程。后面文章再深入这些话题。
总的来说,LevelDB 通过在键值中引入版本号,实现了多版本并发控制。通过 snapshot 来实现读取隔离,写入永远创建新版本。对于读操作来说,不需要加锁,可以并发读取。对于写操作来说,需要加锁,保证写入的顺序。
这种设计提供了很好的并发性能,保证了读取的一致性,同时减少了锁冲突。不过代价是存储空间的额外开销,以及需要保存多个版本带来的代码复杂度。