在 LevelDB 中,Block 是一个重要的数据结构,它用于存储一组键值对。Block 的设计目标是将一组键值对存储在一起,以便可以快速地将它们一起读取到内存中,从而提高查询效率。

Block 概览

在 LevelDB 中,磁盘上的数据是以块(Block)为单位进行存储的。块通常为 4-KB 大小,这相当于操作系统中的页面大小和 SSD 上的页面大小。一个块存储有序的键值对,这些键值对是通过一种特殊的编码格式进行存储的,以便可以高效地进行查询和更新操作。

一个 SST(Sorted String Table)文件由多个块组成,每个块都存储了一部分键值对。当内存中的 MemTable 的数量超过系统限制时,LevelDB 会将 MemTable 刷新为一个 SST 文件。这个过程称为 Compaction,它是 LevelDB 管理磁盘空间和提高查询效率的重要机制。

块的编码和解码是 LevelDB 中的一个重要部分。在编码过程中,LevelDB 会将一组键值对按照一定的格式编码为一个字节流,然后将这个字节流写入到磁盘中。在解码过程中,LevelDB 会从磁盘中读取一个字节流,然后按照编码格式将这个字节流解码为一组键值对。

table/block.cctable/block.h 文件中,实现了 Block 类,这个类封装了块的编码和解码操作。在 Block 类中,定义了一些方法,如 NewIteratorReadBlockApproximateMemoryUsage 等,这些方法用于创建迭代器、读取块的内容和估计块的内存使用量等。

table/format.h 文件中,定义了 BlockHandle 类和 Footer 类,这两个类用于描述块在 SST 文件中的位置和元数据信息。

Block 的编码格式中,包括了一系列的键值对和一个重启点数组。每个键值对包括一个共享键长度,一个非共享键长度,一个值长度,一个非共享键和一个值。重启点数组是一个固定大小的整数数组,用于帮助在 Block 中进行二分查找。

Block 的解码过程中,LevelDB 会首先读取 Block 的元数据,包括 Block 的大小和校验和,然后根据这些元数据读取 Block 的内容。然后,LevelDB 会按照编码格式将这个字节流解码为一组键值对。

总的来说,块的编码和解码是 LevelDB 管理磁盘空间和提高查询效率的重要部分。

Block 的编码格式

键值对部分的编码格式如下:

  1. 共享键长度:这是一个变长整数,表示当前键与前一个键共享的前缀长度。对于块中的第一个键,这个值总是为 0,因为它没有前一个键可以共享前缀。

  2. 非共享键长度:这也是一个变长整数,表示当前键与前一个键不共享的后缀长度。也就是说,这个值表示当前键的独有部分的长度。

  3. 值长度:这同样是一个变长整数,表示当前键对应的值的长度。

  4. 非共享键:这是一个字节序列,长度为非共享键长度。这个序列与前一个键的共享前缀连接在一起,就构成了当前键。

  5. 值:这是一个字节序列,长度为值长度。这就是当前键对应的值。

重启点数组部分的编码格式如下:

重启点数组是一个固定大小的整数数组,每个整数的大小为 4 字节。这个数组包含了一系列的重启点,每个重启点都是一个偏移量,指向 Block 中的一个键。这个数组用于帮助在 Block 中进行二分查找。

这种编码格式的设计使得 Block 可以高效地进行二分查找。当我们需要查找一个键时,可以先在重启点数组中进行二分查找,找到最接近的重启点,然后从这个重启点开始,顺序查找到我们需要的键。这样,我们就可以在 Block 中快速地查找到任何一个键。

一个具体的例子

在 LevelDB 中,键值对的编码过程主要在 BlockBuilder::Add 方法中完成。假设我们有一个键值对,键为 "key123",值为 "value123",我们将看到如何将其编码为块。

首先,我们需要计算当前键与上一个键的共享前缀长度。假设上一个键是 "key122",那么共享前缀长度就是 5,因为 "key122" 和 "key123" 的前 5 个字符是相同的。

然后,我们需要计算当前键的非共享部分的长度,也就是当前键去掉共享前缀后的长度。在这个例子中,非共享部分是 "3",所以非共享键长度就是 1。

接下来,我们需要计算值的长度。在这个例子中,值是 "value123",所以值长度就是 7。

然后,我们将共享键长度、非共享键长度和值长度编码为变长整数,添加到缓冲区中。在这个例子中,共享键长度、非共享键长度和值长度分别为 5、1 和 7,编码后的结果分别为 05、01 和 07。

接着,我们将非共享键和值添加到缓冲区中。在这个例子中,非共享键是 "3",值是 "value123"。

最后,我们更新上一个键和键值对数量。在这个例子中,上一个键变为 "key123",键值对数量加 1。

所以,"key123" 和 "value123" 编码为块的结果就是 "0501073value123"。

这只是一个简化的例子,实际的编码过程可能会涉及到更复杂的情况,比如前缀压缩、重启点的添加等。

restarts array

在 LevelDB 中,Block 的数据部分是由一系列的键值对组成,这些键值对是按照键的顺序存储的。为了提高查找效率,LevelDB 在每个 Block 的末尾添加了一个重启点数组(restarts array)。

重启点数组是一个包含多个偏移量的数组,每个偏移量都指向 Block 中的一个键。这些键是特殊的,因为它们是完整的,没有进行前缀压缩。这些键被称为重启点(restart points),因为它们是新一轮前缀压缩的开始。

当我们要在 Block 中查找一个键时,我们可以先在重启点数组中进行二分查找,找到最接近目标键的重启点,然后从这个重启点开始,顺序查找到目标键。这样,我们就可以避免扫描整个 Block,从而提高查找效率。

重启点数组的编码格式如下:

  • 重启点数组是一个固定大小的整数数组,每个整数的大小为 4 字节。这个数组包含了一系列的重启点,每个重启点都是一个偏移量,指向 Block 中的一个键。
  • 重启点数组的末尾是一个 4 字节的整数,表示重启点的数量。

这就是 Block 中的重启点数组的基本概念和作用。

在块中查找一个键的时间复杂度是多少?

在 LevelDB 中,查找一个键的时间复杂度主要取决于两个部分:在重启点数组中进行二分查找的时间复杂度和从找到的重启点开始顺序查找到目标键的时间复杂度。

  1. 在重启点数组中进行二分查找的时间复杂度是 O(log n),其中 n 是重启点的数量。因为二分查找是一种高效的查找算法,其时间复杂度是对数级别的。

  2. 从找到的重启点开始顺序查找到目标键的时间复杂度是 O(m),其中 m 是两个重启点之间的键的数量。因为这部分是顺序查找,所以时间复杂度是线性级别的。

因此,总的来说,查找一个键的时间复杂度是 O(log n + m)。在实际应用中,由于 LevelDB 的重启点间隔(block_restart_interval)通常设置得比较小(例如16),所以 m 的值通常不会很大,因此查找一个键的效率是相当高的。

当查找一个不存在的键时,光标会停在哪里?

在 LevelDB 中,当查找一个不存在的键时,光标(Cursor)会停在大于或等于目标键的第一个键的位置。如果所有的键都小于目标键,那么光标会移动到数据的末尾,此时调用 Valid() 方法会返回 false,表示已经没有更多的数据。这是因为 LevelDB 的迭代器设计为半开区间,即 [start, end),当目标键不存在时,迭代器会定位到大于或等于目标键的第一个位置。

写入块的数字的字节顺序是什么?

在 LevelDB 中,写入块的数字使用的是小端字节序(Little-Endian)。这是由于 LevelDB 的设计者们选择了这种字节序,因为它在大多数现代处理器(包括 x86 和 ARM)上都能提供更好的性能。

在源代码中,可以看到 PutFixed32 函数,它用于将 32 位整数写入到块中:

void PutFixed32(std::string* dst, uint32_t value) {
  char buf[sizeof(value)];
  EncodeFixed32(buf, value);
  dst->append(buf, sizeof(buf));
}

EncodeFixed32 函数中,你可以看到它是如何将一个 32 位整数编码为小端字节序的:

void EncodeFixed32(char* buf, uint32_t value) {
  if (port::kLittleEndian) {
    memcpy(buf, &value, sizeof(value));
  } else {
    buf[0] = value & 0xff;
    buf[1] = (value >> 8) & 0xff;
    buf[2] = (value >> 16) & 0xff;
    buf[3] = (value >> 24) & 0xff;
  }
}

这里,port::kLittleEndian 是一个编译时常量,它表示当前平台是否使用小端字节序。如果是,那么直接使用 memcpy 函数将整数的内存复制到缓冲区即可。否则,需要手动将每个字节编码为小端字节序。

Leveldb 是否容易受到恶意构建的块的影响?如果用户故意构造一个无效的块,会有无效的内存访问或 OOMs 吗?

LevelDB 是设计得相当健壮的,它有很多检查和验证机制来确保数据的完整性。然而,如果一个恶意用户故意构造一个无效的块并尝试将其插入到数据库中,LevelDB 会在尝试读取或写入这个块时遇到问题。

首先,LevelDB 在读取块时会进行一些基本的验证,例如检查块的大小是否合理,以及块的校验和是否正确。如果这些检查失败,LevelDB 将拒绝读取这个块,并返回一个错误。

其次,LevelDB 在处理块中的键值对时也有一些保护措施。例如,它会检查每个键值对的长度是否合理,以及键是否按照正确的顺序排列。如果这些检查失败,LevelDB 将停止处理这个块,并返回一个错误。

然而,尽管有这些保护措施,但是如果一个恶意用户故意构造一个非常大的块,并尝试将其插入到数据库中,那么这可能会导致 LevelDB 使用大量的内存,甚至可能导致内存耗尽(OOM)。因此,对于不受信任的输入,最好在将其插入到 LevelDB 之前进行一些额外的验证。

总的来说,虽然 LevelDB 有一些内置的保护措施来防止无效的块,但是它并不能完全防止所有的恶意攻击。因此,如果你的应用需要处理不受信任的输入,那么你应该在将数据插入到 LevelDB 之前进行一些额外的验证。

一个块可以包含重复的键吗?

在 LevelDB 中,一个块(Block)中的键是不允许重复的。这是因为 LevelDB 是一个键值存储系统,每个键在数据库中都应该是唯一的。在添加新的键值对时,如果键已经存在,那么新的值将会覆盖旧的值。

如果用户添加一个大于目标块大小的键会发生什么?

如果用户尝试添加一个大于目标块大小的键,那么这将会导致问题。首先,如果键的大小超过了块的大小,那么这个键将无法被添加到块中。其次,由于 LevelDB 使用前缀压缩来存储键,如果一个键的大小超过了块的大小,那么前缀压缩将无法工作,这将会导致存储效率降低。

在实际使用中,键的大小通常远小于块的大小,因此这种情况很少发生。如果你的应用需要处理大键,那么你可能需要调整 LevelDB 的配置,或者寻找其他更适合处理大键的数据库系统。

总结

LevelDB 是一个键值存储系统,其中数据以块(Block)的形式存储,每个块包含一组有序的键值对。块的设计使得可以快速地将一组键值对读取到内存中,提高查询效率。块的编码和解码是 LevelDB 管理磁盘空间和提高查询效率的重要部分。在编码过程中,LevelDB 会将一组键值对按照一定的格式编码为一个字节流,然后将这个字节流写入到磁盘中。在解码过程中,LevelDB 会从磁盘中读取一个字节流,然后按照编码格式将这个字节流解码为一组键值对。LevelDB 在读取块时会进行一些基本的验证,例如检查块的大小是否合理,以及块的校验和是否正确。如果这些检查失败,LevelDB 将拒绝读取这个块,并返回一个错误。在 LevelDB 中,一个块中的键是不允许重复的。如果用户尝试添加一个大于目标块大小的键,那么这将会导致问题。