Aller au contenu
Tous les articles

Réduire une base de données Room en 2026 : VACUUM, auto_vacuum et les lignes qui ne partent jamais vraiment

Pourquoi le fichier d'une base de données Room/SQLite continue de grossir même après la suppression de lignes, et comment récupérer cet espace en toute sécurité avec VACUUM, l'auto_vacuum incrémental et une véritable tâche de nettoyage.

MFKAPPS 5 min de lecture

Supprimez dix mille lignes d’une base de données Room, et le fichier sur le disque ne rétrécit pas. Les utilisateurs le remarquent de la manière la plus honnête : ils suppriment de vieilles transactions, désinstallent puis réinstallent l’app pour « repartir de zéro », et l’entrée de stockage de l’app dans les Réglages bouge à peine. Rien ne cloche dans la suppression — les lignes ont disparu, les résultats des requêtes sont corrects — mais SQLite ne rend pas les pages au système de fichiers simplement parce qu’une table a rétréci. Il les marque comme libres et les garde, dans le même fichier, pour que la prochaine écriture les réutilise.

C’est un comportement par défaut délibéré, pas un bug, et comprendre pourquoi il fonctionne ainsi représente l’essentiel de ce qu’il faut savoir pour le corriger.

Pourquoi le fichier ne rétrécit pas tout seul

SQLite organise une base de données en pages de taille fixe. Un DELETE retire des lignes d’une page et ajoute cette page à une liste libre interne (freelist) — disponible pour le prochain INSERT, mais toujours allouée au fichier. Rien ne réduit le fichier lui-même à moins de le demander explicitement à SQLite, car c’est une opération distincte, bien plus coûteuse : il doit réécrire toute la base de données dans un nouveau fichier où les pages libérées sont réellement supprimées, puis le substituer à l’ancien.

Par défaut, le mode auto_vacuum de Room est NONE. C’est le bon choix pour la plupart des apps, la plupart du temps — cela signifie que chaque écriture n’est qu’une mise à jour de page, pas une réécriture de fichier — mais cela signifie aussi qu’une base de données qui a grossi jusqu’à 40 Mo pendant un an d’utilisation intensive, puis dont 90 % des lignes ont été supprimées, reste un fichier de 40 Mo. Dans une app comme Granyn, où quelqu’un peut supprimer des années d’historique de transactions après avoir changé de méthode de budgétisation, c’est exactement de là que viennent les e-mails de support sur le « gonflement du stockage ».

Trois façons de récupérer l’espace, et leur coût réel

PRAGMA vacuum reconstruit tout le fichier et récupère chaque page libre d’un coup. C’est l’option la plus complète et la plus coûteuse : elle nécessite à peu près autant d’espace disque libre que la base de données en occupe actuellement, elle maintient un verrou exclusif pendant toute sa durée, et sur une base de données de quelques dizaines de mégaoctets sur un appareil milieu de gamme, cela peut prendre d’une fraction de seconde perceptible à plusieurs secondes. Ne l’appelez jamais sur le thread principal, et ne l’appelez jamais automatiquement à chaque lancement de l’app — c’est une opération de maintenance, pas une étape de démarrage.

auto_vacuum = INCREMENTAL est celle qu’il vaut la peine d’adopter par défaut pour une app locale-first en croissance. Défini une seule fois, avant que la moindre table existe, il ne réécrit pas le fichier automatiquement — il vous permet plutôt de récupérer les pages petit à petit avec PRAGMA incremental_vacuum(N), que vous pouvez exécuter selon un calendrier sans le coût tout-ou-rien d’un VACUUM complet.

auto_vacuum = FULL récupère l’espace après chaque transaction automatiquement. Cela semble pratique, et je l’éviterais : cela transforme les suppressions courantes en opérations lourdes en réécriture, échangeant un coût de maintenance rare contre une petite taxe sur chaque écriture, pour toujours.

auto_vacuum ne peut être défini que sur une base de données vide, ou modifié ensuite via un VACUUM complet — ce n’est pas un réglage que vous pouvez basculer plus tard sans réécriture, donc décidez avant de publier :

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

Notez que définir ce pragma sur une base de données existante en mode NONE ne prend effet qu’après le prochain VACUUM complet — ce callback prépare correctement une nouvelle installation ; une app déjà publiée avec NONE a besoin d’une migration unique qui exécute un VACUUM une fois pour changer de mode.

La tâche de nettoyage qui compte en réalité bien plus que tout cela

VACUUM et le vacuuming incrémental récupèrent l’espace que SQLite sait déjà être libre. Ils ne font rien pour l’espace que votre propre schéma retient délibérément — la cause bien plus fréquente du gonflement dans une app locale-first. Une colonne de suppression douce (isDeleted = true, conservée pour l’annulation ou la synchronisation) jamais réellement supprimée gardera la ligne, et son espace, pour toujours, quelle que soit l’agressivité de vos vacuums.

La solution est une tâche WorkManager périodique qui supprime définitivement les lignes ayant dépassé leur délai de grâce de suppression douce, puis récupère les pages ainsi libérées :

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

Planifiez-la comme un travail périodique unique, une fois par semaine, avec une contrainte de batterie non faible — c’est du nettoyage, pas quelque chose qui mérite de réveiller l’appareil :

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) récupère jusqu’à 500 pages par exécution plutôt que d’essayer de tout faire d’un coup — assez peu coûteux pour être exécuté chaque semaine sur un thread d’arrière-plan sans qu’un utilisateur ne le remarque jamais.

Vérifier votre travail

Ne devinez pas si l’une de ces mesures a aidé — mesurez-le. PRAGMA page_count et PRAGMA page_size, une fois multipliés, donnent l’espace réel utilisé par SQLite, et PRAGMA freelist_count vous indique combien de pages sont libres mais pas encore rendues au système de fichiers :

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 reste élevé version après version, le vacuuming incrémental ne s’exécute pas assez souvent. Si la taille du fichier elle-même continue de grimper malgré une freelist basse, le problème n’est pas le vacuuming du tout — c’est une colonne de suppression douce que personne ne supprime définitivement. Corrigez celui qui est réellement cassé ; exécuter VACUUM plus souvent ne nettoiera pas les lignes que votre propre schéma continue de conserver.