提升 SQLite 效能的最佳做法

SQLite 是高效的 SQL 資料庫,而 Android 內建 SQLite 支援功能。只要採用下列最佳做法,就能充分提升應用程式效能,確保即使資料量增加,應用程式仍能維持快速穩定的表現。透過這些最佳做法,您也可以減少遇到效能問題的可能性,畢竟這類問題不容易重現及排解。

如要加快運作效能,請依循下列效能原則:

  • 減少讀取的資料列/欄數量:以最佳方式查詢內容,僅擷取必要資料。請盡可能減少從資料庫讀取的資料量,因為過度擷取資料可能會影響效能。

  • 將工作推送至 SQLite 引擎:在 SQL 查詢中執行運算、篩選和排序作業。使用 SQLite 的查詢引擎可大幅提升效能。

  • 修改資料庫結構定義:妥善設計資料庫的結構定義,讓 SQLite 建構有效的查詢計畫和資料表示法。請為資料表建立適當索引,並改善資料表結構,進而提升效能。

此外,還能使用我們提供的疑難排解工具評估 SQLite 資料庫的效能,找出需要最佳化的部分。

建議使用 Jetpack Room 程式庫。

設定資料庫以提升效能

請按照本節步驟設定資料庫,進而在 SQLite 中獲得最佳效能。

啟用預寫記錄功能

SQLite 實作異動時,會將異動內容附加至記錄檔,而記錄檔偶爾會壓縮到資料庫中,這項技術稱為預寫記錄 (WAL)。

除非您使用 ATTACH DATABASE,否則請啟用 WAL。

請透過架構啟用 WAL,而非自行執行 PRAGMA journal_mode=WAL。在非低 RAM 裝置上,Room 預設會啟用 WAL。如果不使用 Room,請改用下列其中一種方法:

當架構知道資料庫使用 WAL 時,會開啟連線集區,讓讀取作業與寫入作業同時執行,並套用下一節所述的 WAL 同步模式。如果您自行執行 PRAGMA journal_mode=WAL,架構仍會將資料庫視為復原日誌資料庫。這項作業會使用單一連線,且修訂版本會保留預設的 FULL 同步模式。

放寬同步處理模式

SQLite 提供控管同步模式的選項。在 FULL 模式下,每個修訂版本都會發出 fsync,確保資料能傳送至磁碟。這可提升資料耐用性,但會拖慢修訂版本執行速度。使用 WAL 時,NORMAL 模式不會在每次修訂時同步,但仍可保護資料庫免於損毀。

透過架構啟用 WAL 時,Android 會套用裝置的預設 WAL 同步模式,在 Android 9 以上版本的 AOSP 中為 NORMAL。裝置製造商可以變更這項預設值,而不使用 WAL 的資料庫則預設為 FULL。如要確保資料庫使用 NORMAL,請在開啟資料庫時設定:

// 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")

在此設定下,修訂版本可在資料儲存至磁碟前傳回。當裝置因為沒電或發生核心錯誤等原因而關機時,修訂資料可能會遺失。不過因為有記錄功能,資料庫並不會損毀。

如果只是應用程式當機,資料仍會傳送至磁碟。對大多數應用程式來說,這項設定不需花費大量成本,就能提升效能。

管理連結和記憶

每個開啟的資料庫連線都會保留 SQLite 記憶體:頁面快取、lookaside 分配器,以及已編譯陳述式的快取。資料庫檔案頁面也會保留在核心頁面快取中。請按照本節的做法,在不降低查詢速度的情況下,控管這項記憶體。

開啟每個資料庫一次

在整個程序中,每個資料庫檔案使用單一 SQLiteOpenHelper 或 RoomDatabase 執行個體,例如將其設為單例模式。每個執行個體都有專屬的連線集區、頁面快取和陳述式快取,開啟相同檔案的兩個執行個體無法共用這些項目。在一項基準測試中,將開啟相同檔案的兩個輔助程式合併為一個,可將 SQLite 頁面快取記憶體減少近一半,並將 p95 查詢延遲時間減少一半以上。

如要找出重複的例項,請檢查 dumpsys dbinfo,看看多個 Connection pool for 標題下方是否有相同的資料庫路徑。

避免在開啟回呼中變更結構定義

每次資料庫開啟時,SQLiteOpenHelper 的 onOpen 回呼都會執行,RoomDatabase.Callback 的 onOpen 回呼也會執行。請將 CREATE TABLE IF NOT EXISTS 和 CREATE INDEX IF NOT EXISTS 等陳述式放在 onCreate 和 onUpgrade 中,或放在 Room 遷移中。請勿在 onOpen 中呼叫 setVersion,因為 SQLiteOpenHelper 已在 onCreate 和 onUpgrade 之後設定版本。即使結構定義已存在,這些陳述式仍會在每次開啟時執行,且 setVersion 會寫入資料庫檔案。

調整預先編譯陳述式快取的大小

Android 會快取每個連線的已編譯陳述式。快取預設會保留 25 個陳述式,您可以使用 setMaxSqlCacheSize 將上限提高至 MAX_SQL_CACHE_SIZE (100)。如果資料庫在熱路徑中執行超過 25 個不同的陳述式,快取就會發生顛簸,SQLite 也會反覆編譯相同的陳述式。

首先,請將查詢參數化。任何快取大小都無法協助以字串串連方式建構的 SQL,因為每個字串都是新的陳述式。然後在 dumpsys meminfo 的 POOL STATS 下方,查看陳述式快取命中和未命中。舉例來說,該部分範例輸出內容中的 phenotype.db 資料庫有 147,921 次命中和 89,616 次未命中,未命中率為 38%。提高這類資料庫的快取大小,幾乎可以消除所有未命中。每個快取的陳述式會使用幾 KB 的記憶體。

如要提高快取大小,請覆寫 SQLiteOpenHelper 子類別中的 onConfigure:

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

如果您使用 Room,請在 RoomDatabase.Callback 的 onOpen 回呼中,對 SupportSQLiteDatabase 呼叫相同的方法。

關閉閒置的資料庫,但讓忙碌的資料庫保持開啟

開啟的資料庫即使處於閒置狀態,也會保留連線、快取和檔案描述元。在某項基準測試中,處於 WAL 模式且保持開啟狀態的閒置 Room 資料庫,會占用約 678 KB 的 SQLite 頁面快取記憶體和 5 個檔案描述元。如果您只是偶爾使用資料庫 (例如在每日同步期間),請在完成後關閉資料庫。

如要安全關閉閒置資料庫,請遵循下列規範:

  • Room 可使用實驗性 API setAutoCloseTimeout 關閉閒置資料庫。Room 會在下次存取時重新開啟資料庫,並再次執行 onOpen 回呼,因此請在該處設定連線。
  • 避免使用 SQLiteOpenHelper 和 SQLiteDatabase.OpenParams.Builder 的 setIdleConnectionTimeout 方法。這些方法在 API 級別 29 中已淘汰,因為它們會關閉連線,而不會還原您使用 PRAGMA 陳述式套用的設定。
  • 請勿關閉每隔幾秒就會查詢的資料庫。如果逾時時間短於查詢之間的間隔,資料庫就會反覆關閉及重新開啟,每次都會捨棄頁面快取和陳述式快取,導致下次查詢速度變慢。

SQLite 記憶體

SQLite 記憶體可能難以偵測。SQLite 頁面快取和陳述式快取是原生堆積分配量。不過,Android 預設不會啟用 SQLite 的記憶體對應 I/O,因此 SQLite 會透過一般檔案讀取作業讀取資料庫檔案,而核心快取的檔案內容則是未對應的檔案頁面。不會計入 dumpsys meminfo 中應用程式的 PSS。在採用 cgroup v2 記憶體計算機制的裝置上,核心仍會將這些頁面計入應用程式的記憶體 cgroup。如要減少這類隱藏足跡,請使用索引減少讀取的頁面數量,並只讀取需要的資料列和資料欄。關閉不需要的資料庫,也能釋出 SQLite 快取。

定義高效的資料表結構定義

如要盡可能提升效能並降低資料用量,請設定高效的資料表結構定義。SQLite 會建構有效的查詢計畫和資料,進而加快資料擷取速度。本節說明建立資料表結構定義的最佳做法。

考慮採用 INTEGER PRIMARY KEY

請為這個範例定義並填入資料表,如下所示:

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');

資料表輸出內容如下:

rowid id 姓名 城市
1 456 約翰藍儂 英國利物浦
2 123 麥可傑克森 印第安納州蓋瑞
3 789 桃莉巴頓 田納西州塞維爾郡

rowid 欄是保存插入順序的索引。依 rowid 篩選的查詢會實作為快速的 B 樹搜尋作業,依 id 篩選的查詢則是緩慢的資料表掃描作業。

如果您打算依 id 執行查詢,可避免儲存 rowid 資料欄,進而減少儲存空間中的資料,提升資料庫整體速度:

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

現在資料表會如下所示:

id 姓名 城市
123 麥可傑克森 印第安納州蓋瑞
456 約翰藍儂 英國利物浦
789 桃莉巴頓 田納西州塞維爾郡

由於您不需要儲存 rowid 欄,因此 id 查詢速度會很快。請注意,資料表現在是依 id 排序,而非依照插入順序。

利用索引加快查詢速度

SQLite 會使用索引加快查詢速度。在篩選 (WHERE)、排序 (ORDER BY) 或匯總 (GROUP BY) 資料欄時,如果資料表有資料欄的索引,查詢速度就會加快。

在上一個範例中,如果想依照 city 篩選,必須掃描整個資料表:

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

如果應用程式的城市查詢數量龐大,您可以利用索引加快這類查詢:

CREATE INDEX city_index ON Customers(city);

索引會實作為額外資料表,依索引欄排序,並對應至 rowid:

城市 rowid
印第安納州蓋瑞 2
英國利物浦 1
田納西州塞維爾郡 3

請注意,city 欄的儲存空間成本現已翻倍,因為原始資料表和索引都會顯示這項資料。但由於您採用索引,新增的儲存空間成本並不會白費,反而能帶來加快查詢速度的優勢。不過,請不要保留未使用的索引,以免平白花費儲存空間成本,而未提升查詢效能。

建立多欄索引

如果查詢牽涉多個資料欄,您可以建立多欄索引,充分加快查詢速度。您也可以在外部資料欄使用索引,並讓內部搜尋以線性掃描的方式執行。

以下列查詢為例:

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

您可以按照查詢中指定的順序,使用多欄索引加快查詢速度:

CREATE INDEX city_name_index ON Customers(city, name);

如果只有 city 的索引,外部排序仍會加快,但內部排序需執行線性掃描作業。

這也適用於前置字串查詢。舉例來說,索引 ON Customers (city, name) 也能依照 city 加快篩選、排序及分組的速度,因為多欄索引的索引資料表會按照指定的索引順序排序。

考慮採用 WITHOUT ROWID

根據預設,SQLite 會為資料表建立 rowid 欄,其中 rowid 是隱式 INTEGER PRIMARY KEY AUTOINCREMENT。如果已有屬於 INTEGER PRIMARY KEY 的資料欄,這個資料欄就會變成 rowid 的別名。

若資料表的主鍵不是 INTEGER 或複合資料欄,請考慮採用 WITHOUT ROWID。

分別將小型/大型資料儲存為 BLOB 和檔案

如果想將圖片縮圖、聯絡人相片等大型資料與資料列建立關聯,可以將這類資料儲存在 BLOB 欄或檔案中,再將路徑儲存在資料欄中。

檔案通常會以 4 KB (四捨五入) 做為遞增單位。對極小型檔案來說,四捨五入誤差的影響很大,因此較有效率的做法是以 BLOB 的形式將檔案儲存在資料庫。SQLite 可減少檔案系統的呼叫次數,且在某些情況下,速度會比基礎檔案系統更快。

改善查詢效能

請按照下列最佳做法,盡可能縮短回應時間並提高處理效率,進而改善 SQLite 查詢效能。

僅讀取所需資料列

您可以利用篩選器指定特定條件,例如日期範圍、位置或名稱,縮小搜尋結果範圍。設定限制可用來控制顯示的結果數量:

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

僅讀取所需資料欄

如果選取不需要的資料欄,可能會減慢查詢速度並浪費資源,因此請只選取要使用的資料欄。

在以下範例中,選取的是 id、name 和 phone:

// 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
    }
}

不過,您只需要 name 欄:

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

參數化查詢

查詢字串可能包含只有在執行階段才知道的參數,例如:

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
        }
    }
}

在上述程式碼中,每個查詢都會建構不同的字串,因此無法從陳述式快取中受益。每次呼叫都需要 SQLite 先編譯才能執行。您可以改用參數取代 id 引數,並使用 selectionArgs 繫結值:

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
        }
    }
}

現在,查詢可以編譯一次並快取。編譯後的查詢會在 getNameById(long) 的不同叫用之間重複使用。

在 SQL 中疊代,而非在程式碼中

請使用單一查詢傳回所有目標結果,而非透過程式輔助迴圈疊代 SQL 查詢來傳回個別結果。程式輔助迴圈的速度比單一 SQL 查詢慢約 1000 倍。

使用 DISTINCT 處理不重複值

使用 DISTINCT 關鍵字可減少需要處理的資料量,因此能改善查詢效能。舉例來說,如果只想傳回資料欄中的不重複值,就可以使用 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
    }
}

盡量使用匯總函式

對於沒有列資料的匯總結果,不妨使用匯總函式。舉例來說,以下程式碼會檢查是否至少有一個相符的資料列,就適用這種做法:

// 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
}

如果只需擷取第一個資料列,在沒有相符的資料列時,可以使用 EXISTS() 傳回 0;當有一或多個資料列相符時,則可傳回 1:

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
    }
}

請在應用程式程式碼中使用 SQLite 匯總函式:

  • COUNT:計算資料欄中的列數。
  • SUM:加總資料欄中的所有數值。
  • MIN 或 MAX:決定最低或最高值。適用於數值欄、DATE 類型和文字類型。
  • AVG:找出平均數值。
  • GROUP_CONCAT:以選用分隔符串連字串。

以 COUNT() 取代 Cursor.getCount()

在以下範例中,Cursor.getCount() 函式會讀取資料庫中的所有資料列,並傳回所有資料列值:

// 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
}

不過,如果使用 COUNT(),資料庫就只會傳回計數:

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

為查詢 (而非程式碼) 建立巢狀結構

SQL 具有可組合的性質,可支援子查詢、彙整和外鍵限制。因此即使不存取應用程式程式碼,也可以在另一項查詢中使用某項查詢的結果。這個方式可減少從 SQLite 複製資料的需求,並讓資料庫引擎將查詢最佳化。

在以下範例中,您可以藉由執行查詢,找出哪個城市的客戶最多,然後利用另一項查詢的結果,找出該城市的所有客戶:

// 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
            }
        }
    }
}

如果希望只用上述例子的一半時間取得結果,請使用單一的 SQL 查詢搭配巢狀陳述式:

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
    }
}

檢查 SQL 中的不重複值

如果只有在資料表中特定資料欄的值未重複時,才能插入資料列,那麼強制將該不重複值設為資料欄限制,可能是更有效率的做法。

在以下範例中,系統會執行一項查詢來驗證要插入的資料列,並透過另一項查詢實際插入資料列:

// 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
    )
)

定義資料表時,您可以在 SQL 中檢查限制的不重複值,而不用在 Kotlin 中檢查:

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

SQLite 的情況相同,如下所示:

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

您現在可以插入資料列,讓 SQLite 檢查限制條件:

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

SQLite 支援具有多個資料欄的不重複索引:

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

相較於 Kotlin 程式碼,SQLite 驗證限制條件的速度更快,且負擔較少。因此最佳做法是使用 SQLite,而非應用程式程式碼。

在單一交易中批次處理多個插入項目

交易可修訂多項作業,同時提高效率和準確度。因此,如要提升資料一致性和效能,您可以批次處理插入項目:

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

在明確交易之外,每項寫入作業都會在自己的隱含交易中執行,並支付完整的修訂費用,包括 FULL 同步模式中的 fsync。批次處理只需支付一次費用,因此批次寫入的速度可提高一個數量級以上。

使用疑難排解工具

SQLite 提供下列疑難排解工具,協助您評估效能。

使用 SQLite 的互動提示

請在電腦上執行 SQLite,接著開始執行查詢及學習。不同 Android 平台版本會採用不同的 SQLite 修訂版本。如要使用 Android 裝置上的引擎,請在目標裝置上使用 adb shell 並執行 sqlite3。

您可以要求 SQLite 記錄查詢時間:

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

EXPLAIN QUERY PLAN

您可以使用 EXPLAIN QUERY PLAN,要求 SQLite 提供有關查詢回答方式的說明:

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

在上一個範例中,必須在沒有索引的情況下執行完整的資料表掃描作業,才能找出巴黎的所有客戶。這就是「線性複雜度」。SQLite 需要讀取所有資料列,但只保留與巴黎客戶相符的資料列。如要修正這個問題,您可以新增索引:

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=?

如果使用互動式殼層,則可要求 SQLite 一律說明查詢計畫:

sqlite> .eqp on

詳情請參閱「查詢計畫」。

SQLite 分析工具

SQLite 提供 sqlite3_analyzer 指令列介面 (CLI),可轉儲用於排解效能問題的其他資訊。如要安裝此介面,請前往 SQLite 下載頁面。

若要進行分析,您可以使用 adb pull,從目標裝置將資料庫檔案下載至工作站:

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

SQLite 瀏覽器

您也可以前往 SQLite 下載頁面,安裝 GUI 工具 SQLite 瀏覽器。

Android 記錄功能

Android 會記錄 SQLite 查詢時間,供您查閱:

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

在 Android 13 以上版本中,您也可以只記錄緩慢作業。設定以毫秒為單位的門檻,然後重新啟動應用程式,讓應用程式讀取記錄標記:

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

緩慢作業會顯示在 SQLiteConnection 標記下的 logcat 中,並附上其持續時間、SQL 和資料庫路徑。如要設定單一應用程式的門檻,請使用 db.log.slow_query_threshold.<uid>,其中 <uid> 是應用程式的 UID。

Perfetto 追蹤

設定 Perfetto 時,請新增 database atrace 類別,在追蹤記錄中加入 SQLite 作業。如要查看每個執行緒在執行 SQL 時執行的作業,請一併新增排程器事件:

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 會將每項資料庫作業追蹤為以作業及其 SQL 命名的切片,例如 executeForCursorWindow SELECT * FROM message WHERE body LIKE ?。SQL 會保留 ? 預留位置,並截斷為 256 個字元。不含繫結引數。作業包括 open、close、prepare、execute 和 executeFor 變體。prepare 切片並不代表陳述式快取失敗。

測試群組版面配置會因 Android 版本而異:

  • 在 Android 17 以上版本中,每個連線在應用程式程序中都有一個非同步追蹤記錄,名稱為 SQLiteConnection-,後面接著連線編號。交易會顯示為 TRANSACTION-EXCLUSIVE、TRANSACTION-IMMEDIATE 或 TRANSACTION-DEFERRED 切片,其中執行的陳述式會以巢狀結構顯示在下方。執行作業的執行緒也會在執行緒軌跡上,針對每筆交易和交易外的每個陳述式,取得 START- 和 END- 即時事件。這些事件的名稱結尾為連線追蹤名稱,例如 -SQLiteConnection-0。
  • 在 Android 16 以下版本中,每個不同的 SQL 陳述式在應用程式程序中都有專屬的非同步追蹤記錄,且不會追蹤交易。

每個資料庫的連線編號都會從 0 開始,因此程序中所有資料庫的主要連線都會共用 SQLiteConnection-0 追蹤記錄。如果應用程式同時使用多個資料庫,請在執行緒追蹤記錄上使用 START- 和 END- 事件來測量時間長度。

Perfetto 中的主執行緒和 SQLiteConnection-0 追蹤記錄,顯示 LIKE 查詢在主執行緒上掃描整個資料表 12 毫秒

圖 1. 在 Android 17 的主執行緒上執行完整資料表掃描。

您可以在 Perfetto UI 中使用 PerfettoSQL 或 trace_processor 分析這些片段。下列查詢會找出每個程序中最頻繁的 SQLite 作業。這項查詢適用於所有 Android 版本:

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;

下列查詢會列出在 Android 17 以上版本中,主執行緒上耗時超過 1 毫秒的 SQLite 作業:

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;

Perfetto 查詢結果,列出測試應用程式主要執行緒上超過 1 毫秒的 SQLite 作業

圖 2. 主執行緒上超過 1 毫秒的 SQLite 作業。

下列查詢會找出在明確交易外寫入的執行緒,其中每次寫入都會在 Android 17 以上版本中支付自己的修訂版本:

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> 會列印與應用程式記憶體用量相關的統計資料,包括 DATABASES 區段中 SQLite 的詳細資料。舉例來說,以下是從開發人員裝置上的 adb shell dumpsys meminfo com.google.android.gms.persistent 輸出內容中擷取的:

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

Android 14 以上版本會輸出範例中顯示的 PER CONNECTION STATS 和 POOL STATS 表格。Android 13 以下版本只會列印每個連線的資料表,並將快取統計資料合併為單一 cache 欄,格式為 hits/misses/size。

「PER CONNECTION STATS」下方會顯示以下資訊:

  • pgsz:單一資料庫頁面的大小,以 KB 為單位。
  • dbsz:整個資料庫檔案的大小,以 KB 為單位。
  • Lookaside(b):連線使用的後備記憶體插槽數量。雖然標頭中有 (b),但這是運算單元數量,而非位元組。
  • cache hits、cache misses 和 cache size:連線預備陳述式快取的統計資料。這些值會計算已編譯的 SQL 陳述式,而非資料庫頁面。
  • Dbname:資料庫檔案的路徑。如果與同一個資料庫有其他連線,則會附加括號中的數字,例如 (1)。有了 WAL,資料庫可以有多個連線,因此可以平行執行讀取作業。

在 POOL STATS 下方,您會看到每個連線集區的預備陳述式快取統計資料:

  • cache hits:SQLite 會快取預先備妥的陳述式,並在執行查詢時嘗試重複使用,以節省編譯 SQL 陳述式所需的時間和記憶體。這是陳述式快取命中次數。
  • cache misses:陳述式快取失敗次數。
  • cache size:在 Android 16 QPR2 以上版本中,快取中預備陳述式的數量,即集區閒置連線的總和。在 Android 14 到 Android 16 QPR1 中,這個值是命中和未命中的總和,不代表快取大小。

請根據這些統計資料決定要修正的問題:

  • 未命中率偏高表示 SQLite 會重複編譯相同的陳述式。首先,找出以字串串連建構的 SQL,並參數化。如果查詢已參數化,且每個連線的 cache size 已達上限 25,請提高快取大小。
  • 如果資料庫路徑出現多次,且沒有 (1) 樣式的後置字串,表示應用程式開啟了同一個檔案兩次。逐一開啟資料庫。
  • 應用程式不常使用的資料庫在開啟時仍會占用記憶體。完成後關閉。

dumpsys dbinfo

adb shell dumpsys dbinfo <package-name> 會列印應用程式中每個連線集區的狀態。新增 -v,也會列出每個連線的預先編譯陳述式快取中的陳述式。以下範例輸出內容來自 Android 17,路徑和長行已縮短:

** 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=...
        ...

請找出以下項目:

  • Configuration:集區的日誌模式和同步模式。在 Android 13 以上版本中,這一行會顯示架構套用的模式,包括平台預設值。在範例中,events.db 使用復原日誌和 FULL 同步模式,因此每次修訂都會等待完整同步。這行不會反映您自行執行的 PRAGMA 陳述式。
  • Max connections:可寫入資料庫的值為 1,表示架構不認為資料庫處於 WAL 模式,因此讀取和寫入作業會共用單一連線。
  • Total execution time、Total statements executed 和 Average time per statement:適用於 Android 13 以上版本。可用來找出最忙碌的資料庫。
  • Most recently executed operations:每個連線最近 20 項作業,以及作業時間長度和 SQL。請找出特定模式,例如在迴圈中執行相同查詢,或在交易外連續寫入多筆資料。在 Android 15 QPR1 以上版本中,這份清單也會包含明確交易。
  • totalLongOperations 和 Operations exceeding 2000ms:適用於 Android 15 QPR1 以上版本。這些圖表會顯示耗時超過 2 秒的作業和交易數量,並列出最近 10 筆。
  • Acquired connections 和 Connection waiters:使用中的連線和等待連線的執行緒。等待者表示爭用,例如長時間的寫入交易會封鎖其他寫入。
  • Prepared statement cache:使用 -v 列印。如果許多項目的差異僅在於常值,則表示 SQL 是以字串串連建構而成。
  • 如果多個 Connection pool for 標題下出現相同路徑,表示應用程式開啟了同一個檔案兩次。

其他資源

查看內容