Saltar al contenido
Todas las entradas

Reducir una base de datos Room en 2026: VACUUM, auto_vacuum y las filas que nunca se van de verdad

Por qué el archivo de una base de datos Room/SQLite sigue creciendo incluso después de borrar filas, y cómo recuperar ese espacio de forma segura con VACUUM, auto_vacuum incremental y una tarea de limpieza real.

MFKAPPS 5 min de lectura

Borra diez mil filas de una base de datos Room y el archivo en disco no se hace más pequeño. Los usuarios lo notan de la forma más honesta: borran transacciones antiguas, desinstalan y reinstalan la app para “empezar de cero”, y la entrada de almacenamiento de la app en Ajustes apenas se mueve. No hay nada malo en el borrado — las filas desaparecieron, los resultados de las consultas son correctos — pero SQLite no devuelve páginas al sistema de archivos solo porque una tabla se haya encogido. Las marca como libres y las conserva, en el mismo archivo, para que la próxima escritura las reutilice.

Ese es un comportamiento predeterminado deliberado, no un error, y entender por qué funciona así es la mayor parte de lo que hace falta para solucionarlo.

Por qué el archivo no se encoge solo

SQLite organiza una base de datos en páginas de tamaño fijo. Un DELETE quita filas de una página y añade esa página a una lista libre interna (freelist) — disponible para el próximo INSERT, pero todavía asignada al archivo. Nada reduce el archivo en sí a menos que se lo pidas explícitamente a SQLite, porque esa es una operación distinta y mucho más costosa: tiene que reescribir toda la base de datos en un archivo nuevo con las páginas liberadas realmente eliminadas, y luego sustituirlo.

Por defecto, el modo auto_vacuum de Room es NONE. Esa es la elección correcta para la mayoría de las apps la mayor parte del tiempo — significa que cada escritura es solo una actualización de página, no una reescritura de archivo — pero también significa que una base de datos que creció a 40 MB durante un año de uso intenso y luego perdió el 90 % de sus filas sigue siendo un archivo de 40 MB. En una app como Granyn, donde alguien podría borrar años de historial de transacciones tras cambiar de método de presupuesto, ese hueco es exactamente de donde salen los correos de soporte sobre “hinchazón de almacenamiento”.

Tres formas de recuperar el espacio, y su coste real

PRAGMA vacuum reconstruye todo el archivo y recupera cada página libre de una sola vez. Es la opción más exhaustiva y la más costosa: necesita aproximadamente tanto espacio libre en disco como el que ocupa actualmente la base de datos, mantiene un bloqueo exclusivo durante toda su duración, y en una base de datos de decenas de megabytes en un dispositivo de gama media puede tardar desde una fracción de segundo perceptible hasta varios segundos. Nunca lo llames en el hilo principal, y nunca lo llames automáticamente en cada arranque de la app — es una operación de mantenimiento, no un paso de inicio.

auto_vacuum = INCREMENTAL es la que merece la pena usar por defecto en una app local-first que crece. Se establece una sola vez, antes de que exista ninguna tabla, y no reescribe el archivo automáticamente — en su lugar, te permite recuperar páginas poco a poco con PRAGMA incremental_vacuum(N), que puedes ejecutar según un calendario sin el coste de todo o nada de un VACUUM completo.

auto_vacuum = FULL recupera espacio automáticamente después de cada transacción. Suena conveniente y yo lo evitaría: convierte los borrados rutinarios en operaciones cargadas de reescritura, cambiando un coste de mantenimiento poco frecuente por un pequeño impuesto en cada escritura, para siempre.

auto_vacuum solo se puede establecer en una base de datos vacía, o cambiarse después mediante un VACUUM completo — no es un ajuste que puedas cambiar más adelante sin una reescritura, así que decide antes de publicar:

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

Ten en cuenta que establecer este pragma en una base de datos existente en modo NONE solo tiene efecto tras el siguiente VACUUM completo — este callback prepara correctamente una instalación nueva; una app ya publicada con NONE necesita una migración única que ejecute un VACUUM una vez para cambiar de modo.

La tarea de limpieza que en realidad importa mucho más que todo esto

VACUUM y el vacuuming incremental recuperan el espacio que SQLite ya sabe que está libre. No hacen nada respecto al espacio que tu propio esquema retiene deliberadamente — la causa mucho más habitual de la hinchazón en una app local-first. Una columna de borrado suave (isDeleted = true, conservada para deshacer o sincronizar) que nunca se borra de forma definitiva mantendrá la fila, y su espacio, para siempre, por muy agresivamente que hagas vacuum.

La solución es una tarea periódica de WorkManager que borra definitivamente las filas que han superado su periodo de gracia de borrado suave, y luego recupera las páginas que eso libera:

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

Prográmala como trabajo periódico único, una vez por semana, con una restricción de batería no baja — esto es limpieza, no algo que merezca despertar el dispositivo:

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) recupera hasta 500 páginas por ejecución en lugar de intentar hacerlo todo de golpe — lo bastante barato como para ejecutarse semanalmente en un hilo en segundo plano sin que el usuario lo note nunca.

Comprobar tu trabajo

No adivines si algo de esto ayudó — mídelo. PRAGMA page_count y PRAGMA page_size, multiplicados, dan el espacio real que está usando SQLite, y PRAGMA freelist_count te dice cuántas páginas están libres pero aún no se han devuelto al sistema de archivos:

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

Si freelist_count se mantiene alto versión tras versión, el vacuuming incremental no se está ejecutando con la suficiente frecuencia. Si el tamaño del archivo en sí sigue subiendo pese a una freelist baja, el problema no es el vacuuming en absoluto — es una columna de borrado suave que nadie borra de forma definitiva. Arregla el que realmente esté roto; ejecutar VACUUM más a menudo no limpiará las filas que tu propio esquema sigue conservando.