Best Practices für die SQLite-Leistung

Android bietet integrierte Unterstützung für SQLite, eine effiziente SQL-Datenbank. Befolgen Sie diese Best Practices, um die Leistung Ihrer Anwendung zu optimieren und dafür zu sorgen, dass sie auch bei wachsendem Datenvolumen schnell und vorhersehbar schnell bleibt. Wenn Sie diese Best Practices befolgen, verringern Sie auch die Wahrscheinlichkeit, dass Leistungsprobleme auftreten, die schwer zu reproduzieren und zu beheben sind.

Beachten Sie die folgenden Leistungsprinzipien, um eine schnellere Leistung zu erzielen:

  • Weniger Zeilen und Spalten lesen: Optimieren Sie Ihre Abfragen, um nur die erforderlichen Daten abzurufen. Minimieren Sie die Menge der aus der Datenbank gelesenen Daten, da ein übermäßiger Datenabruf die Leistung beeinträchtigen kann.

  • Arbeit an die SQLite-Engine übertragen: Berechnungen, Filter- und Sortiervorgänge innerhalb der SQL-Abfragen ausführen. Die Verwendung der Abfrage-Engine von SQLite kann die Leistung erheblich verbessern.

  • Datenbankschema ändern: Entwerfen Sie Ihr Datenbankschema so, dass SQLite effiziente Abfragepläne und Datendarstellungen erstellen kann. Tabellen richtig indexieren und Tabellenstrukturen optimieren, um die Leistung zu verbessern.

Außerdem können Sie die verfügbaren Tools zur Fehlerbehebung verwenden, um die Leistung Ihrer SQLite-Datenbank zu messen und Bereiche zu identifizieren, die optimiert werden müssen.

Wir empfehlen die Verwendung der Jetpack Room-Bibliothek.

Datenbank für Leistung konfigurieren

Folgen Sie den Schritten in diesem Abschnitt, um Ihre Datenbank für eine optimale Leistung in SQLite zu konfigurieren.

Write-Ahead-Logging aktivieren

SQLite implementiert Mutationen, indem sie an ein Log angehängt werden, das gelegentlich in die Datenbank komprimiert wird. Dies wird als Write-Ahead-Logging (WAL) bezeichnet.

Aktivieren Sie WAL, es sei denn, Sie verwenden ATTACH DATABASE.

Aktivieren Sie WAL über das Framework, anstatt PRAGMA journal_mode=WAL selbst auszuführen. Room aktiviert WAL standardmäßig auf Geräten, die keine Geräte mit wenig RAM sind. Ohne Room können Sie eine der folgenden Optionen verwenden:

Wenn das Framework weiß, dass die Datenbank WAL verwendet, öffnet es einen Verbindungspool, damit Lesevorgänge gleichzeitig mit einem Schreibvorgang ausgeführt werden können. Außerdem wird der im nächsten Abschnitt beschriebene synchrone WAL-Modus angewendet. Wenn Sie PRAGMA journal_mode=WAL selbst ausführen, behandelt das Framework die Datenbank weiterhin als Rollback-Journal-Datenbank. Es wird eine einzelne Verbindung verwendet und Commits behalten den standardmäßigen synchronen FULL-Modus bei.

Synchronisierungsmodus lockern

SQLite bietet eine Option zur Steuerung des synchronen Modus. Im Modus FULL gibt jeder Commit einen fsync aus, um sicherzustellen, dass die Daten das Laufwerk erreichen. Dies verbessert die Datenbeständigkeit, verlangsamt jedoch Ihre Commits. Mit WAL wird der NORMAL-Modus nicht bei jedem Commit synchronisiert und die Datenbank wird trotzdem vor Beschädigung geschützt.

Wenn du WAL über das Framework aktivierst, wendet Android den standardmäßigen synchronen WAL-Modus des Geräts an. Dieser ist in AOSP für Android 9 und höher NORMAL. Gerätehersteller können diese Standardeinstellung ändern. Datenbanken, die WAL nicht verwenden, haben standardmäßig FULL. Damit Ihre Datenbank NORMAL verwendet, legen Sie dies beim Öffnen der Datenbank fest:

// When opening the database
val params = SQLiteDatabase.OpenParams.Builder()
    .addOpenFlags(SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING)
    // SYNC_MODE_NORMAL is available in API level 33 and higher.
    // On API levels 28 to 32, pass "NORMAL" instead.
    .setSynchronousMode(SQLiteDatabase.SYNC_MODE_NORMAL)
    .build()
// With SQLiteOpenHelper, call setOpenParams(params) in your constructor.

// Or: after having opened the database
db.execSQL("PRAGMA synchronous = NORMAL")

In dieser Einstellung kann ein Commit zurückgegeben werden, bevor die Daten auf einem Laufwerk gespeichert werden. Wenn ein Gerät heruntergefahren wird, z. B. bei einem Stromausfall oder einer Kernel-Panik, können die festgeschriebenen Daten verloren gehen. Aufgrund des Loggings wird Ihre Datenbank jedoch nicht beschädigt.

Wenn nur Ihre Anwendung abstürzt, werden Ihre Daten trotzdem auf das Laufwerk geschrieben. Bei den meisten Apps führt diese Einstellung zu Leistungsverbesserungen ohne nennenswerte Kosten.

Verbindungen und gemerkte Informationen verwalten

Jede offene Datenbankverbindung belegt SQLite-Speicher: einen Seitencache, einen Lookaside-Allocator und einen Cache mit kompilierten Anweisungen. Die Datenbankdateiseiten bleiben auch im Kernel-Seiten-Cache. Befolgen Sie die Best Practices in diesem Abschnitt, um diesen Arbeitsspeicher im Blick zu behalten, ohne Ihre Abfragen zu verlangsamen.

Öffnen Sie jede Datenbank einmal.

Verwenden Sie für den gesamten Prozess eine einzelne SQLiteOpenHelper- oder RoomDatabase-Instanz pro Datenbankdatei, z. B. indem Sie sie als Singleton festlegen. Jede Instanz hat einen eigenen Verbindungspool, Seiten-Caches und Anweisungs-Caches. Zwei Instanzen, die dieselbe Datei öffnen, können diese nicht gemeinsam nutzen. In einem Benchmark wurde die Zusammenführung von zwei Hilfsprogrammen, die dieselbe Datei öffneten, in ein einziges Hilfsprogramm getestet. Dadurch wurde der SQLite-Seitencachespeicher um fast die Hälfte und die p95-Abfragelatenz um mehr als die Hälfte reduziert.

Wenn Sie nach doppelten Instanzen suchen möchten, prüfen Sie dumpsys dbinfo auf denselben Datenbankpfad unter mehr als einer Connection pool for-Überschrift.

Schemaänderungen nicht im Open-Callback vornehmen

Der onOpen-Callback von SQLiteOpenHelper wird jedes Mal ausgeführt, wenn die Datenbank geöffnet wird. Das gilt auch für den onOpen-Callback von RoomDatabase.Callback. Fügen Sie Anweisungen wie CREATE TABLE IF NOT EXISTS und CREATE INDEX IF NOT EXISTS in onCreate und onUpgrade oder in Raum-Migrationen ein. Rufen Sie setVersion nicht in onOpen auf, da SQLiteOpenHelper die Version bereits nach onCreate und onUpgrade festlegt. Auch wenn das Schema bereits vorhanden ist, werden diese Anweisungen bei jedem Öffnen ausgeführt und setVersion schreibt in die Datenbankdatei.

Größe des Cache für vorbereitete Anweisungen festlegen

Android speichert kompilierte Anweisungen für jede Verbindung im Cache. Der Cache enthält standardmäßig 25 Anweisungen. Sie können ihn mit setMaxSqlCacheSize auf bis zu MAX_SQL_CACHE_SIZE (100) erhöhen. Wenn eine Datenbank mehr als 25 verschiedene Anweisungen in ihren Hot Paths ausführt, kommt es zu Cache-Thrashing und SQLite kompiliert dieselben Anweisungen immer wieder.

Parametrisieren Sie zuerst Ihre Abfragen. Keine Cache-Größe kann SQL-Anweisungen helfen, die mit Stringverkettung erstellt wurden, da jeder String eine neue Anweisung ist. Prüfen Sie dann die Cache-Treffer und -Fehler für Anweisungen unter POOL STATS in dumpsys meminfo. Die Datenbank phenotype.db in der Beispielausgabe dieses Abschnitts hat beispielsweise 147.921 Treffer und 89.616 Fehler, was einer Fehlerrate von 38 % entspricht. Wenn Sie die Cache-Größe für eine solche Datenbank erhöhen, können Sie fast alle Fehler beseitigen. Jede im Cache gespeicherte Anweisung belegt einige Kilobyte Arbeitsspeicher.

Wenn Sie die Cache-Größe erhöhen möchten, überschreiben Sie onConfigure in Ihrer SQLiteOpenHelper-Unterklasse:

override fun onConfigure(db: SQLiteDatabase) {
    db.setMaxSqlCacheSize(SQLiteDatabase.MAX_SQL_CACHE_SIZE)
}

Wenn du Room verwendest, rufe dieselbe Methode für SupportSQLiteDatabase im onOpen-Callback von RoomDatabase.Callback auf.

Inaktive Datenbanken schließen, aber aktive Datenbanken geöffnet lassen

Eine geöffnete Datenbank behält ihre Verbindungen, Caches und Dateideskriptoren auch dann bei, wenn sie inaktiv ist. In einem Benchmark hielt eine inaktive Room-Datenbank im WAL-Modus, die geöffnet blieb, etwa 678 KB SQLite-Seiten-Cache-Speicher und 5 Dateideskriptoren. Wenn Sie eine Datenbank nur gelegentlich verwenden, z. B. während einer täglichen Synchronisierung, schließen Sie sie, wenn Sie fertig sind.

Beachten Sie die folgenden Richtlinien, um inaktive Datenbanken sicher zu schließen:

  • Room kann eine inaktive Datenbank mit setAutoCloseTimeout schließen, einer experimentellen API. Room öffnet die Datenbank beim nächsten Zugriff wieder und führt den onOpen-Callback noch einmal aus. Platzieren Sie dort also die Verbindungskonfiguration.
  • Vermeiden Sie die setIdleConnectionTimeout-Methoden von SQLiteOpenHelper und SQLiteDatabase.OpenParams.Builder. Sie wurden in API-Ebene 29 verworfen, da sie Verbindungen schließen, ohne die Einstellungen wiederherzustellen, die Sie mit PRAGMA-Anweisungen angewendet haben.
  • Schließen Sie keine Datenbank, die Sie alle paar Sekunden abfragen. Ein Zeitlimit, das kürzer ist als die Zeitspanne zwischen Ihren Abfragen, führt dazu, dass die Datenbank wiederholt geschlossen und geöffnet wird. Dadurch werden der Seiten- und der Anweisungscache jedes Mal verworfen und die nächste Abfrage wird langsamer.

SQLite-Arbeitsspeicher berücksichtigen

SQLite-Speicher kann schwer zu erkennen sein. Der SQLite-Seiten-Cache und der Anweisungs-Cache sind native Heap-Zuweisungen. Android aktiviert jedoch nicht standardmäßig die speicherzugeordnete E/A von SQLite. SQLite liest Datenbankdateien also mit regulären Dateilesevorgängen und die vom Kernel im Cache gespeicherten Dateiinhalte sind nicht zugeordnete Dateiseiten. Sie werden nicht auf den PSS Ihrer App in dumpsys meminfo angerechnet. Auf Geräten, die die cgroup v2-Arbeitsspeicherabrechnung verwenden, rechnet der Kernel diese Seiten weiterhin der Arbeitsspeicher-cgroup Ihrer App an. Wenn Sie diesen versteckten Speicherbedarf reduzieren möchten, sollten Sie weniger Seiten lesen, indem Sie Indexe verwenden und nur die Zeilen und Spalten lesen, die Sie benötigen. Wenn Sie Datenbanken schließen, die Sie nicht benötigen, werden auch deren SQLite-Caches freigegeben.

Effiziente Tabellenschemas definieren

Definieren Sie ein effizientes Tabellenschema, um die Leistung zu optimieren und den Datenverbrauch zu minimieren. SQLite erstellt effiziente Abfragepläne und Daten, was zu einem schnelleren Datenabruf führt. In diesem Abschnitt finden Sie Best Practices zum Erstellen von Tabellenschemas.

Achte auf INTEGER PRIMARY KEY

Definieren und füllen Sie für dieses Beispiel eine Tabelle so:

CREATE TABLE Customers(
  id INTEGER,
  name TEXT,
  city TEXT
);
INSERT INTO Customers Values(456, 'John Lennon', 'Liverpool, England');
INSERT INTO Customers Values(123, 'Michael Jackson', 'Gary, IN');
INSERT INTO Customers Values(789, 'Dolly Parton', 'Sevier County, TN');

Die Tabellenausgabe sieht so aus:

rowid id Name Stadt
1 456 John Lennon Liverpool, England
2 123 Michael Jackson Gary, IN
3 789 Zwei Belegte Brote mit Schinken und mit Ei Sevier County, TN

Die Spalte rowid ist ein Index, der die Einfügereihenfolge beibehält. Abfragen, die nach rowid filtern, werden als schnelle B-Baum-Suche implementiert. Abfragen, die nach id filtern, sind jedoch ein langsamer Tabellenscan.

Wenn Sie Suchvorgänge nach id planen, können Sie das Speichern der Spalte rowid vermeiden, um weniger Daten im Speicher zu haben und die Datenbank insgesamt zu beschleunigen:

CREATE TABLE Customers(
  id INTEGER PRIMARY KEY,
  name TEXT,
  city TEXT
);

Ihre Tabelle sieht jetzt so aus:

id Name Stadt
123 Michael Jackson Gary, IN
456 John Lennon Liverpool, England
789 Zwei Belegte Brote mit Schinken und mit Ei Sevier County, TN

Da Sie die Spalte rowid nicht speichern müssen, sind id-Abfragen schnell. Die Tabelle ist jetzt nach id statt nach Einfügereihenfolge sortiert.

Abfragen mit Indexen beschleunigen

SQLite verwendet Indexe, um Abfragen zu beschleunigen. Wenn Sie eine Spalte filtern (WHERE), sortieren (ORDER BY) oder aggregieren (GROUP BY) und die Tabelle einen Index für die Spalte hat, wird die Abfrage beschleunigt.

Im vorherigen Beispiel muss die gesamte Tabelle gescannt werden, um nach city zu filtern:

SELECT id, name
WHERE city = 'London, England';

Bei einer Anwendung mit vielen Abfragen nach Städten können Sie diese Abfragen mit einem Index beschleunigen:

CREATE INDEX city_index ON Customers(city);

Ein Index wird als zusätzliche Tabelle implementiert, die nach der Indexspalte sortiert und rowid zugeordnet ist:

Stadt rowid
Gary, IN 2
Liverpool, England 1
Sevier County, TN 3

Beachten Sie, dass die Speicherkosten für die Spalte city jetzt doppelt so hoch sind, da sie sowohl in der ursprünglichen Tabelle als auch im Index vorhanden ist. Da Sie den Index verwenden, sind die Kosten für den zusätzlichen Speicherplatz die Vorteile schnellerer Abfragen wert. Sie sollten jedoch keinen Index verwalten, den Sie nicht verwenden, um Speicherkosten ohne Leistungssteigerung bei Abfragen zu vermeiden.

Mehrspaltige Indexe erstellen

Wenn Ihre Abfragen mehrere Spalten kombinieren, können Sie mehrspaltige Indexe erstellen, um die Abfrage vollständig zu beschleunigen. Sie können auch einen Index für eine äußere Spalte verwenden und die innere Suche als lineare Auswahl ausführen lassen.

Beispiel für eine Abfrage:

SELECT id, name
WHERE city = 'London, England'
ORDER BY city, name

Sie können die Abfrage mit einem mehrspaltigen Index in derselben Reihenfolge beschleunigen, die in der Abfrage angegeben ist:

CREATE INDEX city_name_index ON Customers(city, name);

Wenn Sie jedoch nur einen Index für city haben, wird die äußere Sortierung weiterhin beschleunigt, während für die innere Sortierung ein linearer Scan erforderlich ist.

Das funktioniert auch mit Präfixanfragen. Beispielsweise beschleunigt ein Index ON Customers (city, name) auch das Filtern, Sortieren und Gruppieren nach city, da die Indextabelle für einen mehrspaltigen Index nach den angegebenen Indizes in der angegebenen Reihenfolge sortiert wird.

Achte auf WITHOUT ROWID

Standardmäßig erstellt SQLite eine rowid-Spalte für Ihre Tabelle, wobei rowid ein impliziter INTEGER PRIMARY KEY AUTOINCREMENT ist. Wenn Sie bereits eine Spalte mit dem Namen INTEGER PRIMARY KEY haben, wird diese Spalte zu einem Alias von rowid.

Für Tabellen mit einem anderen Primärschlüssel als INTEGER oder einer Kombination aus Spalten sollten Sie WITHOUT ROWID in Betracht ziehen.

Kleine Datenmengen als BLOB und große Datenmengen als Datei speichern

Wenn Sie große Datenmengen mit einer Zeile verknüpfen möchten, z. B. ein Thumbnail eines Bildes oder ein Foto für einen Kontakt, können Sie die Daten entweder in einer BLOB-Spalte oder in einer Datei speichern und dann den Pfad in der Spalte speichern.

Dateien werden in der Regel auf 4-KB-Schritte aufgerundet. Bei sehr kleinen Dateien, bei denen der Rundungsfehler erheblich ist, ist es effizienter, sie als BLOB in der Datenbank zu speichern. SQLite minimiert Dateisystemaufrufe und ist in einigen Fällen schneller als das zugrunde liegende Dateisystem.

Abfrageleistung verbessern

Befolgen Sie diese Best Practices, um die Abfrageleistung in SQLite zu verbessern, indem Sie die Antwortzeiten minimieren und die Verarbeitungseffizienz maximieren.

Nur die benötigten Zeilen lesen

Mit Filtern können Sie die Ergebnisse eingrenzen, indem Sie bestimmte Kriterien wie Zeitraum, Standort oder Name angeben. Mit Limits können Sie die Anzahl der angezeigten Ergebnisse steuern:

db.rawQuery("""
    SELECT name
    FROM Customers
    LIMIT 10;
    """.trimIndent(),
    null
).use { cursor ->
    while (cursor.moveToNext()) {
        // Process cursor data
    }
}

Nur die benötigten Spalten lesen

Vermeiden Sie die Auswahl nicht benötigter Spalten, da dies Ihre Abfragen verlangsamen und Ressourcen verschwenden kann. Wählen Sie stattdessen nur die Spalten aus, die verwendet werden.

Im folgenden Beispiel wählen Sie id, name und phone aus:

// This is not the most efficient way of doing this.
// See the following example for a better approach.

db.rawQuery(
    """
    SELECT id, name, phone
    FROM customers;
    """.trimIndent(),
    null
).use { cursor ->
    while (cursor.moveToNext()) {
        val name = cursor.getString(1)
        // Further processing
    }
}

Sie benötigen jedoch nur die Spalte name:

db.rawQuery("""
    SELECT name
    FROM Customers;
    """.trimIndent(),
    null
).use { cursor ->
    while (cursor.moveToNext()) {
        val name = cursor.getString(0)
        // Further processing
    }
}

Abfragen parametrisieren

Ihr Abfragestring kann einen Parameter enthalten, der nur zur Laufzeit bekannt ist, z. B.:

fun getNameById(id: Long): String?
    db.rawQuery(
        "SELECT name FROM customers WHERE id=$id", null
    ).use { cursor ->
        return if (cursor.moveToFirst()) {
            cursor.getString(0)
        } else {
            null
        }
    }
}

Im vorherigen Code wird für jede Abfrage ein anderer String erstellt. Daher wird der Anweisungscache nicht genutzt. Bei jedem Aufruf muss SQLite die Abfrage kompilieren, bevor sie ausgeführt werden kann. Stattdessen können Sie das Argument id durch einen Parameter ersetzen und den Wert mit selectionArgs binden:

fun getNameById(id: Long): String? {
    db.rawQuery(
        """
          SELECT name
          FROM customers
          WHERE id=?
        """.trimIndent(), arrayOf(id.toString())
    ).use { cursor ->
        return if (cursor.moveToFirst()) {
            cursor.getString(0)
        } else {
            null
        }
    }
}

Jetzt kann die Abfrage einmal kompiliert und im Cache gespeichert werden. Die kompilierte Abfrage wird zwischen verschiedenen Aufrufen von getNameById(long) wiederverwendet.

In SQL iterieren, nicht im Code

Verwenden Sie eine einzelne Abfrage, die alle gewünschten Ergebnisse zurückgibt, anstatt eine programmatische Schleife zu verwenden, die SQL-Abfragen durchläuft, um einzelne Ergebnisse zurückzugeben. Die programmatische Schleife ist etwa 1.000-mal langsamer als eine einzelne SQL-Abfrage.

DISTINCT für eindeutige Werte verwenden

Mit dem Keyword DISTINCT können Sie die Leistung Ihrer Abfragen verbessern, da die Menge der zu verarbeitenden Daten reduziert wird. Wenn Sie beispielsweise nur die eindeutigen Werte aus einer Spalte zurückgeben möchten, verwenden Sie DISTINCT:

db.rawQuery("""
    SELECT DISTINCT name
    FROM Customers;
    """.trimIndent(),
    null
).use { cursor ->
    while (cursor.moveToNext()) {
        // Only iterate over distinct names in Kotlin
        // Process distinct name
    }
}

Nach Möglichkeit Aggregatfunktionen verwenden

Verwenden Sie Aggregatfunktionen für aggregierte Ergebnisse ohne Zeilendaten. Mit dem folgenden Code wird beispielsweise geprüft, ob mindestens eine übereinstimmende Zeile vorhanden ist:

// This is not the most efficient way of doing this.
// See the following example for a better approach.

db.rawQuery("""
    SELECT id, name
    FROM Customers
    WHERE city = 'Paris';
    """.trimIndent(),
    null
).use { cursor ->
    if (cursor.moveToFirst()) {
        // At least one customer from Paris
        // Handle found
    } else {
        // No customers from Paris
        // Handle not found
}

Wenn Sie nur die erste Zeile abrufen möchten, können Sie EXISTS() verwenden, um 0 zurückzugeben, wenn keine übereinstimmende Zeile vorhanden ist, und 1, wenn eine oder mehrere Zeilen übereinstimmen:

db.rawQuery("""
    SELECT EXISTS (
        SELECT null
        FROM Customers
        WHERE city = 'Paris';
    );
    """.trimIndent(),
    null
).use { cursor ->
    if (cursor.moveToFirst() && cursor.getInt(0) == 1) {
        // At least one customer from Paris
        // Handle found
    } else {
        // No customers from Paris
        // Handle not found
    }
}

Verwenden Sie SQLite-Aggregatfunktionen im App-Code:

  • COUNT: Zählt, wie viele Zeilen in einer Spalte vorhanden sind.
  • SUM: Addiert alle numerischen Werte in einer Spalte.
  • MIN oder MAX: Bestimmt den niedrigsten oder höchsten Wert. Funktioniert für numerische Spalten, DATE-Typen und Texttypen.
  • AVG: Ermittelt den durchschnittlichen numerischen Wert.
  • GROUP_CONCAT: Verkettet Strings mit einem optionalen Trennzeichen.

COUNT() anstelle von Cursor.getCount() verwenden

Im folgenden Beispiel liest die Funktion Cursor.getCount() alle Zeilen aus der Datenbank und gibt alle Zeilenwerte zurück:

// This is not the most efficient way of doing this.
// See the following example for a better approach.

db.rawQuery("""
    SELECT id
    FROM Customers;
    """.trimIndent(),
    null
).use { cursor ->
    val count = cursor.getCount()
    // Use count
}

Wenn Sie jedoch COUNT() verwenden, gibt die Datenbank nur die Anzahl zurück:

db.rawQuery("""
    SELECT COUNT(*)
    FROM Customers;
    """.trimIndent(),
    null
).use { cursor ->
    cursor.moveToFirst()
    val count = cursor.getInt(0)
    // Use count
}

Abfragen statt Code verschachteln

SQL ist zusammensetzbar und unterstützt Unterabfragen, Joins und Fremdschlüsseleinschränkungen. Sie können das Ergebnis einer Abfrage in einer anderen Abfrage verwenden, ohne den Anwendungscode zu durchlaufen. Dadurch müssen weniger Daten aus SQLite kopiert werden und die Datenbank-Engine kann Ihre Abfrage optimieren.

Im folgenden Beispiel können Sie eine Abfrage ausführen, um herauszufinden, in welcher Stadt die meisten Kunden leben. Anschließend können Sie das Ergebnis in einer anderen Abfrage verwenden, um alle Kunden aus dieser Stadt zu finden:

// This is not the most efficient way of doing this.
// See the following example for a better approach.

db.rawQuery("""
    SELECT city
    FROM Customers
    GROUP BY city
    ORDER BY COUNT(*) DESC
    LIMIT 1;
    """.trimIndent(),
    null
).use { cursor ->
    if (cursor.moveToFirst()) {
        val topCity = cursor.getString(0)
        db.rawQuery("""
            SELECT name, city
            FROM Customers
            WHERE city = ?;
        """.trimIndent(),
        arrayOf(topCity)).use { innerCursor ->
            while (innerCursor.moveToNext()) {
                // Process inner cursor data
            }
        }
    }
}

Wenn Sie das Ergebnis in der Hälfte der Zeit des vorherigen Beispiels erhalten möchten, verwenden Sie eine einzelne SQL-Abfrage mit verschachtelten Anweisungen:

db.rawQuery("""
    SELECT name, city
    FROM Customers
    WHERE city IN (
        SELECT city
        FROM Customers
        GROUP BY city
        ORDER BY COUNT (*) DESC
        LIMIT 1;
    );
    """.trimIndent(),
    null
).use { cursor ->
    if (cursor.moveToNext()) {
        // Process cursor data
    }
}

Eindeutigkeit in SQL prüfen

Wenn eine Zeile nur eingefügt werden darf, wenn ein bestimmter Spaltenwert in der Tabelle eindeutig ist, kann es effizienter sein, diese Eindeutigkeit als Spalteneinschränkung zu erzwingen.

Im folgenden Beispiel wird eine Abfrage ausgeführt, um die einzufügende Zeile zu validieren, und eine weitere, um die Zeile tatsächlich einzufügen:

// This is not the most efficient way of doing this.
// See the following example for a better approach.

db.rawQuery(
    """
    SELECT EXISTS (
        SELECT null
        FROM customers
        WHERE username = ?
    );
    """.trimIndent(),
    arrayOf(customer.username)
).use { cursor ->
    if (cursor.moveToFirst() && cursor.getInt(0) == 1) {
        throw AddCustomerException(customer)
    }
}
db.execSQL(
    "INSERT INTO customers VALUES (?, ?, ?)",
    arrayOf(
        customer.id.toString(),
        customer.name,
        customer.username
    )
)

Anstatt die Unique-Einschränkung in Kotlin zu prüfen, können Sie sie in SQL prüfen, wenn Sie die Tabelle definieren:

CREATE TABLE Customers(
  id INTEGER PRIMARY KEY,
  name TEXT,
  username TEXT UNIQUE
);

SQLite macht dasselbe wie Folgendes:

CREATE TABLE Customers(...);
CREATE UNIQUE INDEX CustomersUsername ON Customers(username);

Jetzt können Sie eine Zeile einfügen und die Einschränkung von SQLite prüfen lassen:

try {
    db.execSQL(
        "INSERT INTO Customers VALUES (?, ?, ?)",
        arrayOf(customer.id.toString(), customer.name, customer.username)
    )
} catch(e: SQLiteConstraintException) {
    throw AddCustomerException(customer, e)
}

SQLite unterstützt eindeutige Indexe mit mehreren Spalten:

CREATE TABLE table(...);
CREATE UNIQUE INDEX unique_table ON table(column1, column2, ...);

SQLite validiert Einschränkungen schneller und mit weniger Aufwand als Kotlin-Code. Es empfiehlt sich, SQLite anstelle von App-Code zu verwenden.

Mehrere Einfügungen in einer einzelnen Transaktion zusammenfassen

Eine Transaktion führt mehrere Vorgänge aus, was nicht nur die Effizienz, sondern auch die Korrektheit verbessert. Wenn Sie die Datenkonsistenz verbessern und die Leistung beschleunigen möchten, können Sie Einfügungen im Batch ausführen:

db.beginTransaction()
try {
    customers.forEach { customer ->
        db.execSQL(
            "INSERT INTO Customers VALUES (?, ?, ?)",
            arrayOf(customer.id.toString(), customer.name, "customerValue")
        )
    }
    db.setTransactionSuccessful()
} finally {
    db.endTransaction()
}

Außerhalb einer expliziten Transaktion wird jeder Schreibvorgang in einer eigenen impliziten Transaktion ausgeführt und es fallen die vollen Kosten für einen Commit an, einschließlich eines fsync im synchronen FULL-Modus. Bei der Batchverarbeitung fallen diese Kosten nur einmal an, wodurch ein Batch von Schreibvorgängen um eine Größenordnung oder mehr schneller sein kann.

Tools zur Fehlerbehebung verwenden

SQLite bietet die folgenden Tools zur Fehlerbehebung, mit denen Sie die Leistung messen können.

Interaktive Eingabeaufforderung von SQLite verwenden

Führen Sie SQLite auf Ihrem Computer aus, um Abfragen auszuführen und zu lernen. Verschiedene Android-Plattformversionen verwenden unterschiedliche Versionen von SQLite. Wenn Sie dieselbe Engine wie auf einem Android-Gerät verwenden möchten, verwenden Sie adb shell und führen Sie sqlite3 auf Ihrem Zielgerät aus.

Sie können SQLite bitten, die Abfragen zu messen:

sqlite> .timer on
sqlite> SELECT ...
Run Time: real ... user ... sys ...

EXPLAIN QUERY PLAN

Sie können SQLite bitten, zu erklären, wie eine Abfrage beantwortet werden soll. Verwenden Sie dazu EXPLAIN QUERY PLAN:

sqlite> EXPLAIN QUERY PLAN
SELECT id, name
FROM Customers
WHERE city = 'Paris';
QUERY PLAN
`--SCAN Customers

Im vorherigen Beispiel ist ein vollständiger Tabellenscan ohne Index erforderlich, um alle Kunden aus Paris zu finden. Dies wird als lineare Komplexität bezeichnet. SQLite muss alle Zeilen lesen und nur die Zeilen behalten, die Kunden aus Paris entsprechen. Um dieses Problem zu beheben, können Sie einen Index hinzufügen:

sqlite> CREATE INDEX Idx1 ON Customers(city);
sqlite> EXPLAIN QUERY PLAN
SELECT id, name
FROM Customers
WHERE city = 'Paris';
QUERY PLAN
`--SEARCH test USING INDEX Idx1 (city=?

Wenn Sie die interaktive Shell verwenden, können Sie SQLite anweisen, Abfragepläne immer zu erklären:

sqlite> .eqp on

Weitere Informationen finden Sie unter Abfrageplanung.

SQLite Analyzer

SQLite bietet die Befehlszeile (CLI) sqlite3_analyzer, um zusätzliche Informationen auszugeben, die zur Fehlerbehebung bei der Leistung verwendet werden können. Rufen Sie die SQLite-Downloadseite auf, um SQLite zu installieren.

Mit adb pull können Sie eine Datenbankdatei von einem Zielgerät auf Ihre Workstation herunterladen, um sie zu analysieren:

adb pull /data/data/<app_package_name>/databases/<db_name>.db

SQLite Browser

Sie können das GUI-Tool SQLite Browser auch auf der SQLite-Downloadseite installieren.

Android-Protokollierung

Android misst die Zeit von SQLite-Abfragen und protokolliert sie für Sie:

# Enable query time logging
$ adb shell setprop log.tag.SQLiteTime VERBOSE
# Disable query time logging
$ adb shell setprop log.tag.SQLiteTime ERROR

Unter Android 13 und höher können Sie auch nur die langsamen Vorgänge protokollieren. Legen Sie einen Grenzwert in Millisekunden fest und starten Sie die App neu, damit das Log-Tag gelesen wird:

$ adb shell setprop log.tag.SQLiteSlowQueries VERBOSE
$ adb shell setprop db.log.slow_query_threshold 100
$ adb shell am force-stop <package-name>

Langsame Vorgänge werden in Logcat unter dem Tag SQLiteConnection mit ihrer Dauer, dem SQL-Befehl und dem Datenbankpfad angezeigt. Wenn Sie den Grenzwert für eine einzelne App festlegen möchten, verwenden Sie db.log.slow_query_threshold.<uid>, wobei <uid> die UID der App ist.

Perfetto-Tracing

Fügen Sie beim Konfigurieren von Perfetto die atrace-Kategorie database hinzu, um SQLite-Vorgänge in den Trace aufzunehmen. Wenn Sie sehen möchten, was die einzelnen Threads während der Ausführung von SQL tun, fügen Sie auch die Planerereignisse hinzu:

data_sources {
  config {
    name: "linux.ftrace"
    ftrace_config {
      ftrace_events: "sched/sched_switch"
      ftrace_events: "sched/sched_waking"
      ftrace_events: "sched/sched_blocked_reason"
      atrace_categories: "database"
    }
  }
}

Android verfolgt jeden Datenbankvorgang als Slice, der nach dem Vorgang und dem zugehörigen SQL benannt ist, z. B. executeForCursorWindow SELECT * FROM message WHERE body LIKE ?. Die SQL-Anweisung behält ihre ?-Platzhalter bei und wird auf 256 Zeichen gekürzt. Bindungsargumente sind nicht enthalten. Zu den Vorgängen gehören open, close, prepare, execute und die executeFor-Varianten. Ein prepare-Slice bedeutet nicht, dass der Anweisungscache nicht gefunden wurde.

Das Track-Layout hängt von der Android-Version ab:

  • In Android 17 und höher hat jede Verbindung einen asynchronen Track im App-Prozess mit dem Namen SQLiteConnection-, gefolgt von einer Verbindungsnummer. Transaktionen werden als TRANSACTION-EXCLUSIVE-, TRANSACTION-IMMEDIATE- oder TRANSACTION-DEFERRED-Slices angezeigt, wobei die darin ausgeführten Anweisungen darunter verschachtelt sind. Der Thread, der einen Vorgang ausführt, erhält außerdem die Sofortereignisse START- und END- auf seinem Thread-Track, und zwar für jede Transaktion und für jede Anweisung außerhalb einer Transaktion. Die Namen dieser Ereignisse enden mit dem Namen des Verbindungstracks, z. B. -SQLiteConnection-0.
  • In Android 16 und niedriger hat jede einzelne SQL-Anweisung einen eigenen asynchronen Track im App-Prozess und Transaktionen werden nicht verfolgt.

Die Verbindungsnummern beginnen für jede Datenbank bei 0. Daher teilen sich die primären Verbindungen aller Datenbanken in einem Prozess den SQLiteConnection-0-Track. Wenn Ihre App mehrere Datenbanken gleichzeitig verwendet, messen Sie die Dauer mit den Ereignissen START- und END- in den Thread-Tracks.

Die Tracks „main thread“ und „SQLiteConnection-0“ in Perfetto, die eine LIKE-Abfrage zeigen, die die gesamte Tabelle 12 Millisekunden lang im Hauptthread scannt

Abbildung 1. Ein vollständiger Tabellenscan im Hauptthread unter Android 17.

Sie können diese Slices mit PerfettoSQL in der Perfetto-Benutzeroberfläche oder mit trace_processor analysieren. Mit der folgenden Abfrage werden die häufigsten SQLite-Vorgänge in jedem Prozess ermittelt. Diese Abfrage funktioniert mit allen Android-Versionen:

INCLUDE PERFETTO MODULE slices.with_context;

SELECT process_name, name AS operation, COUNT(*) AS count
FROM process_slice
WHERE name GLOB 'open *' OR name GLOB 'close *'
  OR name GLOB 'prepare *' OR name GLOB 'execute*'
  OR name GLOB 'TRANSACTION-*'
GROUP BY process_name, name
ORDER BY count DESC
LIMIT 10;

Mit der folgenden Abfrage werden SQLite-Vorgänge aufgelistet, die in Android 17 und höher länger als 1 ms in einem Hauptthread gedauert haben:

INCLUDE PERFETTO MODULE slices.with_context;

WITH marks AS (
  SELECT process_name, utid, is_main_thread, ts, name,
    LEAD(ts) OVER (PARTITION BY utid ORDER BY ts) AS end_ts,
    LEAD(name) OVER (PARTITION BY utid ORDER BY ts) AS end_name
  FROM thread_slice
  WHERE name GLOB 'START-*' OR name GLOB 'END-*'
)
SELECT process_name, ts, ROUND((end_ts - ts) / 1e6, 1) AS dur_ms,
  substr(name, 7) AS operation
FROM marks
WHERE name GLOB 'START-*'
  AND end_name = 'END-' || substr(name, 7)
  AND is_main_thread
  AND end_ts - ts > 1e6
ORDER BY dur_ms DESC
LIMIT 10;

Ein Perfetto-Abfrageergebnis, in dem SQLite-Vorgänge aufgeführt sind, die im Hauptthread einer Test-App länger als 1 Millisekunde dauern

Abbildung 2: SQLite-Vorgänge, die länger als 1 ms in einem Hauptthread dauern.

Mit der folgenden Abfrage werden Threads gefunden, die außerhalb einer expliziten Transaktion schreiben, wobei jeder Schreibvorgang für seinen eigenen Commit bezahlt, in Android 17 und höher:

INCLUDE PERFETTO MODULE slices.with_context;

WITH ops AS (
  SELECT process_name, thread_name, utid, name,
    upper(ltrim(substr(name, instr(name, ' ') + 1))) AS sql
  FROM thread_slice
  WHERE name GLOB 'START-*'
)
SELECT process_name, thread_name,
  SUM(name GLOB 'START-TRANSACTION-*') AS transactions,
  SUM(name GLOB 'START-execute*' AND (sql GLOB 'INSERT *'
    OR sql GLOB 'UPDATE *' OR sql GLOB 'DELETE *' OR sql GLOB 'REPLACE *'))
    AS autocommit_writes
FROM ops
GROUP BY utid
HAVING autocommit_writes > 0
ORDER BY autocommit_writes DESC
LIMIT 10;

dumpsys meminfo

adb shell dumpsys meminfo <package-name> gibt Statistiken zur Speichernutzung der App aus, einschließlich Details zu SQLite im Abschnitt DATABASES. Dieses Beispiel stammt aus der Ausgabe von adb shell dumpsys meminfo com.google.android.gms.persistent auf dem Gerät eines Entwicklers:

DATABASES
      pgsz     dbsz   Lookaside(b) cache hits cache misses cache size  Dbname
PER CONNECTION STATS
         4       52             45     8    41     6  /data/user/10/com.google.android.gms/databases/gaia-discovery
         4        8                    0     0     0    (attached) temp
         4       52             56     5    23     6  /data/user/10/com.google.android.gms/databases/gaia-discovery (1)
         4      252             95   233   124    12  /data/user_de/10/com.google.android.gms/databases/phenotype.db
         4        8                    0     0     0    (attached) temp
         4      252             17     0    17     1  /data/user_de/10/com.google.android.gms/databases/phenotype.db (1)
         4     9280            105 103169 69805    25  /data/user/10/com.google.android.gms/databases/phenotype.db
         4       20                    0     0     0    (attached) temp
         4     9280            108 13877  6394    25  /data/user/10/com.google.android.gms/databases/phenotype.db (2)
         4        8                    0     0     0    (attached) temp
         4     9280            105 12548  5519    25  /data/user/10/com.google.android.gms/databases/phenotype.db (3)
         4        8                    0     0     0    (attached) temp
         4     9280            107 18328  7886    25  /data/user/10/com.google.android.gms/databases/phenotype.db (1)
         4        8                    0     0     0    (attached) temp
         4       36             51   156    29     5  /data/user/10/com.google.android.gms/databases/mobstore_gc_db_v0
         4       36             97    47    27    10  /data/user/10/com.google.android.gms/databases/context_feature_default.db
         4       36             56     3    16     4  /data/user/10/com.google.android.gms/databases/context_feature_default.db (2)
         4      300             40  2111    24     5  /data/user/10/com.google.android.gms/databases/gservices.db
         4      300             39     3    17     4  /data/user/10/com.google.android.gms/databases/gservices.db (1)
         4       20             17     0    14     1  /data/user/10/com.google.android.gms/databases/gms.notifications.db
         4       20             33     1    15     2  /data/user/10/com.google.android.gms/databases/gms.notifications.db (1)
         4      120             40   143   163     4  /data/user/10/com.google.android.gms/databases/android_pay
         4      120            123    86    32    19  /data/user/10/com.google.android.gms/databases/android_pay (1)
         4       28             33     4    17     3  /data/user/10/com.google.android.gms/databases/googlesettings.db
POOL STATS
     cache hits  cache misses    cache size  Dbname
             13            68            81  /data/user/10/com.google.android.gms/databases/gaia-discovery
            233           145           378  /data/user_de/10/com.google.android.gms/databases/phenotype.db
         147921         89616        237537  /data/user/10/com.google.android.gms/databases/phenotype.db
            156            30           186  /data/user/10/com.google.android.gms/databases/mobstore_gc_db_v0
             50            57           107  /data/user/10/com.google.android.gms/databases/context_feature_default.db
           2114            43          2157  /data/user/10/com.google.android.gms/databases/gservices.db
              1            31            32  /data/user/10/com.google.android.gms/databases/gms.notifications.db
            229           197           426  /data/user/10/com.google.android.gms/databases/android_pay
              4            18            22  /data/user/10/com.google.android.gms/databases/googlesettings.db

Unter Android 14 und höher werden die im Beispiel gezeigten Tabellen PER CONNECTION STATS und POOL STATS ausgegeben. Unter Android 13 und niedriger wird nur die Tabelle pro Verbindung ausgegeben, wobei die Cache-Statistiken in einer einzigen cache-Spalte als hits/misses/size zusammengefasst werden.

Unter PER CONNECTION STATS finden Sie Folgendes:

  • pgsz: die Größe einer Datenbankseite in KB.
  • dbsz: die Größe der gesamten Datenbankdatei in KB.
  • Lookaside(b): die Anzahl der Lookaside-Speicherplätze, die die Verbindung verwendet. Trotz des (b) im Header ist dies eine Anzahl von Slots, nicht von Byte.
  • cache hits, cache misses und cache size: Statistiken für den Cache der vorbereiteten Anweisung der Verbindung. Diese Werte zählen kompilierte SQL-Anweisungen, nicht Datenbankseiten.
  • Dbname: der Pfad zur Datenbankdatei. Bei zusätzlichen Verbindungen zur selben Datenbank wird eine Zahl in Klammern angehängt, z. B. (1). Mit WAL kann eine Datenbank mehrere Verbindungen haben, sodass Lesevorgänge parallel ausgeführt werden können.

Unter POOL STATS finden Sie die Cache-Statistiken für vorbereitete Anweisungen für jeden Verbindungspool:

  • cache hits: SQLite speichert vorbereitete Anweisungen im Cache und versucht, sie beim Ausführen von Abfragen wiederzuverwenden, um beim Kompilieren von SQL-Anweisungen Aufwand und Arbeitsspeicher zu sparen. Dies ist die Anzahl der Cache-Treffer für Anweisungen.
  • cache misses: die Anzahl der Cache-Fehler für Anweisungen.
  • cache size: In Android 16 QPR2 und höher die Anzahl der vorbereiteten Anweisungen im Cache, summiert über die inaktiven Verbindungen des Pools. In Android 14 bis Android 16 QPR1 ist dieser Wert die Summe aus Treffern und Fehlern und entspricht nicht der Cache-Größe.

Anhand dieser Statistiken können Sie entscheiden, was Sie beheben möchten:

dumpsys dbinfo

adb shell dumpsys dbinfo <package-name> gibt den Status jedes Verbindungspools in Ihrer Anwendung aus. Fügen Sie -v hinzu, um auch die Anweisungen im Cache für vorbereitete Anweisungen jeder Verbindung aufzulisten. Die folgende Beispielausgabe stammt von Android 17, wobei Pfade und lange Zeilen gekürzt wurden:

** Database info for pid 2705 [com.example.app] **
Connection pool for /data/user/0/com.example.app/databases/settings.db:
  Open: true
  Max connections: 4
  Total execution time (ms): 4963
  Total statements executed: 231239
  Average time per statement (ms): 0
  Configuration: openFlags=805306368, ..., journalMode=WAL, syncMode=NORMAL
  IsReadOnlyDatabase: false
  Available primary connection:
    <none>
  Available non-primary connections:
    <none>
  Acquired connections:
    <none>
  Connection waiters:
    <none>
Connection pool for /data/user/0/com.example.app/databases/events.db:
  Open: true
  Max connections: 1
  Total execution time (ms): 842
  Total statements executed: 840
  Average time per statement (ms): 1
  Configuration: openFlags=268435456, ..., journalMode=TRUNCATE, syncMode=FULL
  IsReadOnlyDatabase: false
  Available primary connection:
    Connection #0:
      isPrimaryConnection: true
      onlyAllowReadOnlyOperations: false
      totalLongOperations: 0
      Most recently executed operations:
        0: [10-26 04:22:32.133] executeForChangedRowCount took 0ms -
           succeeded, sql="DELETE FROM params WHERE app_id=?", path=...
        1: [10-26 04:22:32.133] prepare took 0ms - succeeded,
           sql="DELETE FROM params WHERE app_id=?", path=...
        ...

Achten Sie auf Folgendes:

  • Configuration: der Journalmodus und der synchrone Modus des Pools. In Android 13 und höher werden in dieser Zeile die vom Framework angewendeten Modi angezeigt, einschließlich der Plattformstandardwerte. Im Beispiel verwendet events.db das Rollback-Journal mit dem synchronen Modus FULL, sodass bei jedem Commit auf eine vollständige Synchronisierung gewartet wird. In dieser Zeile werden keine PRAGMA-Anweisungen berücksichtigt, die Sie selbst ausführen.
  • Max connections: Ein Wert von 1 für eine beschreibbare Datenbank bedeutet, dass das Framework die Datenbank nicht im WAL-Modus betrachtet. Lese- und Schreibvorgänge teilen sich also eine einzelne Verbindung.
  • Total execution time, Total statements executed und Average time per statement: verfügbar in Android 13 und höher. Verwenden Sie sie, um die am stärksten ausgelasteten Datenbanken zu finden.
  • Most recently executed operations: die letzten 20 Vorgänge für jede Verbindung, mit ihrer Dauer und SQL. Suchen Sie nach Mustern wie derselben Abfrage, die in einer Schleife ausgeführt wird, oder vielen Schreibvorgängen nacheinander außerhalb einer Transaktion. In Android 15 QPR1 und höher enthält diese Liste auch explizite Transaktionen.
  • totalLongOperations und Operations exceeding 2000ms: Verfügbar in Android 15 QPR1 und höher. Sie zeigen, wie viele Vorgänge und Transaktionen länger als 2 Sekunden gedauert haben, und listen die letzten 10 auf.
  • Acquired connections und Connection waiters: Verbindungen, die verwendet werden, und Threads, die auf eine Verbindung warten. Wartende weisen auf Konflikte hin, z. B. eine lange Schreibtransaktion, die andere Schreibvorgänge blockiert.
  • Prepared statement cache: gedruckt mit -v. Viele Einträge, die sich nur in Literalwerten unterscheiden, weisen auf SQL hin, das mit Stringverkettung erstellt wurde.
  • Wenn derselbe Pfad unter mehr als einer Connection pool for-Überschrift angezeigt wird, bedeutet das, dass Ihre App dieselbe Datei zweimal geöffnet hat.

Zusätzliche Ressourcen

Inhalte ansehen