Heim > Backend-Entwicklung > Golang > Aufbau einer LSM-Tree-Speicher-Engine von Grund auf

Aufbau einer LSM-Tree-Speicher-Engine von Grund auf

Barbara Streisand
Freigeben: 2025-01-03 07:37:39
Original
573 Leute haben es durchsucht

Vorwort

Dieser Artikel führt Sie durch das Verständnis des Log-Structured Merge-Tree (LSM-Tree), einschließlich seiner Kernkonzepte und Struktur. Am Ende werden Sie in der Lage sein, Ihre eigene Speicher-Engine auf Basis von LSM-Tree von Grund auf zu erstellen.

Was ist LSM-Baum?

Grundkonzepte

Der Log-Structured Merge-Tree (LSM-Tree) ist eine Datenstruktur, die für Schreibvorgänge mit hohem Durchsatz optimiert ist. Es wird häufig in Datenbanken und Speichersystemen wie Cassandra, RocksDB und LevelDB verwendet.

Die Schlüsselidee von LSM-Tree besteht darin, Operationen zunächst in eine speicherinterne Datenstruktur zu schreiben (normalerweise eine geordnete Struktur wie eine Sprungliste oder ein AVL-Baum). Später werden diese Schreibvorgänge gestapelt und nacheinander als SSTables auf die Festplatte geschrieben, wodurch zufällige E/A minimiert werden.

Grundstruktur

Building an LSM-Tree Storage Engine from Scratch

Der LSM-Baum ist in zwei Hauptkomponenten unterteilt:

  • In-Memory-Speicher
    • Die Kernstruktur im Speicher ist die Memtable.
    • Alle Schreiboperationen (z. B. Festlegen, Löschen) gehen zunächst zur Memtable, die diese Operationen in eine geordnete Datenstruktur einfügt (z. B. einen geordneten Baum im Diagramm).
    • Sobald die Memtable einen bestimmten Größenschwellenwert erreicht, wird sie als SSTable (sequentiell geschrieben) auf die Festplatte geschrieben.
    • Neue Schreibvorgänge werden auf einer neuen Memtable fortgesetzt.
  • Festplattenspeicher
    • Festplattenspeicher umfasst WAL- und SSTable-Dateien.
    • Das WAL (Write-Ahead Log) stellt sicher, dass aktuelle Schreibvorgänge (in der Memtable gespeichert, aber noch nicht auf der Festplatte gespeichert) im Falle eines Datenbankabsturzes nicht verloren gehen. Jeder Schreibvorgang in die Memtable wird an die WAL angehängt. Beim Neustart der Datenbank können Einträge aus der WAL wiedergegeben werden, um die Memtable wieder in den Zustand vor dem Absturz zu versetzen.
    • Die SSTable (Sorted String Table) ist ein Datenspeicherformat, das eine Reihe geordneter Schlüssel-Wert-Paare enthält.
    • Wenn die Memtable ihren Größenschwellenwert erreicht, generiert sie eine neue SSTable und speichert sie auf der Festplatte. Da die Memtable auf einer speicherinternen geordneten Datenstruktur basiert, ist beim Erstellen der SSTable keine zusätzliche Sortierung erforderlich.
    • SSTables auf der Festplatte sind in mehreren Ebenen organisiert. Neu geleerte SSTables werden in Ebene 0 gespeichert. Während nachfolgender Komprimierungsphasen werden SSTables in L0 mit Ebene 1 und höheren Ebenen zusammengeführt.
    • Wenn die Größe einer Ebene einen Schwellenwert überschreitet, wird ein SSTable-Komprimierungsprozess ausgelöst. Während der Komprimierung werden SSTables in der aktuellen Ebene mit höheren Ebenen zusammengeführt, wodurch größere, geordnetere Dateien entstehen. Dies reduziert die Fragmentierung und verbessert die Abfrageeffizienz.

Typischerweise umfasst die Struktur einer SSTable mehr als nur eine Reihe geordneter Schlüssel-Wert-Paare (Datenblöcke). Es enthält außerdem einen Indexblock, einen Metadatenblock und andere Komponenten. Diese Details werden im Implementierungsabschnitt ausführlich besprochen.

Daten schreiben

Das Schreiben von Daten erfordert das Hinzufügen eines neuen Schlüssel-Wert-Paares oder das Aktualisieren eines vorhandenen. Updates überschreiben alte Schlüssel-Wert-Paare, die später während des Komprimierungsprozesses entfernt werden.

Wenn Daten geschrieben werden, werden sie zunächst in die Memtable verschoben, wo das Schlüssel-Wert-Paar zur im Speicher geordneten Datenstruktur hinzugefügt wird. Gleichzeitig wird der Schreibvorgang im WAL protokolliert und auf der Festplatte gespeichert, um Datenverlust im Falle eines Datenbankabsturzes zu verhindern.

Die Memtable hat einen definierten Schwellenwert (normalerweise basierend auf der Größe). Wenn die Memtable diesen Schwellenwert überschreitet, wird sie in den schreibgeschützten Modus geschaltet und in eine neue SSTable konvertiert, die dann auf Level 0 auf der Festplatte gespeichert wird.

Sobald die Memtable als SSTable geleert wurde, kann die entsprechende WAL-Datei sicher gelöscht werden. Nachfolgende Schreibvorgänge werden auf einer neuen Memtable (und einer neuen WAL) durchgeführt.

Daten löschen

In LSM-Tree werden Daten nicht sofort entfernt. Stattdessen werden Löschungen mithilfe eines Mechanismus namens Tombstones durchgeführt (ähnlich wie vorläufige Löschungen). Wenn ein Schlüssel-Wert-Paar gelöscht wird, wird ein neuer Eintrag geschrieben, der mit einem „Tombstone“ markiert ist, der die Löschung des entsprechenden Schlüssel-Wert-Paares anzeigt. Die eigentliche Entfernung erfolgt während des Verdichtungsprozesses.

Diese Tombstone-basierte Löschung gewährleistet die Append-only-Eigenschaft von LSM-Tree, vermeidet zufällige E/A und behält sequentielle Schreibvorgänge auf die Festplatte bei.

Daten abfragen

Der Prozess der Datenabfrage beginnt mit einer Suche in der Memtable. Wenn das Schlüssel-Wert-Paar gefunden wird, wird es an den Client zurückgegeben. Wenn ein mit einem Tombstone markiertes Schlüssel-Wert-Paar gefunden wird, bedeutet dies, dass die angeforderten Daten gelöscht wurden, und diese Informationen werden ebenfalls zurückgegeben. Wenn der Schlüssel nicht in der Memtable gefunden wird, durchsucht die Abfrage die SSTables von Level 0 bis Level N.

Da das Abfragen von Daten möglicherweise das Durchsuchen mehrerer SSTable-Dateien erfordert und zu zufälligen Festplatten-E/A-Vorgängen führen kann, eignet sich LSM-Tree im Allgemeinen besser für schreibintensive Arbeitslasten als für leseintensive.

Eine häufige Optimierung der Abfrageleistung ist die Verwendung eines Bloom-Filters. Ein Bloom-Filter kann schnell feststellen, ob ein Schlüssel-Wert-Paar in einer bestimmten SSTable vorhanden ist, und so unnötige Festplatten-E/A reduzieren. Darüber hinaus ermöglicht die sortierte Natur von SSTables die Verwendung effizienter Suchalgorithmen, wie z. B. der binären Suche, für schnellere Suchvorgänge.

Datenkomprimierung

Hier stellen wir die Leveled Compaction Strategy vor, die von LevelDB und RocksDB verwendet wird.

Eine weitere gängige Strategie ist die Size-Tiered Compaction Strategy, bei der neuere und kleinere SSTables sukzessive mit älteren und größeren SSTables zusammengeführt werden.

Wie bereits erwähnt speichert eine SSTable eine Reihe von Einträgen, sortiert nach Schlüssel. In der Leveled Compaction Strategy sind SSTables in mehreren Ebenen organisiert (Ebene 0 bis Ebene N).

In Level 0 können SSTables überlappende Schlüsselbereiche haben, da sie direkt aus der Memtable gelöscht werden. In den Ebenen 1 bis N haben SSTables innerhalb derselben Ebene jedoch keine überlappenden Schlüsselbereiche, obwohl Überlappungen von Schlüsselbereichen zwischen SSTables in verschiedenen Ebenen zulässig sind.

Ein anschauliches (wenn auch nicht ganz genaues) Beispiel finden Sie unten. In Ebene 0 überlappen sich die Schlüsselbereiche der ersten und zweiten SSTables, während in Ebene 1 und Ebene 2 die SSTables innerhalb jeder Ebene disjunkte Schlüsselbereiche haben . SSTables zwischen verschiedenen Ebenen (z. B. Ebene 0 und Ebene 1 oder Ebene 1 und Ebene 2) können jedoch überlappende Schlüsselbereiche haben.

Building an LSM-Tree Storage Engine from Scratch

Lassen Sie uns nun untersuchen, wie die Leveled Compaction Strategy diese Organisationsstruktur aufrechterhält.

Da Level 0 ein Sonderfall ist, ist die Diskussion der Verdichtungsstrategie in zwei Teile unterteilt:

  • Stufe 0 bis Stufe 1 Da Level 0 überlappende Schlüssel zwischen SSTables zulässt, beginnt die Komprimierung mit der Auswahl einer SSTable aus Level 0, zusammen mit allen anderen SSTables in Level 0, die überlappende Schlüsselbereiche haben. Als nächstes werden alle SSTables in Level 1 mit überlappenden Schlüsselbereichen ausgewählt. Diese ausgewählten SSTables werden zusammengeführt und zu einer einzigen neuen SSTable komprimiert, die dann in Ebene 1 eingefügt wird. Alle alten SSTables, die am Komprimierungsprozess beteiligt sind, werden gelöscht.
  • Stufe N bis Stufe N 1 (N > 0) Ab Level 1 haben SSTables innerhalb derselben Ebene keine überlappenden Schlüsselbereiche. Während der Komprimierung wird eine SSTable aus Ebene N ausgewählt, und alle SSTables in Ebene N 1 mit überlappenden Schlüsselbereichen werden ebenfalls ausgewählt. Diese SSTables werden zusammengeführt und zu einem oder mehreren neuen SSTables komprimiert, die in Ebene N 1 eingefügt werden, während die alten SSTables gelöscht werden.

Der Hauptunterschied zwischen der Komprimierung von Level 0 bis Level 1 und der Komprimierung von Level N bis Level N 1 (N > 0) liegt in der Auswahl von SSTables auf niedrigeren Ebenen (Level 0 oder Stufe N).

Der Multi-SSTable-Komprimierungs- und Zusammenführungsprozess ist unten dargestellt. Während der Zusammenführung wird nur der neueste Wert für jeden Schlüssel beibehalten. Wenn der letzte Wert eine „Tombstone“-Markierung aufweist, wird der Schlüssel gelöscht. In der Implementierung verwenden wir den K-Way-Merge-Algorithmus, um diesen Prozess durchzuführen.

Building an LSM-Tree Storage Engine from Scratch

Es ist wichtig zu beachten, dass die obige Beschreibung des Verdichtungsprozesses nur einen allgemeinen Überblick bietet. Bei der eigentlichen Umsetzung müssen viele Details geklärt werden.

Wenn beispielsweise in LevelDB während der Komprimierung neue SSTables für Ebene N 1 erstellt werden und sich die neue SSTable mit mehr als 10 SSTables in Ebene N 2 überschneidet, wechselt der Prozess zum Erstellen einer weiteren SSTable. Dadurch wird die Datengröße einer einzelnen Komprimierung begrenzt.

Durchführung

Basierend auf der obigen Übersicht über LSM-Tree glaube ich, dass Sie jetzt ein grundlegendes Verständnis von LSM-Tree und einige Ideen zu seiner Implementierung haben. Als nächstes werden wir eine Speicher-Engine basierend auf LSM-Tree von Grund auf erstellen. Im Folgenden stellen wir nur den Kerncode vor; Den vollständigen Code finden Sie unter https://github.com/B1NARY-GR0UP/originium.

Wir werden die Implementierung von LSM-Tree in die folgenden Kernkomponenten aufteilen und diese einzeln implementieren:

  • Liste überspringen
  • WAL
  • Memtable
  • SSTable
  • K-Way Merge
  • Blütenfilter
  • Ebenenverdichtung

Liste überspringen

Bei der Einführung des Datenschreibens haben wir erwähnt, dass der LSM-Baum Daten zunächst in eine im Speicher geordnete Datenstruktur schreibt. Einige gängige geordnete Datenstrukturen und die zeitliche Komplexität ihrer Operationen sind wie folgt:

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)

Wir haben uns aus zwei Hauptgründen für die Skip List entschieden: Sie ist einfacher zu implementieren und zu warten (KISS-Prinzip) und die zugrunde liegende Struktur der verknüpften Liste erleichtert das sequentielle Durchlaufen, wodurch es einfacher wird, In-Memory-Daten auf der Festplatte beizubehalten.

Kernstruktur

Die vollständige Implementierung der Skip List ist unter https://github.com/B1NARY-GR0UP/originium/blob/main/pkg/skiplist/skiplist.go verfügbar.

Eine Skip-Liste besteht aus einer verknüpften Basisliste und mehreren darauf aufbauenden Indexebenen. Bei großen Datensätzen verkürzen die Indexebenen den Suchpfad erheblich.

In unserer Implementierung ist die Kernstruktur der Skip List wie folgt definiert:

type SkipList struct {
    maxLevel int
    p        float64
    level    int
    rand     *rand.Rand
    size     int
    head     *Element
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
  • maxLevel: Die maximale Anzahl von Ebenen in der Überspringliste (die verknüpfte Basisliste hat eine Ebene).
  • Ebene: Die aktuelle Anzahl der Ebenen in der Überspringliste.
  • p: Die Wahrscheinlichkeit, dass ein Knoten auf eine höhere Ebene befördert wird. Wenn beispielsweise p = 0,5 ist, verfügt eine verknüpfte Liste mit 10 Knoten auf der Basisebene über ungefähr 5 Knoten auf der nächsten Indexebene.
  • rand: Ein Zufallszahlengenerator zum Vergleich mit p.
  • Größe: Die Anzahl der in der Überspringliste gespeicherten Schlüssel-Wert-Paare, die verwendet wird, um zu bestimmen, ob die Memtable ihren Größenschwellenwert überschreitet.
  • Kopf: Der Kopfknoten, der Verweise auf den ersten Knoten in jeder Ebene enthält.

Die Struktur der in der Skip List gespeicherten Elemente ist wie folgt definiert:

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  
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
  • types.Entry stellt ein Schlüssel-Wert-Paar in der Speicher-Engine dar, einschließlich Schlüssel, Wert und einem Tombstone-Flag zum Löschen.

  • next: Enthält Zeiger auf das nächste Element auf jeder Ebene.

Diese Struktur mag abstrakt erscheinen, also veranschaulichen wir sie anhand eines Beispiels:

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 ]
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

In dieser dreistufigen Sprungliste verweisen die nächsten Zeiger des Kopfknotens auf den ersten Knoten auf jeder Ebene. Die Elemente 3 und 6 speichern das nächste Element für jede ihrer Ebenen.

Wenn wir beispielsweise den nächsten Knoten von Element 19 auf Ebene 2 finden möchten, verwenden wir e19.next[2-1].

Satz

func (s *SkipList) Set(entry types.Entry)
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Der LSM-Baum verwendet Tombstones, um Löschvorgänge durchzuführen, sodass wir in der Skip-List-Implementierung keine Löschmethode benötigen. Um ein Element zu löschen, setzen Sie einfach den Tombstone des Eintrags auf true. Somit übernimmt die Set-Methode das Einfügen neuer Schlüssel-Wert-Paare, das Aktualisieren bestehender und das Löschen von Elementen.

Lassen Sie uns die Implementierung der Set-Methode untersuchen. Durch das Durchlaufen der Knoten in jeder Ebene von der höchsten wird das letzte Element, das kleiner als der festzulegende Schlüssel ist, im Update-Slice gespeichert.

type SkipList struct {
    maxLevel int
    p        float64
    level    int
    rand     *rand.Rand
    size     int
    head     *Element
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Am Ende dieses Durchlaufs zeigt curr auf das letzte Element, das kleiner als der festzulegende Schlüssel in der verknüpften Liste der untersten Ebene ist. Wir müssen also nur prüfen, ob das nächste Element von curr dem Schlüssel entspricht, den wir festlegen möchten. Wenn es übereinstimmt, wurde das Element bereits eingefügt; Wir aktualisieren das vorhandene Element und kehren zurück.

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  
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Wenn das Element nicht gefunden wird, wird es als neues Element eingefügt. Mit randomLevel berechnen wir die Indexstufe dieses Elements. Wenn die aktuelle Anzahl der Ebenen in der Sprungliste überschritten wird, fügen wir den Hauptknoten zum Update-Slice hinzu und aktualisieren s.level auf die neue Ebenenanzahl.

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 ]
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Als nächstes konstruieren Sie das einzufügende Element und die nächsten Zeiger jeder Ebene werden aktualisiert, um das Einfügen abzuschließen.

func (s *SkipList) Set(entry types.Entry)
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Erhalten

Die Sprungliste kann schnelle Suchvorgänge durchführen, indem sie auf mehreren Indexebenen basiert. Die verschachtelten for-Schleifen in der Implementierung stellen die indexbasierte Suchoperation dar. Wenn das entsprechende Element schließlich in der untersten verknüpften Liste gefunden wird, wird es zurückgegeben.

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
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Alle

Ein Grund, warum wir uns für die Sprungliste entschieden haben, ist ihr praktisches sequentielles Durchlaufen, das durch einfaches Durchlaufen der untersten verknüpften Liste ermöglicht wird.

// 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
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

WAL

Die vollständige Implementierung von WAL finden Sie unter https://github.com/B1NARY-GR0UP/originium/blob/main/wal/wal.go.

Wie bereits erwähnt besteht der Zweck von WAL (Write-Ahead Logging) darin, Datenverluste in der Memtable durch Datenbankabstürze zu verhindern. Daher muss WAL Vorgänge in der Memtable aufzeichnen und die Memtable aus der WAL-Datei wiederherstellen, wenn die Datenbank neu gestartet wird.

Kernstruktur

Die Kernstruktur von WAL ist wie folgt, wobei fd den Dateideskriptor der WAL-Datei speichert:

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

if level > s.level {
    for i := s.level; i < level; i++ {
        update[i] = s.head
    }
    s.level = level
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Schreiben

Da wir Vorgänge in der Memtable aufzeichnen müssen, umfasst dies im Wesentlichen das Schreiben jeder Operation (Setzen, Löschen) als Eintrag in die WAL. Die Definition der Write-Methode lautet wie folgt:

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)))
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Wenn wir diese Einträge in die Datei schreiben, müssen wir das WAL-Dateiformat standardisieren. Das Format, das wir hier verwenden, ist Längendaten. Zuerst serialisieren wir den Eintrag, berechnen dann die Länge der serialisierten Daten und schreiben schließlich die Länge und die serialisierten Daten nacheinander in die WAL-Datei.

Der Kerncode lautet wie folgt:

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
}
Nach dem Login kopieren
Nach dem Login kopieren

Lesen

Da wir das WAL-Dateiformat Längendaten verwenden, lesen wir beim Lesen zuerst 8 Bytes (int64), um die Länge der Daten zu erhalten, und lesen dann die Daten basierend auf dieser Länge und deserialisieren sie um den Eintrag abzurufen.

Der Kerncode lautet wie folgt:

type SkipList struct {
    maxLevel int
    p        float64
    level    int
    rand     *rand.Rand
    size     int
    head     *Element
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Merkbar

Die vollständige Implementierung von Memtable finden Sie unter https://github.com/B1NARY-GR0UP/originium/blob/main/memtable.go.

Die Memtable ist dafür verantwortlich, Client-Operationen in die Skip-Liste zu schreiben und sie in der WAL aufzuzeichnen. Es kann auch Daten von der WAL wiederherstellen, wenn die Datenbank gestartet wird.

Kernstruktur

Die Kernstruktur von Memtable ist wie folgt und umfasst die beiden Hauptkomponenten Skiplist und 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  
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Satz

Beim Ausführen einer Set-Operation müssen sowohl die Sprungliste als auch die WAL gleichzeitig aktualisiert werden.

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 ]
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Erhalten

Um einen Wert abzurufen, geben Sie einfach das Ergebnis des Get-Vorgangs der Skip-Liste zurück.

func (s *SkipList) Set(entry types.Entry)
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Genesen

Um die Memtable aus der WAL-Datei wiederherzustellen, müssen Sie zunächst die WAL-Datei lesen, dann nacheinander die Eintragsdatensätze aus der WAL-Datei auf die Memtable anwenden und schließlich die wiederhergestellte WAL-Datei löschen.

Rufen Sie die Liste der WAL-Dateien ab:

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
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Lesen Sie die WAL und stellen Sie die Memtable wieder her:

// 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
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

SSTable

LevelDB SSTable

In der vorherigen Einführung haben wir nur erwähnt, dass „SSTable (Sorted String Table) ein Datenspeicherformat ist, das eine Reihe geordneter Schlüssel-Wert-Paare verwaltet.“ Hier geben wir eine detailliertere Erläuterung der Struktur von SSTable.

In LevelDB besteht eine SSTable aus mehreren Blöcken mit unterschiedlichen Zwecken. Ein schematisches Diagramm ist unten dargestellt:

Building an LSM-Tree Storage Engine from Scratch

  • Datenblock: Speichert eine Folge geordneter Schlüssel-Wert-Paare.
  • Meta-Block: Enthält zwei Typen: Filter und Statistiken. Der Filtertyp speichert Daten für Bloom-Filter, während der Statistiktyp statistische Informationen über die Datenblöcke speichert.
  • MetaIndex-Block: Speichert Indexinformationen für die Meta-Blöcke.
  • Indexblock: Speichert Indexinformationen für die Datenblöcke.
  • Fußzeile: Mit fester Länge speichert es die Indexinformationen des MetaIndex-Blocks und des Index-Blocks sowie eine magische Zahl.

Die Indexinformationen sind im Wesentlichen eine Zeigerstruktur namens BlockHandle, die zwei Attribute enthält: Offset und Größe, die zum Auffinden des entsprechenden Blocks verwendet werden.

Unser SSTable

In unserer Implementierung von SSTable haben wir die LevelDB SSTable-Struktur vereinfacht. Ein schematisches Diagramm ist unten dargestellt:

Building an LSM-Tree Storage Engine from Scratch

  • Datenblock: Speichert eine Folge geordneter Schlüssel-Wert-Paare.
  • Meta-Block: Speichert einige Metadaten für die SSTable.
  • Indexblock: Speichert Indexinformationen für die Datenblöcke.
  • Fußzeile: Feste Länge, speichert die Indexinformationen des Metablocks und des Indexblocks.

Die vollständige Implementierung von SSTable finden Sie unter https://github.com/B1NARY-GR0UP/originium/tree/main/sstable.

Datenblock

Die Struktur des Datenblocks ist wie folgt definiert und speichert eine geordnete Folge von Einträgen.

type SkipList struct {
    maxLevel int
    p        float64
    level    int
    rand     *rand.Rand
    size     int
    head     *Element
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Wir haben drei Hauptmethoden für den Datenblock implementiert:

  • Encode: Codiert den Datenblock in Binärdaten.
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  
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Wir verwenden Präfixkomprimierung, um die Schlüsselwertsequenz zu kodieren. In den Puffer schreiben wir nacheinander die Länge des gemeinsamen Präfixes, die Länge des Suffixes, das Suffix selbst, die Länge des Werts, den Wert und die „Tombstone“-Markierung.

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 ]
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Zuletzt komprimieren wir die Daten mit s2.

S2 ist eine leistungsstarke Erweiterung des Snappy-Komprimierungsalgorithmus.

func (s *SkipList) Set(entry types.Entry)
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
  • Dekodieren: Dekodiert Binärdaten in einen Datenblock.
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
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Beim Dekodieren wird der Vorgang einfach umgekehrt. Das vollständige Schlüssel-Wert-Paar wird mithilfe des Präfixes und Suffixes rekonstruiert.

// 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
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
  • Suche: Findet Schlüssel-Wert-Paare mithilfe der binären Suche.
// add entry
level := s.randomLevel()

if level > s.level {
    for i := s.level; i < level; i++ {
        update[i] = s.head
    }
    s.level = level
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Indexblock

Die Struktur des Indexblocks ist wie folgt definiert. Es speichert den ersten und letzten Schlüssel jedes Datenblocks zusammen mit dem BlockHandle des entsprechenden Datenblocks.

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)))
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

In ähnlicher Weise implementiert der Indexblock drei Hauptmethoden: Encode, Decode und Search. Die Implementierungsideen für die Methoden „Encode“ und „Decode“ sind im Wesentlichen dieselben, daher konzentrieren wir uns auf die Methode Search.

Die Suchmethode des Datenblocks dient dazu, ein bestimmtes Schlüssel-Wert-Paar innerhalb der geordneten Schlüssel-Wert-Sequenz zu finden, die in einem einzelnen Datenblock gespeichert ist. Im Gegensatz dazu wird die Suchmethode des Indexblocks verwendet, um den Datenblock zu finden, der den angegebenen Schlüssel innerhalb der gesamten SSTable enthält.

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
}
Nach dem Login kopieren
Nach dem Login kopieren

Metablock und Fußzeile

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
}
Nach dem Login kopieren

Die Implementierungen dieser beiden Blöcke sind recht einfach, da beide nur die Methoden „Encode“ und „Decode“ erfordern.

Bauen

Nachdem wir alle Blöcke in unserer SSTable eingeführt haben, umfasst die Erstellung einer SSTable lediglich den schrittweisen Aufbau jedes Blocks auf der Grundlage der Schlüssel-Wert-Paare. Abschließend werden der In-Memory-Index und die codierte SSTable zurückgegeben.

type SkipList struct {
    maxLevel int
    p        float64
    level    int
    rand     *rand.Rand
    size     int
    head     *Element
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

K-Way-Zusammenführung

Die vollständige Implementierung von K-Way Merge ist unter https://github.com/B1NARY-GR0UP/originium/tree/main/pkg/kway verfügbar.

Im konzeptionellen Abschnitt veranschaulichen wir den Prozess des Komprimierens und Zusammenführens mehrerer SSTables anhand von Diagrammen. Dieser Prozess wird mithilfe des k-way merge-Algorithmus durchgeführt.

Der k-way-Merge-Algorithmus ist eine Methode zum Zusammenführen von k sortierten Sequenzen zu einer einzigen sortierten Sequenz mit einer zeitlichen Komplexität von O(knlogk).

Eine Implementierung dieses Algorithmus verwendet einen Min-Heap als Hilfsstruktur:

  • Fügen Sie das erste Element jeder Sequenz in den Heap ein.
  • Nehmen Sie den kleinsten Wert aus dem Heap und fügen Sie ihn der Ergebnismenge hinzu. Wenn die Sequenz des entfernten Elements noch mehr Elemente enthält, fügen Sie das nächste Element aus dieser Sequenz in den Heap ein.
  • Wiederholen Sie diesen Vorgang, bis alle Elemente aus allen Sequenzen zusammengeführt sind.

Haufen

Die Standardbibliothek bietet eine Heap-Implementierung im Container/Heap. Durch die Implementierung der heap.Interface können wir einen Min-Heap erstellen.

  • Definieren Sie zunächst die Grundstruktur des Min-Heaps. Zur Speicherung der Elemente wird ein Slice verwendet. Jedes Element enthält nicht nur einen Eintrag, sondern auch einen LI, um anzugeben, aus welcher sortierten Sequenz dieses Element stammt.
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  
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
  • Implementieren Sie die sort.Interface-Methoden, um Elemente im Heap zu sortieren. Besondere Aufmerksamkeit ist bei der Less-Methode erforderlich: Durch den Vergleich der LI der Elemente stellen wir sicher, dass bei Elementen mit demselben Schlüssel diejenigen aus früheren Sequenzen zuerst geordnet werden. Dies erleichtert die Deduplizierung beim Zusammenführen von Elementen in die Ergebnismenge. Diese Anforderung bedeutet auch, dass die sortierten Sequenzen bei Verwendung des K-Way-Merge-Algorithmus in der Reihenfolge vom ältesten zum neuesten angeordnet werden sollten.
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 ]
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
  • Zuletzt implementieren Sie die Push- und Pop-Methoden. Push hängt ein Element an das Ende des Slice an, während Pop das letzte Element aus dem Slice entfernt.
func (s *SkipList) Set(entry types.Entry)
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Verschmelzen

Die Funktionsdefinition der Merge-Methode:

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
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Folgt dem K-Way-Merge-Algorithmus-Prozess.

  • Fügen Sie zunächst das erste Element jeder sortierten Sequenz in den Min-Heap ein.
type SkipList struct {
    maxLevel int
    p        float64
    level    int
    rand     *rand.Rand
    size     int
    head     *Element
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
  • Entfernen Sie iterativ ein Element aus dem Min-Heap und fügen Sie es der Ergebniswarteschlange hinzu. Wenn die Sequenz des entfernten Elements noch mehr Elemente enthält, fügen Sie das nächste Element aus dieser Sequenz in den Heap ein. Hier wird eine Karte anstelle einer Ergebnissequenz verwendet. Die Karte übernimmt die Deduplizierung automatisch, wobei neuere Schlüssel immer ältere überschreiben.
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  
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Durchlaufen Sie abschließend die Karte, um Elemente zur Ergebniswarteschlange hinzuzufügen, und entfernen Sie alle als „Tombstones“ markierten Schlüssel-Wert-Paare. Da die Karte ungeordnet ist, muss die Ergebniswarteschlange vor der Rückgabe sortiert werden.

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 ]
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Bloom-Filter

Die vollständige Implementierung des Bloom-Filters finden Sie unter https://github.com/B1NARY-GR0UP/originium/blob/main/pkg/filter/filter.go.

Ein Bloom-Filter ist eine Datenstruktur, die effizient prüft, ob ein Element Mitglied einer Menge ist.

  • Es verwendet ein Bit-Array und mehrere Hash-Funktionen.
  • Beim Hinzufügen eines Elements wird das Element mithilfe mehrerer Hash-Funktionen gehasht, die es verschiedenen Positionen im Bit-Array zuordnen und diese Positionen auf 1 setzen.
  • Wenn während einer Abfrage alle von den Hash-Funktionen zugeordneten Positionen 1 sind, ist das Element möglicherweise vorhanden. Wenn eine Position 0 ist, existiert das Element definitiv nicht.

Die zeitliche Komplexität sowohl für Einfügungs- als auch für Abfragevorgänge beträgt O(k), wobei k die Anzahl der Hash-Funktionen ist. Falsch-positive Ergebnisse können auftreten (der Bloom-Filter sagt voraus, dass ein Element in der Menge vorhanden ist, dies ist jedoch nicht der Fall), aber falsch-negative Ergebnisse können nicht auftreten (der Bloom-Filter sagt voraus, dass ein Element nicht vorhanden ist). im Set, ist es aber tatsächlich).

True Positive (TP):Das System sagt ein Ereignis als „positiv“ voraus und es ist tatsächlich positiv.
False Positive (FP):Das System sagt ein Ereignis als „positiv“ voraus, in Wirklichkeit ist es jedoch negativ.
True Negative (TN):Das System sagt ein Ereignis als „negativ“ voraus, und es ist tatsächlich negativ.
Falsch Negativ (FN):Das System sagt ein Ereignis als „negativ“ voraus, aber es ist tatsächlich positiv.

Kernstruktur

Die Kernstruktur eines Bloom-Filters umfasst das Bit-Array (das für die Verwendung von uint8 optimiert werden kann) und mehrere Hash-Funktionen.

func (s *SkipList) Set(entry types.Entry)
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Neu

Die Methode zum Erstellen einer Bloom-Filter-Instanz akzeptiert zwei Parameter: n (die erwartete Anzahl von Elementen) und p (die gewünschte Falsch-Positiv-Rate).

Zuerst werden die Parameter validiert. Anschließend werden die Größe des Bit-Arrays (m) und die Anzahl der Hash-Funktionen (k) mithilfe spezifischer Formeln berechnet. Abschließend werden die Bit-Array- und Hash-Funktionen basierend auf m und k initialisiert.

type SkipList struct {
    maxLevel int
    p        float64
    level    int
    rand     *rand.Rand
    size     int
    head     *Element
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Hinzufügen

Beim Hinzufügen eines Elements werden alle Hash-Funktionen verwendet, um Hash-Werte für den Schlüssel zu berechnen. Diese Werte werden dann auf Indizes im Bit-Array abgebildet und die entsprechenden Positionen werden auf true gesetzt.

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  
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Enthält

Um zu überprüfen, ob ein Schlüssel in der Menge vorhanden ist, berechnen die Hash-Funktionen Hash-Werte und ordnen sie Indizes im Bit-Array zu. Wenn eine dieser Positionen nicht wahr ist, ist das Element nicht in der Menge und es wird „false“ zurückgegeben.

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 ]
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Geebnete Verdichtung

Die vollständige Implementierung von Leveled Compaction finden Sie unter https://github.com/B1NARY-GR0UP/originium/blob/main/level.go.

Nach der Implementierung von Komponenten wie K-Way Merge und Bloom Filter können wir den letzten Teil unserer Implementierung abschließen, den wichtigsten SSTable-Komprimierungsprozess in der LSM-Tree-Speicher-Engine. Diese Implementierung folgt der Leveled Compaction Strategy, die im Abschnitt „Datenkomprimierung“ beschrieben wird.

In der Leveled Compaction Strategy sind SSTables in mehreren Ebenen organisiert (Ebene 0 – Ebene N). Wir benötigen eine Struktur, um diese Informationen zu speichern und SSTables über verschiedene Ebenen hinweg zu verwalten.

Daher haben wir eine Struktur namens levelManager implementiert. Wir verwenden eine []*list.List, um SSTable-Informationen für jede Ebene zu speichern, wobei der Index des Slice der Ebene entspricht. Jedes Element im Slice ist eine list.List, eine doppelt verknüpfte Liste, die Informationen über alle SSTables in einer bestimmten Ebene enthält.

func (s *SkipList) Set(entry types.Entry)
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

kompaktLN

Die Methode „compactLN“ ist für die Verdichtung von Level N bis Level N 1 (N > 0) verantwortlich. Es wählt die älteste SSTable von LN und alle SSTables von LN 1 aus, die überlappende Schlüsselbereiche mit dieser SSTable haben.

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
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Die ausgewählten SSTables werden in der Reihenfolge vom ältesten zum neuesten verarbeitet. Schlüssel-Wert-Paare aus ihren Datenblöcken werden zu einem zweidimensionalen Slice hinzugefügt und dann mit dem K-Way Merge-Algorithmus zusammengeführt.

// 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
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Mit den zusammengeführten Schlüssel-Wert-Paaren erstellen wir einen neuen Bloom-Filter und eine neue SSTable. Die relevanten Informationen zur neuen SSTable sind am Ende von Level N 1 angehängt.

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

if level > s.level {
    for i := s.level; i < level; i++ {
        update[i] = s.head
    }
    s.level = level
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Schließlich werden die alten SSTables gelöscht und die neu erstellte SSTable wird auf die Festplatte geschrieben.

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)))
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

Die Methode „compactL0“ übernimmt die Komprimierung von Level 0 bis Level 1. Im Gegensatz zu CompactLN wählt es nicht nur eine SSTable aus L0 aus, sondern auch alle überlappenden SSTables in L0. Der weitere Ablauf ist identisch mit compactLN.

suchen

Die Suchmethode findet die entsprechenden Schlüssel-Wert-Paare in allen SSTables. Es beginnt bei L0 und iteriert durch jede Ebene bis zu LN. Durch die Nutzung des Bloom-Filters und der geordneten Struktur von SSTables können SSTables, die nicht die gewünschten Schlüssel-Wert-Paare enthalten, effizient übersprungen werden.

type SkipList struct {
    maxLevel int
    p        float64
    level    int
    rand     *rand.Rand
    size     int
    head     *Element
}
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren
Nach dem Login kopieren

DB

Damit haben wir alle Kernkomponenten der LSM-Tree-basierten Speicher-Engine implementiert. Indem wir diese Komponenten wie in der LSM-Tree-Einführung beschrieben zusammenstellen, können wir die Datenbankschnittstelle finalisieren.

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

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

Zusammenfassung

Wir begannen damit, LSM-Tree zu verstehen, uns mit seinen Kernkomponenten und dem Prozess der Bearbeitung von Kundenanfragen vertraut zu machen. Letztendlich haben wir unsere eigene LSM-Tree-Speicher-Engine von Grund auf erstellt.

Natürlich handelt es sich bei dieser Implementierung nur um einen Prototyp. Bei einer produktionstauglichen Speicher-Engine müssen viele weitere Details berücksichtigt werden. ORIGINIUM wird auch in Zukunft weiterhin Optimierungen und Verbesserungen erhalten. Ich hoffe, dieser Artikel und ORIGINIUM helfen Ihnen, Ihr Verständnis von LSM-Tree zu vertiefen.

Damit ist alles, was in diesem Artikel behandelt wird, abgeschlossen. Wenn es Fehler oder Fragen gibt, können Sie uns gerne per Privatnachricht kontaktieren oder einen Kommentar hinterlassen. Vielen Dank!

Referenz

  • 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

Das obige ist der detaillierte Inhalt vonAufbau einer LSM-Tree-Speicher-Engine von Grund auf. Für weitere Informationen folgen Sie bitte anderen verwandten Artikeln auf der PHP chinesischen Website!

Quelle:dev.to
Erklärung dieser Website
Der Inhalt dieses Artikels wird freiwillig von Internetnutzern beigesteuert und das Urheberrecht liegt beim ursprünglichen Autor. Diese Website übernimmt keine entsprechende rechtliche Verantwortung. Wenn Sie Inhalte finden, bei denen der Verdacht eines Plagiats oder einer Rechtsverletzung besteht, wenden Sie sich bitte an admin@php.cn
Neueste Artikel des Autors
Beliebte Tutorials
Mehr>
Neueste Downloads
Mehr>
Web-Effekte
Quellcode der Website
Website-Materialien
Frontend-Vorlage