Memtable 是一个内存中的数据结构,它用于存储和查找键值对。在 LSM(Log-Structured Merge-tree)存储引擎中,写入操作首先会写入到 Memtable 中。当 Memtable 的大小达到一定阈值时,它会被转换为 SSTable 并刷新到磁盘。

接下来结合 Leveldb 来讲解 Memtable 具体实现细节。

Leveldb 中 Memtable 的组成部分

在 LevelDB 中,Memtable 是一个内存中的数据结构,用于存储和查找键值对。它的主要组成部分包括:

  1. SkipList:SkipList 是 Memtable 的核心数据结构,用于存储键值对。SkipList 是一种可以进行快速查找的有序数据结构。在 LevelDB 中,SkipList 中的每个节点都包含一个键值对。

  2. 内存分配器:LevelDB 的 Memtable 使用一个简单的内存分配器来管理内存。这个分配器会预先分配一大块内存,然后逐渐使用这块内存来存储数据。

  3. 写缓冲区:所有的写操作(包括插入、删除和更新)首先会写入到 Memtable 的写缓冲区中。当写缓冲区满时,数据会被移动到 SkipList 中。

  4. 版本控制信息:LevelDB 的 Memtable 还存储了一些版本控制信息,如每个键的最新版本号。这些信息用于处理并发写入和读取。

  5. 删除标记(Tombstones):当一个键被删除时,Memtable 会插入一个特殊的键值对,其中键是被删除的键,值是一个特殊的标记,表示该键已被删除。这种键值对被称为 Tombstone。

以上就是 LevelDB 中 Memtable 的主要组成部分。

MemTable 的写入过程

当进行写入操作时,会将键值对添加到 MemTable 中。具体来说,MemTable 的写入过程如下:

  1. 首先,会创建一个内部键,这个内部键由用户键、序列号和值类型组成。序列号是一个递增的整数,用于区分同一个用户键的不同版本。值类型可以是 kTypeValuekTypeDeletion,表示这是一个插入操作还是删除操作。

  2. 然后,会将内部键和值编码为一个字符串。编码的格式是:内部键长度(varint32)、内部键(char[])、值长度(varint32)、值(char[])。这个编码后的字符串就是要插入到 MemTable 中的键值对。

  3. 接着,会从 MemTable 的内存池中分配一块内存,用于存储编码后的键值对。

  4. 最后,会将编码后的键值对插入到 MemTable 的跳表中。跳表是一个有序的数据结构,可以在对数时间内完成查找、插入和删除操作。

以上就是 LevelDB 中 MemTable 的写入过程。

在你选中的 MemTable::Add 函数中,实现了上述的写入过程。首先创建了一个内部键,然后将内部键和值编码为一个字符串,接着从内存池中分配了一块内存,最后将编码后的键值对插入到了跳表中。

MemTable 的读取过程

当进行读取操作时,首先会在 MemTable 中查找键。如果在 MemTable 中找到了键,那么就直接返回对应的值。

具体来说,MemTable 的读取过程如下:

  1. 创建一个 MemTable 的迭代器,并将其定位到要查找的键的位置。这是通过调用 Table::Iterator::Seek 方法实现的。

  2. 检查迭代器是否有效,即是否找到了键。如果迭代器有效,那么就获取当前的键值对。

  3. 解析键值对的格式,获取键和值。键值对的格式是:键长度(varint32)、键(char[])、标签(uint64)、值长度(varint32)、值(char[])。

  4. 检查解析出的键是否与要查找的键相同。如果相同,那么就根据标签的类型返回对应的值或者表示键已被删除的状态。

  5. 如果在 MemTable 中没有找到键,那么就返回 false,表示键不存在。

以上就是 LevelDB 中 MemTable 的读取过程。

为什么 MemTable 没有 delete ?

在LSM(Log-structured merge-tree)中,删除操作通常是通过插入一个特殊的键值对来实现的,这个键值对被称为“tombstone”。当我们想要删除一个键时,我们会在MemTable中插入一个带有该键和一个特殊值(如null或特殊标记)的键值对。然后,在读取数据时,如果遇到这个特殊的键值对,我们就知道这个键已经被删除了。

因此,MemTable本身不需要提供一个delete API,因为删除操作可以通过已有的putadd API来实现。这样做的好处是可以简化MemTable的设计,并且可以在读取数据时处理删除操作,这对于LSM的性能优化是有利的。

MemTable 的冻结过程

在 LevelDB 中,MemTable 是一个内存中的数据结构,用于存储写入操作的键值对。当 MemTable 达到一定大小(默认为 4MB)时,它会被转换为一个不可变的(即只读的)MemTable,并开始创建一个新的 MemTable 以接收新的写入操作。这个不可变的 MemTable 会被写入到磁盘,形成一个新的 SSTable 文件。

具体来说,MemTable 的冻结过程如下:

[新的写入] ---> [MemTable] ---> [不可变的 MemTable] ---> [SSTable 文件]
  1. 当有新的写入请求时,会检查当前的 MemTable 是否已经达到了阈值。这是通过调用 MemTable::ApproximateMemoryUsage 方法实现的,该方法会返回 MemTable 当前使用的内存大小。

  2. 如果当前的 MemTable 已经达到了阈值,那么就会将当前的 MemTable 标记为只读,并创建一个新的 MemTable。这是通过调用 DBImpl::MakeRoomForWrite 方法实现的。

  3. 将只读的 MemTable 添加到待写入磁盘的队列中,并唤醒后台线程,将只读的 MemTable 写入到磁盘,形成 SSTable。这是通过调用 DBImpl::WriteLevel0Table 方法实现的。

冻结 MemTable 的好处是可以将写入操作和磁盘 I/O 操作分离,提高写入性能。同时,由于 MemTable 是有序的,所以生成的 SSTable 也是有序的,这对于后续的合并和压缩操作非常有利。

为什么 MemTable 使用跳表?

可以使用其他数据结构作为LSM(Log-Structured Merge-tree)中的MemTable。常见的选择包括哈希表、平衡树(如红黑树)和跳表。

使用跳表作为MemTable的优点包括:

  1. 跳表的插入、删除和查找操作的时间复杂度都是O(log n),这使得跳表在处理大量数据时具有良好的性能。
  2. 跳表的结构简单,易于实现。它不需要复杂的旋转操作,这使得跳表在实现上比平衡树更简单。
  3. 跳表支持范围查询,这对于某些应用(如数据库)来说是非常重要的。

使用跳表作为MemTable的缺点包括:

  1. 跳表的空间效率较低。每个节点需要存储多个指针,这会占用更多的内存。
  2. 跳表的性能受到随机数生成器的影响。在跳表中,节点的级别是由随机数生成器决定的,如果随机数生成器的性能不佳,可能会影响跳表的性能。

其他的数据结构,如哈希表和平衡树,也有其各自的优点和缺点。例如,哈希表的插入和查找操作的时间复杂度是O(1),但它不支持范围查询。平衡树支持范围查询,并且空间效率较高,但它的实现比跳表复杂。

如果一个键出现在多个MemTables中应该返回哪个版本?

当我们向LSM中写入数据时,新的数据会被写入最新的MemTable中。当这个MemTable满了,它会被转换为不可变的,并且会创建一个新的MemTable来存储新的写入。这意味着,对于同一个键,它的最新版本总是在最新的MemTable中。

当我们从LSM中读取数据时,我们需要从最新的MemTable开始探测,然后按照从新到旧的顺序探测其他的MemTable。这是因为我们总是想要获取键的最新版本。如果一个键出现在多个MemTables中,我们应该返回在最新的MemTable中找到的版本,因为这是最新的数据。

因此,存储和探测MemTables的顺序对于保证数据的一致性和正确性是非常重要的。

MemTable的内存布局是否高效/是否具有良好的数据局部性?

MemTable 中,所有的键值对数据和跳表节点都是通过 Arena 内存分配器来分配内存的。Arena 内存分配器的工作原理是预先分配一大块内存,然后在需要分配内存时,直接从这个预先分配的内存块中切割出所需大小的内存给使用者。这种方式的优点是分配和释放内存的速度非常快,因为实际上并没有进行系统级别的内存分配和释放操作。

另外,由于 Arena 分配器是连续分配内存的,因此在 MemTable 中的数据在内存中的布局也是连续的。这种连续的内存布局可以提高 CPU 缓存的命中率,因为连续的内存区域有更大的可能性被一次性加载到 CPU 缓存中,这被称为空间局部性。因此,MemTable 的内存布局是高效的,并且具有良好的数据局部性。

然而,需要注意的是,虽然 Arena 分配器可以提供快速的内存分配和良好的数据局部性,但是它也有一些缺点。例如,一旦内存被分配出去,就不能再被回收和重新分配给其他对象。因此,Arena 分配器更适合于生命周期明确,且不需要动态增长或缩小的内存分配场景。

冻结MemTable后,是否可能有一些线程仍然持有旧的LSM状态并写入这些不可变的MemTables?

在 LevelDB 中,当 MemTable 被冻结(转变为不可变状态)后,新的写入操作将不会再被添加到这个 MemTable 中,而是会被添加到一个新的 MemTable 中。这是通过在写入操作时获取写入锁来实现的,这样可以确保在转变 MemTable 状态的过程中不会有新的写入操作。

然而,可能会有一些线程在 MemTable 被冻结之前已经开始了写入操作,并且这些写入操作可能会在 MemTable 被冻结后才完成。为了处理这种情况,LevelDB 使用了引用计数机制。每当一个线程开始写入操作时,它会增加 MemTable 的引用计数。当写入操作完成后,线程会减少 MemTable 的引用计数。只有当 MemTable 的引用计数降至零时,MemTable 才会被真正地删除。

因此,即使 MemTable 被冻结,只要还有线程持有对它的引用,它就不会被删除。这样可以确保所有已经开始的写入操作都能正确地完成。