本文へスキップ
すべての記事

2026年、Roomデータベースを縮小する: VACUUM、auto_vacuum、そして本当には消えない行

行を削除してもRoom/SQLiteのデータベースファイルが大きくなり続ける理由と、VACUUM、段階的なauto_vacuum、実際に機能するクリーンアップジョブでその領域を安全に取り戻す方法。

MFKAPPS 1 分で読めます

Roomデータベースから1万行を削除しても、ディスク上のファイルは小さくならない。ユーザーはこれを正直な形で気づく。古い取引を削除し、「まっさらにする」ためにアプリをアンインストールして再インストールし、それでも設定画面のアプリのストレージ表示はほとんど動かない。削除自体には何の問題もない — 行は消え、クエリの結果は正しい — しかしSQLiteは、テーブルが縮んだからといってページをファイルシステムに返すわけではない。それらを空きとしてマークし、次の書き込みが再利用できるよう同じファイル内に保持し続ける。

これは意図的なデフォルト動作であってバグではない。なぜそのように動くのかを理解することが、修正に必要なことのほとんどを占める。

ファイルが自然には縮まない理由

SQLiteはデータベースを固定サイズのページとして管理する。DELETEはページから行を取り除き、そのページを内部の空きリスト(freelist)に追加する — 次のINSERTで使えるようになるが、依然としてファイルに割り当てられたままだ。SQLiteに明示的に指示しない限り、ファイル自体が縮むことはない。なぜならそれは別の、はるかにコストの高い操作だからだ。データベース全体を、解放されたページを実際に取り除いた新しいファイルへ書き直し、それを差し替える必要がある。

デフォルトでは、Roomのauto_vacuumモードはNONEだ。これはほとんどのアプリのほとんどの場合において正しい選択であり — すべての書き込みがファイルの書き直しではなく単なるページの更新で済むことを意味する — しかし同時に、1年間の激しい使用で40MBまで成長し、その後行の90%が削除されたデータベースも、依然として40MBのファイルのままであることを意味する。Granynのようなアプリでは、予算管理の方法を変えた後に何年分もの古い取引履歴を削除することがあり、「ストレージの肥大化」に関するサポートメールがまさにそこから生まれる。

領域を取り戻す3つの方法と、その実際のコスト

**PRAGMA vacuum**はファイル全体を再構築し、すべての空きページを一度に取り戻す。最も徹底的な選択肢であり、最もコストが高い。データベースが現在占めているのとほぼ同じだけの空きディスク容量が必要で、実行中は排他ロックを保持し、ミドルレンジ端末上で数十メガバイトのデータベースでは体感できる程度の一瞬から数秒かかることがある。メインスレッドでは絶対に呼び出さず、アプリ起動のたびに自動で呼び出すこともしないこと — これはメンテナンス操作であり、起動時の手順ではない。

**auto_vacuum = INCREMENTAL**は、成長し続けるローカルファーストのアプリでデフォルトにする価値があるものだ。テーブルが1つも存在しない段階で一度だけ設定すれば、ファイルを自動的に書き直すことはない — その代わり、完全なVACUUMのオールオアナッシングのコストなしにスケジュールに沿って実行できるPRAGMA incremental_vacuum(N)によって、少しずつページを取り戻せるようになる。

**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が実行されるまでは効果がない点に注意してほしい — このコールバックは新規インストールを正しく準備するものであり、すでにNONEのまま公開済みのアプリには、モードを切り替えるために一度だけVACUUMを実行する1回限りの移行処理が必要になる。

それらすべてよりも実際にはるかに重要なクリーンアップジョブ

VACUUMと段階的なvacuumingは、SQLiteがすでに空いていると認識している領域を取り戻すものだ。自分のスキーマが意図的に保持している領域については何もしてくれない — これがローカルファーストのアプリにおける肥大化のはるかに一般的な原因だ。undoや同期のために残されている論理削除の列(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_countPRAGMA 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をより頻繁に実行しても、自分のスキーマが今も保持し続けている行は片付かない。