- Zur Untersuchung, wie SQLite-Indizes tatsächlich auf Datenträger und im Arbeitsspeicher angeordnet sind, wurde die B-Tree-Struktur analysiert; Indexdaten wurden gedumpt und visualisiert
- Indizes bestehen aus Pages und Cells; eine Page enthält den Link zum rechten Kind sowie Cell-Daten, eine Cell enthält Indexdaten, rowId und den Link zum linken Kind
- Da Page-Größe, Anzahl der Einträge, B-Tree-Tiefe und Anzahl genutzter Pages aus
sqlite3_analyzernicht ausreichten, wurden dem SQLite-Quellcode Debug-Funktionen hinzugefügt - Die Experimente vergleichen Datensatzanzahl, ASC/DESC, ausdrucksbasierte Indizes, UNIQUE mit NULL, Partial Index, mehrere Spalten sowie Kombinationen aus Text, REAL und Integer+Text
- Bei 1.000.000 Datensätzen ergaben sich 3.342 Pages, wenn der Index vor dem Einfügen erstellt wurde, und 2.930 Pages, wenn er nach dem Einfügen erstellt wurde; nach VACUUM oder REINDEX sank die Zahl ebenfalls auf 2.930 Pages
Warum ich SQLite-Indizes selbst untersucht habe
- Dies ist ein Experiment, das über die Grundstruktur von Indizes hinaus die tatsächlichen Datenstrukturen, Algorithmen und die Speicherung auf Datenträger untersucht
- Ziel ist zu sehen, wie ein DBMS Indizes auf Datenträger und im Arbeitsspeicher speichert und wie es beim Suchen darauf zugreift
- SQLite wurde aus folgenden Gründen als Untersuchungsobjekt gewählt
- Es ist ein in Browsern, mobilen Apps und Betriebssystemen weit verbreitetes DBMS
- Es lässt sich ohne separaten Server nur mit einer Client-Anwendung einfach debuggen
- Die Codebasis ist kleiner als bei MySQL oder PostgreSQL, verwendet für Indizes aber ähnliche Datenstrukturen
- Es ist Open Source
Ein B-Tree aus Pages und Cells
- Laut SQLite-Dokumentation werden Indizes als B-Tree-Struktur gespeichert
- Die Einheit, die in SQLite einem Node entspricht, ist die Page
- Eine Page speichert Cell-Daten
- Eine Page besitzt einen Link zur rechten Kind-Page
- Eine Cell enthält Indexdaten, rowId und den Link zur linken Kind-Page
- Jede Zeile einer SQLite-Tabelle hat standardmäßig eine eindeutige rowId, die wie ein Primärschlüssel funktioniert, wenn kein expliziter Primärschlüssel vorhanden ist
- Jede Page hat eine feste Größe; der Größenbereich liegt bei 512 bis 65.536 Bytes
- Page- und Cell-Header verwenden 4 Bytes zum Speichern von Kind-Links
- Um die Nummer der Kind-Page zu ermitteln, muss der Header separat mit der Funktion
get4byte(...)gelesen werden
- Um die Nummer der Kind-Page zu ermitteln, muss der Header separat mit der Funktion
- Beispiele für interne SQLite-Strukturen:
MemPage: enthält unter anderem die Page-Nummerpgno, die Anzahl der CellsnCell, den Cell-IndexbereichaCellIdxund den Zeiger auf das Datenträgerabbild der Page-DatenaDataCellInfo: enthält unter anderempPayload, das auf die Startposition des Payloads zeigt
Grenzen von sqlite3_analyzer und Debug-Funktionen
- Mit sqlite3_analyzer lassen sich allgemeine Informationen zu Indizes anzeigen
- Beispielausgaben enthalten etwa die Page-Größe
4096, die Anzahl der Einträge1000, die B-Tree-Tiefe2und die Anzahl der genutzten Pages4
- Beispielausgaben enthalten etwa die Page-Größe
- Dieses Tool liefert jedoch nur Übersichtsinformationen und reicht nicht aus, um die internen Cells und Payloads eines Index direkt zu untersuchen
- Nach mehreren Wochen Experimentieren wurde eine Funktion zur Indexanalyse geschrieben
- Code:
sqlite.patch sqlite3DebugGetMemoryPayload(Mem *mem)sqlite3DebugGetCellPayloadAndRowId(BtCursor *pCur, MemPage * pPage, int cellIndex)sqlite3DebugBtreeIndexDump(BtCursor *pCur, int pageNumber)
- Code:
- Diese Funktion liest den Inhalt des ausgewählten Index und gibt ihn auf STDOUT aus
- Der Ablauf ist
SQL query -> selected index -> stdout - Die Ausgabe enthält Page-Nummer, Nummer der rechten Kind-Page, Cell-Nummer, Nummer der linken Kind-Page, Payload und rowId
- Der Ablauf ist
- Die Experimentierumgebung kann mit Docker ausgeführt werden
docker run -it --rm -v "$PWD":/app/data --platform linux/x86_64 mrsuh/sqlite-index bashsh bin/dump-index.sh database.sqlite "SELECT * FROM table INDEXED BY index WHERE column=1" dump.txt
Wandel der Visualisierungsmethode
- Anfangs wurde d3-org-tree verwendet, um die Indexstruktur zu visualisieren
- Als der Baum tiefer wurde und die Zahl der Pages pro Ebene wuchs, ließ sich der Abstand zwischen Pages nur schwer anpassen; die Bilder wurden zu groß und schwer lesbar
- Es wurde versucht, dies mit JavaScript und CSS anzupassen, was jedoch nicht gut passte; zeitweise wurde daher auf eine textbasierte Strukturdarstellung umgestellt
- Die Textausgabe zeigt die Gesamtzahl der Pages, die Gesamtzahl der Cells, die Anzahl von Pages und Cells pro Ebene sowie Page-Informationen, Cell-Informationen und Payloads
- Später wurde daraus eine Bildausgabe mit feinerer Kontrolle über Design und Abstände mithilfe der PHP-Erweiterung ImageMagick
- Das finale Bild enthält folgende Informationen
- Oben links werden die allgemeinen Informationen des Index angezeigt
- Für jede Ebene werden die Gesamtzahl der Pages und Cells angezeigt
- Für jede Page werden Page-Nummer, rechter Kind-Link sowie Informationen zur ersten und letzten Cell angezeigt
- Pro Ebene werden nur einige Pages angezeigt, einschließlich der ersten und letzten Page
- Die Root-Page befindet sich auf der ersten Ebene
- Der Befehl zum Erzeugen eines Bildes aus dem Dump lautet:
php bin/console app:render-index --dumpIndexPath=dump.txt --outputImagePath=image.webp
Wie die Anzahl der Datensätze die Indexform verändert
- Auf einer Tabelle mit
column1 INT NOT NULLwurde ein Indexcolumn1 ASCerstellt und die Struktur bei unterschiedlicher Datensatzanzahl untersucht - Ein Index mit 1 Datensatz besteht aus 1 Ebene, 1 Page und 1 Cell
- Auch ein Index mit 1.000 Datensätzen wurde auf dieselbe Weise erstellt und visualisiert
- Ein Index mit 1.000.000 Datensätzen hat folgende Struktur
- 3 Ebenen
- 2.930 Pages
- 1.000.000 Cells
- Da die Daten der Reihe nach hinzugefügt wurden, gilt bei
rowId = 1:column1 = 1
Sortierrichtung und Ausdrucksindizes
- Für dieselben Daten wurden
idx_ascundidx_descerstellt, um ASC/DESC-Indizes zu vergleichen - Der ASC-Index entspricht dem vorherigen Index, da die Standardsortierung ASC ist
- Der Eintrag mit
rowId=1,000,000,column1=1,000,000,payload=1,000,000befindet sich in der letzten Cell der ganz rechten Page - Der Eintrag mit
rowId=1,column1=1,payload=1befindet sich in der ersten Cell der ganz linken Page
- Der Eintrag mit
- Der DESC-Index ist umgekehrt angeordnet
- Der Eintrag mit
rowId=1,column1=1,payload=1befindet sich in der letzten Cell der ganz rechten Page - Der Eintrag mit
rowId=1,000,000,column1=1,000,000,payload=1,000,000befindet sich in der ersten Cell der ganz linken Page
- Der Eintrag mit
- Ausdrucksbasierte Indizes speichern die vom Ausdruck erzeugte Zeichenkette
- Im Beispiel wird aus JSON-Text
$.timestampextrahiert und anschließend mitstrftime('%Y-%m-%d %H:%M:%S', ..., 'unixepoch')umgewandelt, um einen ASC-Index zu erstellen - Auch komplexere Ausdrücke können verwendet werden; im Index wird nur ihr Ergebnis gespeichert
- Im Beispiel wird aus JSON-Text
NULL, Partial Index und mehrere Spalten
- SQLite unterstützt UNIQUE-Indizes mit NULL-Werten
- Im Beispiel werden die Werte
1, mehrereNULLund1000000eingefügt undCREATE UNIQUE INDEX idx ON table_test (column1 ASC)ausgeführt - Der visualisierte Index wirkt so, als speichere er nur Werte ungleich NULL
- Im Beispiel werden die Werte
- Ein Partial Index mit der Bedingung
WHERE column1 IS NOT NULLfiltert NULL-Werte heraus- Dieser Index enthält nur eine Page
- Er führt zu schnelleren Suchen als das vorherige UNIQUE-Beispiel
- Mehrspaltige Indizes speichern die Daten aller Felder nacheinander in der Cell
- Das Beispiel ist ein Index auf
(column1 ASC, column2 ASC) - In der Visualisierung sind die Felder durch einen Doppelpunkt
:getrennt
- Das Beispiel ist ein Index auf
Zeitpunkt der Indexerstellung und Effekt der Neuaufbau-Operationen
- Verglichen wurde der Fall, dass der Index vor dem Einfügen der Daten erstellt wird, mit dem Fall, dass er erst nach dem Einfügen aller Daten erstellt wird
- Wenn neue Daten hinzugefügt werden, muss der Baum sich selbst neu ausbalancieren
- Einen Index für bereits vorhandene Daten auf einmal zu erzeugen, kann deutlich effizienter sein
- Beide Indizes sehen ähnlich aus, doch der zweite Index mit weniger Pages kann schneller sein
- Das Vergleichsergebnis für 1.000.000 Cells lautet:
| Kategorie | Total Pages | Total Cells |
|---|---|---|
| Vor dem Einfügen erstellt | 3342 | 1000000 |
| Nach dem Einfügen erstellt | 2930 | 1000000 |
- Eine ähnliche Optimierung lässt sich mit VACUUM oder REINDEX durchführen
VACUUMerstellt Indizes und Tabellen zusammen mit den Daten neuREINDEX idxerstellt nur den Index neu
- Beide Befehle reduzierten im Beispiel die Page-Zahl von 3342 auf 2930
Indexspeicherung nach Datentyp
- Textdaten: Kurze Zeichenketten werden direkt in Index-Cells gespeichert, lange Texte müssen jedoch separat gespeichert werden
- Im Beispiel werden Werte von
text-1bistext-1000000eingefügt und ein Indexcolumn1 ASCerstellt - Man kann sehen, dass die tatsächlichen Zeichenketten direkt im Index gespeichert sind
- Im Beispiel werden Werte von
- Auch REAL-Daten wurden im Index gespeichert und visualisiert
- Im Beispiel werden die Werte
1.14,2.14, ...,1000000.14verwendet
- Im Beispiel werden die Werte
- Außerdem wurde ein zusammengesetzter Index aus Integer und Text untersucht
- Im Beispiel wird auf einer Tabelle
(column1 INT, column2 TEXT)ein Index(column1 ASC, column2 ASC)erstellt - Integer und Zeichenkette werden wie bei der Indexerstellung angegeben gemeinsam in derselben Cell gespeichert
- Im Beispiel wird auf einer Tabelle
Reproduktion und nächste Schritte
- Das Experiment zeigt, wie SQLite-Indizes strukturiert sind, wie Datensatzdaten im Speicher abgelegt werden und wie der B-Tree Daten organisiert und darauf zugreift
- Die Visualisierung wird verwendet, um unterschiedliche Indizes zu analysieren und zu vergleichen
- Alle Beispiele lassen sich mit folgenden Befehlen reproduzieren
docker run -it --rm -v "$PWD":/app/data --platform linux/x86_64 mrsuh/sqlite-index bashsh bin/test-index.sh
- Code und Beispiele befinden sich in
mrsuh/sqlite-index - Als Nächstes folgen die Visualisierung indexbasierter Suche und die Untersuchung einiger SQL-Abfragen
1 Kommentare
Meinungen auf Hacker News
Es hieß, jede Zeile in einer SQLite-Tabelle habe standardmäßig eine eindeutige rowId und verhalte sich wie ein Primärschlüssel, wenn kein expliziter Primärschlüssel vorhanden ist; tatsächlich verwendet SQLite aber auch dann rowid, wenn es einen Primärschlüssel gibt
Es wäre interessant, den Primärschlüssel-Index einer
WITHOUT ROWID-Tabelle zu visualisieren. Solche Indizes sind besonders spannendAuch wenn zwei Indizes ähnlich aussehen, heißt das nicht sofort, dass der zweite Index schneller ist, nur weil er weniger Seiten hat. Entscheidend ist die Höhe des Baums; danach kommt die Frage, ob man nach dem Finden eines Werts im Index die übrigen Daten aus einer separaten Tabelle (rowid) lesen muss oder ob die Daten wie bei
WITHOUT ROWIDdirekt vorhanden sind. Besonders bei Bereichsabfragen wiewhere 50 <= col <= 100ist der Unterschied großINTEGER PRIMARY KEYanlegt, verwendet SQLite stattdessen diesen [1][1]: https://sqlite.org/rowidtable.html
SQLite ist in fast jeder Hinsicht ziemlich eigen, und das gilt meiner Ansicht nach besonders für die Query-Verarbeitung
SQLite neigt dazu, Einfachheit gegenüber Performance zu bevorzugen, weshalb vieles anders implementiert ist als in anderen Datenbanken, mit denen ich gearbeitet habe. SQLite konkurriert weniger mit anderen Datenbanken als mit persistent gespeicherten JSON/XML-Dateien. Deshalb lernt man aus der SQLite-Implementierung nicht unbedingt viel darüber, wie echte Datenbanken dieselbe Aufgabe lösen
Das heißt zwar, dass die Anforderungen ziemlich anders sind, aber die Nutzung beschränkt sich nicht nur darauf, JSON/XML-Dateien zu ersetzen
Die Website ist so gut lesbar, dass man sie tatsächlich lesen möchte
„indexes“ ist sowohl die 3. Person Singular Präsens des Verbs „to index“ als auch die Pluralform des Substantivs „index“. „indices“ dagegen ist die traditionelle Pluralform und wird besonders in mathematischen und wissenschaftlichen Kontexten häufig verwendet
Im allgemeinen Englisch ist „indexes“ üblich, aber in technischen Bereichen wird aus sprachlicher Genauigkeit manchmal indices bevorzugt. In diesem Kontext kann „indices“ die Klarheit erhöhen, weil es den Indexierungsvorgang vom Plural von Index unterscheidet
In Finnland habe ich Fälle gesehen, in denen „time series“ als Plural und „time serie“ als Singular verwendet wurde
Alle großen relationalen Datenbankmanagementsysteme verwenden den Begriff indexes
Es wäre auch interessant zu sehen, wie PostgreSQL dieselbe Aufgabe erledigt. Aus dem Vergleich ließe sich vermutlich viel lernen
Um mit weniger Aufwand verschiedene Layouts zu sehen, könnte man auch TGF für yEd ausgeben lassen