2026 में Room डेटाबेस को छोटा करना: VACUUM, auto_vacuum, और वे पंक्तियाँ जो कभी सच में नहीं जातीं
पंक्तियाँ डिलीट करने के बाद भी Room/SQLite डेटाबेस फ़ाइल क्यों बढ़ती रहती है, और VACUUM, इन्क्रीमेंटल auto_vacuum और एक असली क्लीनअप जॉब से उस जगह को सुरक्षित रूप से कैसे वापस पाया जाए।
एक Room डेटाबेस से दस हज़ार पंक्तियाँ डिलीट कर दीजिए, डिस्क पर मौजूद फ़ाइल छोटी नहीं होती। यूज़र्स इसे सबसे ईमानदार तरीके से नोटिस करते हैं: वे पुराने ट्रांज़ैक्शन साफ़ करते हैं, “फ्रेश स्टार्ट” के लिए ऐप अनइंस्टॉल करके दोबारा इंस्टॉल करते हैं, और सेटिंग्स में ऐप की स्टोरेज एंट्री मुश्किल से हिलती है। डिलीट में कुछ भी गलत नहीं है — पंक्तियाँ जा चुकी हैं, क्वेरी के नतीजे सही हैं — लेकिन SQLite सिर्फ इसलिए पेज फ़ाइल सिस्टम को वापस नहीं करता क्योंकि कोई टेबल छोटी हो गई। यह उन्हें खाली के रूप में चिह्नित करता है और उसी फ़ाइल में रखता है, ताकि अगली राइट उन्हें दोबारा इस्तेमाल कर सके।
यह एक जानबूझकर किया गया डिफ़ॉल्ट व्यवहार है, कोई बग नहीं, और यह समझना कि यह ऐसे क्यों काम करता है, इसे ठीक करने के लिए ज़रूरी ज़्यादातर समझ यही देता है।
फ़ाइल अपने आप क्यों नहीं सिकुड़ती
SQLite एक डेटाबेस को निश्चित आकार के पेजों में व्यवस्थित करता है। एक DELETE किसी पेज से पंक्तियाँ हटाता है और उस पेज को एक आंतरिक फ्रीलिस्ट में जोड़ देता है — अगले INSERT के लिए उपलब्ध, लेकिन अभी भी फ़ाइल को आवंटित। जब तक आप SQLite से साफ़ तौर पर न कहें, फ़ाइल खुद नहीं सिकुड़ती, क्योंकि यह एक अलग, कहीं ज़्यादा महंगा ऑपरेशन है: इसे पूरे डेटाबेस को एक नई फ़ाइल में फिर से लिखना पड़ता है जिसमें खाली हुए पेज वाकई हटा दिए गए हों, फिर उसे बदलना पड़ता है।
डिफ़ॉल्ट रूप से, Room का auto_vacuum मोड NONE होता है। यह ज़्यादातर ऐप्स के लिए ज़्यादातर समय सही विकल्प है — इसका मतलब है कि हर राइट सिर्फ एक पेज अपडेट है, फ़ाइल की दोबारा राइटिंग नहीं — लेकिन इसका यह भी मतलब है कि एक साल के भारी इस्तेमाल में 40 MB तक बढ़ चुका और फिर जिसकी 90% पंक्तियाँ डिलीट हो चुकी हों, ऐसा डेटाबेस अब भी 40 MB की फ़ाइल ही रहता है। Granyn जैसे ऐप में, जहाँ बजट का तरीका बदलने के बाद कोई व्यक्ति सालों पुराना ट्रांज़ैक्शन इतिहास डिलीट कर सकता है, “स्टोरेज ब्लोट” के बारे में सपोर्ट ईमेल ठीक यहीं से आते हैं।
जगह वापस पाने के तीन तरीके, और उनकी असली कीमत
PRAGMA vacuum पूरी फ़ाइल को फिर से बनाता है और हर खाली पेज को एक साथ वापस पा लेता है। यह सबसे संपूर्ण विकल्प है और सबसे महंगा भी: इसे लगभग उतनी ही खाली डिस्क जगह चाहिए जितनी डेटाबेस फ़िलहाल घेरे हुए है, यह पूरी अवधि के लिए एक एक्सक्लूसिव लॉक रखता है, और मिड-रेंज डिवाइस पर दसियों मेगाबाइट के डेटाबेस पर यह एक सेकंड के महसूस होने लायक हिस्से से लेकर कुछ सेकंड तक ले सकता है। इसे कभी भी मेन थ्रेड पर मत बुलाइए, और हर ऐप लॉन्च पर इसे अपने आप मत बुलाइए — यह एक मेंटेनेंस ऑपरेशन है, स्टार्टअप का कोई क़दम नहीं।
auto_vacuum = INCREMENTAL वह है जिसे बढ़ते हुए लोकल-फर्स्ट ऐप के लिए डिफ़ॉल्ट बनाना उचित है। किसी भी टेबल के बनने से पहले एक बार सेट किया गया, यह फ़ाइल को अपने आप दोबारा नहीं लिखता — इसके बजाय यह आपको 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 के बाद ही असर करता है — यह कॉलबैक एक नई इंस्टॉल को सही तरीके से तैयार करता है; जो ऐप पहले से NONE के साथ शिप हो चुका है, उसे मोड बदलने के लिए एक बार VACUUM चलाने वाले एकबारगी माइग्रेशन की ज़रूरत होगी।
वह क्लीनअप जॉब जो असल में इन सबसे कहीं ज़्यादा मायने रखता है
VACUUM और इन्क्रीमेंटल vacuuming उस जगह को वापस पाते हैं जिसके बारे में SQLite को पहले से पता है कि वह खाली है। आपके अपने स्कीमा द्वारा जानबूझकर रोकी गई जगह के लिए वे कुछ नहीं करते — जो लोकल-फर्स्ट ऐप में ब्लोट का कहीं ज़्यादा आम कारण है। एक सॉफ़्ट-डिलीट कॉलम (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 पर्याप्त बार नहीं चल रहा। अगर फ्रीलिस्ट कम होने के बावजूद फ़ाइल का साइज़ बढ़ता ही जा रहा है, तो समस्या vacuuming बिल्कुल नहीं है — यह एक सॉफ़्ट-डिलीट कॉलम है जिसे कोई हार्ड-डिलीट नहीं कर रहा। जो वाकई टूटा है उसे ठीक कीजिए; VACUUM को ज़्यादा बार चलाना उन पंक्तियों को साफ़ नहीं करेगा जिन्हें आपका अपना स्कीमा अब भी रोके हुए है।
// संबंधित पठन
जर्नल से और भी
2026 में Room TypeConverters: अपने स्कीमा को खराब किए बिना एनम, डेट, और लिस्ट स्टोर करना
Android पर Room TypeConverters के लिए एक व्यावहारिक गाइड — एनम, Instant/LocalDate, और लिस्ट — साथ ही वे ग़लतियाँ जो एक कनवर्टर को एक चुपचाप डेटा-करप्शन बग में बदल देती हैं।
2026 में Room डेटाबेस इंडेक्स: वाकई धीमी क्वेरी को ढूँढना और ठीक करना
Android पर Room/SQLite डेटाबेस को इंडेक्स करने की व्यावहारिक गाइड — EXPLAIN QUERY PLAN पढ़ना, बिना अंदाज़े के @Index जोड़ना, और वे गलतियाँ जो चुपचाप इंडेक्स को बेअसर कर देती हैं।
Room का @Relation: Android पर N+1 क्वेरी के बिना वन-टू-मेनी डेटा क्वेरी करना
Room के @Relation एनोटेशन की एक व्यावहारिक गाइड — categories और entries जैसे वन-टू-मेनी डेटा को N+1 क्वेरी या मैनुअल join के बिना मॉडल करना।