Maison > développement back-end > Golang > Créer un moteur de stockage LSM-Tree à partir de zéro

Créer un moteur de stockage LSM-Tree à partir de zéro

Barbara Streisand
Libérer: 2025-01-03 07:37:39
original
570 Les gens l'ont consulté

Préface

Cet article vous guidera dans la compréhension du Log-Structured Merge-Tree (LSM-Tree), y compris ses concepts de base et sa structure. À la fin, vous serez en mesure de créer votre propre moteur de stockage basé sur LSM-Tree à partir de zéro.

Qu’est-ce que LSM-Tree ?

Concepts de base

Le Log-Structured Merge-Tree (LSM-Tree) est une structure de données optimisée pour les opérations d'écriture à haut débit. Il est largement utilisé dans les bases de données et les systèmes de stockage, tels que Cassandra, RocksDB et LevelDB.

L'idée clé de LSM-Tree est d'abord d'écrire les opérations dans une structure de données en mémoire (généralement une structure ordonnée comme une liste de sauts ou un arbre AVL). Plus tard, ces écritures sont regroupées et écrites séquentiellement sur le disque sous forme de SSTables, minimisant ainsi les E/S aléatoires.

Structure de base

Building an LSM-Tree Storage Engine from Scratch

Le LSM-Tree est divisé en deux composants principaux :

  • Stockage en mémoire
    • La structure de base de la mémoire est la Memtable.
    • Toutes les opérations d'écriture (par exemple, définir, supprimer) vont d'abord dans la Memtable, qui insère ces opérations dans une structure de données ordonnée (par exemple, un arbre ordonné dans le diagramme).
    • Une fois que la Memtable atteint un certain seuil de taille, elle est vidée sur le disque en tant que SSTable (écrite séquentiellement).
    • Les nouvelles opérations d'écriture se poursuivent sur une nouvelle table Memtable.
  • Stockage sur disque
    • Le stockage sur disque implique les fichiers WAL et SSTable.
    • Le WAL (Write-Ahead Log) garantit que les écritures récentes (stockées dans la Memtable mais pas encore conservées sur le disque) ne sont pas perdues en cas de panne de la base de données. Chaque écriture dans Memtable est ajoutée au WAL. Au redémarrage de la base de données, les entrées du WAL peuvent être relues afin de restaurer la Memtable à son état d'avant le crash.
    • Le SSTable (Sorted String Table) est un format de stockage de données qui contient une série de paires clé-valeur ordonnées.
    • Lorsque la Memtable atteint son seuil de taille, elle génère une nouvelle SSTable et la conserve sur le disque. Étant donné que Memtable repose sur une structure de données ordonnée en mémoire, aucun tri supplémentaire n'est requis lors de la construction de SSTable.
    • Les SSTables sur disque sont organisées en plusieurs niveaux. Les SSTables nouvellement vidées sont stockées au Niveau 0. Au cours des phases de compactage suivantes, les SSTables de L0 sont fusionnées dans le Niveau 1 et les niveaux supérieurs.
    • Lorsque la taille d'un niveau dépasse un seuil, un processus de compactage SSTable est déclenché. Pendant le compactage, les SSTables du niveau actuel sont fusionnées dans des niveaux supérieurs, produisant des fichiers plus volumineux et plus ordonnés. Cela réduit la fragmentation et améliore l'efficacité des requêtes.

En général, la structure d'un SSTable comprend plus qu'une simple série de paires clé-valeur ordonnées (blocs de données). Il contient également un bloc d'index, un bloc de métadonnées et d'autres composants. Ces détails seront discutés en profondeur lors de la section de mise en œuvre.

Écriture de données

L'écriture de données implique l'ajout d'une nouvelle paire clé-valeur ou la mise à jour d'une paire existante. Les mises à jour écrasent les anciennes paires clé-valeur, qui sont ensuite supprimées pendant le processus de compactage.

Lorsque les données sont écrites, elles vont d'abord dans la Memtable, où la paire clé-valeur est ajoutée à la structure de données ordonnée en mémoire. Simultanément, l'opération d'écriture est enregistrée dans le WAL et conservée sur le disque pour éviter la perte de données en cas de panne de la base de données.

La Memtable a un seuil défini (généralement basé sur la taille). Lorsque la Memtable dépasse ce seuil, elle passe en mode lecture seule et est convertie en une nouvelle SSTable, qui est ensuite conservée au Niveau 0 sur le disque.

Une fois la Memtable vidée en tant que SSTable, le fichier WAL correspondant peut être supprimé en toute sécurité. Les opérations d'écriture ultérieures se dérouleront sur une nouvelle Memtable (et un nouveau WAL).

Suppression de données

Dans LSM-Tree, les données ne sont pas immédiatement supprimées. Au lieu de cela, les suppressions sont gérées à l'aide d'un mécanisme appelé tombstones (similaire aux suppressions logicielles). Lorsqu'une paire clé-valeur est supprimée, une nouvelle entrée marquée d'une « pierre tombale » est écrite, indiquant la suppression de la paire clé-valeur correspondante. L'enlèvement proprement dit a lieu pendant le processus de compactage.

Cette suppression basée sur la pierre tombale garantit la propriété ajout uniquement de LSM-Tree, évitant les E/S aléatoires et conservant les écritures séquentielles sur le disque.

Interrogation de données

Le processus d'interrogation des données commence par une recherche dans la Memtable. Si la paire clé-valeur est trouvée, elle est renvoyée au client. Si une paire clé-valeur marquée par une pierre tombale est trouvée, cela indique que les données demandées ont été supprimées et ces informations sont également renvoyées. Si la clé n'est pas trouvée dans la Memtable, la requête procède à la recherche dans les SSTables du Niveau 0 au Niveau N.

Étant donné que l'interrogation de données peut impliquer la recherche de plusieurs fichiers SSTable et peut conduire à des E/S aléatoires sur le disque, LSM-Tree est généralement mieux adapté aux charges de travail à forte écriture qu'à celles à forte intensité de lecture.

Une optimisation courante des performances des requêtes consiste à utiliser un filtre Bloom. Un filtre Bloom peut déterminer rapidement si une paire clé-valeur existe dans une SSTable spécifique, réduisant ainsi les E/S disque inutiles. De plus, la nature triée des SSTables permet d'utiliser des algorithmes de recherche efficaces, tels que la recherche binaire, pour des recherches plus rapides.

Compactage des données

Ici, nous présentons la Stratégie de compactage par niveaux, qui est utilisée par LevelDB et RocksDB.

Une autre stratégie courante est la Stratégie de compactage par niveaux de taille, dans laquelle des SSTables plus récentes et plus petites sont successivement fusionnées en SSTables plus anciennes et plus grandes.

Comme mentionné précédemment, une SSTable stocke une série d'entrées triées par clé. Dans la stratégie de compactage par niveaux, les SSTables sont organisées en plusieurs niveaux (Niveau 0 au niveau N).

Au niveau 0, les SSTables peuvent avoir des plages de clés qui se chevauchent, car elles sont directement vidées de la Memtable. Cependant, aux niveaux 1 à N, les SSTables d'un même niveau n'ont pas de plages de clés qui se chevauchent, bien que les chevauchements de plages de clés soient autorisés entre les SSTables de différents niveaux.

Un exemple illustratif (bien que pas entièrement précis) est présenté ci-dessous. Au Niveau 0, les plages de clés des première et deuxième SSTables se chevauchent, tandis qu'au Niveau 1 et au Niveau 2, les SSTables de chaque niveau ont des plages de clés disjointes. . Cependant, les SSTables entre différents niveaux (par exemple, niveau 0 et niveau 1, ou niveau 1 et niveau 2) peuvent avoir des plages de clés qui se chevauchent.

Building an LSM-Tree Storage Engine from Scratch

Explorons maintenant comment la stratégie de compactage nivelé maintient cette structure organisationnelle.

Le niveau 0 étant un cas particulier, la discussion sur la stratégie de compactage est divisée en deux parties :

  • Niveau 0 au niveau 1 Étant donné que le niveau 0 autorise le chevauchement des clés entre les SSTables, le compactage commence par la sélection d'une SSTable du niveau 0, ainsi que de toutes les autres SSTables du niveau 0 comportant des plages de clés qui se chevauchent. Ensuite, toutes les SSTables du niveau 1 avec des plages de clés qui se chevauchent sont sélectionnées. Ces SSTables sélectionnées sont fusionnées et compactées en une seule nouvelle SSTable, qui est ensuite insérée au niveau 1. Toutes les anciennes SSTables impliquées dans le processus de compactage sont supprimées.
  • Niveau N au niveau N 1 (N> 0) À partir du niveau 1, les SSTables d'un même niveau n'ont pas de plages de clés qui se chevauchent. Pendant le compactage, une SSTable est sélectionnée à partir du niveau N, et toutes les SSTables du niveau N 1 avec des plages de clés qui se chevauchent sont également sélectionnées. Ces SSTables sont fusionnées et compactées en une ou plusieurs nouvelles SSTables, qui sont insérées dans le niveau N 1, tandis que les anciennes SSTables sont supprimées.

La principale différence entre le compactage Niveau 0 au niveau 1 et le Niveau N au niveau N 1 (N > 0) réside dans la sélection des SSTables aux niveaux inférieurs (Niveau 0 ou Niveau N).

Le processus de compactage et de fusion multi-SSTable est illustré ci-dessous. Lors de la fusion, seule la dernière valeur de chaque clé est conservée. Si la dernière valeur comporte un marqueur « tombstone », la clé est supprimée. Dans l'implémentation, nous utilisons l'algorithme de fusion k-way pour effectuer ce processus.

Building an LSM-Tree Storage Engine from Scratch

Il est important de noter que la description ci-dessus du processus de compactage ne fournit qu'un aperçu de haut niveau. De nombreux détails doivent être abordés lors de la mise en œuvre réelle.

Par exemple, dans LevelDB, lors de la construction de nouvelles SSTables pour le niveau N 1 pendant le compactage, si la nouvelle SSTable chevauche plus de 10 SSTables au niveau N 2, le processus passe à la construction d'une autre SSTable. Cela limite la taille des données impliquées dans un seul compactage.

Mise en œuvre

Sur la base de l'aperçu de LSM-Tree ci-dessus, je pense que vous avez maintenant une compréhension de base de LSM-Tree et quelques idées sur sa mise en œuvre. Ensuite, nous allons construire un moteur de stockage basé sur LSM-Tree à partir de zéro. Ci-dessous, nous présenterons uniquement le code principal ; pour le code complet, veuillez vous référer à https://github.com/B1NARY-GR0UP/originium.

Nous décomposerons la mise en œuvre de LSM-Tree en composants de base suivants et les implémenterons un par un :

  • Liste de sauts
  • WAL
  • Mémable
  • SSTable
  • Fusion K-Way
  • Filtre Bloom
  • Compactage nivelé

Ignorer la liste

Dans le processus d'introduction de l'écriture de données, nous avons mentionné que le LSM-Tree écrit d'abord les données dans une structure de données ordonnée en mémoire. Certaines structures de données ordonnées courantes et la complexité temporelle de leurs opérations sont les suivantes :

Data Structure Insert Delete Search Traverse
Skip List O(log⁡n) O(log⁡n) O(log⁡n) O(n)
AVL Tree O(log⁡n) O(log⁡n) O(log⁡n) O(n)
Red-Black Tree O(log⁡n) O(log⁡n) O(log⁡n) O(n)

Nous avons choisi la Skip List pour deux raisons principales : elle est plus simple à mettre en œuvre et à maintenir (principe KISS), et la structure de liste chaînée sous-jacente facilite le parcours séquentiel, ce qui facilite la conservation des données en mémoire sur le disque.

Structure de base

La mise en œuvre complète de la Skip List est disponible sur https://github.com/B1NARY-GR0UP/originium/blob/main/pkg/skiplist/skiplist.go .

Une liste de sauts se compose d'une liste chaînée de base et de plusieurs niveaux d'index construits dessus. Pour les grands ensembles de données, les couches d'index raccourcissent considérablement le chemin de recherche.

Dans notre implémentation, la structure de base de la Skip List est définie comme suit :

type SkipList struct {
    maxLevel int
    p        float64
    level    int
    rand     *rand.Rand
    size     int
    head     *Element
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
  • maxLevel : Le nombre maximum de niveaux dans la liste à ignorer (la liste chaînée de base a un niveau).
  • niveau : le nombre actuel de niveaux dans la liste de sauts.
  • p : La probabilité qu'un nœud soit promu à un niveau supérieur. Par exemple, si p = 0,5, une liste chaînée avec 10 nœuds au niveau de base aura environ 5 nœuds au niveau d'indices suivant.
  • rand : Un générateur de nombres aléatoires utilisé pour comparer avec p.
  • taille : le nombre de paires clé-valeur stockées dans la liste de sauts, utilisée pour déterminer si la Memtable dépasse son seuil de taille.
  • head : Le nœud principal, qui contient les références au premier nœud de chaque niveau.

La structure des éléments stockés dans la Skip List est définie comme suit :

type Element struct {
    types.Entry
    next []*Element
}

// https://github.com/B1NARY-GR0UP/originium/blob/main/pkg/types/entry.go
type Entry struct {
    Key       string
    Value     []byte
    Tombstone bool  
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
  • types.Entry représente une paire clé-valeur dans le moteur de stockage, comprenant la clé, la valeur et un indicateur de pierre tombale pour la suppression.

  • suivant : contient des pointeurs vers l'élément suivant à chaque niveau.

Cette structure peut paraître abstraite, alors illustrons-la avec un exemple :

Level 3:       3 ----------- 9 ----------- 21 --------- 26
Level 2:       3 ----- 6 ---- 9 ------ 19 -- 21 ---- 25 -- 26
Level 1:       3 -- 6 -- 7 -- 9 -- 12 -- 19 -- 21 -- 25 -- 26

next of head [ ->3, ->3, ->3 ]
next of Element 3 [ ->6, ->6, ->9 ]
next of Element 6 [ ->7, ->9 ]
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Dans cette liste de sauts à trois niveaux, les pointeurs suivants du nœud principal font référence au premier nœud de chaque niveau. Les éléments 3 et 6 stockent l'élément suivant pour chacun de leurs niveaux.

Par exemple, si nous voulons trouver le nœud suivant de l'élément 19 au niveau 2, nous utilisons e19.next[2-1].

Ensemble

func (s *SkipList) Set(entry types.Entry)
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Le LSM-Tree utilise des pierres tombales pour effectuer des suppressions, nous n'avons donc pas besoin d'une méthode Supprimer dans l'implémentation de la liste de sauts. Pour supprimer un élément, définissez simplement le Tombstone de l'entrée sur true. Ainsi, la méthode Set gère l'insertion de nouvelles paires clé-valeur, la mise à jour de celles existantes et la suppression d'éléments.

Explorons l'implémentation de la méthode Set. En parcourant les nœuds de chaque niveau depuis le plus haut, le dernier élément plus petit que la clé à définir est enregistré dans la tranche de mise à jour.

type SkipList struct {
    maxLevel int
    p        float64
    level    int
    rand     *rand.Rand
    size     int
    head     *Element
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

À la fin de ce parcours, curr pointe vers le dernier élément plus petit que la clé à définir dans la liste chaînée de niveau inférieur. Il nous suffit donc de vérifier si l’élément suivant de curr est égal à la clé que nous voulons définir. S'il correspond, l'élément a déjà été inséré ; nous mettons à jour l'élément existant et revenons.

type Element struct {
    types.Entry
    next []*Element
}

// https://github.com/B1NARY-GR0UP/originium/blob/main/pkg/types/entry.go
type Entry struct {
    Key       string
    Value     []byte
    Tombstone bool  
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Si l'élément n'est pas trouvé, il est inséré comme nouvel élément. En utilisant randomLevel, nous calculons le niveau d'index de cet élément. S'il dépasse le nombre actuel de niveaux dans la liste à ignorer, nous ajoutons le nœud principal à la tranche de mise à jour et mettons à jour s.level avec le nouveau nombre de niveaux.

Level 3:       3 ----------- 9 ----------- 21 --------- 26
Level 2:       3 ----- 6 ---- 9 ------ 19 -- 21 ---- 25 -- 26
Level 1:       3 -- 6 -- 7 -- 9 -- 12 -- 19 -- 21 -- 25 -- 26

next of head [ ->3, ->3, ->3 ]
next of Element 3 [ ->6, ->6, ->9 ]
next of Element 6 [ ->7, ->9 ]
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Ensuite, construisez l'élément à insérer et les pointeurs suivants de chaque niveau sont mis à jour pour terminer l'insertion.

func (s *SkipList) Set(entry types.Entry)
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Obtenir

La liste de sauts peut effectuer des opérations de recherche rapides en s'appuyant sur plusieurs couches d'index. Les boucles for imbriquées dans l'implémentation représentent l'opération de recherche basée sur l'index. Si l'élément correspondant est finalement trouvé dans la liste chaînée de niveau inférieur, il sera renvoyé.

curr := s.head
update := make([]*Element, s.maxLevel)

for i := s.maxLevel - 1; i >= 0; i-- {
    for curr.next[i] != nil && curr.next[i].Key < entry.Key {
        curr = curr.next[i]
    }
    update[i] = curr
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Tous

L'une des raisons pour lesquelles nous avons choisi la liste de sauts est son parcours séquentiel pratique, qui est rendu possible en parcourant simplement la liste chaînée de niveau inférieur.

// update entry
if curr.next[0] != nil && curr.next[0].Key == entry.Key {
    s.size += len(entry.Value) - len(curr.next[0].Value)

    // update value and tombstone
    curr.next[0].Value = entry.Value
    curr.next[0].Tombstone = entry.Tombstone
    return
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

WAL

La mise en œuvre complète de WAL peut être trouvée sur https://github.com/B1NARY-GR0UP/originium/blob/main/wal/wal.go.

Comme mentionné précédemment, le but de WAL (Write-Ahead Logging) est d'éviter la perte de données dans la Memtable causée par des pannes de base de données. Par conséquent, WAL doit enregistrer les opérations sur la Memtable et récupérer la Memtable à partir du fichier WAL au redémarrage de la base de données.

Structure de base

La structure de base de WAL est la suivante, où fd stocke le descripteur de fichier du fichier WAL :

// add entry
level := s.randomLevel()

if level > s.level {
    for i := s.level; i < level; i++ {
        update[i] = s.head
    }
    s.level = level
}
Copier après la connexion
Copier après la connexion
Copier après la connexion

Écrire

Puisque nous devons enregistrer les opérations sur la Memtable, cela implique essentiellement d'écrire chaque opération (Set, Supprimer) en tant qu'entrée dans le WAL. La définition de la méthode Write est la suivante :

e := &Element{
    Entry: types.Entry{
        Key:       entry.Key,
        Value:     entry.Value,
        Tombstone: entry.Tombstone,
    },
    next: make([]*Element, level),
}

for i := range level {
    e.next[i] = update[i].next[i]
    update[i].next[i] = e
}
s.size += len(entry.Key) + len(entry.Value) + int(unsafe.Sizeof(entry.Tombstone)) + len(e.next)*int(unsafe.Sizeof((*Element)(nil)))
Copier après la connexion
Copier après la connexion
Copier après la connexion

Lors de l'écriture de ces entrées dans le fichier, nous devons standardiser le format de fichier WAL. Le format que nous utilisons ici est données de longueur. Tout d'abord, nous sérialisons l'entrée, puis calculons la longueur des données sérialisées et enfin écrivons la longueur et les données sérialisées séquentiellement dans le fichier WAL.

Le code de base est le suivant :

func (s *SkipList) Get(key types.Key) (types.Entry, bool) {
    curr := s.head

    for i := s.maxLevel - 1; i >= 0; i-- {
        for curr.next[i] != nil && curr.next[i].Key < key {
            curr = curr.next[i]
        }
    }

    curr = curr.next[0]

    if curr != nil && curr.Key == key {
        return types.Entry{
            Key:       curr.Key,
            Value:     curr.Value,
            Tombstone: curr.Tombstone,
        }, true
    }
    return types.Entry{}, false
}
Copier après la connexion
Copier après la connexion

Lire

Puisque nous utilisons le format de fichier WAL longueur des données, lors de la lecture, nous lisons d'abord 8 octets (int64) pour obtenir la longueur des données, puis lisons les données en fonction de cette longueur et les désérialisons pour récupérer l'entrée.

Le code de base est le suivant :

type SkipList struct {
    maxLevel int
    p        float64
    level    int
    rand     *rand.Rand
    size     int
    head     *Element
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Mémorisable

L'implémentation complète de Memtable peut être trouvée sur https://github.com/B1NARY-GR0UP/originium/blob/main/memtable.go.

La Memtable est chargée d'écrire les opérations du client dans la liste de sauts et de les enregistrer dans le WAL. Il peut également récupérer les données du WAL au démarrage de la base de données.

Structure de base

La structure de base de Memtable est la suivante, qui comprend deux composants principaux skiplist et wal :

type Element struct {
    types.Entry
    next []*Element
}

// https://github.com/B1NARY-GR0UP/originium/blob/main/pkg/types/entry.go
type Entry struct {
    Key       string
    Value     []byte
    Tombstone bool  
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Ensemble

Lors de l'exécution d'une opération Set, la liste de sauts et le WAL doivent être mis à jour simultanément.

Level 3:       3 ----------- 9 ----------- 21 --------- 26
Level 2:       3 ----- 6 ---- 9 ------ 19 -- 21 ---- 25 -- 26
Level 1:       3 -- 6 -- 7 -- 9 -- 12 -- 19 -- 21 -- 25 -- 26

next of head [ ->3, ->3, ->3 ]
next of Element 3 [ ->6, ->6, ->9 ]
next of Element 6 [ ->7, ->9 ]
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Obtenir

Pour récupérer une valeur, retournez simplement le résultat de l'opération Get de la liste à sauter.

func (s *SkipList) Set(entry types.Entry)
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Récupérer

La récupération de la Memtable à partir du fichier WAL implique d'abord de lire le fichier WAL, puis d'appliquer séquentiellement les enregistrements d'entrée du fichier WAL à la Memtable, et enfin de supprimer le fichier WAL récupéré.

Récupérer la liste des fichiers WAL :

curr := s.head
update := make([]*Element, s.maxLevel)

for i := s.maxLevel - 1; i >= 0; i-- {
    for curr.next[i] != nil && curr.next[i].Key < entry.Key {
        curr = curr.next[i]
    }
    update[i] = curr
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Lire le WAL et récupérer la Memtable :

// update entry
if curr.next[0] != nil && curr.next[0].Key == entry.Key {
    s.size += len(entry.Value) - len(curr.next[0].Value)

    // update value and tombstone
    curr.next[0].Value = entry.Value
    curr.next[0].Tombstone = entry.Tombstone
    return
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

SSTable

LevelDB SSTable

Dans l'introduction précédente, nous avons seulement mentionné que "SSTable (Sorted String Table) est un format de stockage de données qui maintient une série de paires clé-valeur ordonnées." Ici, nous fournirons une explication plus détaillée de la structure de SSTable.

Dans LevelDB, une SSTable se compose de plusieurs blocs ayant des objectifs différents. Un diagramme schématique est présenté ci-dessous :

Building an LSM-Tree Storage Engine from Scratch

  • Bloc de données : stocke une séquence de paires clé-valeur ordonnées.
  • Méta-bloc : comprend deux types : filtre et statistiques. Le type de filtre stocke les données des filtres Bloom, tandis que le type stats stocke des informations statistiques sur les blocs de données.
  • Bloc MetaIndex : stocke les informations d'index pour les blocs méta.
  • Bloc d'index : stocke les informations d'index pour les blocs de données.
  • Pied de page : De longueur fixe, il stocke les informations d'index du bloc MetaIndex et du bloc d'index, ainsi qu'un nombre magique.

Les informations d'index sont essentiellement une structure de pointeur appelée BlockHandle, qui comprend deux attributs : offset et size, utilisés pour localiser le bloc correspondant.

Notre SSTable

Dans notre implémentation de SSTable, nous avons simplifié la structure LevelDB SSTable. Un diagramme schématique est présenté ci-dessous :

Building an LSM-Tree Storage Engine from Scratch

  • Bloc de données : stocke une séquence de paires clé-valeur ordonnées.
  • Meta Block : stocke certaines métadonnées pour le SSTable.
  • Bloc d'index : stocke les informations d'index pour les blocs de données.
  • Pied de page : De longueur fixe, il stocke les informations d'index du Meta Block et du Index Block.

L'implémentation complète de SSTable peut être trouvée sur https://github.com/B1NARY-GR0UP/originium/tree/main/sstable.

Bloc de données

La structure du bloc de données est définie comme suit, stockant une séquence ordonnée d'entrées.

type SkipList struct {
    maxLevel int
    p        float64
    level    int
    rand     *rand.Rand
    size     int
    head     *Element
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Nous avons implémenté trois méthodes principales pour le bloc de données :

  • Encode : encode le bloc de données en données binaires.
type Element struct {
    types.Entry
    next []*Element
}

// https://github.com/B1NARY-GR0UP/originium/blob/main/pkg/types/entry.go
type Entry struct {
    Key       string
    Value     []byte
    Tombstone bool  
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Nous utilisons la compression de préfixe pour encoder la séquence clé-valeur. Dans le tampon, nous écrivons séquentiellement la longueur du préfixe commun, la longueur du suffixe, le suffixe lui-même, la longueur de la valeur, la valeur et le marqueur « pierre tombale ».

Level 3:       3 ----------- 9 ----------- 21 --------- 26
Level 2:       3 ----- 6 ---- 9 ------ 19 -- 21 ---- 25 -- 26
Level 1:       3 -- 6 -- 7 -- 9 -- 12 -- 19 -- 21 -- 25 -- 26

next of head [ ->3, ->3, ->3 ]
next of Element 3 [ ->6, ->6, ->9 ]
next of Element 6 [ ->7, ->9 ]
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Enfin, nous compressons les données en utilisant s2.

S2 est une extension hautes performances de l'algorithme de compression Snappy.

func (s *SkipList) Set(entry types.Entry)
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
  • Decode : décode les données binaires en un bloc de données.
curr := s.head
update := make([]*Element, s.maxLevel)

for i := s.maxLevel - 1; i >= 0; i-- {
    for curr.next[i] != nil && curr.next[i].Key < entry.Key {
        curr = curr.next[i]
    }
    update[i] = curr
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Lors du décodage, le processus est simplement inversé. La paire clé-valeur complète est reconstruite à l'aide du préfixe et du suffixe.

// update entry
if curr.next[0] != nil && curr.next[0].Key == entry.Key {
    s.size += len(entry.Value) - len(curr.next[0].Value)

    // update value and tombstone
    curr.next[0].Value = entry.Value
    curr.next[0].Tombstone = entry.Tombstone
    return
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
  • Recherche : localise les paires clé-valeur à l'aide de la recherche binaire.
// add entry
level := s.randomLevel()

if level > s.level {
    for i := s.level; i < level; i++ {
        update[i] = s.head
    }
    s.level = level
}
Copier après la connexion
Copier après la connexion
Copier après la connexion

Bloc d'index

La structure du bloc d'index est définie comme suit. Il stocke la première et la dernière clé de chaque bloc de données, ainsi que le BlockHandle du bloc de données correspondant.

e := &Element{
    Entry: types.Entry{
        Key:       entry.Key,
        Value:     entry.Value,
        Tombstone: entry.Tombstone,
    },
    next: make([]*Element, level),
}

for i := range level {
    e.next[i] = update[i].next[i]
    update[i].next[i] = e
}
s.size += len(entry.Key) + len(entry.Value) + int(unsafe.Sizeof(entry.Tombstone)) + len(e.next)*int(unsafe.Sizeof((*Element)(nil)))
Copier après la connexion
Copier après la connexion
Copier après la connexion

De même, le Index Block implémente trois méthodes principales : Encode, Decode et Search. Les idées d'implémentation pour les méthodes Encode et Decode sont essentiellement les mêmes, nous allons donc nous concentrer sur la méthode Search.

La méthode de recherche du bloc de données est conçue pour localiser une paire clé-valeur spécifique dans la séquence clé-valeur ordonnée stockée dans un seul bloc de données. En revanche, la méthode de recherche du bloc d'index est utilisée pour localiser le bloc de données contenant la clé donnée dans l'ensemble de la SSTable.

func (s *SkipList) Get(key types.Key) (types.Entry, bool) {
    curr := s.head

    for i := s.maxLevel - 1; i >= 0; i-- {
        for curr.next[i] != nil && curr.next[i].Key < key {
            curr = curr.next[i]
        }
    }

    curr = curr.next[0]

    if curr != nil && curr.Key == key {
        return types.Entry{
            Key:       curr.Key,
            Value:     curr.Value,
            Tombstone: curr.Tombstone,
        }, true
    }
    return types.Entry{}, false
}
Copier après la connexion
Copier après la connexion

Bloc méta et pied de page

func (s *SkipList) All() []types.Entry {
    var all []types.Entry

    for curr := s.head.next[0]; curr != nil; curr = curr.next[0] {
        all = append(all, types.Entry{
            Key:       curr.Key,
            Value:     curr.Value,
            Tombstone: curr.Tombstone,
        })
    }

    return all
}
Copier après la connexion

Les implémentations de ces deux blocs sont assez simples, les deux ne nécessitant que les méthodes Encode et Decode.

Construire

Après avoir introduit tous les blocs de notre SSTable, construire une SSTable implique simplement de construire chaque bloc étape par étape en fonction des paires clé-valeur. Enfin, l'index en mémoire et le SSTable encodé sont renvoyés.

type SkipList struct {
    maxLevel int
    p        float64
    level    int
    rand     *rand.Rand
    size     int
    head     *Element
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Fusion K-Way

L'implémentation complète de K-Way Merge est disponible sur https://github.com/B1NARY-GR0UP/originium/tree/main/pkg/kway.

Dans la section conceptuelle, nous illustrons le processus de compression et de fusion de plusieurs SSTables à travers des diagrammes. Ce processus est accompli à l'aide de l'algorithme k-way merge.

L'algorithme de fusion k-way est une méthode pour fusionner k séquences triées en une seule séquence triée, avec une complexité temporelle de O(knlogk).

Une implémentation de cet algorithme utilise un min-heap comme structure auxiliaire :

  • Insérez le premier élément de chaque séquence dans le tas.
  • Supprimez la plus petite valeur du tas et ajoutez-la à l'ensemble de résultats. Si la séquence de l'élément sauté contient encore plus d'éléments, insérez l'élément suivant de cette séquence dans le tas.
  • Répétez ce processus jusqu'à ce que tous les éléments de toutes les séquences soient fusionnés.

Tas

La bibliothèque standard fournit une implémentation de tas en conteneur/heap. En implémentant l'interface heap.Interface, nous pouvons créer un min-heap.

  • Tout d’abord, définissez la structure de base du min-heap. Une tranche est utilisée pour stocker les éléments. Chaque élément comprend non seulement une entrée mais également un LI pour indiquer de quelle séquence triée cet élément provient.
type Element struct {
    types.Entry
    next []*Element
}

// https://github.com/B1NARY-GR0UP/originium/blob/main/pkg/types/entry.go
type Entry struct {
    Key       string
    Value     []byte
    Tombstone bool  
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
  • Implémentez les méthodes sort.Interface pour trier les éléments dans le tas. Une attention particulière est nécessaire pour la méthode Less : en comparant les LI des éléments, on s'assure que lorsque les éléments ont la même clé, ceux des séquences précédentes sont ordonnés en premier. Cela facilite la déduplication lors de la fusion d'éléments dans le jeu de résultats. Cette exigence signifie également que les séquences triées doivent être classées de la plus ancienne à la plus récente lors de l'utilisation de l'algorithme de fusion k-way.
Level 3:       3 ----------- 9 ----------- 21 --------- 26
Level 2:       3 ----- 6 ---- 9 ------ 19 -- 21 ---- 25 -- 26
Level 1:       3 -- 6 -- 7 -- 9 -- 12 -- 19 -- 21 -- 25 -- 26

next of head [ ->3, ->3, ->3 ]
next of Element 3 [ ->6, ->6, ->9 ]
next of Element 6 [ ->7, ->9 ]
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
  • Enfin, implémentez les méthodes Push et Pop. Push ajoute un élément à la fin de la tranche, tandis que Pop supprime le dernier élément de la tranche.
func (s *SkipList) Set(entry types.Entry)
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Fusionner

La définition de la fonction de la méthode Merge :

curr := s.head
update := make([]*Element, s.maxLevel)

for i := s.maxLevel - 1; i >= 0; i-- {
    for curr.next[i] != nil && curr.next[i].Key < entry.Key {
        curr = curr.next[i]
    }
    update[i] = curr
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Suit le processus de l'algorithme de fusion k-way.

  • Tout d’abord, insérez le premier élément de chaque séquence triée dans le min-heap.
type SkipList struct {
    maxLevel int
    p        float64
    level    int
    rand     *rand.Rand
    size     int
    head     *Element
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
  • Extrayez itérativement un élément du min-heap et ajoutez-le à la file d'attente des résultats. Si la séquence de l'élément sauté contient encore plus d'éléments, insérez l'élément suivant de cette séquence dans le tas. Ici, une carte est utilisée à la place d'une séquence de résultats. La carte gère automatiquement la déduplication, les clés les plus récentes écrasant toujours les plus anciennes.
type Element struct {
    types.Entry
    next []*Element
}

// https://github.com/B1NARY-GR0UP/originium/blob/main/pkg/types/entry.go
type Entry struct {
    Key       string
    Value     []byte
    Tombstone bool  
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Enfin, parcourez la carte pour ajouter des éléments à la file d'attente des résultats, en supprimant toutes les paires clé-valeur marquées comme « pierres tombales ». Étant donné que la carte n'est pas ordonnée, la file d'attente des résultats doit être triée avant d'être renvoyée.

Level 3:       3 ----------- 9 ----------- 21 --------- 26
Level 2:       3 ----- 6 ---- 9 ------ 19 -- 21 ---- 25 -- 26
Level 1:       3 -- 6 -- 7 -- 9 -- 12 -- 19 -- 21 -- 25 -- 26

next of head [ ->3, ->3, ->3 ]
next of Element 3 [ ->6, ->6, ->9 ]
next of Element 6 [ ->7, ->9 ]
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Filtre de floraison

La mise en œuvre complète du filtre Bloom peut être trouvée sur https://github.com/B1NARY-GR0UP/originium/blob/main/pkg/filter/filter.go.

Un filtre Bloom est une structure de données qui vérifie efficacement si un élément est membre d'un ensemble.

  • Il utilise un tableau de bits et plusieurs fonctions de hachage.
  • Lors de l'ajout d'un élément, l'élément est haché à l'aide de plusieurs fonctions de hachage, qui le mappent à différentes positions dans le tableau de bits, en définissant ces positions sur 1.
  • Lors d'une requête, si toutes les positions mappées par les fonctions de hachage sont 1, l'élément peut exister. Si une position est 0, l'élément n'existe définitivement pas.

La complexité temporelle des opérations d'insertion et de requête est O(k), où k est le nombre de fonctions de hachage. Des faux positifs peuvent se produire (le filtre Bloom prédit qu'un élément est dans l'ensemble, mais il ne l'est pas), mais des faux négatifs ne peuvent pas se produire (le filtre Bloom prédit qu'un élément n'est pas dans l'ensemble, mais c'est effectivement le cas).

Vrai positif (TP) : Le système prédit un événement comme « positif », et il est effectivement positif.
Faux positif (FP) : Le système prédit un événement comme « positif », mais il est en réalité négatif.
Vrai Négatif (TN) : Le système prédit un événement comme « négatif », et il est effectivement négatif.
Faux Négatif (FN) :Le système prédit un événement comme « négatif », mais il est en réalité positif.

Structure de base

La structure de base d'un filtre Bloom comprend le tableau de bits (qui peut être optimisé pour utiliser uint8) et plusieurs fonctions de hachage.

func (s *SkipList) Set(entry types.Entry)
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Nouveau

La méthode pour créer une instance de Bloom Filter accepte deux paramètres : n (le nombre d'éléments attendu) et p (le taux de faux positifs souhaité).

Tout d'abord, les paramètres sont validés. Ensuite, la taille du tableau de bits (m) et le nombre de fonctions de hachage (k) sont calculés à l'aide de formules spécifiques. Enfin, les fonctions de tableau de bits et de hachage sont initialisées en fonction de m et k.

type SkipList struct {
    maxLevel int
    p        float64
    level    int
    rand     *rand.Rand
    size     int
    head     *Element
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Ajouter

Lors de l'ajout d'un élément, toutes les fonctions de hachage sont utilisées pour calculer les valeurs de hachage de la clé. Ces valeurs sont ensuite mappées sur des indices dans le tableau de bits et les positions correspondantes sont définies sur true.

type Element struct {
    types.Entry
    next []*Element
}

// https://github.com/B1NARY-GR0UP/originium/blob/main/pkg/types/entry.go
type Entry struct {
    Key       string
    Value     []byte
    Tombstone bool  
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Contient

Pour vérifier si une clé est dans l'ensemble, les fonctions de hachage calculent les valeurs de hachage et les mappent aux indices du tableau de bits. Si l'une de ces positions n'est pas vraie, l'élément n'est pas dans l'ensemble et false est renvoyé.

Level 3:       3 ----------- 9 ----------- 21 --------- 26
Level 2:       3 ----- 6 ---- 9 ------ 19 -- 21 ---- 25 -- 26
Level 1:       3 -- 6 -- 7 -- 9 -- 12 -- 19 -- 21 -- 25 -- 26

next of head [ ->3, ->3, ->3 ]
next of Element 3 [ ->6, ->6, ->9 ]
next of Element 6 [ ->7, ->9 ]
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Compactage nivelé

La mise en œuvre complète de Leveled Compaction peut être trouvée sur https://github.com/B1NARY-GR0UP/originium/blob/main/level.go.

Après avoir implémenté des composants tels que K-Way Merge et Bloom Filter, nous pouvons terminer la dernière partie de notre implémentation, le processus de compactage SSTable le plus crucial dans le moteur de stockage LSM-Tree. Cette mise en œuvre suit la Stratégie de compactage par niveaux décrite dans la section « Compactage des données ».

Dans la stratégie de compactage par niveaux, les SSTables sont organisées en plusieurs niveaux (Niveau 0 - Niveau N). Nous avons besoin d'une structure pour stocker ces informations et gérer les SSTables à différents niveaux.

Ainsi, nous avons implémenté une structure appelée levelManager. Nous utilisons un []*list.List pour stocker les informations SSTable pour chaque niveau, où l'index de la tranche correspond au niveau. Chaque élément de la tranche est une liste.List, une liste à double lien qui contient des informations sur tous les SSTables d'un niveau spécifique.

func (s *SkipList) Set(entry types.Entry)
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

compactLN

La méthode compactLN est responsable du compactage du niveau N au niveau N 1 (N > 0). Il sélectionne la SSTable la plus ancienne de LN et toutes les SSTables de LN 1 dont les plages de clés se chevauchent avec cette SSTable.

curr := s.head
update := make([]*Element, s.maxLevel)

for i := s.maxLevel - 1; i >= 0; i-- {
    for curr.next[i] != nil && curr.next[i].Key < entry.Key {
        curr = curr.next[i]
    }
    update[i] = curr
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Les SSTables sélectionnées sont traitées du plus ancien au plus récent. Les paires clé-valeur de leurs blocs de données sont ajoutées à une tranche bidimensionnelle puis fusionnées à l'aide de l'algorithme K-Way Merge.

// update entry
if curr.next[0] != nil && curr.next[0].Key == entry.Key {
    s.size += len(entry.Value) - len(curr.next[0].Value)

    // update value and tombstone
    curr.next[0].Value = entry.Value
    curr.next[0].Tombstone = entry.Tombstone
    return
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Avec les paires clé-valeur fusionnées, nous construisons un nouveau filtre Bloom et un nouveau SSTable. Les informations pertinentes sur la nouvelle SSTable sont annexées à la fin du niveau N 1.

// add entry
level := s.randomLevel()

if level > s.level {
    for i := s.level; i < level; i++ {
        update[i] = s.head
    }
    s.level = level
}
Copier après la connexion
Copier après la connexion
Copier après la connexion

Enfin, les anciennes SSTables sont supprimées et la SSTable nouvellement construite est écrite sur le disque.

e := &Element{
    Entry: types.Entry{
        Key:       entry.Key,
        Value:     entry.Value,
        Tombstone: entry.Tombstone,
    },
    next: make([]*Element, level),
}

for i := range level {
    e.next[i] = update[i].next[i]
    update[i].next[i] = e
}
s.size += len(entry.Key) + len(entry.Value) + int(unsafe.Sizeof(entry.Tombstone)) + len(e.next)*int(unsafe.Sizeof((*Element)(nil)))
Copier après la connexion
Copier après la connexion
Copier après la connexion

La méthode compactL0 gère le compactage du niveau 0 au niveau 1. Contrairement à compactLN, il sélectionne non seulement une SSTable de L0, mais également toutes les SSTables qui se chevauchent dans L0. Le reste du processus est identique à compactLN.

recherche

La méthode de recherche localise les paires clé-valeur correspondantes dans toutes les SSTables. Il commence à partir de L0 et parcourt chaque niveau jusqu'à LN. En tirant parti du filtre Bloom et de la structure ordonnée des SSTables, les SSTables qui ne contiennent pas les paires clé-valeur souhaitées peuvent être ignorées efficacement.

type SkipList struct {
    maxLevel int
    p        float64
    level    int
    rand     *rand.Rand
    size     int
    head     *Element
}
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion
Copier après la connexion

Base de données

Grâce à cela, nous avons implémenté tous les composants principaux du moteur de stockage basé sur LSM-Tree. En assemblant ces composants comme décrit dans l'introduction de LSM-Tree, nous pouvons finaliser l'interface de la base de données.

  • Code complet : https://github.com/B1NARY-GR0UP/originium/blob/main/db.go

  • Documentation : https://github.com/B1NARY-GR0UP/originium?tab=readme-ov-file#usage

Résumé

Nous avons commencé par comprendre LSM-Tree, en nous familiarisant avec ses composants principaux et le processus de traitement des demandes des clients. En fin de compte, nous avons construit notre propre moteur de stockage LSM-Tree à partir de zéro.

Bien sûr, cette implémentation n'est qu'un prototype. Un moteur de stockage de niveau production nécessite de prendre en compte bien plus de détails. ORIGINIUM continuera de recevoir des optimisations et des améliorations à l'avenir. J'espère que cet article et ORIGINIUM vous aideront à approfondir votre compréhension de LSM-Tree.

Cela conclut tout ce qui est couvert dans cet article. S'il y a des erreurs ou des questions, n'hésitez pas à nous contacter par message privé ou à laisser un commentaire. Merci !

Référence

  • https://github.com/B1NARY-GR0UP/originium
  • https://www.cnblogs.com/whuanle/p/16297025.html
  • https://vonng.gitbook.io/vonng/part-i/ch3#sstables-he-lsm-shu
  • https://github.com/google/leveldb/blob/main/doc/table_format.md

Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!

source:dev.to
Déclaration de ce site Web
Le contenu de cet article est volontairement contribué par les internautes et les droits d'auteur appartiennent à l'auteur original. Ce site n'assume aucune responsabilité légale correspondante. Si vous trouvez un contenu suspecté de plagiat ou de contrefaçon, veuillez contacter admin@php.cn
Derniers articles par auteur
Tutoriels populaires
Plus>
Derniers téléchargements
Plus>
effets Web
Code source du site Web
Matériel du site Web
Modèle frontal