Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Im Herbst 2019 fand in der iOS-Abteilung von Cloud Mail.ru ein lang erwartetes Ereignis statt. Die Hauptdatenbank fĂŒr die permanente Speicherung des Anwendungszustands wurde eine fĂŒr die mobile Welt recht exotische Lightning Memory-Mapped Database (LMDB). Unter dem Cut finden Sie eine detaillierte Übersicht in vier Teilen. Zuerst werden wir die GrĂŒnde fĂŒr diese unkonventionelle und schwierige Wahl erörtern. Dann gehen wir zu den drei SĂ€ulen der Architektur von LMDB ĂŒber: speicherabgebildete Dateien, B+-BĂ€ume, Copy-on-Write-Ansatz zur Umsetzung von Transaktionssicherheit und Multi-Versioning. Schließlich widmen wir uns praktischen Aspekten. Dabei betrachten wir, wie man auf der Grundlage einer niedrigstufigen Key-Value-API ein Schema einer Datenbank mit mehreren Tabellen, einschließlich Indizes, entwerfen und implementieren kann.

Inhalt

  1. Motivation zur EinfĂŒhrung
  2. Positionierung von LMDB
  3. Die drei SĂ€ulen von LMDB
    3.1. SĂ€ule Nr. 1. Speicherabgebildete Dateien
    3.2. SĂ€ule Nr. 2. B+-Baum
    3.3. SĂ€ule Nr. 3. Copy-on-Write
  4. Entwurf des Datenschemas auf der Key-Value-API
    4.1. Grundlagenabstraktionen
    4.2. Modellierung von Tabellen
    4.3. Modellierung von Beziehungen zwischen Tabellen

1. Motivation zur EinfĂŒhrung

Einst war es im Jahr 2015 unser Anliegen, eine Metrik darĂŒber zu erheben, wie oft die BenutzeroberflĂ€che unserer Anwendung laggt. Wir taten dies nicht ohne Grund. Es gab immer wieder Beschwerden, dass die Anwendung manchmal auf Benutzeraktionen nicht reagiert: Tasten funktionieren nicht, Listen lassen sich nicht scrollen usw. Über die Messmethodik erzĂ€hlt auf AvitoTech, daher hier nur die GrĂ¶ĂŸenordnung.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Die Ergebnisse der Messungen waren fĂŒr uns eine kalte Dusche. Es stellte sich heraus, dass die durch HĂ€nger verursachten Probleme erheblich grĂ¶ĂŸer sind als andere. WĂ€hrend vor der Erkenntnis dieser Tatsache der Hauptindikator fĂŒr die technische QualitĂ€t "crash free" war, verschob sich nach diesem Fokus auf "freeze free". Nachdem wir

ein Dashboard mit HĂ€ngern erstellt und eine quantitative qualitative und Analyse der Ursachen durchgefĂŒhrt hatten, war der Hauptgegner klar – die komplexe GeschĂ€ftslogik, die im Hauptthread der Anwendung ausgefĂŒhrt wird. Eine natĂŒrliche Reaktion auf dieses Chaos war der brennende Wunsch, sie auf Worker-Threads zu verteilen. Zur systematischen Lösung dieser Aufgabe haben wir eine mehrfĂ€dige Architektur auf Basis von leichten Akteuren eingesetzt. Ihrer Anpassung an die Welt von iOS habe ich zwei Threads in unserem kollektiven Twitter gewidmet und einen Artikel auf HabrĂ© . Im Rahmen der aktuellen ErzĂ€hlung möchte ich die Aspekte der Lösung betonen, die die Wahl der Datenbank beeinflussten.Im aktuellen Kontext möchte ich die Aspekte der Lösung hervorheben, die die Wahl der Datenbank beeinflusst haben.

Das actorbasierte Organisationsmodell des Systems geht davon aus, dass Mehrfach-Threading zu seiner zweiten Essenz wird. Die Objekte des Modells ĂŒberqueren dabei gerne die Grenzen der Threads. Und sie tun dies nicht gelegentlich und irgendwo, sondern praktisch stĂ€ndig und ĂŒberall.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Die Datenbank ist eines der grundlegendsten Komponenten im dargestellten Schema. Ihre Hauptaufgabe besteht in der Umsetzung des Makropatterns Shared Database. WĂ€hrend sie im Unternehmensumfeld zur Synchronisation von Daten zwischen Services eingesetzt wird, geschieht dies im Falle der Actor-Architektur – zwischen Threads. Daher benötigten wir eine solche Datenbank, deren Nutzung in der Multi-Thread-Umgebung nicht einmal minimale Schwierigkeiten mit sich bringt. Das bedeutet konkret, dass die aus ihr erhaltenen Objekte mindestens threadsicher sein sollten und idealerweise vollkommen unverĂ€nderlich. Wie bekannt ist, können Letztere gleichzeitig von mehreren Threads verwendet werden, ohne auf irgendwelche Sperren zurĂŒckgreifen zu mĂŒssen, was sich positiv auf die Leistung auswirkt.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-AnwendungenEin weiterer bedeutender Faktor, der die Wahl der Datenbank beeinflusste, ist unsere Cloud-API. Sie wurde von dem Synchronisationsansatz inspiriert, der in git ĂŒbernommen wurde. Wie dieses zielt auch unser Ansatz auf offline-first API, was fĂŒr Cloud-Kunden mehr als passend erscheint. Es war geplant, dass sie nur einmal den gesamten Zustand der Cloud herunterladen und dann in den ĂŒberwiegenden meisten FĂ€llen die Synchronisation durch das Einspielen von Änderungen erfolgen wird. Leider befindet sich diese Möglichkeit noch immer nur im theoretischen Bereich, und in der Praxis haben die Kunden nicht gelernt, mit Patches umzugehen. DafĂŒr gibt es eine Reihe objektiver GrĂŒnde, die wir der Übersichtlichkeit halber außen vor lassen. Derzeit sind die lehrreichen Ergebnisse der Lektion darĂŒber, was passiert, wenn die API „A“ sagt und ihr Verbraucher nicht „B“ sagt, von viel grĂ¶ĂŸerem Interesse.

Wenn Sie sich also git vorstellen, das beim AusfĂŒhren des Befehls pull anstelle der Anwendung von Patches auf einen lokalen Snapshot dessen vollstĂ€ndigen Status mit dem vollstĂ€ndigen Serverstatus vergleicht, haben Sie eine ziemlich prĂ€zise Vorstellung davon, wie die Synchronisation in Cloud-Clients erfolgt. Es ist nicht schwer zu erraten, dass dafĂŒr zwei DOM-BĂ€ume im Speicher mit Metainformationen ĂŒber alle Server- und lokalen Dateien alloziert werden mĂŒssen. Das bedeutet, dass, wenn ein Benutzer 500.000 Dateien in der Cloud speichert, zur Synchronisation zwei BĂ€ume mit 1 Million Knoten rekonstruiert und zerstört werden mĂŒssen. Jeder Knoten ist schließlich ein Aggregat, das einen Graph von Unterobjekten enthĂ€lt. Vor diesem Hintergrund sind die Ergebnisse der Profilierung nicht ĂŒberraschend. Es hat sich herausgestellt, dass selbst ohne BerĂŒcksichtigung der Algorithmik von Merges allein das Erstellen und anschließende Zerstören einer großen Anzahl kleiner Objekte bereits ins Geld geht. Die Situation wird dadurch verschĂ€rft, dass die grundlegende Synchronisationsoperation in eine große Anzahl von Benutzerszenarien integriert ist. Als Ergebnis dokumentieren wir das zweite wichtige Kriterium bei der Auswahl einer Datenbank – die Möglichkeit, CRUD-Operationen ohne dynamische Allokation von Objekten durchzufĂŒhren.

Die anderen Anforderungen sind traditioneller und ihre vollstĂ€ndige Liste sieht folgendermaßen aus.

  1. Thread-Sicherheit.
  2. MultiprozessorfĂ€higkeit. Diktierte den Wunsch, dieselbe Instanz der Datenbank fĂŒr die Synchronisation des Status nicht nur zwischen Threads, sondern auch zwischen der Hauptanwendung und iOS-Extensions zu verwenden.
  3. Die Möglichkeit, gespeicherte EntitÀten als nicht verÀnderbare Objekte darzustellen.
  4. Keine dynamischen Allokationen im Rahmen von CRUD-Operationen.
  5. UnterstĂŒtzung der grundlegenden Eigenschaften von Transaktionen ACID: AtomaritĂ€t, Konsistenz, Isolation und ZuverlĂ€ssigkeit.
  6. Geschwindigkeit bei den beliebtesten AnwendungsfÀllen.

Eine gute Wahl mit diesem Anforderungssatz war und ist SQLite. Im Rahmen der Erforschung von Alternativen bin ich auf ein Buch gestoßen „Getting Started with LevelDB“. Unter ihrer Leitung wurde ein Benchmark erstellt, der die Geschwindigkeit der Arbeit mit verschiedenen Datenbanken in realen Cloud-Szenarien vergleicht. Das Ergebnis ĂŒbertraf die kĂŒhnsten Erwartungen. Bei den beliebtesten AnwendungsfĂ€llen — dem Abrufen eines Cursors fĂŒr eine sortierte Liste aller Dateien und einer sortierten Liste aller Dateien fĂŒr ein bestimmtes Verzeichnis — war LMDB zehnmal schneller als SQLite. Die Wahl wurde offensichtlich.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

2. Positionierung von LMDB

LMDB ist eine sehr kleine Bibliothek (nur 10K Zeilen), die die grundlegendste Schicht von Datenbanken — das Speichersystem — implementiert.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Das dargestellte Diagramm zeigt, dass es nicht korrekt ist, LMDB mit SQLite zu vergleichen, das auch höhere Ebenen implementiert, Ă€hnlich wie man SQLite nicht mit Core Data vergleichen sollte. Es wĂ€re gerechter, gleichwertige Speicher-Engines wie BerkeleyDB, LevelDB, Sophia, RocksDB usw. zu vergleichen. Es gibt sogar Entwicklungen, bei denen LMDB als Komponenten-Storage-Engine fĂŒr SQLite dient. Ein erstes solches Experiment fand 2012 statt. durchgefĂŒhrt von dem Autor von LMDB Howard Chu. Ergebnisse , das so faszinierend war, dass sein Vorhaben von OSS-Enthusiasten aufgegriffen wurde und in Form von LumoSQLFortsetzung fand. Im Januar 2020 stellte der Autor dieses Projekts, Den Shearer, die es auf der LinuxConfAu vor.

Die Hauptanwendung von LMDB findet sich als Engine fĂŒr Anwendungsdatenbanken. Die Bibliothek verdankt ihr Dasein Entwicklern OpenLDAP, die mit BerkeleyDB als Basis fĂŒr ihr Projekt sehr unzufrieden waren. Ausgehend von der bescheidenen Bibliothek btree, konnte Howard Chu eine der heute populĂ€rsten Alternativen schaffen. Dieser Geschichte sowie der internen Struktur von LMDB widmete er seinen sehr intensiven Vortrag „The Lightning Memory-mapped Database“. Ein gutes Beispiel fĂŒr die erfolgreiche Nutzung des Speichers teilte Leonid Jurjew (aka yleo) von Positive Technologies in seinem Vortrag auf dem Highload 2015 „Die LMDB-Engine — ein besonderer Champion“. Darin erlĂ€utert er LMDB im Kontext einer Ă€hnlichen Aufgabe zur Implementierung von ReOpenLDAP, wĂ€hrend LevelDB einer vergleichenden Kritik unterzogen wurde. Nach der Implementierung entstand bei Positive Technologies sogar ein aktiv entwickelter Fork MDBX , der mit sehr interessanten Funktionen, Optimierungen und Bugfixes.

. LMDB wird auch oft als Speicher ohne Modifikationen verwendet. Zum Beispiel hat der Browser Mozilla Firefox es ausgewĂ€hlt fĂŒr eine Reihe von BedĂŒrfnissen, und ab Version 9 bevorzuge Xcode es gegenĂŒber SQLite zur Speicherung von Indizes. Die Engine hat sich auch in der Welt der mobilen Entwicklung bemerkbar gemacht. Seine Verwendung findet man an verschiedenen Stellen.

Die Engine hat auch in der Welt der mobilen Entwicklung Aufmerksamkeit erregt. Ihre Verwendung ist nachzuvollziehen. gefunden im iOS-Client fĂŒr Telegram. LinkedIn ist sogar noch weiter gegangen und hat LMDB als Standardspeicher fĂŒr das eigens entwickelte Daten-Cache-Framework Rocket Data ausgewĂ€hlt, was erzĂ€hlte in seinem Artikel im Jahr 2016.

LMDB kĂ€mpft erfolgreich um einen Platz in der Nische, die BerkeleyDB nach der Übernahme durch Oracle hinterlassen hat. Die Bibliothek wird fĂŒr ihre Geschwindigkeit und ZuverlĂ€ssigkeit geliebt, selbst im Vergleich zu Ă€hnlichen Lösungen. Wie bekannt ist, gibt es kein kostenloses Mittagessen, und es ist wichtig, den Trade-off zu betonen, mit dem man bei der Wahl zwischen LMDB und SQLite konfrontiert wird. Das obige Diagramm veranschaulicht, wodurch die erhöhte Geschwindigkeit erreicht wird. Erstens, wir zahlen nicht fĂŒr zusĂ€tzliche Abstraktionsschichten ĂŒber dem Speichersystem. Es versteht sich von selbst, dass man in einer guten Architektur nicht um sie herumkommt, und sie werden unweigerlich im Anwendungscode erscheinen, aber sie werden viel schlanker sein. Sie enthalten keine Funktionen, die von der spezifischen Anwendung nicht benötigt werden, wie z.B. die UnterstĂŒtzung fĂŒr SQL-Abfragen. Zweitens ergibt sich die Möglichkeit, die Abbildung von Anwendungsoperationen auf Anforderungen an das Speichersystem optimal umzusetzen. Wenn SQLite bei seiner Arbeit von den durchschnittlichen Anforderungen einer durchschnittlichen Anwendung ausgeht, sind Sie als Anwendungsentwickler bestens ĂŒber die grundlegenden Lastszenarien informiert. FĂŒr eine leistungsfĂ€higere Lösung mĂŒssen Sie höhere Kosten sowohl fĂŒr die Entwicklung der ursprĂŒnglichen Lösung als auch fĂŒr deren nachfolgende UnterstĂŒtzung in Kauf nehmen.

3. Die drei SĂ€ulen von LMDB

Wenn wir LMDB aus der Vogelperspektive betrachten, ist es an der Zeit, tiefer einzutauchen. Die nÀchsten drei Abschnitte widmen sich der Analyse der grundlegenden SÀulen, auf denen die Architektur des Speichers ruht:

  1. Speicherabbilddateien als Mechanismus zur Arbeit mit der Festplatte und zur Synchronisation interner Datenstrukturen.
  2. B+-Baum als Organisation der Struktur der gespeicherten Daten.
  3. Copy-on-Write als Ansatz zur GewÀhrleistung der ACID-Eigenschaften von Transaktionen und Multi-Versioning.

3.1. SĂ€ule Nr. 1. Speicherabbilddateien

Die in den Speicher abgebildeten Dateien sind ein so wichtiger architektonischer Bestandteil, dass sie sogar im Namen des Speichers auftauchen. Die Fragen der Caching- und Synchronisierung des Zugriffs auf die gespeicherten Informationen liegen vollstĂ€ndig in der Verantwortung des Betriebssystems. LMDB enthĂ€lt keinerlei Caches. Dies ist eine bewusste Entscheidung des Autors, da das direkte Lesen von Daten aus den abgebildeten Dateien zahlreiche Umwege in der Implementierung der Engine vermeidet. Im Folgenden sind einige der vielen Punkte aufgefĂŒhrt.

  1. Die Aufrechterhaltung der Konsistenz der Daten im Speicher beim Zugriff aus mehreren Prozessen liegt in der Verantwortung des Betriebssystems. Im nÀchsten Abschnitt wird diese Mechanik im Detail und mit Bildern behandelt.
  2. Das Fehlen von Caches befreit LMDB vollstÀndig von den Overhead-Kosten, die mit dynamischen Allokationen verbunden sind. Das Lesen von Daten stellt in der Praxis lediglich die Einrichtung eines Zeigers auf die richtige Adresse im virtuellen Speicher dar und nicht mehr. Das klingt nach Science-Fiction, aber im Quellcode des Speichers sind alle Aufrufe von salloc in der Funktion zur Konfiguration des Speichers konzentriert.
  3. Das Fehlen von Caches bedeutet auch das Fehlen von Sperren, die mit der Synchronisierung ihres Zugriffs verbunden sind. Leser, von denen jederzeit beliebig viele existieren können, begegnen auf ihrem Weg zu den Daten keinem einzigen Mutex. Dadurch hat die Leseleistung eine ideale lineare Skalierbarkeit entsprechend der CPU-Anzahl. In LMDB unterliegen nur modifizierende Operationen der Synchronisierung. Es kann immer nur einen Schreiber gleichzeitig geben.
  4. Die minimale Logik von Caching und Synchronisierung befreit den Code von Ă€ußerst komplizierten Fehlern, die durch den Betrieb in einer Mehrfadenumgebung entstehen können. Auf der Usenix OSDI 2014 fanden sich zwei interessante Datenbankforschungen: „Alle Dateisysteme sind nicht gleich: Über die KomplexitĂ€t der Erstellung von absturzsicheren Anwendungen“ und „Datenbanken zum Spaß und zur Profit“. Aus ihnen kann man sowohl Informationen ĂŒber die beispiellose ZuverlĂ€ssigkeit von LMDB als auch ĂŒber die praktisch makellose Implementierung der ACID-Eigenschaften von Transaktionen entnehmen, die in SQLite ĂŒbertroffen werden.
  5. Die MinimalitÀt von LMDB erlaubt es, dass ihre MaschinenreprÀsentation vollstÀndig im L1-Cache des Prozessors untergebracht werden kann, was sich in den entsprechend hohen Geschwindigkeitsmerkmalen niederschlÀgt.

Leider ist die Situation mit den im Speicher angezeigten Dateien unter iOS nicht so rosig, wie man es sich wĂŒnschen wĂŒrde. Um die damit verbundenen Nachteile gezielter zu besprechen, ist es notwendig, die allgemeinen Prinzipien der Implementierung dieses Mechanismus in Betriebssystemen in Erinnerung zu rufen.

Allgemeine Informationen zu im Speicher angezeigten Dateien

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-AnwendungenMit jeder ausfĂŒhrbaren Anwendung assoziiert das Betriebssystem eine EntitĂ€t namens Prozess. Jederem Prozess wird ein zusammenhĂ€ngender Adressbereich zugewiesen, in dem er alles unterbringt, was er fĂŒr seine Arbeit benötigt. In den niedrigsten Adressen befinden sich Abschnitte mit Code sowie fest kodierten Daten und Ressourcen. Darauf folgt ein aufsteigender Block des dynamischen Adressraums, der uns unter dem Namen Heap gut bekannt ist. In ihm stehen die Adressen von EntitĂ€ten, die wĂ€hrend der ProgrammausfĂŒhrung entstehen. Oben befindet sich der Speicherbereich, der vom Stack der Anwendung genutzt wird. Dieser wĂ€chst und schrumpft, anders ausgedrĂŒckt, seine GrĂ¶ĂŸe hat ebenfalls eine dynamische Natur. Damit der Stack und der Heap sich nicht gegenseitig in die Quere kommen, sind sie an verschiedenen Enden des Adressraums angesiedelt. Zwischen diesen beiden dynamischen Abschnitten gibt es ein Loch. Die Adressen in diesem mittleren Abschnitt nutzt das Betriebssystem zur Assoziation verschiedener EntitĂ€ten mit dem Prozess. Insbesondere kann es einem kontinuierlichen Adresssatz eine Datei auf der Festplatte zuordnen. Eine solche Datei wird als im Speicher angezeigte Datei bezeichnet.

Der dem Prozess zugewiesene Adressraum ist gewaltig. Theoretisch ist die Anzahl der Adressen nur durch die GrĂ¶ĂŸe des Zeigers begrenzt, die sich aus der Bitanzahl des Systems ergibt. Wenn ihm 1:1 physischer Speicher zugeordnet wĂ€re, wĂŒrde der erste Prozess sofort den gesamten Arbeitsspeicher aufbrauchen, und von Multitasking könnte keine Rede sein.

Jedoch wissen wir aus eigener Erfahrung, dass moderne Betriebssysteme gleichzeitig beliebig viele Prozesse ausfĂŒhren können. Das ist möglich, weil sie den Prozessen nur auf dem Papier eine Menge Speicher zuweisen, in der Praxis jedoch nur den Teil in den Hauptspeicher laden, der im Moment benötigt wird. Daher wird der mit dem Prozess verknĂŒpfte Speicher als virtuell bezeichnet.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Das Betriebssystem organisiert den virtuellen und physischen Speicher in Form von Seiten einer bestimmten GrĂ¶ĂŸe. Sobald eine Seite des virtuellen Speichers benötigt wird, lĂ€dt das Betriebssystem sie in den physischen Speicher und stellt eine Zuordnung in einer speziellen Tabelle her. Wenn keine freien Slots vorhanden sind, wird eine der zuvor geladenen Seiten auf die Festplatte kopiert, und die geforderte nimmt ihren Platz ein. Dieses Verfahren, auf das wir bald zurĂŒckkommen werden, wird als Swapping bezeichnet. Die untenstehende Abbildung illustriert den beschriebenen Prozess. Dort wurde die Seite A mit der Adresse 0 geladen und auf die Seite des Hauptspeichers mit der Adresse 4 platziert. Diese Tatsache spiegelt sich in der Zuordnungstabelle im Feld Nummer 0 wider.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Die Geschichte der in den Speicher angezeigten Dateien ist genau die gleiche. Logisch gesehen scheinen sie kontinuierlich und vollstĂ€ndig im virtuellen Adressraum platziert zu sein. Dennoch gelangen sie nur seitenweise und auf Anfrage in den physischen Speicher. Änderungen an solchen Seiten werden mit der Datei auf der Festplatte synchronisiert. So kann man Datei-Ein-/Ausgaben durchfĂŒhren, indem man einfach mit Bytes im Speicher arbeitet – alle Änderungen werden automatisch vom Betriebssystem-Kern in die ursprĂŒngliche Datei ĂŒbertragen.
​
Die folgende Abbildung demonstriert, wie LMDB seinen Zustand synchronisiert, wÀhrend es mit der Datenbank aus verschiedenen Prozessen arbeitet. Indem wir den virtuellen Speicher verschiedener Prozesse auf dieselbe Datei abbilden, verpflichten wir de facto das Betriebssystem, bestimmte Blöcke ihrer AdressrÀume transitiv untereinander zu synchronisieren, auf die LMDB zugreift.
​

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Ein wichtiger Punkt ist, dass LMDB standardmĂ€ĂŸig die Datei mit den Daten ĂŒber den Systemaufruf write modifiziert, wĂ€hrend die Datei selbst im nur-Lese-Modus abgebildet wird. Dieser Ansatz hat zwei wichtige Konsequenzen.

Die erste Folge ist fĂŒr alle Betriebssysteme allgemein. Ihr Inhalt besteht darin, einen Schutz vor unbeabsichtigter BeschĂ€digung der Datenbank durch fehlerhaften Code hinzuzufĂŒgen. Wie bekannt ist, können die ausfĂŒhrenden Anweisungen eines Prozesses auf Daten aus jedem Teil seines Adressraums zugreifen. Gleichzeitig bedeutet das Abbilden einer Datei im Lese-Schreib-Modus, dass jede Anweisung sie zusĂ€tzlich modifizieren kann. Wenn dies versehentlich geschieht, etwa beim Versuch, ein Element eines Arrays an einem nicht bestehenden Index zu ĂŒberschreiben, könnte dies dazu fĂŒhren, dass die auf diese Adresse abgebildete Datei versehentlich verĂ€ndert wird, was zu einer BeschĂ€digung der Datenbank fĂŒhrt. Wenn die Datei jedoch im schreibgeschĂŒtzten Modus abgebildet ist, fĂŒhrt der Versuch, den entsprechenden Adressraum zu Ă€ndern, zu einem Programmabbruch mit einem Signal SIGSEGV, und die Datei bleibt intakt.

Die zweite Folge ist bereits spezifisch fĂŒr iOS. Weder der Autor noch irgendwelche anderen Quellen erwĂ€hnen dies ausdrĂŒcklich, aber ohne ihn wĂ€re LMDB fĂŒr die Arbeit in diesem mobilen Betriebssystem ungeeignet. Der nĂ€chsten Abschnitt widmet sich dieser Thematik.

Die Spezifik der in den Speicher abgebildeten Dateien in iOS

Auf der WWDC 2018 gab es einen hervorragenden Vortrag „iOS Memory Deep Dive“. Darin wird erlĂ€utert, dass in iOS alle Seiten, die sich im physischen Speicher befinden, in eine der 3 Typen eingeteilt sind: dirty, compressed und clean.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Clean Memory sind alle Seiten, die schmerzlos aus dem physischen Speicher ausgelagert werden können. Die darin enthaltenen Daten können bei Bedarf wieder aus ihren ursprĂŒnglichen Quellen geladen werden. Read-Only Memory-mapped Dateien fallen genau in diese Kategorie. iOS hat keine Angst, jederzeit die auf die Datei abgebildeten Seiten aus dem Speicher auszulagern, da sie garantiert mit der Datei auf der Festplatte synchronisiert sind.
​
In den Dirty Memory fallen alle modifizierten Seiten, unabhĂ€ngig davon, wo sie ursprĂŒnglich gespeichert waren. Insbesondere werden auch die ĂŒber das Schreiben auf den zugehörigen virtuellen Speicher modifizierten Memory-mapped Dateien so klassifiziert. Wenn man LMDB mit dem Flag MDB_WRITEMAP, öffnet, kann man sich persönlich davon ĂŒberzeugen, nachdem Änderungen vorgenommen wurden.

Sobald die Anwendung zu viel physischen Speicher belegt, komprimiert iOS die dirty Seiten. Der Gesamtumfang des Speichers, der von dirty und komprimierten Seiten belegt wird, wird als sogenannter Memory Footprint der Anwendung bezeichnet. Wenn dieser einen bestimmten Schwellenwert erreicht, wird der Systemdaemon OOM-Killer aktiv und beendet den Prozess zwangsweise. Das ist eine Besonderheit von iOS im Vergleich zu Desktop-Betriebssystemen. Im Gegensatz zu diesen ist in iOS kein Absenken des Memory Footprints durch das Swapping von Seiten aus dem physischen Speicher auf die Festplatte vorgesehen. Die GrĂŒnde hierfĂŒr sind nur Spekulation. Möglicherweise ist der Prozess des intensiven Verschiebens von Seiten auf die Festplatte und zurĂŒck zu energieintensiv fĂŒr mobile GerĂ€te, oder iOS spart die Ressourcen fĂŒr das Überschreiben von Zellen auf SSDs, oder vielleicht waren die Designer mit der allgemeinen Systemleistung nicht zufrieden, wo alles stĂ€ndig geswappt wird. Wie dem auch sei, Fakt ist Fakt.

Die gute Nachricht, die bereits zuvor erwĂ€hnt wurde, ist, dass LMDB standardmĂ€ĂŸig keinen mmap-Mechanismus fĂŒr die Aktualisierung von Dateien verwendet. Das bedeutet, dass die angezeigten Daten von iOS als saubere Speicherinhalte klassifiziert werden und nicht zum Memory Footprint beitragen. Das lĂ€sst sich mit dem Xcode-Tool namens VM Tracker ĂŒberprĂŒfen. Auf dem untenstehenden Screenshot ist der Zustand des virtuellen Speichers der iOS-Anwendung fĂŒr Cloud wĂ€hrend der AusfĂŒhrung dargestellt. Zu Beginn wurden 2 Instanzen von LMDB initialisiert. Der ersten wurde erlaubt, ihre Datei im virtuellen Speicher auf 1 GiB abzubilden, der zweiten auf 512 MiB. Obwohl beide Speicher einen bestimmten Umfang an residentem Speicher belegen, trĂ€gt keine von ihnen zur dirty GrĂ¶ĂŸe bei.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Und jetzt zu den schlechten Nachrichten. Durch den Swapping-Mechanismus in 64-Bit-Desktop-Betriebssystemen kann jeder Prozess so viel virtuelles Adressraum einnehmen, wie der freie Platz auf der Festplatte fĂŒr sein potenzielles Swapping zulĂ€sst. Der Wechsel von Swapping zu Kompression in iOS reduziert das theoretische Maximum radikal. Jetzt mĂŒssen alle laufenden Prozesse im Haupt- (d. h. RAM) Speicher untergebracht werden, und alle, die nicht hinein passen, unterliegen der Zwangsbeendigung. Dies wird sowohl in der oben genannten als auch in der weitergehenden... PrĂ€sentation, als auch in offiziellen DokumentationFolglich begrenzt iOS rigoros die GrĂ¶ĂŸe des Speichers, der ĂŒber mmap zugewiesen werden kann. Hier ist. hier Man kann die empirischen Grenzwerte des Speicherplatzes betrachten, der auf verschiedenen GerĂ€ten mit diesem Systemaufruf zugewiesen werden konnte. Bei den modernsten iOS-Smartphones wurden 2 Gigabyte bereitgestellt, wĂ€hrend die Top-Versionen des iPads sogar 4 Gigabyte erhielten. In der Praxis ist es jedoch notwendig, sich nach den Ă€ltesten unterstĂŒtzten Modelle zu orientieren, wo die Situation sehr trist ist. Schlimmer noch, wenn man den Speicherstatus der Anwendung im VM Tracker betrachtet, stellt man fest, dass LMDB nicht die einzige ist, die sich um den speichermape Speicher bemĂŒht. Gute Teile werden von den System-Allocatoren, Ressourcen-Dateien, Bildbearbeitungsframeworks und anderen kleineren RĂ€ubern in Anspruch genommen.

Nach den Ergebnissen der Experimente in der Cloud kamen wir zu den folgenden Kompromisswerten fĂŒr den zugewiesenen LMDB-Speicher: 384 Megabyte fĂŒr 32-Bit-GerĂ€te und 768 Megabyte fĂŒr 64-Bit-GerĂ€te. Nach Verbrauch dieses Volumens beginnen alle modifizierenden Operationen mit dem Code MDB_MAP_FULL. Solche Fehler beobachten wir in unserem Monitoring, aber sie sind so selten, dass sie in diesem Stadium vernachlĂ€ssigt werden können.

Eine nicht offensichtliche Ursache fĂŒr den ĂŒbermĂ€ĂŸigen Speicherverbrauch durch den Speicher könnte die langanhaltenden Transaktionen sein. Um zu verstehen, wie diese beiden PhĂ€nomene zusammenhĂ€ngen, hilft uns die Betrachtung der verbleibenden zwei Grundlagen von LMDB.

3.2. Grundlage Nr. 2. B+-Baum

Um Tabellen ĂŒber einem Key-Value-Speicher zu emulieren, mĂŒssen die folgenden Operationen in der API des Speichers vorhanden sein:

  1. EinfĂŒgen eines neuen Elements.
  2. Suchen eines Elements mit einem bestimmten SchlĂŒssel.
  3. Löschen eines Elements.
  4. Iterieren ĂŒber SchlĂŒsselintervalle in der Reihenfolge ihrer Sortierung.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-AnwendungenDie einfachste Datenstruktur, mit der alle vier Operationen leicht umgesetzt werden können, ist der binĂ€re Suchbaum. Jeder Knoten stellt einen SchlĂŒssel dar, der das gesamte Teilmengen der untergeordneten SchlĂŒssel in zwei TeilbĂ€ume unterteilt. Im linken werden die SchlĂŒssel gesammelt, die kleiner als der ĂŒbergeordnete sind, und im rechten die, die grĂ¶ĂŸer sind. Der Erhalt einer geordneten Menge von SchlĂŒsseln erfolgt durch einen der klassischen BaumdurchlĂ€ufe.

BinarbĂ€ume haben zwei grundlegende Nachteile, die sie als Datenspeicher auf Festplatten ineffizient machen. Erstens ist der Grad ihrer Ausgewogenheit unvorhersehbar. Es besteht ein nicht unerheblicher Risikos, BĂ€ume zu erhalten, bei denen die Höhe der verschiedenen Zweige stark variieren kann, was die algorithmische KomplexitĂ€t der Suche im Vergleich zu den Erwartungen erheblich verschlechtert. Zweitens raubt die FĂŒlle an Querverweisen zwischen den Knoten den BinarbĂ€umen die LokalitĂ€t im Speicher. Nahegelegene Knoten (in Bezug auf die Verbindungen zwischen ihnen) können sich auf völlig verschiedenen Seiten im virtuellen Speicher befinden. Dies fĂŒhrt dazu, dass selbst fĂŒr eine einfache Durchquerung mehrerer benachbarter Knoten im Baum eine vergleichbare Anzahl von Seiten besucht werden muss. Dies ist ein Problem, selbst wenn wir ĂŒber die Effizienz von BinarbĂ€umen als In-Memory-Datenstruktur nachdenken, da die stĂ€ndige Rotation von Seiten im Cache des Prozessors teuer wird. Wenn es darum geht, hĂ€ufig die mit den Knoten verbundenen Seiten von der Festplatte zu laden, wird die Situation noch schlimmer. bedauerlich.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-AnwendungenB-BĂ€ume, die eine Evolution der BinarbĂ€ume darstellen, lösen die im vorherigen Absatz genannten Probleme. Erstens sind sie selbstbalancierend. Zweitens unterteilt jeder Knoten eine Vielzahl von Tochter-SchlĂŒsseln nicht in 2, sondern in M geordnete Teilmengen, wobei M ziemlich groß sein kann, im Bereich von mehreren Hundert bis hin zu Tausenden.

Durch diese Struktur:

  1. Befinden sich in jedem Knoten eine große Anzahl bereits geordneter SchlĂŒssel, wodurch die BĂ€ume sehr niedrig werden.
  2. Der Baum erlangt die Eigenschaft der LokalitĂ€t der Speicherung im Speicher, da nahegelegene SchlĂŒssel aufgrund ihrer Werte auf natĂŒrliche Weise nahe beieinander in einem oder benachbarten Knoten angeordnet sind.
  3. Die Anzahl der transitiven Knoten verringert sich beim Herabsteigen im Baum wÀhrend der Suchoperation.
  4. Die Anzahl der zu lesenden Zielknoten bei Range-Abfragen verringert sich, da jeder von ihnen bereits eine große Anzahl geordneter SchlĂŒssel enthĂ€lt.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

In LMDB wird eine der Varianten des B-Baums, das B+-Baum, zur Speicherung von Daten verwendet. In der obigen Abbildung sind drei Arten von Knoten dargestellt, die darin vorkommen:

  1. An der Spitze befindet sich die Wurzel (root). Sie verkörpert nichts anderes als das Konzept einer Datenbank innerhalb des Speichers. Innerhalb einer LMDB-Instanz können mehrere Datenbanken erstellt werden, die sich den abgebildeten virtuellen Adressraum teilen. Jede von ihnen beginnt mit ihrer eigenen Wurzel.
  2. Am unteren Ende befinden sich die BlĂ€tter (leaf). Nur sie enthalten die in der Datenbank gespeicherten SchlĂŒssel-Wert-Paare. Das ist das Besondere an B+-BĂ€umen. WĂ€hrend ein regulĂ€rer B-Baum die Value-Teile in den Knoten aller Ebenen speichert, speichert die B+-Variation nur auf der untersten Ebene. Nachdem wir diesen Fakt festgestellt haben, werden wir den verwendeten Baumtyp in LMDB einfach B-Baum nennen.
  3. Zwischen der Wurzel und den BlĂ€ttern befinden sich 0 oder mehr technische Ebenen mit Navigationsknoten (branch). Ihre Aufgabe besteht darin, die sortierte Menge der SchlĂŒssel auf die BlĂ€tter zu verteilen.

Physisch sind die Knoten Blöcke von vordefinierter LĂ€nge im Speicher. Ihre GrĂ¶ĂŸe ist ein Vielfaches der SeitengrĂ¶ĂŸe des Speichers im Betriebssystem, ĂŒber die wir zuvor gesprochen haben. Unten ist die Struktur eines Knotens dargestellt. Im Header befinden sich Metainformationen, die offensichtlichste fĂŒr unser Beispiel ist die PrĂŒfziffer. Danach folgen Informationen ĂŒber die Offsets, die angeben, wo sich die Datenspeicher befinden. Bei den Daten kann es sich um SchlĂŒssel handeln, wenn wir von den Navigationsknoten sprechen, oder um komplette SchlĂŒssel-Wert-Paare im Fall der BlĂ€tter. Mehr ĂŒber die Struktur der Seiten kann in der Arbeit gelesen werden „Evaluation of High Performance Key-Value Stores“.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Nachdem wir uns mit dem inneren Inhalt der Seitenknoten beschÀftigt haben, werden wir den B-Baum von LMDB nachfolgend vereinfacht darstellen.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Die Seiten mit den Knoten sind auf der Festplatte nacheinander angeordnet. Seiten mit höheren Nummern befinden sich nĂ€her am Ende der Datei. Die sogenannte Metaseite (meta page) enthĂ€lt Informationen ĂŒber die Offsets, die angeben, wo die Wurzeln aller BĂ€ume zu finden sind. Beim Öffnen der LMDB-Datei scannt das System die Datei seitenweise vom Ende zum Anfang auf der Suche nach einer gĂŒltigen Metaseite und findet darĂŒber die existierenden Datenbanken.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Jetzt, da man ein VerstĂ€ndnis fĂŒr die logische und physikalische Struktur der Datenorganisation hat, kann man sich dem dritten Grundpfeiler von LMDB widmen. Mit seiner Hilfe erfolgen alle Modifikationen des Speichers transaktional und isoliert voneinander, wodurch der Datenbank insgesamt auch die Eigenschaft der Multi-Versionierung verliehen wird.

3.3. Grundpfeiler Nr. 3. Copy-on-Write

Einige Operationen mit dem B-Baum erfordern eine Reihe von Änderungen in seinen Knoten. Ein Beispiel ist das HinzufĂŒgen eines neuen SchlĂŒssels zu einem Knoten, der bereits seine maximale KapazitĂ€t erreicht hat. In diesem Fall muss zunĂ€chst der Knoten in zwei Teile geteilt werden und zweitens muss ein Verweis auf den neuen abgezweigten Kindknoten im ĂŒbergeordneten Knoten hinzugefĂŒgt werden. Dieser Prozess ist potenziell sehr gefĂ€hrlich. Wenn aus irgendeinem Grund (Absturz, Stromausfall usw.) nur ein Teil der Änderungen aus der Reihe durchgefĂŒhrt wird, bleibt der Baum in einem inkonsistenten Zustand.

Eine der traditionellen Lösungen zur GewĂ€hrleistung der Ausfallsicherheit einer Datenbank besteht darin, neben dem B-Baum eine zusĂ€tzliche Datenspeicherstruktur, das Transaktionsprotokoll, auch bekannt als Write-Ahead Log (WAL), hinzuzufĂŒgen. Dabei handelt es sich um eine Datei, in die die beabsichtigte Operation strikt vor der Modifikation des eigentlichen B-Baums geschrieben wird. So kann, wenn wĂ€hrend einer Selbstdiagnose Datenkorruption festgestellt wird, die Datenbank das Protokoll zur Wiederherstellung heranziehen.

LMDB hat fĂŒr die Bereitstellung von Ausfallsicherheit einen anderen Ansatz gewĂ€hlt, der als Copy-on-Write bezeichnet wird. Dabei werden anstelle der Aktualisierung von Daten auf einer bestehenden Seite diese zunĂ€chst vollstĂ€ndig kopiert, und alle Modifikationen werden bereits in der Kopie vorgenommen.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Um sicherzustellen, dass die aktualisierten Daten verfĂŒgbar sind, muss der Verweis auf den nun relevanten Knoten im ĂŒbergeordneten Knoten geĂ€ndert werden. Da auch dieser modifiziert werden muss, wird er ebenfalls zuerst kopiert. Der Vorgang setzt sich rekursiv bis zur Wurzel fort. Zuletzt werden die Daten auf der Metadatei geĂ€ndert.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Wenn wĂ€hrend des Aktualisierungsprozesses ein unerwartetes Ende des Prozesses auftritt, wird entweder die neue Meta-Seite nicht erstellt oder sie wird bis zum Ende nicht auf die Festplatte geschrieben, sodass ihre PrĂŒfziffer ungĂŒltig ist. In beiden FĂ€llen sind die neuen Seiten unerreichbar, wĂ€hrend die alten nicht betroffen sind. Dies erspart LMDB die Notwendigkeit, ein Write Ahead Log zur Aufrechterhaltung der Datenkonsistenz zu fĂŒhren. Die oben beschriebene Datenstruktur auf der Festplatte ĂŒbernimmt gleichzeitig deren Funktion. Das Fehlen eines expliziten Transaktionslogs ist eines der Merkmale von LMDB, das hohe Lesegeschwindigkeiten gewĂ€hrleistet.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Die entstehende Struktur, die als Append-Only B-Baum bezeichnet wird, sorgt ganz natĂŒrlich fĂŒr die Isolierung von Transaktionen und Mehrversionierung. In LMDB ist mit jeder offenen Transaktion der aktuelle Baumwurzelpflicht verknĂŒpft. Solange die Transaktion nicht abgeschlossen ist, werden die Seiten des zugehörigen Baums niemals geĂ€ndert oder fĂŒr neue Datenversionen wiederverwendet. So kann man so lange arbeiten, wie man möchte mit genau dem Datensatz, der zum Zeitpunkt der Eröffnung der Transaktion relevant war, selbst wenn das Speicher wĂ€hrenddessen weiterhin aktiv aktualisiert wird. Das ist das Wesen der Mehrversionierung, die LMDB zur idealen Datenquelle fĂŒr uns alle macht. UICollectionView. Wenn eine Transaktion geöffnet wird, muss der Speicherbedarf der Anwendung nicht erhöht werden, indem man hastig die relevanten Daten in eine in-memory Struktur ĂŒbertrĂ€gt, aus Angst, beim Wortbruch mitten im Nichts zu stehen. Dieses Merkmal hebt LMDB vorteilhaft von SQLite ab, das nicht mit einer solchen umfassenden Isolierung aufwarten kann. Wenn zwei Transaktionen im letzteren geöffnet werden und ein Datensatz in einer von ihnen gelöscht wird, kann dieser Datensatz in der zweiten verbleibenden nicht mehr abgerufen werden.

Die Kehrseite der Medaille ist ein potenziell wesentlich höherer Verbrauch an virtuellem Speicher. Auf der Folie ist dargestellt, wie die Datenbankstruktur aussehen wird, wenn sie gleichzeitig mit 3 geöffneten Lese-Transaktionen, die auf verschiedene Versionen der Datenbank blicken, modifiziert wird. Da LMDB keine Knoten, die von den Wurzeln aktueller Transaktionen erreichbar sind, wiederverwenden kann, bleibt dem Speicher nichts anderes ĂŒbrig, als einen weiteren vierten Wurzelknoten im Speicher zu platzieren und die modifizierbaren Seiten erneut zu klonen.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Es ist nicht lĂ€stig, sich an den Abschnitt ĂŒber speicherkartierte Dateien zu erinnern. Obwohl der zusĂ€tzliche Verbrauch an virtuellem Speicher uns nicht allzu sehr beunruhigen sollte, da er keinen Beitrag zur Speicherauslastung der Anwendung leistet. Es wurde jedoch festgestellt, dass iOS bei der Zuteilung Ă€ußerst sparsam ist, und wir können nicht wie auf einem Server oder Desktop einfach einen LMDB-Bereich von 1 Terabyte bereitstellen und uns keine Gedanken ĂŒber diese Besonderheit machen. Soweit möglich, sollten wir versuchen, die Lebensdauer der Transaktionen so kurz wie möglich zu gestalten.

4. Entwurf des Datenschemas ĂŒber die Key-Value-API

Wir beginnen die Analyse der API mit den grundlegenden Abstraktionen, die von LMDB bereitgestellt werden: Umgebung und Datenbanken, SchlĂŒssel und Werte, Transaktionen und Cursors.

Anmerkung zu den Code-Listings

Alle Funktionen in der öffentlichen API von LMDB geben das Ergebnis ihrer Arbeit in Form eines Fehlercodes zurĂŒck, aber in allen nachfolgenden Listings wird diese ÜberprĂŒfung zugunsten der Knappheit weggelassen. In der Praxis haben wir fĂŒr die Interaktion mit dem Speicher unsere Fork C++-Wrapper lmdbxx, in dem Fehler als C++-Ausnahmen materialisiert werden.

Als schnellster Weg, LMDB in ein Projekt fĂŒr iOS oder macOS einzubinden, schlage ich mein CocoaPod vor POSLMDB.

4.1. Grundlegende Abstraktionen

Umgebung (environment)

Struktur MDB_env ist das Speicherreservoir des internen Zustands von LMDB. Die FunktionalitĂ€ten mit dem PrĂ€fix mdb_env ermöglichen es, einige seiner Eigenschaften zu konfigurieren. Im einfachsten Fall sieht die Initialisierung der Engine folgendermaßen aus.

mdb_env_create(env);​
mdb_env_set_map_size(*env, 1024 * 1024 * 512)​
mdb_env_open(*env, path.UTF8String, MDB_NOTLS, 0664);

In der Mail.ru Cloud-Anwendung haben wir die Standardwerte lediglich bei zwei Parametern geÀndert.

Der erste Punkt betrifft die GrĂ¶ĂŸe des virtuellen Adressraums, in den die Speicherdaten abgebildet werden. Leider kann der spezifische Wert selbst auf demselben GerĂ€t von AusfĂŒhrung zu AusfĂŒhrung erheblich variieren. Um diese Besonderheit von iOS zu berĂŒcksichtigen, wird der maximale Speicherplatz dynamisch festgelegt. Beginnend mit einem bestimmten Wert wird er schrittweise halbiert, bis die Funktion mdb_env_open ein Ergebnis zurĂŒckgibt, das sich von ENOMEMunterscheidet. Theoretisch gibt es auch einen entgegengesetzten Weg — zunĂ€chst dem Engine ein Minimum an Speicher zuzuweisen und dann, beim Auftreten von Fehlern, dessen GrĂ¶ĂŸe zu erhöhen. Dieser Ansatz ist jedoch viel komplizierter. Der Grund liegt darin, dass der Prozess der Speicherumverteilung (remap) durch die Funktion MDB_MAP_FULLmdb_env_set_map_size alle EntitĂ€ten (Cursors, Transaktionen, SchlĂŒssel und Werte), die zuvor von der Engine erhalten wurden, ungĂŒltig macht. Diese Wendung im Code zu berĂŒcksichtigen, fĂŒhrt zu einer erheblichen KomplexitĂ€t. Wenn Ihnen jedoch virtueller Speicher sehr wichtig ist, könnte dies ein Grund sein, einen Fork in Betracht zu ziehen, der weit voraus ist, , bei dem unter den angekĂŒndigten Features "automatische Anpassung der DatenbankgrĂ¶ĂŸe im laufenden Betrieb" enthalten ist. MDBXDer zweite Parameter, dessen Standardwert fĂŒr uns nicht geeignet war, regelt die Mechanik der Thread-Sicherheit. Leider gibt es mindestens in iOS 10 Probleme mit der UnterstĂŒtzung von Thread-Local Storage. Aus diesem Grund wird im obigen Beispiel das Speicher-Handle mit dem Flag

MDB_NOTLS geöffnet. Außerdem mussten wir auch dieC++-Wrapper anpassen, um Variablen mit diesem Attribut in ihr zu entfernen. Die Datenbank ist eine separate Instanz eines B-Baums, ĂŒber den wir oben gesprochen haben. Ihre Öffnung erfolgt innerhalb einer Transaktion, was zunĂ€chst etwas seltsam erscheinen mag. lmdbxxMDB_txn *txn;​ MDB_dbi dbi;​ mdb_txn_begin(env, NULL, MDB_RDONLY, &txn);​ mdb_dbi_open(txn, NULL, MDB_CREATE, &dbi);​ mdb_txn_abort(txn);

Datenbanken

In der Tat ist eine Transaktion in LMDB eine EntitĂ€t des Speichers und nicht einer bestimmten Datenbank. Dieses Konzept ermöglicht atomare Operationen an EntitĂ€ten, die sich in verschiedenen Datenbanken befinden. Theoretisch eröffnet dies die Möglichkeit, Tabellen in Form verschiedener Datenbanken zu modellieren, aber ich habe mich damals fĂŒr einen anderen Weg entschieden, der weiter unten detailliert beschrieben ist.

SchlĂŒssel und Werte

MDB_val

SchlĂŒssel und Werte

Struktur MDB_val modelliert das Konzept sowohl von SchlĂŒsseln als auch von Werten. Das Speicherformat hat nicht den leisesten Schimmer von ihrer Semantik. FĂŒr es ist etwas, was das andere ist, einfach ein Array von Bytes fester GrĂ¶ĂŸe. Die maximale GrĂ¶ĂŸe eines SchlĂŒssels betrĂ€gt 512 Bytes.

typedef struct MDB_val {​
    size_t mv_size;​
    void *mv_data;​
} MDB_val;​​

Mit Hilfe des Comparators sortiert das Speicherformat die SchlĂŒssel in aufsteigender Reihenfolge. Wenn man ihn nicht durch einen eigenen ersetzt, wird der Standard verwendet, der sie byteweise in lexikografischer Reihenfolge sortiert.​

Transaktionen

Das Transaktionssystem wird ausfĂŒhrlich beschrieben in dem vorherigen Kapitel, daher wiederhole ich hier kurz ihre Hauptmerkmale:

  1. UnterstĂŒtzung aller grundlegenden Eigenschaften ACID: AtomaritĂ€t, Konsistenz, Isolation und ZuverlĂ€ssigkeit. Ich kann nicht umhin zu bemerken, dass es in Bezug auf die Haltbarkeit unter macOS und iOS einen Fehler gibt, der in MDBX behoben wurde. Weitere Details können in ihrem README.
  2. Ansatz zur Multithreaded-Verarbeitung wird durch das Schema „ein einzelner Schreiber / mehrere Leser“ beschrieben. Schreiber blockieren einander, blockieren jedoch nicht die Leser. Leser blockieren weder Schreiber noch einander.
  3. UnterstĂŒtzung fĂŒr verschachtelte Transaktionen.
  4. UnterstĂŒtzung der Multi-Versionierung.

Die Multi-Versionierung in LMDB ist so gut, dass ich sie gerne in der Praxis demonstrieren möchte. Der unten stehende Code zeigt, dass jede Transaktion genau mit der Version der Datenbank arbeitet, die zum Zeitpunkt ihrer Eröffnung aktuell war, und dabei vollstĂ€ndig von allen nachfolgenden Änderungen isoliert bleibt. Die Initialisierung des Speichers und das HinzufĂŒgen eines Testeintrags sind nicht besonders interessant, daher wurden diese Rituale im Spoiler verborgen.

HinzufĂŒgen eines Testeintrags

MDB_env *env;
MDB_dbi dbi;
MDB_txn *txn;

mdb_env_create(&env);
mdb_env_open(env, ".\/testdb", MDB_NOTLS, 0664);

mdb_txn_begin(env, NULL, 0, &txn);
mdb_dbi_open(txn, NULL, 0, &dbi);
mdb_txn_abort(txn);

char k = 'k';
MDB_val key;
key.mv_size = sizeof(k);
key.mv_data = (void *)&k;

int v = 997;
MDB_val value;
value.mv_size = sizeof(v);
value.mv_data = (void *)&v;

mdb_txn_begin(env, NULL, 0, &txn);
mdb_put(txn, dbi, &key, &value, MDB_NOOVERWRITE);
mdb_txn_commit(txn);

MDB_txn *txn1, *txn2, *txn3;
MDB_val val;

// Wir öffnen 2 Transaktionen, von denen jede
// die Version der Datenbank mit einem Datensatz betrachtet.
mdb_txn_begin(env, NULL, 0, &txn1); // Lese- und Schreibmodus
mdb_txn_begin(env, NULL, MDB_RDONLY, &txn2); // Nur lesen

// Im Rahmen der ersten Transaktion entfernen wir den bestehenden Datensatz aus der Datenbank.
mdb_del(txn1, dbi, &key, NULL);
// Wir bestÀtigen die Löschung.
mdb_txn_commit(txn1);

// Wir öffnen die dritte Transaktion, die auf
// die aktuelle Version der Datenbank schaut, in der der Datensatz bereits nicht mehr existiert.
mdb_txn_begin(env, NULL, MDB_RDONLY, &txn3);
// Wir stellen sicher, dass der Datensatz mit dem gesuchten SchlĂŒssel bereits nicht mehr existiert.
assert(mdb_get(txn3, dbi, &key, &val) == MDB_NOTFOUND);
// Wir beenden die Transaktion.
mdb_txn_abort(txn3);

// Wir stellen sicher, dass wir im Rahmen der zweiten Transaktion, die zum Zeitpunkt
// des Vorhandenseins des Datensatzes geöffnet wurde, ihn nach wie vor ĂŒber den SchlĂŒssel finden können.
assert(mdb_get(txn2, dbi, &key, &val) == MDB_SUCCESS);
// Wir ĂŒberprĂŒfen, dass wir mit dem SchlĂŒssel keine beliebigen MĂŒll, sondern valide Daten erhalten haben.
assert(*(int *)val.mv_data == 997);
// Wir beenden die Transaktion, die zwar mit einer veralteten, aber konsistenten Datenbank arbeitet.
mdb_txn_abort(txn2);

Optional empfehle ich, den gleichen Trick mit SQLite auszuprobieren und zu sehen, was dabei herauskommt.

Die Multi-Versionierung bringt sehr angenehme Vorteile fĂŒr iOS-Entwickler mit sich. Mit dieser Eigenschaft lĂ€sst sich die Geschwindigkeit der Aktualisierung der Datenquelle fĂŒr Bildschirmformulare ganz einfach und unbeschwert regeln, basierend auf den Überlegungen zum Benutzererlebnis. Zum Beispiel nehmen wir eine Funktion der Mail.ru Cloud-Anwendung, wie das automatische Laden von Inhalten aus der systemeigenen Mediengalerie. Bei einer guten Verbindung kann der Client mehrere Fotos pro Sekunde auf den Server hochladen. Wenn wir nach jedem Upload aktualisieren, UICollectionView mit Medieninhalten in der Cloud des Benutzers, können wir an 60 fps und flĂŒssigem Scrollen wĂ€hrend dieses Prozesses vergessen. Um hĂ€ufige Bildschirmaktualisierungen zu verhindern, muss die Geschwindigkeit der DatenĂ€nderungen in der Grundlage UICollectionViewDataSource.

Wenn die Datenbank keine Multi-Versionierung unterstĂŒtzt und nur mit dem aktuellen, gĂŒltigen Zustand arbeiten kann, ist es notwendig, eine stabil zeitlich festgelegte Momentaufnahme der Daten zu erstellen, indem sie entweder in eine in-memory Datenstruktur oder in eine temporĂ€re Tabelle kopiert wird. Jeder dieser AnsĂ€tze ist sehr kostenintensiv. Bei einem in-memory Speicher entstehen sowohl Speicher- als auch Zeitkosten, die durch die Speicherung konstruierter Objekte und durch ĂŒbermĂ€ĂŸige ORM-Transformationen verursacht werden. Was die temporĂ€re Tabelle betrifft, so ist dies ein noch teureres VergnĂŒgen, das nur in nicht trivialen FĂ€llen sinnvoll ist.

Die Multi-Versionierung von LMDB löst das Problem der Aufrechterhaltung einer stabilen Datenquelle sehr elegant. Es reicht aus, einfach eine Transaktion zu öffnen, und voilĂ  – solange wir diese nicht abschließen, ist der Datensatz garantiert fixiert. Die Logik der Aktualisierungsgeschwindigkeit liegt jetzt vollstĂ€ndig in der Hand der PrĂ€sentationsschicht, ohne bedeutende RessourcenĂŒberschĂŒsse.

Cursors

Cursors bieten einen Mechanismus fĂŒr die geordnete Iteration ĂŒber SchlĂŒssel-Wert-Paare mittels Traversieren eines B-Baums. Ohne sie wĂ€re es unmöglich, Tabellen in der Datenbank effizient zu modellieren, auf die wir ĂŒbergehen werden.

4.2. Modellierung von Tabellen

Die Eigenschaft der Ordnung der SchlĂŒssel erlaubt es, auf Basis grundlegender Abstraktionen eine hochgradige Struktur wie eine Tabelle zu konstruieren. Lassen Sie uns diesen Prozess am Beispiel der Haupttabelle eines Cloud-Kunden betrachten, in der Informationen ĂŒber alle Dateien und Ordner des Benutzers zwischengespeichert sind.

Schema der Tabelle

Ein hĂ€ufiges Szenario, fĂŒr das die Struktur der Tabelle mit einem Ordnersystem entworfen werden sollte, ist die Abfrage aller Elemente, die sich innerhalb eines bestimmten Verzeichnisses befinden. Ein gutes Modell zur Organisation der Daten fĂŒr effiziente Anfragen dieser Art ist Adjacency List. Um es ĂŒber einem Key-Value-Speicher zu implementieren, mĂŒssen die SchlĂŒssel der Dateien und Ordner so sortiert werden, dass sie basierend auf der Zugehörigkeit zum ĂŒbergeordneten Verzeichnis gruppiert werden. DarĂŒber hinaus, um den Inhalt des Verzeichnisses im gewohnten Windows-Format anzuzeigen (zuerst Ordner, dann Dateien, wobei beide alphabetisch sortiert sind), mĂŒssen die entsprechenden zusĂ€tzlichen Felder in den SchlĂŒssel aufgenommen werden.

Das Bild unten zeigt, wie die Darstellung der SchlĂŒssel als Byte-Array aussehen kann, basierend auf der gestellten Aufgabe. ZunĂ€chst werden die Bytes mit der ID des ĂŒbergeordneten Verzeichnisses (rot) platziert, gefolgt von den Typen (grĂŒn) und schließlich mit den Namen (blau). Durch die Standardvergleichsfunktion von LMDB im lexikografischen Ordner werden sie in der erforderlichen Weise sortiert. Die sequenzielle Durchlauf der SchlĂŒssel mit demselben roten PrĂ€fix liefert uns die zugehörigen Werte in der Reihenfolge, in der sie im Benutzerinterface (rechts) ausgegeben werden sollen, ohne dass eine zusĂ€tzliche Nachbearbeitung erforderlich ist.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Serialisierung von SchlĂŒsseln und Werten

In der Welt gibt es viele Methoden zur Serialisierung von Objekten. Da wir keine anderen Anforderungen als Geschwindigkeit hatten, haben wir uns fĂŒr die schnellste mögliche Methode entschieden – den Speicher-Dump, der von einer Instanz der C-Struktur belegt wird. So kann der SchlĂŒssel eines Verzeichniselements durch die folgende Struktur modelliert werden. NodeKey.

typedef struct NodeKey {
    EntityId parentId;
    uint8_t type;
    uint8_t nameBuffer[256];
} NodeKey;

Zur Speicherung NodeKey muss im Objekt MDB_val der Zeiger auf die Daten an die Adresse des Anfangs der Struktur ausgerichtet werden, und ihre GrĂ¶ĂŸe wird durch die Funktion sizeof.

MDB_val serialize(NodeKey * const key) {
    return MDB_val {
        .mv_size = sizeof(NodeKey),
        .mv_data = (void *)key
    };
}

Im ersten Kapitel ĂŒber die Kriterien zur Auswahl einer Datenbank habe ich die Minimierung dynamischer Allokationen im Rahmen von CRUD-Operationen als wichtigen Faktor erwĂ€hnt. Der Code der Funktion serialize zeigt, wie man im Fall von LMDB diese bei der EinfĂŒgung neuer EintrĂ€ge in die Datenbank vollstĂ€ndig vermeiden kann. Das vom Server erhaltene Byte-Array wird zunĂ€chst in Stapelstrukturen umgewandelt und dann trivial in den Speicher geschrieben. Da auch innerhalb von LMDB keine dynamischen Allokationen stattfinden, kann mit einer fantastischen Situation fĂŒr iOS gearbeitet werden – fĂŒr die Datenverarbeitung vom Netz bis zur Festplatte wird nur Stapelspeicher verwendet!

Sortierung von SchlĂŒsseln durch einen binĂ€ren Vergleichsoperator

Die Reihenfolge der SchlĂŒssel wird durch eine spezielle Funktion festgelegt, die als Komparator bezeichnet wird. Da die Engine nichts ĂŒber die Semantik der enthaltenen Bytes weiß, bleibt dem Standardkomparator nichts anderes ĂŒbrig, als die SchlĂŒssel lexikografisch zu sortieren, wobei ein Byte-fĂŒr-Byte-Vergleich verwendet wird. Seine Verwendung zur Sortierung von Strukturen ist vergleichbar mit dem Rasieren mit einem Schnittholz. Dennoch finde ich diese Methode in einfachen FĂ€llen akzeptabel. Die Alternative wird gleich weiter unten beschrieben, und hier möchte ich ein paar Stolpersteine erwĂ€hnen, die auf diesem Weg liegen.

Das erste, woran man sich erinnern sollte, ist die ReprĂ€sentation primitiver Datentypen im Speicher. So werden auf allen Apple-GerĂ€ten ganzzahlige Variablen im Format Little Endian. Das bedeutet, dass das am wenigsten signifikante Byte links steht, und es ist nicht möglich, ganze Zahlen mithilfe ihres Byte-Vergleichs zu sortieren. Zum Beispiel fĂŒhrt der Versuch, dies mit einer Reihe von Zahlen von 0 bis 511 zu tun, zu folgendem Ergebnis.

// value (hex dump)
000 (0000)
256 (0001)
001 (0100)
257 (0101)
...
254 (fe00)
510 (fe01)
255 (ff00)
511 (ff01)

Um dieses Problem zu lösen, mĂŒssen ganze Zahlen im SchlĂŒssel in ein fĂŒr den Byte-Vergleich geeignete Format gespeichert werden. Die erforderliche Umwandlung ermöglichen Funktionen aus der Familie der hton* (insbesondere htons fĂŒr die zwei Byte-Zahlen aus dem Beispiel).

Das Format der String-Darstellung in der Programmierung ist, wie bekannt, ganzzahlig. HistorieWenn die Semantik von Strings sowie die verwendete Codierung zu ihrer Darstellung im Speicher impliziert, dass mehr als ein Byte auf ein Zeichen fallen kann, sollte man von der Verwendung des Standardkomparators besser gleich absehen.

Das zweite, was man im Kopf behalten sollte, sind die Ausrichtungsprinzipien des Compilers fĂŒr die Felder einer Struktur. Aufgrund dieser können im Speicher zwischen den Feldern Bytes mit MĂŒllwerten entstehen, was natĂŒrlich den Byte-Vergleich durcheinanderbringt. Um den MĂŒll zu beseitigen, muss man entweder die Felder in einer streng bestimmten Reihenfolge deklarieren, wobei die Ausrichtungsregeln im Hinterkopf behalten werden mĂŒssen, oder im Struktur-Declaration-Attribut packed.

Die Sortierung der SchlĂŒssel durch einen externen Komparator

Die Logik des SchlĂŒsselsvergleichs kann sich als zu komplex fĂŒr den binĂ€ren Komparator erweisen. Einer der vielen GrĂŒnde dafĂŒr ist das Vorhandensein technischer Felder innerhalb der Strukturen. Ich veranschauliche ihr Auftreten am Beispiel des uns bereits bekannten SchlĂŒssels fĂŒr ein Verzeichniselement.

typedef struct NodeKey {
    EntityId parentId;
    uint8_t type;
    uint8_t nameBuffer[256];
} NodeKey;

Trotz seiner Einfachheit benötigt er in den ĂŒberwiegenden meisten FĂ€llen zu viel Speicher. Der Puffer nimmt 256 Byte ein, obwohl die durchschnittlichen Namen von Dateien und Ordnern selten 20-30 Zeichen ĂŒberschreiten.

Eine der Standardmethoden zur Optimierung der DatensatzgrĂ¶ĂŸe besteht darin, ihn auf die tatsĂ€chliche GrĂ¶ĂŸe "zuzuschneiden". Dabei wird das Prinzip verfolgt, dass der Inhalt aller variablen LĂ€ngenfelder am Ende der Struktur im Puffer gespeichert und deren LĂ€ngen in separaten Variablen gehalten werden. In Übereinstimmung mit diesem Ansatz wird der SchlĂŒssel NodeKey wie folgt transformiert.

typedef struct NodeKey {​
    EntityId parentId;​
    uint8_t type;​
    uint8_t nameLength;​
    uint8_t nameBuffer[256];​
} NodeKey;

Bei der Serialisierung wird als DatengrĂ¶ĂŸe nicht sizeof die gesamte Struktur, sondern die GrĂ¶ĂŸe aller Felder fester LĂ€nge plus die GrĂ¶ĂŸe des tatsĂ€chlich verwendeten Teils des Puffers angegeben.

MDB_val serialize(NodeKey * const key) {
    return MDB_val {
        .mv_size = offsetof(NodeKey, nameBuffer) + key->nameLength,
        .mv_data = (void *)key
    };
}

Durch die durchgefĂŒhrte Umstrukturierung haben wir einen erheblichen Platzersparnis durch die SchlĂŒssel erzielt. Aufgrund des technischen Feldes nameLength, ist der standardmĂ€ĂŸige binĂ€re Komparator nicht mehr fĂŒr den Vergleich der SchlĂŒssel geeignet. Wenn wir ihn nicht durch unseren eigenen ersetzen, wird die LĂ€nge des Namens bei der Sortierung wichtiger sein als der Name selbst.

LMDB ermöglicht es, fĂŒr jede Datenbank eine eigene Vergleichsfunktion fĂŒr die SchlĂŒssel festzulegen. Dies geschieht durch die Funktion mdb_set_compare strictly before opening. Aus offensichtlichen GrĂŒnden darf die Funktion wĂ€hrend der gesamten Lebensdauer der Datenbank nicht verĂ€ndert werden. Der Komparator erhĂ€lt zwei SchlĂŒssel im binĂ€ren Format und gibt das Ergebnis des Vergleichs zurĂŒck: kleiner (-1), grĂ¶ĂŸer (1) oder gleich (0). Der Pseudocode fĂŒr NodeKey sieht so aus.

int compare(MDB_val * const a, MDB_val * const b) {​
    NodeKey * const aKey = (NodeKey * const)a->mv_data;​
    NodeKey * const bKey = (NodeKey * const)b->mv_data;​
    return  // ...
}​

Solange alle SchlĂŒssel in der Datenbank vom gleichen Typ sind, ist eine bedingungslose Umwandlung ihrer byteweisen Darstellung in den Typ der Anwendungsstruktur des SchlĂŒssels zulĂ€ssig. Hier gibt es jedoch einen Nuance, die weiter unten im Unterabschnitt "DatensĂ€tze lesen" behandelt wird.

Serialisierung von Werten

Der Umgang mit den SchlĂŒsseln der gespeicherten LMDB-DatensĂ€tze erfolgt Ă€ußerst intensiv. Ihr Vergleich untereinander findet im Rahmen jeder Anwendungsoperation statt, und die Geschwindigkeit des Comparators beeinflusst die gesamte Leistung der Lösung. In einer idealen Welt sollte der Standard-BinĂ€rkomparator fĂŒr den Vergleich der SchlĂŒssel ausreichend sein, aber wenn man schon seinen eigenen verwenden muss, sollte der Deserialisierungsprozess der SchlĂŒssel so schnell wie möglich sein.

Der Wertteil des Datensatzes (Wert) interessiert die Datenbank nicht besonders. Die Umwandlung aus der Byte-Darstellung in ein Objekt erfolgt nur dann, wenn dies bereits vom Anwendungscode benötigt wird, beispielsweise fĂŒr die Anzeige auf dem Bildschirm. Da dies relativ selten geschieht, sind die Anforderungen an die Geschwindigkeit dieses Verfahrens nicht so kritisch, und bei dessen Umsetzung sind wir in viel grĂ¶ĂŸerem Maße frei, uns nach der Benutzerfreundlichkeit zu orientieren. Zum Beispiel verwenden wir zur Serialisierung von Metadaten zu noch nicht geladenen Dateien NSKeyedArchiver.

NSData *data = serialize(object);​
MDB_val value = {​
    .mv_size = data.length,​
    .mv_data = (void *)data.bytes​
};

Es gibt jedoch FĂ€lle, in denen die Leistung dennoch von Bedeutung ist. Beispielsweise verwenden wir beim Speichern von Metainformationen ĂŒber die File-Struktur der Benutzer-Cloud immer noch denselben Speicher-Dump von Objekten. Das Besondere an der Aufgabe, ihre serialisierte Darstellung zu erstellen, ist die Tatsache, dass die Elemente des Verzeichnisses durch eine Hierarchie von Klassen modelliert werden.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

FĂŒr die Implementierung in C werden spezifische Felder der Nachkommen in separate Strukturen ausgegliedert, und ihre Verbindung zur Basisklasse erfolgt ĂŒber ein Feld vom Typ union. Der aktuelle Inhalt der Vereinigung wird ĂŒber das technische Attribut type festgelegt.

typedef struct NodeValue {​
    EntityId localId;​
    EntityType type;​
    union {​
        FileInfo file;​
        DirectoryInfo directory;​
    } info;​
    uint8_t nameLength;​
    uint8_t nameBuffer[256];​
} NodeValue;​

HinzufĂŒgen und Aktualisieren von DatensĂ€tzen

Die serialisierten SchlĂŒssel und Werte können im Speicher hinzugefĂŒgt werden. DafĂŒr wird die Funktion verwendet mdb_put.

// key Đž value ĐžĐŒĐ”ŃŽŃ‚ топ MDB_val​
mdb_put(..., &key, &value, MDB_NOOVERWRITE);

Im Konfigurationsschritt kann dem Speicher erlaubt oder verboten werden, mehrere DatensĂ€tze mit dem gleichen SchlĂŒssel zu speichern. Wenn Duplikate von SchlĂŒsseln verboten sind, kann beim EinfĂŒgen eines Datensatzes festgelegt werden, ob ein Update eines bereits bestehenden Datensatzes zulĂ€ssig ist oder nicht. Wenn das Überschreiben nur aufgrund eines Fehlers im Code passieren kann, kann man sich absichern, indem man ein Flag angibt. NOOVERWRITE.

Lesen von DatensÀtzen

Die Funktion ist fĂŒr das Lesen von DatensĂ€tzen in LMDB vorgesehen. mdb_get. Wenn das SchlĂŒssel-Wert-Paar zuvor mit dumpierten Strukturen dargestellt wurde, sieht dieser Prozess wie folgt aus.

NodeValue * const readNode(..., NodeKey * const key) {​
    MDB_val rawKey = serialize(key);​
    MDB_val rawValue;​
    mdb_get(..., &rawKey, &rawValue);​
    return (NodeValue * const)rawValue.mv_data;​
}

Der gezeigte Listing zeigt, wie die Serialisierung durch das Dumpen von Strukturen es ermöglicht, dynamische Allokationen nicht nur beim Schreiben, sondern auch beim Lesen von Daten zu vermeiden. Der von der Funktion erhaltene mdb_get Pointer zeigt genau auf die Adresse des virtuellen Speichers, wo die Datenbank die byteweise Darstellung des Objekts speichert. TatsÀchlich erhalten wir eine Art ORM, die nahezu kostenlos eine sehr hohe Lesegeschwindigkeit ermöglicht. Trotz der Eleganz des Ansatzes sollten einige damit verbundene Besonderheiten beachtet werden.

  1. FĂŒr readonly-Transaktionen bleibt der Pointer auf die Struktur-Wert-Daten bis zum Abschluss der Transaktion gĂŒltig. Wie bereits erwĂ€hnt, bleiben die B-Baum-Seiten, auf denen sich das Objekt befindet, dank des Prinzips copy-on-write unverĂ€ndert, solange mindestens eine Transaktion darauf verweist. Gleichzeitig können, sobald die letzte damit verbundene Transaktion abgeschlossen ist, die Seiten fĂŒr neue Daten wiederverwendet werden. Wenn es notwendig ist, dass Objekte die sie erzeugende Transaktion ĂŒberdauern, mĂŒssen sie jedoch kopiert werden.
  2. FĂŒr readwrite-Transaktionen ist der Pointer auf die erhaltene Struktur-Wert-Daten nur bis zur ersten modifizierenden Prozedur (Schreiben oder Löschen von Daten) gĂŒltig.
  3. Obwohl die Struktur NodeValue nicht vollstĂ€ndig, sondern gekĂŒrzt ist (siehe den Abschnitt „Sortierung der SchlĂŒssel durch externen Comparator“), kann ĂŒber den Pointer problemlos auf ihre Felder zugegriffen werden. Wichtig ist, ihn nicht dereferenzieren!
  4. Unter keinen UmstĂ€nden darf die Struktur ĂŒber den erhaltenen Zeiger modifiziert werden. Alle Änderungen mĂŒssen ausschließlich ĂŒber die Methode vorgenommen werden. mdb_putEs ist jedoch so, dass es trotz aller BemĂŒhungen nicht möglich sein wird, da der Speicherbereich, in dem sich diese Struktur befindet, im Modus readonly gemappt ist.
  5. Das Remapping der Datei in den Adressraum des Prozesses soll beispielsweise die maximale GrĂ¶ĂŸe des Speichers mit Hilfe der Funktion erhöhen. alle EntitĂ€ten (Cursors, Transaktionen, SchlĂŒssel und Werte), die zuvor von der Engine erhalten wurden, ungĂŒltig macht. Diese Wendung im Code zu berĂŒcksichtigen, fĂŒhrt zu einer erheblichen KomplexitĂ€t. Wenn Ihnen jedoch virtueller Speicher sehr wichtig ist, könnte dies ein Grund sein, einen Fork in Betracht zu ziehen, der weit voraus ist, dies invalidiert vollstĂ€ndig alle Transaktionen und die damit verbundenen EntitĂ€ten im Allgemeinen sowie die Zeiger auf die gelesenen Objekte im Einzelnen.

Schließlich gibt es eine weitere Eigenschaft, die so tĂŒckisch ist, dass eine ErklĂ€rung ihres Wesens nicht einfach in einen weiteren Punkt passt. In dem Kapitel ĂŒber B-BĂ€ume habe ich das Schema der Anordnung ihrer Seiten im Speicher vorgestellt. Daraus geht hervor, dass die Adresse des Anfangs des Puffers mit den serialisierten Daten absolut beliebig sein kann. Deshalb kann der Zeiger darauf, der in der Struktur erhalten wird, MDB_val und auf einen Zeiger auf die Struktur ĂŒbertragen wird, im Allgemeinen ungerade sein. Gleichzeitig erfordern die Architekturen einiger Chips (im Fall von iOS handelt es sich um armv7), dass die Adresse beliebiger Daten ein Vielfaches der MaschinenwortgrĂ¶ĂŸe oder anders gesagt der Bitbreite des Systems ist (bei armv7 sind das 32 Bit). Mit anderen Worten, eine Operation wie *(int *foo)0x800002 wird als Flucht betrachtet und fĂŒhrt zu einem Urteil ĂŒber EXC_ARM_DA_ALIGN.Es gibt zwei Möglichkeiten, ein so trĂŒbes Schicksal zu vermeiden.

Die erste besteht darin, die Daten vorher in eine eindeutig ausgerichtete Struktur zu kopieren. Zum Beispiel wird dies auf einem benutzerdefinierten Comparator wie folgt aussehen.

int compare(MDB_val * const a, MDB_val * const b) {
    NodeKey aKey, bKey;
    memcpy(&aKey, a->mv_data, a->mv_size);
    memcpy(&bKey, b->mv_data, b->mv_size);
    return // ...
}

Ein alternativer Weg besteht darin, den Compiler im Voraus zu informieren, dass Strukturen mit SchlĂŒssel und Wert möglicherweise nicht ausgerichtet sind, mit dem Attribut aligned(1).Auf ARM kann derselbe Effekt zu erreichen auch mit dem Attribut packed erzielt werden. Da es außerdem die Optimierung des von der Struktur beanspruchten Platzes fördert, scheint mir dieser Weg vorzuziehen, auch wenn er fĂŒhrt an zu höheren Kosten fĂŒr den Datenzugriff fĂŒhrt.

typedef struct __attribute__((packed)) NodeKey {
    uint8_t parentId;
    uint8_t type;
    uint8_t nameLength;
    uint8_t nameBuffer[256];
} NodeKey;

Range-Anfragen.

Zum Iterieren ĂŒber eine Gruppe von DatensĂ€tzen in LMDB gibt es die Cursor-Abstraktion. Wie man damit arbeitet, werden wir am Beispiel der uns bereits bekannten Tabelle mit den Metadaten der Benutzer-Cloud erlĂ€utern.

Im Rahmen der Anzeige einer Dateiliste im Verzeichnis mĂŒssen alle SchlĂŒssel gefunden werden, die mit den untergeordneten Dateien und Ordnern assoziiert sind. In den vorherigen Abschnitten haben wir die SchlĂŒssel sortiert, NodeKey so dass sie zunĂ€chst nach der ID des ĂŒbergeordneten Verzeichnisses geordnet sind. Technisch gesehen reduziert sich die Aufgabe, den Inhalt eines Ordners abzurufen, darauf, den Cursor auf die obere Grenze der SchlĂŒsselgruppe mit dem angegebenen PrĂ€fix zu setzen und dann bis zur unteren Grenze zu iterieren.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Die obere Grenze kann "direkt" durch sequentielles Suchen gefunden werden. Dazu wird der Cursor zu Beginn der gesamten SchlĂŒsselliste in der Datenbank gesetzt und dann inkrementiert, bis sich unter ihm der SchlĂŒssel mit der ID des ĂŒbergeordneten Verzeichnisses befindet. Dieser Ansatz hat zwei offensichtliche Nachteile:

  1. Die lineare KomplexitĂ€t der Suche, obwohl, wie bekannt, in BĂ€umen im Allgemeinen und insbesondere in B-BĂ€umen die Suche in logarithmischer Zeit durchgefĂŒhrt werden kann.
  2. Es werden unnötig alle Seiten aus der Datei in den Hauptspeicher geladen, die vor der gesuchten Seite liegen, was extrem teuer ist.

GlĂŒcklicherweise bietet die API von LMDB einen effizienten Weg, den Cursor initial zu positionieren. Dazu muss ein SchlĂŒssel gebildet werden, dessen Wert ohne Zweifel kleiner oder gleich dem SchlĂŒssel ist, der sich an der oberen Grenze des Intervalls befindet. Zum Beispiel können wir in Bezug auf die Liste oben einen solchen SchlĂŒssel erstellen, bei dem das Feld parentId gleich 2 ist und alle anderen mit Nullen ausgefĂŒllt sind. Dieser teilweise ausgefĂŒllte SchlĂŒssel wird in die Funktion mdb_cursor_get mit dem angegebenen Operation MDB_SET_RANGE.

NodeKey upperBoundSearchKey = {​
    .parentId = 2,​
    .type = 0,​
    .nameLength = 0​
};​
MDB_val value, key = serialize(upperBoundSearchKey);​
MDB_cursor *cursor;​
mdb_cursor_open(..., &cursor);​
mdb_cursor_get(cursor, &key, &value, MDB_SET_RANGE);

Wenn die obere Grenze der SchlĂŒsselgruppe gefunden wurde, iterieren wir weiter, bis entweder ein SchlĂŒssel mit einem anderen begegnet oder die SchlĂŒssel ganz aufgebraucht sind. parentIddo {​ rc = mdb_cursor_get(cursor, &key, &value, MDB_NEXT);​ // Verarbeitung...​ } while (MDB_NOTFOUND != rc && // Ende der Tabelle ĂŒberprĂŒfen​ IsTargetKey(key)); // Ende der SchlĂŒsselgruppe ĂŒberprĂŒfen​​

do {
    rc = mdb_cursor_get(cursor, &key, &value, MDB_NEXT);
    // Verarbeitung...
} while (MDB_NOTFOUND != rc && // Ende der Tabelle ĂŒberprĂŒfen
         IsTargetKey(key));    // Ende der SchlĂŒsselliste ĂŒberprĂŒfen

Was erfreulich ist, ist, dass wir im Rahmen der Iteration mit mdb_cursor_get nicht nur den SchlĂŒssel, sondern auch den Wert erhalten. Wenn zur ErfĂŒllung der Auswahlkriterien auch Felder aus dem Wertteil des Datensatzes ĂŒberprĂŒft werden mĂŒssen, sind diese ohne zusĂ€tzliche UmstĂ€nde durchaus zugĂ€nglich.

4.3. Modellierung von Beziehungen zwischen Tabellen

Bis zum aktuellen Zeitpunkt konnten wir alle Aspekte des Designs und der Arbeit mit einer einseitigen Datenbank betrachten. Man kann sagen, dass eine Tabelle eine Sammlung von sortierten DatensĂ€tzen ist, die aus Ă€hnlichen SchlĂŒssel-Wert-Paaren bestehen. Wenn man den SchlĂŒssel als Rechteck und den damit verbundenen Wert als Quader darstellt, erhĂ€lt man ein visuelles Schema der Datenbank.

​

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

In der realen Welt gelingt es jedoch selten, dies mit so wenig Aufwand zu tun. Oftmals erfordert es in einer Datenbank erstens mehrere Tabellen und zweitens, dass die Auswahl in einer anderen Reihenfolge als dem PrimĂ€rschlĂŒssel erfolgt. Fragen zur Erstellung und VerknĂŒpfung dieser Tabellen sind das Thema dieses letzten Abschnitts.

Indextabellen

In der Cloud-Anwendung gibt es einen Abschnitt „Galerie“. In diesem werden Mediendateien aus der gesamten Cloud angezeigt, sortiert nach Datum. FĂŒr eine optimale Umsetzung dieser Auswahl muss neben der Haupttabelle eine weitere mit neuen SchlĂŒsseln angelegt werden. Diese wird ein Feld fĂŒr das Erstellungsdatum der Datei enthalten, das als primĂ€res Sortierkriterium dient. Da die neuen SchlĂŒssel auf dieselben Daten verweisen wie die SchlĂŒssel in der Haupttabelle, werden sie als Indexpunkte bezeichnet. Auf dem Bild unten sind sie orange markiert.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Um innerhalb einer Datenbank die SchlĂŒssel verschiedener Tabellen voneinander zu trennen, wurde allen ein zusĂ€tzliches technisches Feld tableId hinzugefĂŒgt. Wenn wir dieses Feld zur PrioritĂ€t fĂŒr die Sortierung machen, erreichen wir eine Gruppierung der SchlĂŒssel zuerst nach Tabellen und dann innerhalb der Tabellen nach eigenen Regeln.

Der IndexschlĂŒssel verweist auf dieselben Daten wie der PrimĂ€rschlĂŒssel. Eine direkte Umsetzung dieser Eigenschaft durch die Assoziation einer Kopie des Wertteils des PrimĂ€rschlĂŒssels ist ungeeignet, und zwar aus mehreren GrĂŒnden:

  1. aus Sicht des Speicherplatzes, da Metadaten recht umfangreich sein können.
  2. aus Sicht der Leistung, da die Knoten beim Aktualisieren der Metadaten doppelt umgeschrieben werden mĂŒssen.
  3. Aus Sicht der Code-UnterstĂŒtzung, wenn wir vergessen, die Daten fĂŒr einen der SchlĂŒssel zu aktualisieren, werden wir einen schwer fassbaren Bug in der Dateninkonsistenz im Speicher erhalten.

Lass uns nun ansehen, wie wir diese MÀngel beseitigen können.

Organisation der Verbindungen zwischen Tabellen

FĂŒr die VerknĂŒpfung der Indextabelle mit der Haupttabelle eignet sich gut das Muster „SchlĂŒssel als Wert“. Wie der Name schon sagt, fungiert eine Kopie des Wertes des PrimĂ€rschlĂŒssels als Wertteil des Indexeintrags. Dieser Ansatz beseitigt alle oben genannten Nachteile, die mit der Speicherung einer Kopie des Wertteils des Hauptdatensatzes verbunden sind. Der einzige Nachteil ist, dass man zur Abfrage des Wertes ĂŒber den IndexschlĂŒssel zwei Datenbankabfragen anstelle von einer durchfĂŒhren muss. Schematisch sieht das resultierende Datenbankschema folgendermaßen aus.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Ein weiteres Muster zur Organisation der Verbindungen zwischen Tabellen ist â€žĂŒberschĂŒssiger SchlĂŒssel“. Das Wesen dieses Musters besteht darin, dem SchlĂŒssel zusĂ€tzliche Attribute hinzuzufĂŒgen, die nicht zur Sortierung, sondern zur Rekonstruktion des verbundenen SchlĂŒssels benötigt werden. In der Mail.ru Cloud-Anwendung gibt es reale Beispiele fĂŒr seine Nutzung, aber um eine tiefere Einarbeitung in den Kontext spezifischer iOS-Frameworks zu vermeiden, werde ich ein fiktives, aber verstĂ€ndlicheres Beispiel anfĂŒhren.

In den cloudbasierten mobilen Clients gibt es eine Seite, auf der alle Dateien und Ordner angezeigt werden, denen der Benutzer anderen Personen Zugriff gewĂ€hrt hat. Da es relativ wenige solcher Dateien gibt, jedoch viele spezifische Informationen zu ihrer Öffentlichkeit vorhanden sind (wer Zugriff hat, mit welchen Rechten usw.), wĂ€re es nicht rational, deren Wertteil im Hauptdatensatz zu belasten. Wenn man jedoch solche Dateien offline anzeigen möchte, muss man sie irgendwo speichern. Eine natĂŒrliche Lösung wĂ€re, dafĂŒr eine separate Tabelle einzufĂŒhren. Im untenstehenden Schema hat ihr SchlĂŒssel das PrĂ€fix „P“, und der Platzhalter „propname“ kann durch das konkretere „öffentliche Informationen“ ersetzt werden.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Alle einzigartigen Metadaten, fĂŒr deren Speicherung die neue Tabelle erstellt wurde, werden in den value-Teil des Datensatzes verschoben. Gleichzeitig möchte man die Daten zu Dateien und Ordnern, die bereits in der Haupttabelle gespeichert sind, nicht duplizieren. Stattdessen werden im SchlĂŒssel „P“ redundante Daten in Form der Felder „node ID“ und „timestamp“ hinzugefĂŒgt. Dank dieser kann man einen Indizes-SchlĂŒssel konstruieren, ĂŒber den man den PrimĂ€rschlĂŒssel abrufen kann, mit dem man schließlich die Metadaten des Knotens erhĂ€lt.

Schlussfolgerung

Die Ergebnisse der Implementierung von LMDB bewerten wir positiv. Nach der Implementierung ist die Anzahl der AnwendungsabstĂŒrze um 30 % zurĂŒckgegangen.

Der Glanz und die Armut der Key-Value-Datenbank LMDB in iOS-Anwendungen

Die Ergebnisse der geleisteten Arbeit fanden auch ĂŒber das iOS-Team hinaus Zustimmung. Derzeit hat einer der Hauptabschnitte „Dateien“ in der Android-App ebenfalls auf die Verwendung von LMDB umgestellt, und andere Teile sind in Arbeit. Die Programmiersprache C, in der das Key-Value-Speichersystem implementiert wurde, war eine gute UnterstĂŒtzung, um ursprĂŒnglich eine plattformĂŒbergreifende Anwendungsschicht in C++ darum zu erstellen. FĂŒr die nahtlose Verbindung der entstandenen C++-Bibliothek mit dem plattformabhĂ€ngigen Code in Objective-C und Kotlin wurde ein Code-Generator verwendet. Djinni von Dropbox, aber das ist schon eine ganz andere Geschichte.

Quelle: habr.com

ZuverlĂ€ssiges Hosting fĂŒr Websites mit DDoS-Schutz kaufen, VPS VDS Server đŸ”„ ZuverlĂ€ssiges Hosting fĂŒr Websites mit DDoS-Schutz kaufen, VPS VDS Server - ProHoster