सामग्री पर जाएं
सभी पोस्ट

2026 में Room डेटाबेस इंडेक्स: वाकई धीमी क्वेरी को ढूँढना और ठीक करना

Android पर Room/SQLite डेटाबेस को इंडेक्स करने की व्यावहारिक गाइड — EXPLAIN QUERY PLAN पढ़ना, बिना अंदाज़े के @Index जोड़ना, और वे गलतियाँ जो चुपचाप इंडेक्स को बेअसर कर देती हैं।

MFKAPPS 5 मिनट पढ़ना

Room पर बना एक ऐप जो टेस्टिंग में तुरंत जवाब देता था, महीनों बाद अटकना शुरू कर सकता है — जब असली यूज़र के पास आपके टेस्ट किए बारह की जगह हज़ार transactions हों। सामान्य प्रवृत्ति कहीं एक index जोड़कर उम्मीद लगाने की होती है। यह उतना ही काम करता है जितना नहीं करता, क्योंकि index किसी टेबल को तेज़ नहीं करते — वे एक खास access pattern को तेज़ करते हैं, और गलत index बिना किसी read फ़ायदे के write overhead जोड़ देता है। यहाँ बताया गया है कि वाकई धीमी क्वेरी को कैसे ढूँढें, उसकी पुष्टि करें, और उसे सही तरीके से index करें।

अंदाज़ा मत लगाइए — मापिए

अगर आप पूछें तो SQLite आपको बिल्कुल बताता है कि वह किसी क्वेरी को कैसे चलाने वाला है। किसी भी क्वेरी के आगे EXPLAIN QUERY PLAN लगाइए और उसे adb shell से या एक raw Room क्वेरी के ज़रिए चलाइए:

@RawQuery
fun explain(query: SupportSQLiteQuery): List<ExplainRow>
EXPLAIN QUERY PLAN
SELECT * FROM transactions WHERE category_id = 7 ORDER BY date DESC;

आउटपुट की वह लाइन पूरी कहानी बताती है। SCAN transactions का मतलब है कि SQLite टेबल की हर row पढ़ रहा है और हर एक पर condition जाँच रहा है — cost टेबल के साइज़ के साथ रैखिक रूप से बढ़ती है। SEARCH transactions USING INDEX idx_transactions_category (category_id=?) का मतलब है कि यह सीधे मेल खाती rows पर जाता है। indexing की पूरी बात इतनी है कि जिन क्वेरीज़ को आप वाकई अक्सर चलाते हैं उनमें SCAN को SEARCH में बदल दिया जाए, और बाकी सब कुछ जैसा है वैसा ही छोड़ दिया जाए।

यहाँ बचने वाली गलती यह है कि किस column को इंडेक्स करना है यह सिर्फ इस आधार पर तय न करें कि वह महत्वपूर्ण लगता है। एक notes column शायद ही कभी फ़िल्टर होता है; हर list स्क्रीन के WHERE क्लॉज़ में इस्तेमाल होने वाला category_id बिल्कुल अलग बात है। कुछ भी छेड़ने से पहले अपनी पाँच-छह सबसे व्यस्त DAO क्वेरीज़ पर EXPLAIN QUERY PLAN चलाइए — वे जो मुख्य list स्क्रीन बनाती हैं और हर बार ऐप खुलने पर चलती हैं।

Room में index जोड़ना

Room, SQLite के CREATE INDEX को @Entity एनोटेशन के indices पैरामीटर के ज़रिए उपलब्ध कराता है:

@Entity(
    tableName = "transactions",
    indices = [Index(value = ["category_id"]), Index(value = ["date"])],
)
data class Transaction(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val categoryId: Long,
    val date: Long,
    val amountCents: Long,
)

यह एक schema बदलाव है, इसलिए इसे column जोड़ने जैसे ही एक Migration चाहिए:

val MIGRATION_5_6 = object : Migration(5, 6) {
    override fun migrate(db: SupportSQLiteDatabase) {
        db.execSQL("CREATE INDEX IF NOT EXISTS idx_transactions_category ON transactions(category_id)")
        db.execSQL("CREATE INDEX IF NOT EXISTS idx_transactions_date ON transactions(date)")
    }
}

Room का schema export (exportSchema = true और compiler आर्ग्युमेंट room.schemaLocation) आपके @Entity इंडेक्स और हाथ से लिखी migration के बीच फ़र्क आने पर उसे फ़्लैग कर देगा — यह बग अमूमन इसी तरह प्रोडक्शन में जाने से पहले पकड़ में आता है।

Compound index का जाल

एक क्वेरी जो category_id पर फ़िल्टर करती है और date से sort करती है — ऊपर वाला ठीक वही pattern — दो अलग-अलग single-column indices से पूरा फ़ायदा नहीं उठा पाती। SQLite ज़्यादातर मामलों में प्रति टेबल प्रति क्वेरी सिर्फ एक index इस्तेमाल कर सकता है, इसलिए यह ज़्यादा selective वाला चुनता है और फिर भी नतीजों को memory में sort करना पड़ता है। दोनों columns को सही क्रम में कवर करने वाला एक compound index, SQLite को index का इस्तेमाल फ़िल्टर के लिए और पहले से sorted rows लौटाने के लिए, दोनों के लिए करने देता है:

indices = [Index(value = ["category_id", "date"])]

यहाँ क्रम मायने रखता है। यह index WHERE category_id = ? और WHERE category_id = ? ORDER BY date दोनों की सेवा करता है, क्योंकि दोनों condition index को बाएँ से दाएँ पढ़ते हैं। यह सिर्फ date पर फ़िल्टर करने वाली क्वेरी में मदद नहीं करता — उसके लिए आपको अब भी date पर single-column index चाहिए होगा, या date को पहले रखने वाला एक दूसरा compound index। compound index जोड़ने के बाद EXPLAIN QUERY PLAN फिर से जाँचिए; अगर plan में अब भी USE TEMP B-TREE FOR ORDER BY दिखता है, तो column का क्रम क्वेरी की ज़रूरत से मेल नहीं खा रहा।

Index मुफ़्त नहीं होते

SQLite जो भी index बनाए रखता है, उसे हर उस INSERT, UPDATE या DELETE पर अपडेट करना पड़ता है जो किसी indexed column को छूता है। एक budgeting ऐप में transactions जैसी टेबल के लिए, writes reads के मुक़ाबले अपेक्षाकृत दुर्लभ होते हैं — दिन भर में मुट्ठी भर inserts, बनाम दर्जनों list renders — इसलिए यह trade-off आसान है। लेकिन जो टेबल लगातार लिखी जाती है और शायद ही कभी पढ़ी जाती है (जैसे कोई event log या sync queue), उसके लिए वही तीन indices जिन्होंने transactions टेबल की मदद की, writes को नापने लायक हद तक धीमा कर सकते हैं, बिना किसी ऐसे read फ़ायदे के जो किसी को दिखे भी। schema की हर टेबल को नहीं, बल्कि हर स्क्रीन खुलने पर क्वेरी होने वाली टेबलों को index कीजिए।

Primary key को पहले से ही एक implicit index मिलता है — उसे indices में दोबारा शामिल करना अनावश्यक है। यही बात उस column पर भी लागू होती है जिस पर @PrimaryKey लगा हो या जिसे किसी दूसरे index पर पहले से unique = true घोषित किया जा चुका हो; SQLite अंतर्निहित index अपने आप बना देता है।

यह असल में कहाँ मायने रखता है

मुझे यह उबाऊ तरीके से मिला, Granyn पर: कुछ सौ rows पर तुरंत जवाब देने वाली एक transaction list, असली इस्तेमाल के कुछ हज़ार rows पार करते ही category से फ़िल्टर करने पर एक दिखने लायक ठहराव लेने लगी। DAO क्वेरी पर EXPLAIN QUERY PLAN ने एक पूरा SCAN दिखाया — category_id फ़िल्टर के पास इस्तेमाल करने के लिए कोई index नहीं था। ऊपर वाला compound index जोड़ने से फ़िल्टर की गई क्वेरी पूरे table scan से वापस एक index lookup पर आ गई, और अटकना ख़त्म हो गया। न कोई architecture बदलाव, न कोई नई library, बस एक migration में सही दो लाइनें।

यह सबक इस एक टेबल से आगे भी लागू होता है: जब कोई local-first ऐप धीमा पड़े, तो pagination, caching या rewrite की तरफ़ जाने से पहले, वाकई धीमी क्वेरी पर EXPLAIN QUERY PLAN चलाइए। ज़्यादातर वक़्त समाधान एक index होता है, वह छोटा होता है, और गलत होने पर वापस लिया जा सकता है।

// संबंधित पठन

जर्नल से और भी

MFKAPPS 6 मिनट पढ़ना

2026 में Room TypeConverters: अपने स्कीमा को खराब किए बिना एनम, डेट, और लिस्ट स्टोर करना

Android पर Room TypeConverters के लिए एक व्यावहारिक गाइड — एनम, Instant/LocalDate, और लिस्ट — साथ ही वे ग़लतियाँ जो एक कनवर्टर को एक चुपचाप डेटा-करप्शन बग में बदल देती हैं।

#android #engineering #room
MFKAPPS 5 मिनट पढ़ना

Room का @Relation: Android पर N+1 क्वेरी के बिना वन-टू-मेनी डेटा क्वेरी करना

Room के @Relation एनोटेशन की एक व्यावहारिक गाइड — categories और entries जैसे वन-टू-मेनी डेटा को N+1 क्वेरी या मैनुअल join के बिना मॉडल करना।

#android #engineering #room
MFKAPPS 6 मिनट पढ़ना

Room में फुल-टेक्स्ट सर्च: 2026 में एक लोकल-फर्स्ट Android ऐप में इंस्टेंट सर्च जोड़ना

Android पर Room के FTS4 सपोर्ट की एक व्यावहारिक गाइड — एक वर्चुअल सर्च टेबल बनाना, उसे ट्रिगर्स से सिंक रखना, और FTS5 को मैनुअल माइग्रेशन की ज़रूरत क्यों पड़ती है।

#android #engineering #room