SQLite aus C# in Business-Apps nutzen — WAL-Modus, exklusive Sperren, Schutz vor Datenbankbeschädigung und wann sich EF Core lohnt

· · SQLite, C#, .NET, Microsoft.Data.Sqlite, EF Core, Datenspeicherung, Windows, Betrieb, Technische Beratung

Im vorigen Artikel „Wo Windows-Apps ihre lokalen Daten speichern sollten“ habe ich geschrieben, dass SQLite für wachsende Geschäftsdaten und Verlaufsdaten die erste Wahl ist. Diese Entscheidung steht damit fest, doch sobald man SQLite tatsächlich einbindet, tauchen andere Unsicherheiten auf. In Beratungsgesprächen hören wir oft Sätze wie: „Wenn ich auf NuGet nach SQLite suche, finde ich mehrere Pakete und weiß nicht, welches ich installieren soll“, „Es läuft, aber gelegentlich erscheint database is locked. Ich behelfe mir mit Wiederholungsversuchen — ist das eigentlich richtig?“ und „Reicht es, für das Backup einfach die DB-Datei zu kopieren?“

Jeder dieser Punkte ist eine Frage, auf die man früher oder später stößt, wenn man SQLite über mehrere Jahre in einer Businessanwendung betreibt, und genau die Art von Detail, die einem später viel Ärger erspart, wenn man sie schon im ursprünglichen Entwurf klärt. Dieser Artikel arbeitet, mit Microsoft.Data.Sqlite als Grundlage, die Bibliothekswahl, Verbindungszeichenfolgen und Pooling, die tatsächliche Funktionsweise des WAL-Modus, den Umgang mit SQLITE_BUSY, Fallstricke bei der Typzuordnung, Schutz vor Beschädigung und Backups sowie die Frage durch, wo EF Core im Vergleich zum reinen ADO.NET-Provider seinen Platz hat — die vollständige Liste der Punkte, die ich bei jedem Design-Review prüfe.

1. Die Kurzfassung

  • Für neue Entwicklungen ist die zu verwendende Bibliothek Microsoft.Data.Sqlite (oder der darauf aufbauende EF-Core-SQLite-Provider). Sie ist etwas anderes als System.Data.SQLite — Verbindungszeichenfolge und sogar Detailverhalten sind nicht kompatibel — achten Sie deshalb immer darauf, welche der beiden ein online gefundenes Beispiel voraussetzt.1
  • Aktivieren Sie den WAL-Modus bereits ab der ersten Veröffentlichung. Er erhöht die Nebenläufigkeit von Lese- und Schreibzugriffen und beseitigt die meisten Fälle von database is locked. Die Einstellung wird in der DB-Datei selbst persistiert, funktioniert aber nicht auf einer Netzwerkfreigabe.2
  • database is locked (SQLITE_BUSY) ist nichts, das man mit noch mehr Fehlerbehandlung übertüncht — es ist ein Zeichen dafür, dass das Design überarbeitet werden muss. Microsoft.Data.Sqlite wiederholt busy/locked-Fehler automatisch bis zum Befehls-Timeout (standardmäßig 30 Sekunden)3, doch die eigentliche Lösung ist, den Schreibpfad zu einem einzigen zu bündeln.
  • Führt man viele kleine INSERTs aus, bringt allein das Einschließen in eine explizite Transaktion zwei bis drei Größenordnungen an Geschwindigkeit. Wenn sich SQLite „langsam anfühlt“, prüfen Sie zuerst die Commit-Granularität.
  • SQLite kennt effektiv nur vier Speichertypen — INTEGER / REAL / TEXT / BLOB —, und DateTime, Guid sowie decimal werden allesamt als TEXT gespeichert. Insbesondere decimal wird nach Zeichenkettenregeln verglichen und sortiert, weshalb es am sichersten ist, Geldbeträge als Ganzzahl in der kleinsten Währungseinheit zu speichern.4
  • Sichern Sie niemals durch Kopieren der Datei, während die App läuft. Verwenden Sie VACUUM INTO oder die Backup-API (SqliteConnection.BackupDatabase). Auch das manuelle Löschen der -wal- / -shm-Dateien ist strikt tabu.5
  • Reines SQLite unterstützt keine Verschlüsselung. Das Schlüsselwort Password in der Verbindungszeichenfolge wirkt nur, wenn Sie eine native Bibliothek aus der SQLCipher-Familie eingebunden haben6; erwägen Sie bei geringen Mengen sensibler Daten zunächst den Schutz per DPAPI, bevor Sie zur vollständigen DB-Verschlüsselung greifen.

2. Die Bibliothekswahl — ähnliche Namen, unterschiedlicher Inhalt

Es gibt mehrere Pakete, um SQLite von .NET aus zu nutzen, und die verwirrend ähnlichen Namen sind die erste Stolperfalle. Aufgeschlüsselt sieht das so aus:

Paket Einordnung Empfehlung für neue Projekte
Microsoft.Data.Sqlite Von Microsoft gepflegter ADO.NET-Provider. Schlank, und die native SQLite-Engine selbst liegt dem NuGet-Paket bei Erste Wahl
Microsoft.EntityFrameworkCore.Sqlite Der SQLite-Provider von EF Core. Nutzt intern Microsoft.Data.Sqlite Erste Wahl für entitätszentrierte Apps (Kapitel 8)
System.Data.SQLite Der altehrwürdige Provider aus dem Umfeld des SQLite-Entwicklungsteams. Umfangreiche Erfahrung noch aus der Zeit des .NET Framework Nur zur Pflege bestehender Altbestände
Dapper Ein schlanker Mapper oberhalb von ADO.NET; nicht SQLite-spezifisch Sinnvoll, wenn Sie manuellen ADO.NET-Umfüllcode reduzieren möchten

Microsoft.Data.Sqlite wird vom EF-Core-Team gepflegt und ist zugleich die Grundlage, auf der der EF-Core-SQLite-Provider aufbaut.1 Da das NuGet-Paket die native Binärdatei (die SQLite-Engine selbst) mitbringt, entfällt für Client-PCs jeder gesonderte Verteilungsschritt, und NuGet gleicht die Unterschiede zwischen x86/x64/ARM64 selbst aus.

Ein Punkt, auf den Sie achten sollten: Informationen, die für System.Data.SQLite geschrieben wurden, lassen sich nicht unverändert übernehmen. Da diese Bibliothek schon lange existiert, setzt ein Großteil der Beispiele im Web System.Data.SQLite voraus, und Sie stoßen auf folgende Inkompatibilitäten:

  • Die Verbindungszeichenfolgen sind nicht kompatibel. Schlüsselwörter wie Version=3, UseUTF16Encoding oder das Datumsformat ändernde DateTimeFormat existieren in Microsoft.Data.Sqlite nicht, und ihre Angabe löst eine Ausnahme aus. Die unterstützten Schlüsselwörter sind eine kurze Liste: Data Source / Mode / Cache / Password / Foreign Keys / Default Timeout / Pooling und einige weitere.6
  • Die Typbehandlung unterscheidet sich. Ein Guid wird zum Beispiel in System.Data.SQLite standardmäßig als BLOB gespeichert, in Microsoft.Data.Sqlite dagegen als TEXT. In einer Übergangsphase, in der dieselbe DB-Datei von beiden Bibliotheken gelesen und geschrieben wird, macht sich dieser Unterschied als Dateninkonsistenz bemerkbar.

Microsoft.Data.Sqlite ist bewusst schlank gehalten — sie besitzt keine eigenen Typkonvertierungen oder Komfortfunktionen —, sodass die offizielle SQLite-Dokumentation unmittelbar gilt. Läuft eine bestehende App stabil mit System.Data.SQLite, besteht kein Zwang zur Migration, aber für neuen Code gilt grundsätzlich Microsoft.Data.Sqlite als Zielrichtung, und wer migriert, sollte zunächst die oben genannten Inkompatibilitäten als Migrationspunkte auflisten.

3. Verbindungen und Verbindungszeichenfolgen — Pooling hält die Datei fest

Die Grundform der Verbindungszeichenfolge besteht nur aus einem Dateipfad. In der Praxis wichtig sind Mode und Pooling.6

using Microsoft.Data.Sqlite;

var builder = new SqliteConnectionStringBuilder
{
    DataSource = dbPath,
    Mode = SqliteOpenMode.ReadWriteCreate  // Standardwert: Datei anlegen, falls sie nicht existiert
};
using var conn = new SqliteConnection(builder.ConnectionString);
conn.Open();
  • Mode: Der Standardwert ist ReadWriteCreate (anlegen, falls nicht vorhanden). Für Fälle wie das Verteilen einer schreibgeschützten Master-DB verhindert die Angabe von ReadOnly versehentliche Schreibzugriffe durch Bugs oder Bedienfehler.
  • Cache: Belassen Sie dies normalerweise auf dem Standardwert. Die Dokumentation nennt Cache=Shared in Kombination mit dem WAL-Modus ausdrücklich als nicht empfohlen, sodass es bei der in diesem Artikel empfohlenen WAL-first-Strategie ohnehin nicht zum Einsatz kommt.6
  • Password: Bei Angabe wird direkt nach dem Verbindungsaufbau PRAGMA key gesendet, doch die native Standardbibliothek unterstützt keine Verschlüsselung, sodass nichts passiert.6 Ist die vollständige DB-Verschlüsselung eine Anforderung, müssen Sie auf ein Bundle aus der SQLCipher-Familie umsteigen (z. B. SQLitePCLRaw.bundle_e_sqlcipher), und für die Aufbewahrung dieses Verschlüsselungsschlüssels ist letztlich wieder DPAPI (Kapitel 1) nötig.
  • Default Timeout: Das Befehls-Timeout (standardmäßig 30 Sekunden). Es bildet die Obergrenze für das in Kapitel 5 besprochene Retry-Zeitfenster.

Noch ein Hinweis zu asynchronen APIs. Da SQLite selbst keine asynchrone E/A besitzt, laufen async-Methoden wie ExecuteNonQueryAsync intern synchron ab.7 „Ich habe es async gemacht, also friert die UI nicht ein“ stimmt also nicht, weshalb schwere Abfragen explizit mit etwas wie Task.Run auf einen Worker-Thread ausgelagert werden sollten. Die Kriterien für die Entscheidung, wann async/await sinnvoll ist, sind in „C# async/await: ein praxisnaher Entscheidungsleitfaden“ zusammengestellt.

3.1 Die Pooling-Falle — die Datei bleibt auch nach Close geöffnet

Microsoft.Data.Sqlite hat seit Version 6.0 das Verbindungspooling standardmäßig aktiviert.6 Der Vorteil ist, dass die Datei nicht bei jedem Open neu geöffnet werden muss, der Nachteil, dass die native Verbindung auch nach Close / Dispose im Pool verbleibt und das Datei-Handle der DB weiter hält. Dadurch scheitern Vorgänge wie die folgenden mit der Meldung „die Datei wird bereits verwendet“:

  • Eine Funktion „Daten zurücksetzen“, die die DB-Datei löscht und neu erzeugt
  • Die Wiederherstellung aus einem Backup durch Austauschen der DB-Datei
  • Das Verschieben der DB-Datei bei der Deinstallation oder bei einem Evakuierungsvorgang

Die Abhilfe besteht darin, den Pool unmittelbar vor der Dateioperation zu leeren.

// Den zu dieser Verbindungszeichenfolge gehörenden Pool verwerfen und das Datei-Handle freigeben
SqliteConnection.ClearPool(new SqliteConnection(connectionString));
File.Delete(dbPath);

Im WAL-Modus (nächstes Kapitel) können außerdem noch -wal- / -shm-Dateien vorhanden sein, räumen Sie diese also ebenfalls auf. Wollen Sie beim Beenden der App alles freigeben, verwenden Sie SqliteConnection.ClearAllPools(); benötigt ein einmaliges Tool gar kein Pooling, können Sie in der Verbindungszeichenfolge auch Pooling=False setzen. „Ich habe Close aufgerufen, kann die Datei aber trotzdem nicht löschen“ ist eine Support-Frage, die nach der Umstellung auf SQLite häufig auftaucht — bauen Sie ClearPool deshalb von Anfang an in jede Hilfsroutine ein, die die DB-Datei anfasst.

4. Die Funktionsweise des WAL-Modus — verstehen, bevor Sie umschalten

Im vorigen Artikel stand nur „WAL-Modus aktivieren“, diesmal gehe ich auf die tatsächliche Funktionsweise ein. Bei der standardmäßigen Rollback-Journal-Methode werden Lesevorgänge blockiert, während ein Schreibvorgang läuft — die Hauptursache für database is locked. Die WAL-Methode (Write-Ahead Logging) schreibt Änderungen statt in die DB-Hauptdatei in eine nur anhängbare Protokolldatei, sodass Lesevorgänge Schreibvorgänge nicht blockieren und Schreibvorgänge Lesevorgänge nicht blockieren.2 Die typische Business-App-Konstellation — ein UI-Thread zeigt den Verlauf an, während ein Hintergrundprozess Messwerte schreibt — funktioniert damit genau so, wie sie gedacht ist.

Nach dem Umschalten auf den WAL-Modus erscheinen neben der DB-Hauptdatei (der durch einen Checkpoint festgeschriebene Inhalt) zwei zusätzliche Dateien:

Datei Rolle
app.db-wal Das nur anhängbare Änderungsprotokoll. Enthält bereits committete, aber noch nicht in die Hauptdatei übernommene Änderungen
app.db-shm Der als wal-index bezeichnete gemeinsame Speicher. Koordiniert die WAL-Leseposition zwischen Prozessen

Vier Eigenschaften sollten Sie sich merken:

  • Checkpoints: der Vorgang, der den Inhalt von -wal in die Hauptdatei überträgt; er läuft standardmäßig automatisch, sobald WAL 1.000 Seiten (etwa 4 MB) erreicht.2 Bleibt eine langlaufende Lesetransaktion bestehen, kann der Checkpoint nicht fortschreiten und -wal wächst an — vermeiden Sie deshalb ein Design, bei dem „eine Leseverbindung offengehalten und herumgereicht wird“; öffnen Sie sie stattdessen bei Bedarf und schließen Sie sie wieder (dank Pooling ist das erneute Öffnen schnell).
  • Die Einstellung wird in der DB persistiert: PRAGMA journal_mode=WAL wird, einmal ausgeführt, in der DB-Datei selbst festgehalten, und die DB bleibt danach im WAL-Modus, unabhängig davon, welche Verbindung sie öffnet.2 Sie müssen es nicht bei jeder Verbindung erneut angeben.
  • Schreibvorgänge finden weiterhin nur einzeln statt: Was WAL erhöht, ist die Nebenläufigkeit von Lesen und Schreiben — Schreibvorgänge untereinander bleiben weiterhin exklusiv. Wer dies falsch versteht und annimmt, „mit WAL kann ich von mehreren Threads aus frei schreiben“, läuft direkt in das in Kapitel 5 besprochene SQLITE_BUSY.
  • Funktioniert nicht auf Netzwerkfreigaben: Da der wal-index gemeinsamen Speicher voraussetzt, funktioniert er nicht zwischen Prozessen auf unterschiedlichen Rechnern.2 Dass SQLite ohnehin nicht auf einer Netzwerkfreigabe liegen sollte, wurde bereits im vorigen Artikel besprochen.

Ein betrieblicher Hinweis: In der -wal-Datei stehen Transaktionen, die bereits committet, aber noch nicht in die Hauptdatei übernommen wurden. Sowohl „es reicht, nur die Hauptdatei .db zu kopieren“ als auch „-wal ist eine temporäre Datei, die kann man löschen“ sind falsch — entweder gehen die jüngsten Commits verloren, oder im schlimmsten Fall wird die DB beschädigt.5 Das führt direkt zu der Aussage in Kapitel 7, dass „Backups keine reine Dateikopie sein dürfen“.

5. Sperren und SQLITE_BUSY — Schreibvorgänge auf einen Pfad bündeln

SQLITE_BUSY (in Ausnahmemeldungen als database is locked sichtbar) tritt auf, wenn eine andere Verbindung die Schreibsperre hält. Wissenswert ist zunächst, dass Microsoft.Data.Sqlite busy/locked-Fehler automatisch bis zum Befehls-Timeout (standardmäßig 30 Sekunden) wiederholt.3 Eine eigene catch-Sleep-Retry-Logik in der App ist also meist überflüssig. Fliegt nach Erreichen des Timeouts dennoch eine Ausnahme, liegt einer der folgenden Fälle vor:

  • Eine andere Verbindung (oder ein anderer Prozess) hält eine lange Transaktion, die 30 Sekunden überschreitet
  • Eine als Lesevorgang begonnene Transaktion versuchte, zu einem Schreibvorgang hochzustufen, und kollidierte mit einem anderen Schreibvorgang — Warten löst das nicht, daher schlägt sie sofort fehl. Vorgänge nach dem Muster „erst lesen, dann schreiben“ sollten von Anfang an als Schreibtransaktion entworfen werden
  • Viele kleine Schreibvorgänge aus mehreren Threads treffen gleichzeitig ein und kämpfen um die Sperre

Keiner dieser Fälle wird durch „die Anzahl der Wiederholungen erhöhen“ gelöst. Die Lösung besteht darin, Transaktionen zu verkürzen und den Schreibpfad auf einen einzigen zu bündeln.

5.1 Eine Schreibwarteschlange mit System.Threading.Channels aufbauen

Für Daten, die aus mehreren Threads stammen (Messwerte, Bedienprotokolle und Ähnliches), sollte nicht jeder Thread selbst in die DB schreiben, sondern die Daten an eine Warteschlange übergeben, die eine dedizierte Schreibschleife verarbeitet. In .NET passt System.Threading.Channels dafür direkt.

using System.Globalization;
using System.Threading.Channels;
using Microsoft.Data.Sqlite;

public sealed record Measurement(string DeviceId, double Value, DateTime CreatedAtUtc);

public sealed class MeasurementWriter : IAsyncDisposable
{
    private readonly Channel<Measurement> _channel =
        Channel.CreateBounded<Measurement>(new BoundedChannelOptions(10_000)
        {
            FullMode = BoundedChannelFullMode.Wait  // Bei Überlauf den Erzeuger blockieren
        });
    private readonly string _connectionString;
    private readonly Task _loop;

    public MeasurementWriter(string connectionString)
    {
        _connectionString = connectionString;
        _loop = Task.Run(WriteLoop);
    }

    // Darf von jedem Thread aus aufgerufen werden. Fasst die DB nicht an. Ist die
    // Schreibschleife bereits abgestorben, erfährt der Erzeuger das sofort über
    // ChannelClosedException
    public ValueTask EnqueueAsync(Measurement m) => _channel.Writer.WriteAsync(m);

    private async Task WriteLoop()
    {
        try
        {
            await WriteLoopCore();
        }
        catch (Exception ex)
        {
            // Das Absterben des Schreibprozesses (z. B. durch volle Festplatte)
            // über den Channel an die Erzeuger weitergeben. Wird dies versäumt,
            // wartet EnqueueAsync ab dem Zeitpunkt, an dem die Warteschlange
            // voll ist, endlos, ohne dass jemand den Ausfall bemerkt
            _channel.Writer.TryComplete(ex);
            throw;
        }
    }

    private async Task WriteLoopCore()
    {
        using var conn = new SqliteConnection(_connectionString);
        conn.Open();
        var buffer = new List<Measurement>(500);
        while (await _channel.Reader.WaitToReadAsync())
        {
            // Bis zu 500 wartende Einträge abholen und in einer Transaktion schreiben
            buffer.Clear();
            while (buffer.Count < 500 && _channel.Reader.TryRead(out var m))
                buffer.Add(m);

            using var tx = conn.BeginTransaction();
            using var cmd = conn.CreateCommand();
            cmd.Transaction = tx;
            cmd.CommandText =
                "INSERT INTO measurement (device_id, value, created_at) " +
                "VALUES ($device, $value, $at)";
            var pDevice = cmd.Parameters.Add("$device", SqliteType.Text);
            var pValue  = cmd.Parameters.Add("$value",  SqliteType.Real);
            var pAt     = cmd.Parameters.Add("$at",     SqliteType.Text);
            foreach (var m in buffer)
            {
                pDevice.Value = m.DeviceId;
                pValue.Value  = m.Value;
                // InvariantCulture explizit angeben, damit kulturabhängige Kalender/Ziffern nicht eingreifen
                pAt.Value     = m.CreatedAtUtc.ToString(
                    "yyyy-MM-dd HH:mm:ss.fffffff", CultureInfo.InvariantCulture);
                cmd.ExecuteNonQuery();
            }
            tx.Commit();
        }
    }

    public async ValueTask DisposeAsync()
    {
        // Keine Ausnahme werfen, wenn die Schreibschleife den Channel wegen eines
        // Fehlers bereits geschlossen hat (Complete() würde bei doppeltem Schließen
        // werfen und die ursprüngliche Ausnahme verdecken)
        _channel.Writer.TryComplete();
        await _loop;  // Rest abarbeiten. Ist die Schleife abgestorben, tritt die ursprüngliche Ausnahme hier zutage
    }
}

Dieses Muster verhindert Schreibkonflikte strukturell, und da es das in der Warteschlange Angesammelte auf natürliche Weise bündelt, erhalten Sie die im nächsten Abschnitt besprochene Beschleunigung gleich mit dazu. Auch einige weitere Details zählen in einer Businessanwendung: die Warteschlange vor dem Herunterfahren mit DisposeAsync restlos abzuarbeiten, das Verhalten bei Überlauf über FullMode explizit festzulegen und einen Fehler der Schreibschleife per TryComplete(ex) an die Erzeuger zurückzumelden (stirbt der eine, gebündelte Schreibprozess lautlos, zeigt sich der Ausfall erst als endloses Warten, sobald die Warteschlange voll ist). Dieselbe Idee, „den Pfad zu bündeln“, findet sich auch im Sperrdesign für die dateibasierte Kopplung wieder, in „Best Practices für Dateikopplung und Sperren“.

5.2 Bündelung in Transaktionen verändert die Größenordnung

Da SQLite bei jedem Commit einen synchronen Schreibvorgang auf die Platte (fsync) ausführt, kommt das zeilenweise Einfügen mit impliziten Commits selbst auf einer SSD bei einigen Hundert bis wenigen Tausend Zeilen pro Sekunde an seine Grenze, auf einer HDD bei nur wenigen Dutzend pro Sekunde. Fasst man dieselben INSERTs lediglich in expliziten Transaktionen zu je 1.000 Zeilen zusammen (eingeschlossen durch BeginTransaction und ein abschließendes Commit), erreicht man Zehntausende bis Hunderttausende Zeilen pro Sekunde. Eine Drei-Zeilen-Änderung, die man kaum „Tuning“ nennen möchte, verschiebt die Größenordnung um zwei bis drei Stellen.

Viele Beschwerden wie „der CSV-Import dauert 20 Minuten“ oder „die Datenmigration beim Start wird nie fertig“ lassen sich genau darauf zurückführen und lösen sich allein durch das Einschließen in Transaktionen und die Wiederverwendung von Parametern (die im vorigen Abschnitt gezeigte Form). Machen Sie eine Transaktion umgekehrt zu lang, lässt sie andere Schreibvorgänge warten, sodass es sich in der Praxis bewährt, „einige Hundert bis einige Tausend Zeilen oder einige Hundert Millisekunden“ zu einem Commit zusammenzufassen.

6. Fallstricke bei der Typzuordnung — was weist man den nur vier Typen zu?

SQLite kann tatsächlich nur vier Typen speichern — INTEGER / REAL / TEXT / BLOB —, und jeder .NET-Typ wird auf einen davon abgebildet. Die wichtigsten Zuordnungen in Microsoft.Data.Sqlite sind:4

.NET-Typ SQLite-Typ Speicherform Praxishinweis
bool / int / long INTEGER   bool ist 0 / 1
double REAL   Gleitkommafehler bleiben unverändert erhalten
string TEXT UTF-8  
DateTime TEXT yyyy-MM-dd HH:mm:ss.FFFFFFF Format und Zeitzone müssen einheitlich sein
DateTimeOffset TEXT Mit Offset Gemischte Offsets machen die Sortierung unmöglich
Guid TEXT Bindestrich-getrennt Inkompatibel mit dem Standard (BLOB) von System.Data.SQLite
decimal TEXT Format 0.0###... Vergleich/Sortierung folgt Zeichenkettenregeln

Die drei Typen, die auf TEXT fallen, verdienen besondere Vorsicht:

  • DateTime: Solange Format und Zeitzone konsistent bleiben, entspricht bei TEXT im ISO-8601-Stil eine Zeichenkettensortierung der chronologischen Sortierung, sodass es in der Praxis kein Problem gibt. Umgekehrt gesagt, bricht alles zusammen, sobald UTC und lokale Zeit gemischt werden. Von Anfang an festzulegen „gespeichert wird in UTC, in lokale Zeit wird nur bei der Anzeige konvertiert“, und dies auf jedem Pfad durchzuhalten, ist die einzig wirklich funktionierende Lösung; auch SQLite-Funktionen wie datetime('now') liefern UTC zurück. Ein weiterer Punkt: Formatieren Sie die Zeichenkette selbst, übergeben Sie ToString unbedingt CultureInfo.InvariantCulture. Bleibt es beim kulturellen Standard, ändert sich nur auf Rechnern, die unter einer nicht-gregorianischen Kultur laufen (japanische Ären, buddhistischer Kalender usw.), die Jahresdarstellung, was sowohl Sortierung als auch Lesbarkeit zerstört (siehe das Codebeispiel in 5.1).
  • Guid: Wird als Zeichenkette gespeichert, der Abgleich erfolgt also durch Zeichenkettenvergleich. Schreibt ein anderes Werkzeug oder eine andere Bibliothek in einer anderen Notation (Groß-/Kleinschreibung, BLOB-Form), scheitert der Abgleich — dokumentieren Sie die Notation als verbindliche Vorgabe, wenn mehrere Sprachen/Werkzeuge darauf zugreifen.
  • decimal: Die größte Falle. Es wird als TEXT gespeichert, weil REAL Präzision verlieren würde4, doch ein Vergleich wie WHERE amount > 1000 gegen eine TEXT-Spalte verhält sich nicht wie beabsichtigt, weil in der Typreihenfolge von SQLite TEXT immer größer als numerische Werte sortiert wird. Auch Aggregationen wie SUM werden intern nach REAL umgewandelt, wodurch die Präzision verloren geht, die der eigentliche Grund war, decimal zu wählen.

Der praktische Hinweis zu decimal ist einfach: speichern Sie Geldbeträge als Ganzzahl (INTEGER) in der kleinsten Währungseinheit. Für Euro speichern Sie beispielsweise einen long in Cent und rechnen nur zur Anzeige um. Mit einer Ganzzahl sind Vergleich und Aggregation zugleich präzise und schnell, und die Typfalle verschwindet. Zwingt Sie ein bestehendes Schema, decimal beizubehalten, sollten Sie sich damit abfinden, Vergleich und Aggregation nach dem Lesen auf der .NET-Seite statt in SQL durchzuführen.

Noch ein Hinweis: Der in CREATE TABLE angegebene Typname ist nur ein Hinweis auf die „Affinität“ — ein eigener Typname wie STRING lädt zu Unfällen durch implizite Konvertierung ein. Die offizielle Empfehlung lautet, auch für Spaltentypen nur die vier Typnamen INTEGER / REAL / TEXT / BLOB zu verwenden.4

7. Betrieb — nicht kaputtgehen, und im Ernstfall wiederherstellen können

7.1 quick_check beim Start

Obwohl SQLite durch Transaktionen geschützt ist, lässt sich eine Beschädigung durch einen Festplattenausfall oder eine fehlerhafte Dateioperation nie ganz auf null reduzieren. Damit der Schaden nicht dadurch wächst, dass man mit einer beschädigten DB weiterläuft, sollten Sie beim Start eine Integritätsprüfung einbauen. Ein vollständiger integrity_check kann bei einer großen DB lange dauern, für den Alltag reicht das leichtgewichtige quick_check.

using var cmd = conn.CreateCommand();
cmd.CommandText = "PRAGMA quick_check";
var result = (string)cmd.ExecuteScalar()!;
if (result != "ok")
{
    // Nicht weiter in eine beschädigte DB schreiben. Auf Nur-Lese-Modus zurückstufen und zur Wiederherstellung auffordern
    logger.LogError("Datenbankbeschädigung erkannt: {Detail}", result);
    EnterReadOnlyMode(result);
}

Die im vorigen Artikel in Abschnitt 6.2 beschriebene Grundsatzentscheidung, bei erkannter Beschädigung nicht automatisch zu reparieren oder automatisch zurückzurollen, sondern einen menschlichen Bediener einzubeziehen, gilt hier ebenso.

7.2 Backups — warum eine Dateikopie nicht funktioniert

Eine einfache Kopie der laufenden DB-Datei kann einen Zustand mitten in einer Transaktion mit einfangen, und die offizielle SQLite-Dokumentation nennt dies ausdrücklich als Ursache für Beschädigungen.5 Zusätzlich gehen im WAL-Modus, wenn nur die Hauptdatei kopiert und die -wal (die noch nicht übernommene Commits enthält — Kapitel 4) zurückgelassen wird, die jüngsten Daten verloren.

Es gibt zwei korrekte Methoden, die beide während des laufenden Betriebs eine konsistente Momentaufnahme erzeugen:

  • VACUUM INTO: eine einzelne SQL-Anweisung, die eine defragmentierte, minimal große Kopie erzeugt.8 Das Codebeispiel steht in Abschnitt 6.3 des vorigen Artikels, dort finden Sie die Details.
  • SqliteConnection.BackupDatabase: ein Wrapper um die Backup-API von SQLite, der zwischen Verbindungsobjekten kopiert.
using var source = new SqliteConnection($"Data Source={dbPath}");
using var target = new SqliteConnection($"Data Source={backupPath}");
source.Open();
target.Open();
source.BackupDatabase(target);  // Erzeugt auch im laufenden Betrieb eine konsistente Momentaufnahme

BackupDatabase hat allerdings einen Haken. Die aktuelle Implementierung von Microsoft.Data.Sqlite kopiert so schnell wie möglich und blockiert bis zum Abschluss Schreibzugriffe anderer Verbindungen.9 Sichern Sie eine große DB, während Messungen oder Benutzeraktionen weiterlaufen, äußern sich Schreibvorgänge in diesem Zeitraum als SQLITE_BUSY oder ein eingefrorener Bildschirm. Eine sichere Aufteilung besteht darin, VACUUM INTO für routinemäßige Generationen-Backups zu verwenden und BackupDatabase für den wechselseitigen Kopiervorgang mit einer In-Memory-DB oder für Duplikate in einem Zeitfenster ohne Schreibaktivität bzw. während Wartungsarbeiten aufzuheben. Verwenden Sie für die periodische Ausführung die Aufgabenplanung, führen Sie die Dateikopie nicht von außen durch, sondern lassen Sie die App selbst (oder ein kleines Werkzeug, das SQLite korrekt öffnet) das oben beschriebene Backup ausführen. Wie die periodische Ausführung selbst gestaltet wird, steht in „Geplante Aufgaben zuverlässig mit der Aufgabenplanung ausführen“.

7.3 Ablageort und Migration

Der übliche Ablageort für die DB-Datei ist %LOCALAPPDATA%\<Firmenname>\<App-Name>, und die Versionsverwaltung des Schemas per Start-Migration über PRAGMA user_version ist die minimale Grundausstattung. Beides wurde bereits im vorigen Artikel (Kapitel 3 und Abschnitt 6.1) mit Code vorgestellt, deshalb hier keine Wiederholung. Eine Ergänzung noch: Sichern Sie vor einer Migration eine Generation gemäß 7.2, wird aus dem schlimmsten Fall — „Migration fehlgeschlagen, App startet nicht mehr“ — eine einfache Wiederherstellung durch Dateiaustausch.

8. Wann sich EF Core lohnt — die Gewinnschwelle des ORM

Bislang war alles auf reines Microsoft.Data.Sqlite bezogen, doch es gibt klare Fälle, in denen der EF-Core-SQLite-Provider die richtige Wahl ist. Die entscheidende Achse ist der Charakter der App.

App-Charakter Empfehlung Begründung
Viele Bildschirme, überwiegend entitätszentriertes CRUD (Auftragsverwaltung, Stammdaten usw.) EF Core + Migrationen Reduziert Umfüllcode und handgeschriebenes SQL insgesamt, und Schemaänderungen lassen sich über dotnet ef migrations nachverfolgen
Schreibfokussiert mit kleinem Schema (Messprotokolle, Audit-Logs, Caching) Reines Microsoft.Data.Sqlite (+ bei Bedarf Dapper) Der Overhead der Änderungsverfolgung ist verschwendet; die Schreibwarteschlange plus Batching aus Kapitel 5 lässt sich natürlich einbauen
Mischung beider Charaktere Kombination Nichts spricht dagegen, an derselben DB-Datei CRUD-Bildschirme mit EF Core und Protokollschreibvorgänge mit reinem ADO.NET zu betreiben

Entscheiden Sie sich für EF Core, müssen Sie die Einschränkungen kennen, die speziell für den SQLite-Provider gelten.10

  • Tabellen-Neubau wegen ALTER-TABLE-Einschränkungen: SQLite unterstützt das Ändern oder Löschen eines Spaltentyps nicht direkt, sodass Migrationen mit AlterColumn oder DropColumn als Neubau ausgeführt werden — „neue Tabelle anlegen → Daten kopieren → alte Tabelle löschen → umbenennen“. In Umgebungen mit großen Datenmengen wirkt sich das auf Anwendungszeit und Speicherplatz aus, planen Sie Schemaänderungen an großen Tabellen deshalb bewusst.
  • Idempotente Skripte lassen sich nicht erzeugen: Migrationsskripte mit If-Then-Verzweigungen, wie sie etwa für SQL Server erzeugt werden, sind nicht möglich. Realistisch ist die Anwendung über dbContext.Database.Migrate() beim App-Start.
  • Operationen mit decimal / DateTimeOffset werden clientseitig ausgewertet: Die Typproblematik aus Kapitel 6 verschwindet auch unter EF Core nicht. Alles über einen Gleichheitsvergleich oder eine Sortierung hinaus wird clientseitig ausgewertet, sodass die Empfehlung, Geldbeträge als Ganzzahl in der kleinsten Einheit zu speichern, auch unter EF Core gilt (Sie können mit einem Value Converter zur Speicherung nach long konvertieren).
  • WAL ist standardmäßig aktiviert: Eine von EF Core erzeugte DB befindet sich von Anfang an im WAL-Modus7, sodass die Konfiguration aus Kapitel 4 entfällt. Das Verständnis des Verhaltens bleibt trotzdem erforderlich.

Auch wenn Sie EF Core einsetzen, bewährt sich für Unit-Tests der Repository-Schicht der Einsatz einer SQLite-In-Memory-DB. Da sie mit demselben Provider läuft wie in der Produktion, wird die Lücke „mit Mock läuft es durch, an der echten DB scheitert es“ kleiner. Wie man entscheidet, auf welcher Ebene Tests geschrieben werden, ist in „Wo die Grenze zwischen Unit-Tests und Integrationstests verläuft“ beschrieben.

9. Zusammenfassung

SQLite ist eine Bibliothek, bei der „das bloße Einbinden 30 Minuten dauert, der korrekte Betrieb aber Design erfordert“. Das nötige Design ist jedoch klar umrissen, und macht man den Inhalt dieses Artikels zu einer Checkliste, ergeben sich diese sechs Punkte:

  • Verwenden Sie die Bibliothek Microsoft.Data.Sqlite (oder das darauf aufbauende EF Core). Mischen Sie keine Informationen ein, die System.Data.SQLite voraussetzen
  • Aktivieren Sie den WAL-Modus bereits ab der ersten Veröffentlichung, und verstehen Sie die Rollen von -wal / -shm
  • Bündeln Sie Schreibvorgänge auf einen Pfad (eine Schreibwarteschlange mit Channels), und fassen Sie kleine INSERTs in Transaktionen zusammen
  • Halten Sie DateTime einheitlich in UTC, und speichern Sie Geldbeträge als Ganzzahl in der kleinsten Währungseinheit. Vergleichen oder aggregieren Sie decimal nicht unverändert als TEXT
  • Führen Sie beim Start quick_check aus, und sichern Sie mit VACUUM INTO oder BackupDatabase. Kopieren Sie die Datei nie während des laufenden Betriebs
  • Vergessen Sie bei jedem Vorgang, der die DB-Datei löscht oder austauscht, SqliteConnection.ClearPool nicht

Kommt Ihnen etwas davon bekannt vor — database is locked mit wiederholten Versuchen übertüncht, Backups per Dateikopie erstellt — lohnt es sich, die Punkte dieses Artikels einmal durchzugehen, bevor tatsächlich etwas kaputtgeht. Jeder einzelne davon lässt sich mit einer kleinen Änderung beheben.

Verwandte Artikel

Verwandte Beratungsbereiche

Die Komura Soft LLC übernimmt Design-Reviews für Businessanwendungen auf Basis von SQLite (Sperrdesign, Backup-Strategie, Migrationsdesign), die Fehlersuche bei Produktionsproblemen wie database is locked, Datenbeschädigung oder Leistungseinbußen sowie Unterstützung bei der Migration von bestehenden Datenspeichern wie Access.

  1. Microsoft Learn, Microsoft.Data.Sqlite overview. Dazu, dass es sich um einen von Microsoft gepflegten, schlanken ADO.NET-Provider handelt, der zugleich die Grundlage des EF-Core-SQLite-Providers ist.  2

  2. SQLite, Write-Ahead Logging. Zu den Rollen der -wal- / -shm-Dateien, zum Checkpointing (standardmäßig 1.000 Seiten), zur Nebenläufigkeit von Lesen und Schreiben, zur Persistenz des Modus und dazu, dass es auf Netzwerk-Dateisystemen nicht funktioniert.  2 3 4 5

  3. Microsoft Learn, Database errors (Microsoft.Data.Sqlite). Zur automatischen Wiederholung von busy-/locked-Fehlern bis zum Befehls-Timeout (standardmäßig 30 Sekunden) und dazu, dass Verbindungs- und Befehlsobjekte nicht threadsicher sind.  2

  4. Microsoft Learn, Data types (Microsoft.Data.Sqlite). Zu den vier primitiven Typen von SQLite, zur Abbildung von DateTime / Guid / decimal auf TEXT und dazu, dass auch Spaltentypnamen auf diese vier primitiven Typnamen beschränkt sein sollten.  2 3 4

  5. SQLite, How To Corrupt An SQLite Database File. Dazu, dass das Kopieren einer laufenden (in Transaktion befindlichen) DB-Datei sowie das Löschen oder Trennen der Hot-Journal- / WAL-Dateien Ursachen für Beschädigungen sind.  2 3

  6. Microsoft Learn, Connection strings (Microsoft.Data.Sqlite). Zur Liste der Verbindungszeichenfolgen-Schlüsselwörter, dazu, dass Pooling standardmäßig aktiviert ist, dass Password ohne verschlüsselungsfähige native Bibliothek keine Wirkung hat, und dass Cache=Shared zusammen mit WAL nicht empfohlen wird.  2 3 4 5 6

  7. Microsoft Learn, Async limitations (Microsoft.Data.Sqlite). Dazu, dass SQLite keine asynchrone E/A unterstützt und async-Methoden synchron ausgeführt werden, sowie dazu, dass WAL bei von EF Core erzeugten DBs standardmäßig aktiviert ist.  2

  8. SQLite, VACUUM. Dazu, dass die Klausel VACUUM INTO eine konsistente, minimal große Kopie in eine separate Datei erzeugt, ohne die Originaldatei zu verändern. 

  9. Microsoft Learn, Backup (Microsoft.Data.Sqlite). Zur aktuellen Implementierung von BackupDatabase, die so schnell wie möglich kopiert und bis zum Abschluss Schreibzugriffe anderer Verbindungen blockiert. 

  10. Microsoft Learn, SQLite EF Core Database Provider Limitations. Dazu, dass viele Migrationsoperationen als Tabellen-Neubau ausgeführt werden, dass sich keine idempotenten Skripte erzeugen lassen, und dass Operationen mit decimal / DateTimeOffset clientseitig ausgewertet werden. 

Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.

Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

Häufige Fragen

Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.

Welche SQLite-Bibliothek sollte ich in einem C#-Projekt verwenden?
Für neue Projekte sollten Sie Microsoft.Data.Sqlite verwenden — den schlanken ADO.NET-Provider, den das EF-Core-Team von Microsoft pflegt — oder den darauf aufbauenden EF-Core-SQLite-Provider; das NuGet-Paket bringt die native SQLite-Engine bereits mit, sodass keine zusätzliche Verteilung nötig ist. System.Data.SQLite lohnt sich nur noch für die Pflege bestehender Altbestände, und Vorsicht: Beide sind nicht kompatibel. Verbindungsstring-Schlüsselwörter wie Version=3 existieren in Microsoft.Data.Sqlite nicht und lösen eine Ausnahme aus, und auch die Typbehandlung unterscheidet sich — ein Guid wird etwa in System.Data.SQLite standardmäßig als BLOB gespeichert, in Microsoft.Data.Sqlite dagegen als TEXT.
Wie behebe ich den Fehler „database is locked“ (SQLITE_BUSY)?
Wissen Sie zunächst, dass Microsoft.Data.Sqlite Busy-/Locked-Fehler bereits automatisch bis zum Befehls-Timeout (standardmäßig 30 Sekunden) wiederholt, sodass eine eigene Retry-Schleife meist überflüssig ist — taucht die Ausnahme dennoch auf, ist das ein Hinweis auf ein Designproblem und keine Kleinigkeit, die man einfach übertüncht. Aktivieren Sie den WAL-Modus bereits ab der ersten Version, damit sich Lese- und Schreibzugriffe nicht mehr gegenseitig blockieren, halten Sie Transaktionen kurz und bündeln Sie Schreibzugriffe in einem einzigen Pfad, zum Beispiel über einen warteschlangenbasierten Writer auf Basis von System.Threading.Channels. Denken Sie daran, dass WAL nur die Nebenläufigkeit zwischen Lesen und Schreiben erhöht: Schreibvorgänge untereinander bleiben weiterhin exklusiv.
Warum sind meine SQLite-INSERTs so langsam, und wie beschleunige ich sie?
SQLite führt bei jedem Commit einen synchronen Schreibvorgang auf die Platte (fsync) aus. Werden Zeilen einzeln mit impliziten Commits eingefügt, ist auf einer SSD schon bei einigen Hundert bis wenigen Tausend Zeilen pro Sekunde Schluss, auf einer HDD sogar bei wenigen Dutzend. Fasst man dieselben INSERTs lediglich in expliziten Transaktionen zu jeweils rund 1.000 Zeilen zusammen, erreicht man Zehntausende bis Hunderttausende Zeilen pro Sekunde — eine Verbesserung um zwei bis drei Größenordnungen durch eine winzige Änderung. Beschwerden wie „der CSV-Import dauert 20 Minuten“ lassen sich fast immer auf die Commit-Granularität zurückführen; achten Sie aber darauf, Transaktionen nicht so lang werden zu lassen, dass sie andere Schreibvorgänge blockieren.
Kann ich eine SQLite-Datenbank sichern, indem ich einfach die Datei kopiere?
Nicht während die App läuft. Eine einfache Kopie kann einen Zustand mitten in einer Transaktion einfangen, was die offizielle SQLite-Dokumentation ausdrücklich als Ursache für Beschädigungen nennt, und im WAL-Modus enthält die -wal-Datei bereits committete, aber noch nicht in die Hauptdatei übernommene Daten, sodass eine reine Kopie der Hauptdatei die jüngsten Commits stillschweigend verliert. Verwenden Sie für routinemäßige Generationen-Backups VACUUM INTO oder SqliteConnection.BackupDatabase — wobei die aktuelle Implementierung von BackupDatabase Schreibzugriffe anderer Verbindungen blockiert, bis die Kopie abgeschlossen ist. Löschen Sie niemals manuell die -wal- oder -shm-Dateien.

Autorenprofil

Profilseite des Artikelautors.

Go Komura

Geschäftsführer von KomuraSoft LLC

Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.

Zurück zum Blog