Рекомендации по повышению производительности SQLite

Android предлагает встроенную поддержку SQLite , эффективной базы данных SQL. Следуйте этим рекомендациям, чтобы оптимизировать производительность вашего приложения, обеспечивая его быструю и предсказуемую работу по мере роста объёма данных. Использование этих рекомендаций также снижает вероятность возникновения проблем с производительностью, которые трудно воспроизвести и устранить.

Для достижения более высокой производительности следуйте этим принципам:

  • Сокращение количества считываемых строк и столбцов : Оптимизируйте запросы, чтобы извлекать только необходимые данные. Минимизируйте объем данных, считываемых из базы данных, поскольку избыточное извлечение данных может негативно сказаться на производительности.

  • Перенесите часть работы на движок SQLite : выполняйте вычисления, фильтрацию и сортировку в рамках SQL-запросов. Использование механизма запросов SQLite может значительно повысить производительность.

  • Измените схему базы данных : спроектируйте схему базы данных таким образом, чтобы SQLite мог создавать эффективные планы запросов и представления данных. Правильно индексируйте таблицы и оптимизируйте структуру таблиц для повышения производительности.

Кроме того, вы можете использовать доступные инструменты для устранения неполадок, чтобы измерить производительность вашей базы данных SQLite и выявить области, требующие оптимизации.

Мы рекомендуем использовать библиотеку Jetpack Room .

Настройте базу данных для повышения производительности.

Выполните действия, описанные в этом разделе, чтобы настроить базу данных SQLite для оптимальной производительности.

Включить запись в журнал на предварительное ведение.

В SQLite изменения данных реализуются путем добавления их в журнал, который периодически сжимается в базу данных. Это называется опережающей записью в журнал (Write-Ahead Logging, WAL) .

Включите WAL, если вы не используете ATTACH DATABASE .

Ослабьте режим синхронизации

При использовании WAL по умолчанию каждый коммит запускает fsync , чтобы гарантировать доставку данных на диск. Это повышает надежность данных, но замедляет процесс коммитов.

В SQLite есть опция для управления синхронным режимом . Если вы включаете WAL, установите синхронный режим на NORMAL :

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

В этом случае операция фиксации может завершиться до того, как данные будут сохранены на диске. Если произойдет выключение устройства, например, при отключении питания или сбое ядра, зафиксированные данные могут быть потеряны. Однако благодаря логированию ваша база данных не будет повреждена.

Если приложение просто вылетает, данные всё равно сохраняются на диск. Для большинства приложений эта настройка обеспечивает повышение производительности без существенных затрат.

Определите эффективные схемы таблиц

Для оптимизации производительности и минимизации потребления данных определите эффективную схему таблицы. 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');

В результате просмотра таблицы отображается следующая информация:

роуид идентификатор имя город
1 456 Джон Леннон Ливерпуль, Англия
2 123 Майкл Джексон Гэри, Индиана
3 789 Долли Партон Округ Севир, штат Теннесси

Столбец rowid представляет собой индекс, сохраняющий порядок вставки. Запросы, фильтрующие по rowid , реализуются как быстрый поиск по B-дереву, а запросы, фильтрующие по id представляют собой медленное сканирование таблицы.

Если вы планируете выполнять поиск по id , вы можете избежать хранения столбца rowid , что позволит уменьшить объем хранимых данных и в целом ускорить работу базы данных:

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

Теперь ваша таблица выглядит следующим образом:

идентификатор имя город
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 :

город роуид
Гэри, Индиана 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 КБ. Для очень маленьких файлов, где ошибка округления значительна, эффективнее хранить их в базе данных как 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-запросы для получения отдельных результатов, используйте один запрос, возвращающий все целевые результаты. Программный цикл примерно в 1000 раз медленнее, чем один SQL-запрос.

Используйте 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
    )
)

Вместо проверки уникальности в Kotlin, вы можете проверить её в 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 проверить ограничение:

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

SQLite проверяет ограничения быстрее и с меньшими накладными расходами, чем код на Kotlin. Использование SQLite вместо кода приложения является лучшей практикой.

Объединение нескольких вставок в одну транзакцию.

Транзакция фиксирует несколько операций, что повышает не только эффективность, но и корректность. Для повышения согласованности данных и ускорения работы можно использовать пакетную вставку:

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

Используйте инструменты для устранения неполадок.

SQLite предоставляет следующие инструменты для устранения неполадок, помогающие оценить производительность.

Воспользуйтесь интерактивной командной строкой SQLite.

Запустите SQLite на своем компьютере, чтобы выполнять запросы и учиться. Разные версии платформы Android используют разные версии SQLite. Чтобы использовать тот же движок, что и на устройстве под управлением Android, используйте adb shell и запустите sqlite3 на целевом устройстве.

Вы можете попросить SQLite измерить время выполнения запросов:

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

EXPLAIN QUERY PLAN

Вы можете попросить SQLite объяснить, как он намерен ответить на запрос, используя EXPLAIN QUERY PLAN :

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 предоставляет интерфейс командной строки (CLI) sqlite3_analyzer для вывода дополнительной информации, которую можно использовать для устранения неполадок, связанных с производительностью. Для установки посетите страницу загрузки SQLite .

С помощью adb pull можно загрузить файл базы данных с целевого устройства на рабочую станцию ​​для анализа:

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

SQLite Browser

Вы также можете установить графический инструмент SQLite Browser на странице загрузок 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

трассировка Перфетто

При настройке Perfetto вы можете добавить следующие параметры для включения дорожек для отдельных запросов:

data_sources {
  config {
    name: "linux.ftrace"
    ftrace_config {
      atrace_categories: "database"
    }
  }
}

dumpsys meminfo

adb shell dumpsys meminfo <package-name> выведет статистику использования памяти приложением, включая некоторые сведения о памяти 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

В разделе DATABASES вы найдете:

  • pgsz : размер одной страницы базы данных в КБ.
  • dbsz : размер всей базы данных в страницах. Чтобы получить размер в КБ, умножьте pgsz на dbsz .
  • Lookaside(b) : объем памяти, выделяемый для буфера ассоциативной связи SQLite на каждое соединение, в байтах. Обычно они очень малы.
  • cache hits : SQLite поддерживает кэш страниц базы данных. Это количество попаданий страниц в кэш (count).
  • cache misses : количество промахов кэша страниц (количество).
  • cache size : количество страниц в кэше (count). Чтобы получить размер в КБ, умножьте это число на pgsz .
  • Dbname : путь к файлу базы данных. В нашем примере к имени некоторых баз данных добавляется (1) или другое число, указывающее на наличие более чем одного подключения к одной и той же базовой базе данных. Статистика отслеживается для каждого подключения.

В разделе POOL STATS вы найдете:

  • cache hits : SQLite кэширует подготовленные запросы и пытается повторно использовать их при выполнении запросов, чтобы сэкономить ресурсы и память при компиляции SQL-запросов. Это количество попаданий в кэш запросов (count).
  • cache misses : количество промахов кэша запросов (количество).
  • cache size : начиная с Android 17, здесь отображается общее количество подготовленных запросов в кэше. В более ранних версиях это значение эквивалентно сумме попаданий и промахов, указанных в двух других столбцах, и не отражает размер кэша.

Дополнительные ресурсы

Просмотры контента

{% verbatim %} {% endverbatim %} {% verbatim %} {% endverbatim %}