Solución de optimización de RocksDB (1): indexación de SST

Google LevelDB es un ejemplo de una implementación de LSM-Tree. Sin embargo, después de que se lanzó el código abierto, para mantener un estilo ligero y conciso, además de corregir errores, no ha habido actualizaciones ni iteraciones importantes. Para que pueda cumplir con las diversas cargas en el entorno industrial , Facebook (Meta) realizó varias optimizaciones después de Fork LevelDB. En términos de hardware, el hardware moderno se puede usar de manera más efectiva, como memoria flash y discos rápidos, CPU multinúcleo, etc. En términos de software, se han realizado muchas optimizaciones para rutas de lectura y escritura y compactación, como Índice SST, fragmentación de índice, filtro Bloom de prefijo, familia de columnas Espera.

Esta serie de artículos, basada en la serie de blogs RocksDB, combinada con el código fuente y algo de experiencia de uso, compartió algunos puntos de optimización interesantes, con la esperanza de inspirar a todos. El nivel es limitado y la inadecuación es bienvenida para dejar un mensaje para la discusión.

Este artículo es el primero de la serie de optimización de RocksDB Para optimizar el rendimiento de las consultas profundas, SST en diferentes niveles se indexa de cierta manera.

antecedentes

Primero, el diagrama de arquitectura de LevelDB dibujado en el artículo anterior.

Diagrama de arquitectura LevelDB

Entre ellos, Get()la pasará por:

  1. una tabla de memoria mutable

  2. una matriz de tablas de memoria inmutables

  3. Una serie de archivos SST organizados jerárquicamente

Entre ellos, los archivos SST en la capa 0 se eliminan directamente de la tabla de memoria inmutable y la mayoría de sus rangos de teclas (definidos por FileMetaData.smallesty FileMetaData.largest) se superponen entre sí. Por lo tanto, en el nivel 0, todos los archivos SST se buscan uno por uno.

Las SST de otras capas provienen de la compactación continua de la SST de la capa superior , por lo que se sumergen continuamente los datos de la capa superior a la capa inferior. El proceso de compactación combinará varias SST juntas, exprimirá la "humedad" (claves duplicadas) y luego se dividirá en múltiples archivos SST, por lo que bajo 1 capa, todos los rangos de claves de archivos SST están separados y ordenados. Esta organización nos permite realizar búsquedas binarias ( O(log(N))complejidad) en lugar de búsquedas no lineales ( O(N)complejidad) cuando buscamos dentro de una capa.

El método de búsqueda específico es tomar el valor x que se buscará como objetivo, tomar la matriz FileMetaData.largestcompuesta como objeto binario y reducir continuamente el rango de búsqueda para determinar el archivo SST de destino. Si no se encuentra el archivo SST correspondiente al rango de claves que contiene x o no hay x en el archivo SST correspondiente, la búsqueda continúa en la capa inferior.

Sin embargo, en el caso de una gran cantidad de datos, la cantidad de archivos en la capa inferior sigue siendo muy grande.Para operaciones Get de alta frecuencia, incluso si se trata de una búsqueda binaria, la cantidad de comparaciones en cada capa aumentará linealmente. . Por ejemplo, para una proporción de fan - out de 10, es decir, el número de archivos SST en la capa inferior es diez veces mayor que el de la capa inmediatamente superior, habrá cerca de 1000 archivos en la tercera capa, y en el peor de los casos , se requieren alrededor de 10 comparaciones. Puede que no parezca mucho al principio, pero con millones de QPS, esto es una sobrecarga considerable.

mejoramiento

analizar

Se puede observar que bajo el mecanismo de versión de LevelDB, el número y la ubicación de los archivos SST en cada versión (VersionSet, que es guardado por el archivo Manifest) son fijos, y las posiciones relativas de los archivos SST en diferentes capas también son fijas.

Entonces, cuando buscamos en cada capa, en realidad no necesitamos comenzar la bisección desde el principio, y se puede reutilizar parte de la información de ubicación que ha sido bifurcada por la capa superior . Por lo tanto, se pueden agregar algunas estructuras de índice entre niveles similares a los árboles de búsqueda (como árboles B+) para reducir el rango de bisección subyacente. Esta idea se llama cascada fraccional.

Encuentre el diagrama esquemático después de la indexación SST

举个例子,如上图,1 层有 2 个 SST 文件,2 层有 8 个 SST 文件。假设现在我们想要查找 80,首先在第一层中所有文件的 FileMetaData.largest 中搜索,可以得到候选文件 file 1,但通过与其上下界比较发现小于其下界。于是继续向 2 层搜索,如果 SST 上没有索引,我们需要对所有八个候选文件进行二分,但如果有索引,如上图,我们只需要对前三个文件进行二分。由此,每层搜索的比较次数可以做到常数级别 N(扇出因子,即 RocksDB 中 max_bytes_for_level_multiplier 配置项),不会随着层次的加深而线性增加( log(N^L) = N*L)。

实现

RocksDB 中的版本是在每次 compaction 后确定的,只有 compaction 才会改变 SST。因此可以在 compaction 构建 SST 时构建相邻层间的 SST 索引。构建时,每个 SST 文件上下界各对应一个指针。那么如何确定每个指针的指向位置呢?与查找过程类似,拿着上界或者下界对应的 key,在下层所有 SST 文件的 FileMetaData.largest 构成的数组中进行搜索,将其指向下层文件该值对应的右界文件。这么选择原因在于,可以通过该指针(如 100 的指针),将下一层中指向的文件(如 file3)右边的文件(file4,file5,…)全部砍掉,从而减小搜索范围。

举个例子,如上图中的 1 层中 file1 的上下界分别为 100 和 200 。对于 100,搜索后落到 2 层中的 file 3 中,则其指向 file 3;对于 200 ,搜索后落到 210 左边,则指向 file 4。

代码

但在 RocksDB 实际代码中(该功能自 3.0 引入),对于任意一个上层文件,实际上是记下了四个索引。通过细分界定范围,可以进一步减小搜索范围。其基本思想是构建 FileIndexer 的时候多花一倍时间、多算一倍的边界,在查找时,就可以更进一步缩小查找范围。鉴于 SST 都是一次构建,多次查询,这种 tradeoff 是值得的。

struct IndexUnit {
    IndexUnit()
      : smallest_lb(0), largest_lb(0), smallest_rb(-1), largest_rb(-1) {}
    // Point to a left most file in a lower level that may contain a key,
    // which compares greater than smallest of a FileMetaData (upper level)
    // 下层文件中比 FileMetaData.smallest 大的第一个文件下标。 
    // target > FileMetaData.smallest 时搜索用。
    int32_t smallest_lb;

    // Point to a left most file in a lower level that may contain a key,
    // which compares greater than largest of a FileMetaData (upper level)
    // 下层文件中比 FileMetaData.largest 大的第一个文件下标。 
    // target > FileMetaData.largest 时搜索用。
    int32_t largest_lb;

    // Point to a right most file in a lower level that may contain a key,
    // which compares smaller than smallest of a FileMetaData (upper level)
    // 下层文件中比 FileMetaData.smallest 小的最后一个文件下标。 
    // target < FileMetaData.smallest 时搜索用。
    int32_t smallest_rb;

    // Point to a right most file in a lower level that may contain a key,
    // which compares smaller than largest of a FileMetaData (upper level)
    // 下层文件中比 FileMetaData.largest 小的最后一个文件下标。 
    // target < FileMetaData.largest 时搜索用。
    int32_t largest_rb;
  };

复制代码

上面注释看起来比较拗口,但是从如何对其使用入手,就能比较容易理解了。假设我们现在要查找的值为 x,待比较的某个上层文件上下界为[smallest, largest] ,则该上下界将键空间切分为五个区间:(-∞, smallest), smallest, (smallest, largest), largest, (largest, +∞)。仅考虑该文件搜索结束就要去下层搜索的情况,则有:

  1. 如果 x < smallest,则需要在下层中比 smallest 小的最右边那个文件(smallest_rb)找。

  2. 如果 x == smallest,即 smallest ≤ x ≤ smallest,则需要在下层比 smallest 大的最左边的文件(smallest_lb)开始找,到比 smallest 小的最右边的文件(smallest_rb)停止。

  3. 如果 smallest < x < largest,则需要在下层比 smallest 大的最左边的文件(smallest_lb)开始找,到比 largest 小的最右边的文件(largest_rb)停止。

  4. 如果 x == largest,即 largest ≤ x ≤ largest,则需要在下层比 largest 大的最左边的文件(largest_lb)开始找,到比 largest 小的最右边的文件(largest_rb)停止。

  5. 如果 x > largest,则需要在下层比 largest 大的最左边那个文件(largest_lb)开始找。

对应代码如下:

if (cmp_smallest < 0) { // target < smallest,使用 smallest_rb 作为右界
  *left_bound = (level > 0 && file_index > 0)
                    ? index_units[file_index - 1].largest_lb
                    : 0;
  *right_bound = index.smallest_rb;
} else if (cmp_smallest == 0) {
  *left_bound = index.smallest_lb;
  *right_bound = index.smallest_rb;
} else if (cmp_smallest > 0 && cmp_largest < 0) {
  *left_bound = index.smallest_lb;
  *right_bound = index.largest_rb;
} else if (cmp_largest == 0) {
  *left_bound = index.largest_lb;
  *right_bound = index.largest_rb;
} else if (cmp_largest > 0) {
  *left_bound = index.largest_lb;
  *right_bound = level_rb_[level + 1];
} else {
  assert(false);
}

复制代码

**注:**当 x == smallest 或者 x == largest 时,对于 RocksDB 使用场景来说,上层已经找到了,无须再往查找下层。但该 FileIndexer 的应用场景更泛化一些,比如你可以使用其往下层寻找所有等于给定值的结果。

下面,仍以之前例子,来使用图解释下:

crear-indexación-sst.png

RocksDB 中 SST 建立索引

可以看出,当上层文件边界(如 100)落到下层文件内(如 file 3 [95, 110])时,该边界 lb 和 rb 指针指向相同,蜕化为单指针;当文件边界(如 400)落到下层文件空隙内(如 file 7 和 file 8 之间),lb 和 rb 才指向不同,从而在搜索时,相对单指针,总体上减少一个待扫描文件。

另一方面,注意到,当上层文件边界值(如 400)落在下层文件空隙内时(即该值在下层肯定不存在),有 largest_rb < largest_lb ,如果搜索值是 400,则利用此指针直接导致 left_bound > right_bound,搜索结束。

在具体实现时, RocksDB 使用了双指针比较法,一个指针迭代上层,一个指针迭代下层,一次迭代即可为所有文件建立一种索引。为减少代码中的分支判断,使逻辑清晰,RocksDB 将建立过程拆为了四趟,分别构建上述四种指针,逻辑封装在了两个函数中:CalculateLBCalculateRB

下面以 CalculateLB 代码为例,简单注释分析:

// 计算 lower_files 中比某值大的最左(从左到右第一个)文件下标
void FileIndexer::CalculateLB(
    const std::vector<FileMetaData*>& upper_files, // 上层 SST 文件
    const std::vector<FileMetaData*>& lower_files, // 下层 SST 文件
    IndexLevel* index_level,
    std::function<int(const FileMetaData*, const FileMetaData*)> cmp_op,
    std::function<void(IndexUnit*, int32_t)> set_index) {
  const int32_t upper_size = static_cast<int32_t>(upper_files.size());
  const int32_t lower_size = static_cast<int32_t>(lower_files.size());
  int32_t upper_idx = 0;
  int32_t lower_idx = 0;

  IndexUnit* index = index_level->index_units;
  while (upper_idx < upper_size && lower_idx < lower_size) { // 双指针比较法
    // 总是跟 lower_files[lower_idx].largest 比较
    int cmp = cmp_op(upper_files[upper_idx], lower_files[lower_idx]);

    if (cmp == 0) {
      set_index(&index[upper_idx], lower_idx);
      ++upper_idx;
    } else if (cmp > 0) {
      // 下层 lower_idx 处文件的最大值比给定值小,则不满足条件
      ++lower_idx;
    } else {
      // 下层 lower_idx 处文件的最大值相对给定值第一次变大,满足条件,设置索引
      set_index(&index[upper_idx], lower_idx);
      ++upper_idx;
    }
  }

  while (upper_idx < upper_size) {
    // 下层文件用完了,表示现在所有余下的上层文件比所有下层文件都大
    // 于是让他们都指向 lower_size,即不存在(下层文件下标为 0~lower_size-1)。
    set_index(&index[upper_idx], lower_size);
    ++upper_idx;
  }

  // 如果是上层文件用完了,不做额外处理。因为函数作用是为上层文件设置索引,
  // 上层文件用完了,说明已经为所有上层文件设置了索引。
}

复制代码

四趟调用,分别为上层每一个文件索引项 IndexUnit 的四个字段赋值。

CalculateLB(
    upper_files, lower_files, &index_level,
    [this](const FileMetaData* a, const FileMetaData* b) -> int {
      return ucmp_->CompareWithoutTimestamp(a->smallest.user_key(),
                                            b->largest.user_key());
    },
    [](IndexUnit* index, int32_t f_idx) { index->smallest_lb = f_idx; });
CalculateLB(
    upper_files, lower_files, &index_level,
    [this](const FileMetaData* a, const FileMetaData* b) -> int {
      return ucmp_->CompareWithoutTimestamp(a->largest.user_key(),
                                            b->largest.user_key());
    },
    [](IndexUnit* index, int32_t f_idx) { index->largest_lb = f_idx; });
CalculateRB(
    upper_files, lower_files, &index_level,
    [this](const FileMetaData* a, const FileMetaData* b) -> int {
      return ucmp_->CompareWithoutTimestamp(a->smallest.user_key(),
                                            b->smallest.user_key());
    },
    [](IndexUnit* index, int32_t f_idx) { index->smallest_rb = f_idx; });
CalculateRB(
    upper_files, lower_files, &index_level,
    [this](const FileMetaData* a, const FileMetaData* b) -> int {
      return ucmp_->CompareWithoutTimestamp(a->largest.user_key(),
                                            b->smallest.user_key());
    },
    [](IndexUnit* index, int32_t f_idx) { index->largest_rb = f_idx; });

复制代码

小结

El método de indexación de SST es más intuitivo (se puede comparar con el árbol B+) y es fácil de entender. Sin embargo, correspondiente a la implementación del código, hay muchos detalles y condiciones de contorno que deben tratarse, y es fácil cometer errores. Al igual que la búsqueda binaria, que puede escribir flores, no hay otra, solo más entrenamiento auditivo.

Referirse a

  1. RocksDB 博客,Indización de archivos SST para un mejor rendimiento de búsqueda****,**** rocksdb.org/blog/2014/0…

  2. Wikipedia, cascada descentralizada, cascada fraccional, en.wikipedia.org/wiki/Fracti…

  3. RocksDB github FileIndexer: github.com/facebook/ro…

Soy Qingteng Wood Bird, un programador de sistemas de distribución al que le gusta la fotografía. Me gusta prestar atención a mi cuenta pública: Wood Bird Miscellaneous Notes . Si crees que mi artículo no es malo, por favor dale me gusta y apóyalo, gracias.

Artículos relacionados

Hablando de la estructura de datos de LevelDB (3): LRU Cache (LRUCache)

Hablando de la estructura de datos de LevelDB (2): Bloom Filter (Bloom Filter)

Hablando de la estructura de datos de LevelDB (1): lista de omisión

Supongo que te gusta

Origin juejin.im/post/7134225668027449352
Recomendado
Clasificación