SQLite

17. August 2024

SQLite ist eine kleine (leichtgewichtige) SQL-Datenbank-Engine, die ohne einen Datenbankserver auskommt. Die Datenbank ist in C geschrieben und bekannt für ihre Einfachheit und Portabilität.

Die komplette Datenbank (Schema, Daten) ist in einer einzigen Datei gespeichert. Diese Datei kann bis 281 TB groß sein. Sprich hier wird eher die Hardware zum Problem werden, bevor SQLite an seine Grenzen stößt 😜.

Anders als bei Client-/Serverdatenbanken wie MySql, MsSql, PostgreSQL, … benötigt man für den Einsatz keine Konfiguration (Zero Configuration), um die Datenbank zu verwenden.

Dennoch werden alle wichtigen Elemente einer Datenbank unterstützt:

  • ACID-Transaktionen (Atomicity, Consistency, Isolation, Durability)
  • Index, Foreign Keys (mit Einschränkungen), Trigger
  • Erweiterbarkeit

SQLite ist plattformunabhängig und läuft sowohl auf Windows, Linux, macOS als auch auf allen möglichen anderen Plattformen wie iOS und Android. Auch viele IoT-Geräte setzen auf SQLite, da es sich dabei um eine super leichtgewichtige Datenbank handelt. Es wird gemunkelt, dass auf der gesamten Welt ca. eine Trillion (1.000.000.000.000) SQLite-Datenbanken im Einsatz sind, was schon recht beachtlich ist, wenn man betrachtet, dass auf der Welt nur ca. 8 Milliarden Menschen leben.

SQLite unterstützt viele Lesezugriffe, aber immer nur einen Schreibzugriff. Dies klingt auf den ersten Blick nicht optimal, sollte aber im Normalfall kein großes Problem darstellen, wenn Transaktionen für das Schreiben kurz gehalten werden und die „busy_timeout“ (siehe bei Pragmas) passend gesetzt ist.

Aber schauen wir uns ein paar Besonderheiten an.

Schema

SQLite kennt nur 5 Datentypen:

  • Integer: Ganzzahlen
  • Real: Fließkommazahlen
  • Text
  • Blob: Binärdaten
  • NULL: kein Wert

Bei der Anlage einer Tabelle bzw. Spalten können zwar alle möglichen Typen angegeben werden, aber diese haben reinen Informationscharakter.

Die Datentypen einer Spalte sind nicht strikt, d. h., ich kann egal wie immer alles in einer Spalte speichern. Beispielsweise kann in einer Zeile in der Spalte A eine Nummer gespeichert sein, in der Nächsten ein Text.

Somit muss bei der Erstellung auch nicht unbedingt angegeben werden, um was für einen Datentyp es handelt. Die folgenden zwei Statements führen somit zum gleichen Ergebnis:

create table person (
  firstname, 
  lastname, 
  age)

create table person (
  firstname nvarchar(100),
  lastname nvarchar(100),
  age int)

Seit der Version 3.37.0 (Ende 2021) gibt es eine neue Option namens „strict“, mit der Tabellen erstellt werden können. Mit dieser Option wird sichergestellt, dass in jeder Zelle nur Daten gespeichert werden, die der Definition der Datenbank entsprechen.

Foreign Keys müssen in diesem Kontext noch explizit erwähnt werden. Diese werden unterstützt, aber sie sind standardmäßig nicht aktiviert. Diese müssen mit Hilfe eines Pragma-Statements aktiviert werden (siehe unten). Außerdem ist es zum aktuellen Zeitpunkt (3.46.1) nicht möglich, Foreign Keys auf bestehenden Tabellen zu ändern. Das ist leider ein Punkt, der nicht ganz optimal ist.

Pragmas

Pragmas sind spezielle Anweisungen, um die Konfiguration der Datenbank anzupassen und das Verhalten (Speicheroptionen, Integrität, Performance, …) zu steuern.

Die meisten Pragmas müssen beim Aufbau der Verbindung angegeben werden, da diese nicht persistiert werden.

  • PRAGMA cache_size
    Legt die Größe des Page-Cache in Seiten fest, um den Zugriff auf die Festplatte zu minimieren. Der Standardwert sind 2000 Seiten.
  • PRAGMA case_sensitive_like
    Bestimmt, ob der LIKE-Operator zwischen Groß- und Kleinschreibung unterscheidet.
  • PRAGMA foreign_keys
    Aktiviert oder deaktiviert die Unterstützung für Fremdschlüssel.
  • PRAGMA journal_mode
    Legt den Journal-Modus (z. B. DELETE, TRUNCATE, WAL) fest, der für Transaktionen verwendet wird und aussagt, wie Transaktionen protokolliert werden sollen. Der Standardwert ist DELETE.
    Die Empfehlung des SQLite-Teams ist der WAL, da sich dieser Modus sehr positiv auf Schreibvorgänge auswirkt und Locking-Themen minimiert. Mehr Details dazu weiter unten.
    ⇒ bleibt beim Ändern persistiert.
  • PRAGMA synchronous
    Bestimmt die Synchronisationsstufe für Transaktionen (z. B. FULL, NORMAL, OFF). Bei FULL werden Änderungen bei jeder Transaktion auf die Festplatte geschrieben, bei NORMAL nur dann, wenn es die Integrität der Datenbank erfordert, und bei OFF werden die Daten zu einem späteren Zeitpunkt auf der Festplatte persistiert. Dies hat einen direkten Einfluss auf die Performance beim Ändern von Daten – wobei FULL am Langsamsten, dafür aber am Sichersten ist und OFF am Schnellsten, dafür steigt das Risiko für Datenverlust.
  • PRAGMA temp_store
    Bestimmt, wo temporäre Tabellen gespeichert werden (z. B. in RAM oder auf der Festplatte).
  • PRAGMA encoding
    Gibt die Textkodierung der Datenbank an (z. B. UTF-8, UTF-16).
    ⇒ bleibt beim Ändern persistiert
  • PRAGMA integrity_check
    Überprüft die Integrität der Datenbank und gibt das Ergebnis der Prüfung zurück. Diese Operation kann zeitaufwendig sein.
  • PRAGMA page_size
    Legt die Seitenhöhe der Datenbank fest. Diese ist standardmäßig 4 KB.
    ⇒ bleibt beim Ändern persistiert, kann allerdings nur beim Erstellen der Datenbank festgelegt werden
  • PRAGMA locking_mode
    Bestimmt den Sperrmodus (NORMAL oder EXCLUSIVE) der Datenbank. Der Standardwert ist NORMAL und bedeutet, dass mehrere Lesezugriffe möglich sind, aber immer nur ein Schreibzugriff. Bei EXCLUSIVE wird, sobald eine Transaktion gestartet wird, die Datenbank gesperrt und nur noch der Halter der Transaktion kann lesen und schreiben.
  • PRAGMA busy_timeout
    Hiermit kann die Zeit in Millisekunden angegeben werden, die SQLite bei einer Sperre wartet, bevor ein Fehler ausgegeben wird (SQLITE_BUSY). Standardmäßig ist dieser Wert 0.
  • PRAGMA optimize
    Führt ein ANALYZE für Tabellen aus, die ggf. davon profitieren.
  • PRAGMA analysis_limit
    Definiert die Anzahl der Zeilen, die für die Analyse von Indizes und Statistiken berücksichtigt werden sollen.

Hier ein Beispiel für die Pragmas, die direkt nach dem Aufbau einer Verbindung gesetzt werden können/sollen:

PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA foreign_keys = on;

Und folgende Pragmas können/sollen beim Beenden der Verbindung gemacht werden:

PRAGMA analysis_limit=1000;
PRAGMA optimize;

Statistiken

Statistiken werden benötigt, damit der Query-Planer passende Entscheidungen treffen kann, ob für eine Abfrage ein Index besser geeignet ist oder bspw. ein Full-Table-Scan.

Es wird allerdings empfohlen die Statistiken auch regelmäßig manuell zu aktualisieren (wie zuvor erwähnt bspw. beim Beenden einer Verbindung oder täglich) oder nach der Erstellung eines Index.

Dafür gibt es mehrere Möglichkeiten:

PRAGMA optimize;
ANALYZE;
ANALYZE table_name;
ANALYZE index_name;

Wie bei den Pragmas geschrieben, ist es ggf. sinnvoll, eine „analysis_limit“ zu setzen, um die Performance für die Analyse zu optimieren. Die „PRAGMA optimize“-Variante wird grundsätzlich empfohlen, da SQLite prüft, bei welchen Tabellen ein ANALYZE sinnvoll ist.

Vaccum

Werden Daten in SQLite gelöscht, so wird dieser beim Speichern nicht freigegeben, sondern die Pages als leer markiert und für spätere Operationen wiederverwendet.

Soll aufgrund einer großen Löschaktion der Speicher freigegeben werden, so kann die VACCUM-Operation ausgeführt werden:

VACCUM

Hiermit wird die Datenbank neu erstellt, was eine aufwendige Operation sein kann und daher mit Bedacht eingesetzt werden sollte.

WAL-Mode

Der WAL-Mode (Write-Ahead Logging) in SQLite ist eine Methode zur Verwaltung von Datenbanktransaktionen, die einige Vorteile gegenüber dem traditionellen Rollback-Modus (DELETE) bietet.

Im WAL-Mode werden alle Schreibvorgänge zuerst in eine separate WAL-Datei geschrieben, bevor sie in die Hauptdatenbankdatei übernommen werden. Dies ermöglicht eine schnellere Ausführung von Schreiboperationen, da die Hauptdatenbank nicht sofort aktualisiert werden muss.

Während im WAL-Mode geschrieben wird, können auch Lesevorgänge gleichzeitig stattfinden. Dies führt zu einer besseren Parallelität, da Lesevorgänge nicht durch Schreibvorgänge blockiert werden.

Explain und Explain Query Plan

Mit „EXPLAIN“ und „EXPLAIN QUERY PLAN“ werden Informationen über die Ausführung von SQL-Abfragen zurückgegeben. Dies kann nützlich bei der Suche nach Performanceproblemen von einzelnen SQL-Abfragen sein.

Mit „EXPLAIN“ werden Low-Level Informationen zurückgeliefert, während „EXPLAIN QUERY PLAN“ diese in einer für den Benutzer besser lesbaren Form zurückgibt.

Folgende Informationen werden u. a. von „EXPLAIN QUERY PLAN“ zurückgeliefert:

  • SCAN t1
    ⇒ dies ist ein Full-Table-Scan der Tabelle t1 – sprich es werden alle Zeilen gelesen
  • SEARCH t1 USING INDEX i1
    ⇒ Tabelle t1 wird unter Verwendung des Index i1 gelesen
  • SEARCH t1 USING COVERING INDEX i1
    ⇒ in diesem Fall stehen alle benötigten Informationen im Index i1 und die Tabelle t1 muss selbst gar nicht gelesen werden

SCAN bedeutet, dass immer alle Zeilen der Tabelle gelesen werden. SEARCH bedeutet, dass nur ein Teilbereich der Zeilen gelesen werden.

Bei der Analyse eine SQL-Statements prüft SQLite, ob es sinnvoll ist, temporär einen Index zu erstellen

Fulltext-Search

SQLite inkludiert die Extension FTS5 standardmäßig. Diese wird für Fulltext-Search verwendet und ermöglicht das performante Suchen von Text in großen Datenmengen sowie Funktionalität wie Ranking, Phrase-Matching, Highlighting, …

Zur Verwendung von FTS5 muss eine „virtual table“ erstellt werden, in die die zu suchenden Daten eingefügt werden:

create virtual table my_table using fts5(content)

In diesem Beispiel wird eine Tabelle „my_table“ mit einer Spalte „content“ erstellt. Zusätzlich kann der Tokenizer angegeben werden:

create virtual table my_table using fts5(content, tokenize='porter')

Anschließend werden die Daten eingefügt:

insert into my_table (rowid, content) select id, content from data;

Nun können Daten gesucht werden 🙂

select rowid from my_table where content match 'text1'
select rowid from my_table where content match 'text1*'
select rowid from my_table where content match 'text1 OR text2'
select rowid from my_table where content match 'text1 AND text2*'

Wichtig: bei Verwendung von AND/OR müssen diese Werte in Großbuchstaben geschrieben werden. In der SQLite-Dokumentation sind noch viele weitere Beispiele für die Verwendung aufgelistet.

Die Suchtabelle wird nicht automatisch befüllt; dies muss manuell gemacht werden bspw. mit Hilfe von Trigger:

CREATE TRIGGER after_insert_data
AFTER INSERT ON data
BEGIN
    INSERT INTO my_table (rowid, content) VALUES (new.id, new.content);
END;

CREATE TRIGGER after_update_data
AFTER UPDATE ON data
BEGIN
    UPDATE my_table SET content = new.content WHERE rowid = old.id;
END;

CREATE TRIGGER after_delete_data
AFTER DELETE ON data
BEGIN
    DELETE FROM my_table WHERE rowid = old.id;
END

Microsoft.Data.Sqlite

Alle folgenden Punkte bzgl. Erweiterbarkeit sind nicht auf „Microsoft.Data.Sqlite“ beschränkt, sondern können auch mit anderen Programmiersprachen gemacht werden. Da mein Anwendungsfall sich aber primär auf .NET konzentriert, habe ich diese Bibliothek verwendet.

SQLite ermöglicht es, eigene Funktionen zu implementieren und zur Verfügung zu stellen.

Scalar Function

Hiermit können Scalar-Funktionen einfach erweitert werden. Das folgende Beispiel erzeugt eine Funktion „maxlength“ und gibt den Text in der gekürzten Variante + „…“ zurück.

await using var conn = new SqliteConnection("Data Source=data.sqlite");
await conn.OpenAsync();

conn.CreateFunction(
"maxlength",
(string text, int maxLength) =>
{
if (string.IsNullOrEmpty(text))
return text;

if (text.Length < maxLength)
return text;

return $"{text.Substring(0, maxLength)}...";
});

await using var cmd = conn.CreateCommand();
cmd.CommandText =
"""
select maxlength(bezeichnung, 10)
from data
""";

Operators

Hiermit können die Operatoren „glob“, „like“, „match“ und „regexp“ überschrieben werden.

Die Verwendung ist ähnlich dem Scalar-Beispiel. Genaue Informationen zu den Parametern sind auf Microsoft Learn ersichtlich.

Aggregate Functions

Hiermit können neue Aggregat-Funktionen erstellt werden. Da dies vermutlich nicht so häufig benötigt wird, habe ich mir das nicht im Detail angesehen und verweise auch hier auf Microsoft Learn.

Backup

Es gibt mehrere Varianten, ein Backup einer SQLite-Datenbank zu erstellen.

.backup

Die einfachste Variante ist der „backup“ Befehl:

sqlite3 my_db.sqlite
.backup my_backup.sq3

Hierbei wird einfach eine Kopie der Datenbank erstellt. Dies ist an und für sich eine recht schnelle Angelegenheit, dauert aber bei größeren Datenbanken dennoch einige Sekunden bzw. ggf. Minuten und ist mitunter nicht immer optimal, da in dieser Zeit keine Schreibzugriffe möglich sind.

Litestream

Daher gibt es andere Varianten wie bspw. Litestream. Damit kann die Datenbank und die Logs in einen lokalen Pfad, ein Cloud-Service (S3, Azure Blob Storage, …) gesichert werden. Nachfolgend ein Beispiel für eine lokale Sicherung und Restore. Wichtig: Litestream funktioniert nur im WAL-Mode!

Als erstes erstellen wir eine litestream.yml.

dbs:
  - path: ./my_db.sqlite
    replicas:
      - path: ./backup

Nun können wir Litestream im „replicate“-Modus starten.

litestream replicate -config ./litestream.yml

Standardmäßig wird 1x pro Sekunde geprüft, ob neue Logs erstellt wurden und wenn ja, werden diese gesichert.

Um einen Restore einer Datenbank zu machen, muss der „restore“ Befehl aufgerufen werden.

litestream restore -config ./litestream.yml ./my_db.sqlite

Das Ganze kann auch mit einem Sidecar Docker gemacht werden. Allerdings müssen der Host und der Container das gleiche Betriebssystem sein. macOS in Kombination mit einem Linux-Container funktioniert nicht. Weitere Informationen dazu sind in der Dokumentation von Litestream.

.dump und .restore

Eine andere Variante ist das Erzeugen eines Dumps (SQL-Befehle) mit der SQLite CLI.

sqlite3 my_db.sqlite
.output dump.sql
.dump
.exit

Sollte die Datenbank kaputt sein, so kann anstatt „dump“ die Funktion „recover“ verwendet werden:

sqlite3 my_db.sqlite
.output dump.sql
.recover
.exit

Um die Datenbank wieder zu erstellen, muss folgendes gemacht werden:

sqlite3 my_new_db.sqlite
.read dump.sql
.exit

Vektordaten

Bis jetzt ist die Unterstützung für das Speichern von Vektordaten (bspw. Embeddings) in vielen Datenbanken noch nicht direkt integriert, sondern nur über Extensions lösbar. Bei SQLite schaut dies nicht anders aus. Hier gibt es die sqlite-vec Extension. Meine Tests mit C# waren erfolgreich, wenn diese auch sehr beschränkt waren (geringe Datenmenge).

Diverses

Größe von Tabellen ausgeben:

select 
  count(*) as pages, 
  sum(pgsize) / 1024 / 1024 as size_mb 
from dbstat 
where name = 'my_table'