Перейти к содержимому
Все записи

Уменьшение базы данных Room в 2026 году: VACUUM, auto_vacuum и строки, которые никогда по-настоящему не уходят

Почему файл базы данных Room/SQLite продолжает расти даже после удаления строк, и как безопасно вернуть это пространство с помощью VACUUM, инкрементального auto_vacuum и настоящей задачи очистки.

MFKAPPS 5 мин чтения

Удалите десять тысяч строк из базы данных Room — файл на диске не станет меньше. Пользователи замечают это честным образом: они очищают старые транзакции, удаляют и переустанавливают приложение, чтобы “начать с чистого листа”, а запись о хранилище приложения в Настройках почти не сдвигается. С самим удалением всё в порядке — строки исчезли, результаты запросов верны, — но SQLite не возвращает страницы файловой системе просто потому, что таблица уменьшилась. Он помечает их как свободные и хранит в том же файле, чтобы следующая запись могла их переиспользовать.

Это осознанное поведение по умолчанию, а не баг, и понимание того, почему оно устроено именно так, — это большая часть того, что нужно, чтобы это исправить.

Почему файл не уменьшается сам по себе

SQLite организует базу данных в виде страниц фиксированного размера. DELETE удаляет строки со страницы и добавляет эту страницу во внутренний список свободных страниц (freelist) — доступную для следующего INSERT, но всё ещё выделенную файлу. Ничто не уменьшает сам файл, пока вы явно не попросите об этом SQLite, потому что это отдельная, гораздо более затратная операция: нужно переписать всю базу данных в новый файл, из которого освобождённые страницы действительно удалены, а затем подменить им старый.

По умолчанию режим auto_vacuum в Room — NONE. Это правильный выбор для большинства приложений в большинстве случаев — он означает, что каждая запись — это просто обновление страницы, а не перезапись файла, — но это также означает, что база данных, выросшая до 40 МБ за год интенсивного использования, а затем потерявшая 90% своих строк, всё равно остаётся файлом на 40 МБ. В приложении вроде Granyn, где кто-то может удалить многолетнюю историю транзакций после смены метода ведения бюджета, именно из этого разрыва и появляются письма в поддержку о “раздувании хранилища”.

Три способа вернуть пространство и их реальная цена

PRAGMA vacuum пересобирает весь файл и возвращает все свободные страницы разом. Это самый основательный и самый дорогой вариант: ему нужно примерно столько же свободного места на диске, сколько сейчас занимает база данных, он держит эксклюзивную блокировку на всё время выполнения, и на базе данных в десятки мегабайт на устройстве среднего класса это может занять от заметной доли секунды до нескольких секунд. Никогда не вызывайте это в главном потоке и никогда не вызывайте автоматически при каждом запуске приложения — это операция обслуживания, а не шаг запуска.

auto_vacuum = INCREMENTAL — вариант, который стоит сделать выбором по умолчанию для растущего local-first приложения. Установленный один раз, до создания каких-либо таблиц, он не переписывает файл автоматически — вместо этого он позволяет возвращать страницы понемногу с помощью PRAGMA incremental_vacuum(N), который можно запускать по расписанию без цены “всё или ничего” полного VACUUM.

auto_vacuum = FULL автоматически возвращает пространство после каждой транзакции. Звучит удобно, но я бы этого избегал: это превращает рутинные удаления в операции, перегруженные перезаписью, обменивая редкую цену обслуживания на маленький налог на каждую запись — навсегда.

auto_vacuum можно установить только на пустой базе данных или изменить позже с помощью полного VACUUM — это не настройка, которую можно переключить позже без перезаписи, так что решайте до релиза:

Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
    .addCallback(object : RoomDatabase.Callback() {
        override fun onOpen(db: SupportSQLiteDatabase) {
            db.query("PRAGMA auto_vacuum").use { if (it.moveToFirst() && it.getInt(0) == 0) {
                db.execSQL("PRAGMA auto_vacuum = INCREMENTAL")
            } }
        }
    })
    .build()

Обратите внимание: установка этой прагмы на существующей базе данных в режиме NONE вступит в силу только после следующего полного VACUUM — этот callback корректно подготавливает новую установку; приложению, уже выпущенному с NONE, потребуется одноразовая миграция, которая один раз запускает VACUUM для смены режима.

Задача очистки, которая на самом деле важнее всего этого

VACUUM и инкрементальный vacuuming возвращают пространство, которое SQLite уже считает свободным. Они никак не помогают с пространством, которое ваша собственная схема удерживает намеренно, — а это гораздо более распространённая причина раздувания в local-first приложении. Столбец мягкого удаления (isDeleted = true, сохраняемый для отмены действия или синхронизации), который никогда не удаляется по-настоящему, будет держать строку и её место навсегда, сколь бы агрессивно вы ни делали vacuum.

Решение — периодическая задача WorkManager, которая окончательно удаляет строки, у которых истёк льготный период мягкого удаления, а затем возвращает освободившиеся страницы:

class DatabaseMaintenanceWorker(
    context: Context,
    params: WorkerParameters,
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        val cutoff = System.currentTimeMillis() - THIRTY_DAYS_MS
        val db = AppDatabase.getInstance(applicationContext)

        db.transactionDao().hardDeleteSoftDeletedBefore(cutoff)
        db.openHelper.writableDatabase.execSQL("PRAGMA incremental_vacuum(500)")

        return Result.success()
    }

    companion object {
        private const val THIRTY_DAYS_MS = 30L * 24 * 60 * 60 * 1000
    }
}

Запланируйте её как уникальную периодическую задачу, раз в неделю, с условием “заряд батареи не низкий” — это уборка, а не что-то, ради чего стоит будить устройство:

val request = PeriodicWorkRequestBuilder<DatabaseMaintenanceWorker>(7, TimeUnit.DAYS)
    .setConstraints(Constraints.Builder().setRequiresBatteryNotLow(true).build())
    .build()

WorkManager.getInstance(context).enqueueUniquePeriodicWork(
    "db_maintenance",
    ExistingPeriodicWorkPolicy.KEEP,
    request,
)

incremental_vacuum(500) возвращает до 500 страниц за один запуск вместо попытки сделать всё разом — достаточно дёшево, чтобы запускать это еженедельно в фоновом потоке так, что пользователь никогда этого не заметит.

Проверка результата

Не гадайте, помогло ли что-то из этого — измеряйте. PRAGMA page_count и PRAGMA page_size, перемноженные, дают реальный объём, который использует SQLite, а PRAGMA freelist_count сообщает, сколько страниц свободны, но ещё не возвращены файловой системе:

fun databaseStats(db: SupportSQLiteDatabase): Pair<Long, Long> {
    val pageCount = db.query("PRAGMA page_count").use { it.moveToFirst(); it.getLong(0) }
    val pageSize = db.query("PRAGMA page_size").use { it.moveToFirst(); it.getLong(0) }
    val freePages = db.query("PRAGMA freelist_count").use { it.moveToFirst(); it.getLong(0) }
    return (pageCount * pageSize) to (freePages * pageSize)
}

Если freelist_count остаётся высоким от релиза к релизу, значит инкрементальный vacuuming запускается недостаточно часто. Если сам размер файла продолжает расти несмотря на низкий freelist, проблема вовсе не в vacuuming — это столбец мягкого удаления, который никто не удаляет окончательно. Чините то, что действительно сломано; более частый запуск VACUUM не очистит строки, которые всё ещё удерживает ваша собственная схема.

// По теме

Ещё из журнала

MFKAPPS 4 мин чтения

Room TypeConverters в 2026 году: как хранить enum'ы, даты и списки, не повреждая схему

Практическое руководство по Room TypeConverters на Android — enum'ы, Instant/LocalDate и списки — а также ошибки, которые превращают конвертер в незаметный баг повреждения данных.

#android #engineering #room
MFKAPPS 4 мин чтения

Индексы базы данных Room в 2026 году: находим по-настоящему медленный запрос и исправляем его

Практическое руководство по индексированию базы Room/SQLite на Android — чтение EXPLAIN QUERY PLAN, добавление @Index без угадывания и ошибки, которые незаметно сводят индекс на нет.

#android #engineering #room
MFKAPPS 4 мин чтения

@Relation в Room: запрос данных «один ко многим» на Android без N+1-запросов

Практическое руководство по аннотации @Relation в Room — моделирование данных «один ко многим», таких как категории и записи, без N+1-запросов и ручных join'ов.

#android #engineering #room