Hashicorp’s Memory Datenbank

Das GO Module MemDB ermöglicht eine schnelle Datenbank im Arbeitsspeicher. HashiCorp nutzt diese u.a. in Consul oder Vault.

Die Datenbank bietet von ACID Atomicity, Consistency and Isolation. Den letzten Punkt Durability erfüllt sie nicht.

Mit den sehr ausgebauten Indexen kann auf die Daten fast wie in einer relationalen Datenbank zugegriffen werden und durch Transaktionen können Änderungen bei Fehlern auch zurückgesetzt werden. Weitere nützliche Aktionen sind das Erstellen eines “Snapshots” um diesen z.B. parallel zu speichern und “Watchers” auf die ich in diesem Artikel aber nicht weiter eingehen möchte.

Schema link

Vor der Datenbankinitialisierung muss ein Schmea festgelegt werden. In diesem werden die Tabellen und ihre Indexe definiert. In folgenden Beispielen werden in der Datenbank Quadranten (X:Y) gespeichert. Diese haben eine UUID4 als ID und einen Typen (string). MemDB arbeitet direkt mit dem Struct und benötigt keine Tags wie z.B. die meisten SQL Treiber.

type Quadrant struct {
    ID string
    X int
    Y int
    Type string
}

Das Schema mit der Tabelle Quadrant wird wie folgt definiert.

schema := &memdb.DBSchema{
    Tables: map[string]*memdb.TableSchema{
        "quadrant": {
            Name: "quadrant",
            Indexes: map[string]*memdb.IndexSchema{
                // indexes
                "id": {
                    Name:    "id",
                    Unique:  true,
                    Indexer: &memdb.UUIDFieldIndex{Field: "ID"},
                },
            },
        },
    },
}

Initialisierung link

Zur Initialisierung wird das Schmea übergeben. Falls das Schema nicht valide ist, wirft die Funktion einen Error.

// create db
db, err := memdb.NewMemDB(schema)
if err != nil {
    panic(err)
}

Transaction link

Um eine Aktion auszuführen muss eine Transaktion erstellt werden. Diese ist entweder im Modus Nur-Lesen (false) oder Schreiben und Lesen (true). Um die Datenbank nicht unnötig zu blockieren sollte eine Transaktion möglichst kurz vor den Vorgängen erstellt werden und sobald möglich mit Commit oder Abort wieder beendet werden.

isWriting := true
txn := db.Txn(isWriting)
defer txn.Abort()

// Datenbank Aktionen
// txn.First(...)

// Wird nur für Transaktionen die auch schreiben benötigt.
txn.Commit()

Elemente anlegen (Insert) link

Beim Anlegen eines Elements muss angegeben werden in welche Tabelle aus dem Schema es hinzugefügt werden soll.

txn := db.Txn(true)
defer txn.Abort()

quadrant := &Quadrant{
    ID: "e2319ab0-d3bc-4d58-8aa3-bdeea293deda",
    X: 1,
    Y: 1,
    Type: "wasser"
}

txn.Insert("quadrant", quadrant)

txn.Commit()

Elemente löschen (Delete) link

Zum Löschen eines Elements kann dieses mit Angabe der Tabelle an die Delete Funktion übergeben werden.

txn.Delete("quadrant", quadrant)

Abfragen link

Um Einträge aus der Datenbank abzufragen muss immer der Tabellennamen angegeben werden. MemDB gibt immer ein Interface zurück, das anschließend konvertiert werden muss.

Einzelner Eintrag link

txn := db.Txn(false)
defer txn.Abort()

// tabelle, index, ...index felder (id)
raw, err := txn.First("quadrant", "id", "e2319ab0-d3bc-4d58-8aa3-bdeea293deda")
if err != nil {
    panic(err)
}
quadrant := raw.(*Quadrant)

Für gruppierte Index Felder müssen dementsprechend mehr Parameter übergeben werden.

// tabelle, index, ...index felder (x,y)
raw, err := txn.First("quadrant", "position", "1", "1")

Mehrere Einträge link

Mit Get kann durch alle passenden Einträge im Index iteriert werden. Wie bei der einzelnen Abfrage muss die Tabelle und der Index angegeben werden. Optional können auch die Index Felder mit einem Wert versehen werden um z.B. nur alle Elemente mit dem Type: "wasser" zu bekommen.

it, err := txn.Get("quadrant", "type", "wasser")
if err != nil {
    panic(err)
}

for obj := it.Next(); obj != nil; obj = it.Next() {
    quadrant := obj.(*Quadrant)
    fmt.Printf("%d:%d - %s (%s)", quadrant.X, quadrant.Y, quadrant.Type, quadrant.ID)
}

// Output: 1:1 - wasser (e2319ab0-d3bc-4d58-8aa3-bdeea293deda)

Größere / Kleinere Einträge link

Es kann im Index auch durch alle Einträge iteriert werden, die kleiner oder größer als der angegebene Eintrag sind.

// tabelle, index, ...index feld (x)
it, err = txn.LowerBound("quadrant", "x", 0)
if err != nil {
	panic(err)
}

fmt.Println("Quadrant between 0 - 10:")
for obj := it.Next(); obj != nil; obj = it.Next() {
	q := obj.(*Quadrant)

    // stop if quadrant over 10
    if q.X > 10 {
		break
	}

    fmt.Printf("%d:%d - %s (%s)", quadrant.X, quadrant.Y, quadrant.Type, quadrant.ID)
}

Index link

Ein Index hat einen Namen über den später auf Einträge im Index zugegriffen wird. Mit dem Flag Unique kann angegeben werden, ob unter gleichen Indexparametern mehrere Einträge gefunden werden können (z.B. mehrere Einträge mit denselben Namen oder ID haben). Ein am Index definierter Indexer legt beim Speichern den Index an. Dieser benötigt den Feldnamen im Struct.

UUID Index link

MemDB liefert direkt ein UUID Index mit.

// ...
Indexes: map[string]*memdb.IndexSchema{
    "id": {
        Name:    "id",
        Unique:  true,
        Indexer: &memdb.UUIDFieldIndex{Field: "ID"},
    },
},
// ...

String Index link

Im Beispiel hat das Struct ein Feld für den Typen. Um zu ermöglichen, dass mehrere Quadranten denselben Typen haben, wird Unique auf false gesetzt.

// ...
Indexes: map[string]*memdb.IndexSchema{
    "type": {
        Name:    "type",
        Unique:  false,
        Indexer: &memdb.StringFieldIndex{Field: "Type"},
    },
},
// ...

Int Index link

Für die Position können 2 Indexe angelegt werden. Mehrere Einträge können auf der X oder der Y Achse vorhanden sein.

// ...
Indexes: map[string]*memdb.IndexSchema{
    "x": {
        Name:    "x",
        Unique:  false,
        Indexer: &memdb.IntFieldIndex{Field: "X"},
    },
    "y": {
        Name:    "y",
        Unique:  false,
        Indexer: &memdb.IntFieldIndex{Field: "Y"},
    },
},
// ...

Gruppierter Index link

Um ein exakten Quadranten zu erhalten kann neben der ID auch die Position verwendet werden. Da die Kombination aus X und Y Unique ist können diese beiden Felder auch zusammen einen Index ergeben. Hierfür bietet MemDB die CompoundIndex an.

// ...
Indexes: map[string]*memdb.IndexSchema{
    "position": {
        Name:   "position",
        Unique: true,
        Indexer: &memdb.CompoundIndex{
            Indexes: []memdb.Indexer{
                &memdb.IntFieldIndex{Field: "X"},
                &memdb.IntFieldIndex{Field: "Y"},
            },
        },
    },
},
// ...

Es ist auch möglich unterschiedliche FieldIndex Elemente zu gruppieren. Falls z.B. alle Einträge auf der X-Achse mit einem bestimmten Typ benötigt werden.

// ...
Indexes: map[string]*memdb.IndexSchema{
    "xtype": {
        Name:   "xtype",
        Unique: true,
        Indexer: &memdb.CompoundIndex{
            Indexes: []memdb.Indexer{
                &memdb.IntFieldIndex{Field: "X"},
                &memdb.StringFieldIndex{Field: "Type"},
            },
        },
    },
},
// ...

Snapshot link

MemDB kann als Snapshot einen Klon der Datenbank erstellen. Änderungen auf der eigentlichen Datenbank lassen diesen unangetastet. So kann regelmäßig ein Klon erstellt werden und dieser in einem anderen Thread oder goroutine gespeichert werden ohne die den laufenden Prozess aufzuhalten. Dies eignet sich für Prozesse in denen die Geschwindigkeit wichtiger als die Persistents ist. Der Snapshot kann genau so durchsucht werden wie die normale Datenbank.

snapshot := db.Snapshot()

Stand vom 07.07.2020