Индексы базы данных Room в 2026 году: находим по-настоящему медленный запрос и исправляем его
Практическое руководство по индексированию базы Room/SQLite на Android — чтение EXPLAIN QUERY PLAN, добавление @Index без угадывания и ошибки, которые незаметно сводят индекс на нет.
Приложение на Room, которое на тестах откликалось мгновенно, спустя месяцы может начать подтормаживать — когда у реального пользователя накопится тысяча транзакций вместо дюжины, на которых вы всё проверяли. Обычный инстинкт — добавить индекс куда-нибудь и понадеяться на лучшее. Это срабатывает примерно так же часто, как и нет, потому что индексы ускоряют не таблицу целиком, а конкретный шаблон доступа, и неправильный индекс добавляет накладные расходы на запись без всякой пользы для чтения. Вот как найти реально медленный запрос, подтвердить это и правильно его проиндексировать.
Не угадывайте — измеряйте
SQLite точно скажет вам, как он планирует выполнить запрос, если попросить. Добавьте EXPLAIN QUERY PLAN перед любым запросом и выполните его через adb shell или сырой запрос Room:
@RawQuery
fun explain(query: SupportSQLiteQuery): List<ExplainRow>
EXPLAIN QUERY PLAN
SELECT * FROM transactions WHERE category_id = 7 ORDER BY date DESC;
Строка вывода рассказывает всю историю. SCAN transactions значит, что SQLite читает каждую строку таблицы и проверяет условие для каждой — стоимость растёт линейно с размером таблицы. SEARCH transactions USING INDEX idx_transactions_category (category_id=?) значит, что он сразу переходит к подходящим строкам. Вся суть индексирования сводится к тому, чтобы превратить SCAN в SEARCH для тех запросов, которые вы реально выполняете часто, и не трогать всё остальное.
Ошибка, которой стоит избегать здесь, — индексировать столбец потому, что он кажется важным. Столбец notes фильтруют редко; category_id, используемый в предложении WHERE на каждом экране списка, — совсем другое дело. Прежде чем что-то трогать, прогоните EXPLAIN QUERY PLAN по своим пяти-шести самым горячим DAO-запросам — тем, что формируют основные экраны списков и выполняются при каждом открытии приложения.
Добавление индекса в Room
Room предоставляет доступ к CREATE INDEX SQLite через параметр indices аннотации @Entity:
@Entity(
tableName = "transactions",
indices = [Index(value = ["category_id"]), Index(value = ["date"])],
)
data class Transaction(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val categoryId: Long,
val date: Long,
val amountCents: Long,
)
Это изменение схемы, поэтому нужна Migration, как и при добавлении столбца:
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id)")
db.execSQL("CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date)")
}
}
Экспорт схемы Room (exportSchema = true плюс аргумент компилятора room.schemaLocation) пометит расхождение между индексами вашей @Entity и вручную написанной миграцией, если они разойдутся, — обычно именно так эту ошибку и ловят до релиза.
Ловушка составного индекса
Запрос, который фильтрует по category_id и сортирует по date — ровно тот же паттерн, что выше, — не получает полной выгоды от двух отдельных однoколоночных индексов. В большинстве случаев SQLite может использовать только один индекс на таблицу на запрос, поэтому выбирает более избирательный и всё равно вынужден сортировать результаты в памяти. Составной индекс, охватывающий оба столбца в правильном порядке, позволяет SQLite использовать индекс и для фильтра, и для возврата строк уже отсортированными:
indices = [Index(value = ["category_id", "date"])]
Здесь важен порядок. Этот индекс обслуживает и WHERE category_id = ?, и WHERE category_id = ? ORDER BY date, потому что оба условия читают индекс слева направо. Он не помогает запросу, который фильтрует только по date, — для этого всё равно понадобится однoколоночный индекс по date или второй составной индекс, где date стоит первым. Проверьте EXPLAIN QUERY PLAN ещё раз после добавления составного индекса; если в плане всё ещё видно USE TEMP B-TREE FOR ORDER BY, порядок столбцов не соответствует тому, что нужно запросу.
Индексы не бесплатны
Каждый индекс, который поддерживает SQLite, приходится обновлять при каждом INSERT, UPDATE или DELETE, затрагивающем индексированный столбец. Для таблицы вроде transactions в приложении для бюджета записи относительно редки по сравнению с чтениями — горстка вставок в день против десятков отрисовок списка — так что компромисс лёгкий. А для таблицы, которая постоянно пишется и редко читается (журнал событий, очередь синхронизации), те же три индекса, что помогли таблице транзакций, могут заметно замедлить запись без какой-либо пользы для чтения, которую кто-либо заметит. Индексируйте таблицы, к которым обращаются при каждом открытии экрана, а не каждую таблицу в схеме.
Первичный ключ уже получает неявный индекс — включать его снова в indices избыточно. То же касается столбца с @PrimaryKey или уже объявленного unique = true в другом индексе; SQLite создаёт базовый индекс автоматически.
Где это действительно важно
Я наткнулся на это скучным способом, в Granyn: список транзакций, который казался мгновенным при паре сотен строк, начал заметно подвисать при фильтрации по категории, как только реальное использование перевалило за несколько тысяч строк. EXPLAIN QUERY PLAN для DAO-запроса показал полный SCAN — у фильтра category_id не было индекса, который можно использовать. Добавление составного индекса выше вернуло отфильтрованный запрос от полного сканирования таблицы к поиску по индексу, и подвисание исчезло. Никаких изменений архитектуры, никакой новой библиотеки — только две правильные строки в миграции.
Урок распространяется дальше этой одной таблицы: прежде чем тянуться к пагинации, кэшированию или переписыванию, когда local-first приложение замедляется, прогоните EXPLAIN QUERY PLAN для запроса, который реально медленный. Чаще всего решение — это индекс, он маленький и обратимый, если вы ошиблись.
// По теме
Ещё из журнала
Room TypeConverters в 2026 году: как хранить enum'ы, даты и списки, не повреждая схему
Практическое руководство по Room TypeConverters на Android — enum'ы, Instant/LocalDate и списки — а также ошибки, которые превращают конвертер в незаметный баг повреждения данных.
@Relation в Room: запрос данных «один ко многим» на Android без N+1-запросов
Практическое руководство по аннотации @Relation в Room — моделирование данных «один ко многим», таких как категории и записи, без N+1-запросов и ручных join'ов.
Полнотекстовый поиск в Room: добавляем мгновенный поиск в local-first Android-приложение в 2026
Практическое руководство по поддержке FTS4 в Room на Android — создание виртуальной таблицы поиска, синхронизация через триггеры и почему FTS5 требует ручной миграции.