提升 SQLite 效能的最佳做法 (檢視區塊)

概念和 Jetpack Compose 實作

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

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

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

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

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

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

建議使用 Jetpack Room 程式庫

設定資料庫以提升效能

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

放寬同步處理模式

使用 WAL 功能時,根據預設,每個修訂版本都會發出 fsync,確保資料能傳送至磁碟。這可提升資料耐用性,但會拖慢修訂版本執行速度。

SQLite 提供控管同步模式的選項。如已啟用 WAL,請將同步模式設為 NORMAL

Kotlin

// When opening the database
val paramsBuilder: SQLiteDatabase.OpenParams.Builder = SQLiteDatabase.OpenParams.Builder()
paramsBuilder.journalMode = SQLiteDatabase.SYNC_MODE_NORMAL

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

Java

// When opening the database
SQLiteDatabase.OpenParams.Builder paramsBuilder = new SQLiteDatabase.OpenParams.Builder();
paramsBuilder.setJournalMode(SQLiteDatabase.SYNC_MODE_NORMAL);

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

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

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

改善查詢效能

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

僅讀取所需資料列

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

Kotlin

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

Java

try (Cursor cursor = db.rawQuery("""
    SELECT name
    FROM Customers
    LIMIT 10;
    """, null)) {
  while (cursor.moveToNext()) {
    // Process cursor data
  }
}

僅讀取所需資料欄

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

在以下範例中,選取的是 idnamephone

Kotlin

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

Java

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

try (Cursor cursor = db.rawQuery("""
    SELECT id, name, phone
    FROM customers;
    """, null)) {
  while (cursor.moveToNext()) {
    String name = cursor.getString(1);
    // Further processing
  }
}

不過,您只需要 name 欄:

Kotlin

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

Java

try (Cursor cursor = db.rawQuery("""
    SELECT name
    FROM Customers;
    """, null)) {
  while (cursor.moveToNext()) {
    String name = cursor.getString(0);
    // Further processing
  }
}

參數化查詢

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

Kotlin

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

Java

@Nullable
public String getNameById(long id) {
  try (Cursor cursor = db.rawQuery(
      "SELECT name FROM customers WHERE id=" + id, null)) {
    if (cursor.moveToFirst()) {
      return cursor.getString(0);
    } else {
      return null;
    }
  }
}

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

Kotlin

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

Java

@Nullable
public String getNameById(long id) {
  try (Cursor cursor = db.rawQuery("""
          SELECT name
          FROM customers
          WHERE id=?
      """, new String[] {String.valueOf(id)})) {
    if (cursor.moveToFirst()) {
      return cursor.getString(0);
    } else {
      return null;
    }
  }
}

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

使用 DISTINCT 處理不重複值

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

Kotlin

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

Java

try (Cursor cursor = db.rawQuery("""
    SELECT DISTINCT name
    FROM Customers;
    """, null)) {
  while (cursor.moveToNext()) {
    // Only iterate over distinct names in Java
    // Process distinct name
  }
}

盡量使用匯總函式

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

Kotlin

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

Java

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

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

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

Kotlin

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

Java

try (Cursor cursor = db.rawQuery("""
    SELECT EXISTS (
      SELECT null
      FROM Customers
      WHERE city = 'Paris'
    );
    """, null)) {
  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:加總資料欄中的所有數值。
  • MINMAX:決定最低或最高值。適用於數值欄、DATE 類型和文字類型。
  • AVG:找出平均數值。
  • GROUP_CONCAT:以選用分隔符串連字串。

COUNT() 取代 Cursor.getCount()

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

Kotlin

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

Java

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

try (Cursor cursor = db.rawQuery("""
    SELECT id
    FROM Customers;
    """, null)) {
  int count = cursor.getCount();
  // Use count
}

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

Kotlin

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

Java

try (Cursor cursor = db.rawQuery("""
    SELECT COUNT(*)
    FROM Customers;
    """, null)) {
  cursor.moveToFirst();
  int count = cursor.getInt(0);
  // Use count
}

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

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

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

Kotlin

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

Java

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

try (Cursor cursor = db.rawQuery("""
    SELECT city
    FROM Customers
    GROUP BY city
    ORDER BY COUNT(*) DESC
    LIMIT 1;
    """, null)) {
  if (cursor.moveToFirst()) {
    String topCity = cursor.getString(0);
    try (Cursor innerCursor = db.rawQuery("""
        SELECT name, city
        FROM Customers
        WHERE city = ?;
        """, new String[] {topCity})) {
        while (innerCursor.moveToNext()) {
          // Process inner cursor data
        }
    }
  }
}

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

Kotlin

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

Java

try (Cursor cursor = db.rawQuery("""
    SELECT name, city
    FROM Customers
    WHERE city IN (
      SELECT city
      FROM Customers
      GROUP BY city
      ORDER BY COUNT(*) DESC
      LIMIT 1
    );
    """, null)) {
  while(cursor.moveToNext()) {
    // Process cursor data
  }
}

檢查 SQL 中的不重複值

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

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

Kotlin

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

Java

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

try (Cursor cursor = db.rawQuery("""
    SELECT EXISTS (
      SELECT null
      FROM customers
      WHERE username = ?
    );
    """, new String[] { customer.username })) {
  if (cursor.moveToFirst() && cursor.getInt(0) == 1) {
    throw new AddCustomerException(customer);
  }
}
db.execSQL(
    "INSERT INTO customers VALUES (?, ?, ?)",
    new String[] {
      String.valueOf(customer.id),
      customer.name,
      customer.username,
    });

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

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

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

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

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

Kotlin

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

Java

try {
  db.execSQL(
      "INSERT INTO Customers VALUES (?, ?, ?)",
      new String[] {
        String.valueOf(customer.id),
        customer.name,
        customer.username,
      });
} catch (SQLiteConstraintException e) {
  throw new AddCustomerException(customer, e);
}

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

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

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

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

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

Kotlin

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

Java

db.beginTransaction();
try {
  for (customer : Customers) {
    db.execSQL(
        "INSERT INTO Customers VALUES (?, ?, ?)",
        new String[] {
          String.valueOf(customer.id),
          customer.name,
          "customerValue"
        });
  }
} finally {
  db.endTransaction()
}