SQLite のパフォーマンスに関するベスト プラクティス(ビュー)

概念と Jetpack Compose の実装

Android には、効率的な SQL データベースである SQLite の組み込みサポートが 用意されています。データの増加に伴ってアプリの速度と予測可能性を維持するには、以下のベスト プラクティスに沿ってアプリのパフォーマンスを最適化してください。これらのベスト プラクティスを使用すると、再現とトラブルシューティングが難しいパフォーマンスの問題が発生する可能性も低くなります。

パフォーマンスを改善するには、次のパフォーマンス原則に従います。

  • 読み取り対象の行数と列数を減らす: 必要なデータのみを取得するようにクエリを最適化します。データ取得量が過剰になるとパフォーマンスに影響する可能性があるため、データベースから読み取られるデータの量は最小限に抑えてください。

  • SQLite エンジンに処理を push する: SQL クエリ内で計算、フィルタリング、並べ替えを行います。SQLite のクエリエンジンを使用すると、パフォーマンスが大幅に向上する場合があります。

  • データベース スキーマを変更する: SQLite で効率的なクエリプランとデータ表現を作成できるようにデータベース スキーマを設計します。テーブルを適切にインデックス登録し、テーブル構造を最適化してパフォーマンスを改善します。

また、利用可能なトラブルシューティング ツールを使用して、最適化が必要な領域を特定できるように SQLite データベースのパフォーマンスを測定することもできます。

Jetpack Room ライブラリを使用することをおすすめします。

パフォーマンスを重視してデータベースを構成する

このセクションの手順に沿って、SQLite でパフォーマンスを最適化するためにデータベースを構成します。

同期モードを緩和する

WAL を使用する場合、デフォルトでは、データが確実にディスクに届くようにすべての commit で fsync が発行されます。これにより、データの耐久性は向上しますが、commit は低速になります。

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

この設定では、データがディスクに保存される前に commit が返される場合があります。停電やカーネル パニックなどが原因で、デバイスがシャットダウンすると、commit されたデータが失われる可能性があります。ただし、ロギングが原因でデータベースが破損することはありません。

アプリのクラッシュのみが発生した状態では、データはディスクに届きます。ほとんどのアプリでは、この設定により、機材に費用を投じることなくパフォーマンスを改善できます。

クエリのパフォーマンスを向上させる

応答時間を最小限に抑え、処理効率を最大化することで、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;
    }
  }
}

これで、クエリを 1 回コンパイルしてキャッシュに保存できます。コンパイルされたクエリは、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
  }
}

可能な限り集計関数を使用する

行データのない集計結果には、集計関数を使用します。たとえば次のコードでは、一致する行が 1 つ以上あるかどうかを確認します。

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 つ以上の行が一致する場合は 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: すべての数値を列に追加します。
  • MIN または MAX: 最小値または最大値を指定します。数値列、DATE 型、テキスト型で機能します。
  • AVG: 平均数値を見つけます。
  • GROUP_CONCAT: 文字列を必要に応じて区切り文字を使用して連結します。

Cursor.getCount() ではなく COUNT() を使用する

次の例では、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 で一意性を確認する

テーブル内で特定の列の値が一意でない限り、行を挿入する必要がない場合は、該当する一意性を列の制約として適用した方が効率的です。

次の例では、挿入される行を検証するために 1 つのクエリを実行し、実際に挿入するために別のクエリを実行します。

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,
    });

Kotlin または Java の一意の制約を確認する代わりに、テーブルを定義するときに SQL で確認できます。

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, ...);

SQLite は制約を Kotlin コードや Java コードよりも高速かつ、より少ないオーバーヘッドで検証します。アプリのコードではなく SQLite を使用することをおすすめします。

1 つのトランザクションで複数の挿入をバッチ処理する

1 つのトランザクションで複数のオペレーションが commit されるため、効率だけでなく正確性も向上します。データの整合性を向上させ、パフォーマンスを高速化するには、一括挿入を使用します。

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