İçeriğe geç
Tüm yazılar

2026'da Room veritabanı index'leri: gerçekten yavaş olan sorguyu bulmak ve düzeltmek

Android'de Room/SQLite veritabanını index'lemeye pratik bir rehber — EXPLAIN QUERY PLAN okumak, tahmin etmeden @Index eklemek ve bir index'i sessizce etkisiz kılan hatalar.

MFKAPPS 4 dk okuma

Testte anında hissettiren, Room tabanlı bir uygulama aylar sonra takılmaya başlayabilir — kullanıcı gerçekte, sizin doldurduğunuz on iki yerine bin işlem biriktirdiğinde. Alışılmış içgüdü bir yere index eklemek ve umut etmektir. Bu, işe yaramadığı kadar işe yarar da, çünkü index’ler bir tabloyu hızlandırmaz — belirli bir erişim desenini hızlandırır, ve yanlış olanı hiçbir okuma faydası olmadan yazma yükü ekler. İşte gerçekten yavaş olan sorguyu nasıl bulacağınız, nasıl doğrulayacağınız ve doğru şekilde nasıl index’leyeceğiniz.

Tahmin etmeyin — ölçün

SQLite, sorgusunu bir planı sorarsanız size tam olarak nasıl çalıştıracağını söyler. Herhangi bir sorgunun önüne EXPLAIN QUERY PLAN ekleyin ve adb shell üzerinden ya da ham bir Room sorgusuyla çalıştırın:

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

Çıktı satırı hikayenin tamamıdır. SCAN transactions, SQLite’ın tablodaki her satırı okuduğu ve koşulu her birinde kontrol ettiği anlamına gelir — maliyet tablo boyutuyla doğrusal olarak büyür. SEARCH transactions USING INDEX idx_transactions_category (category_id=?) ise doğrudan eşleşen satırlara atladığı anlamına gelir. Index’lemeyle ilgili her şey, sık çalıştırdığınız sorgular için SCAN’i SEARCH’e çevirmeye ve geri kalan her şeyi olduğu gibi bırakmaya iniyor.

Burada kaçınılması gereken hata, hangi sütunun önemli hissettirdiğine göre index eklemektir. Bir notes sütunu nadiren filtrelenir; her liste ekranının WHERE cümlesinde kullanılan bir category_id tamamen farklı bir hikayedir. Herhangi bir şeye dokunmadan önce en sık çalışan beş altı DAO sorgunuzda EXPLAIN QUERY PLAN çalıştırın — ana liste ekranlarını oluşturan ve her uygulama açılışında çalışanlar.

Room’da index eklemek

Room, SQLite’ın CREATE INDEX’ini @Entity ek açıklamasının indices parametresi üzerinden açığa çıkarır:

@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,
)

Bu bir şema değişikliğidir, dolayısıyla bir sütun eklemekle aynı şekilde bir Migration gerektirir:

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’un şema dışa aktarımı (exportSchema = true artı room.schemaLocation derleyici argümanı), @Entity index’leriniz ile elle yazılmış bir migration birbirinden saparsa bunu işaretler — bu hatanın gönderilmeden önce yakalanmasının olağan yoludur.

Bileşik index tuzağı

category_id’ye göre filtreleyen ve date’e göre sıralayan bir sorgu — yukarıdaki tam desen — iki ayrı tek sütunlu index’ten tam fayda görmez. SQLite çoğu durumda sorgu başına tablo başına bir index kullanabilir, bu yüzden daha seçici olanı seçer ve yine de sonuçları bellekte sıralamak zorunda kalır. Her iki sütunu da, doğru sırayla kapsayan bileşik bir index, SQLite’ın hem filtre için index’i kullanmasına hem de satırları zaten sıralı halde döndürmesine izin verir:

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

Burada sıra önemlidir. Bu index, WHERE category_id = ? ve WHERE category_id = ? ORDER BY date sorgularına hizmet eder, çünkü her iki koşul da index’i soldan sağa okur. Yalnızca date’e göre filtreleyen bir sorguya yardımcı olmaz — bunun için hâlâ date üzerinde tek sütunlu bir index’e, ya da önce date olan ikinci bir bileşik index’e ihtiyacınız olur. Bileşik bir index ekledikten sonra EXPLAIN QUERY PLAN’i tekrar kontrol edin; planda hâlâ USE TEMP B-TREE FOR ORDER BY görüyorsanız, sütun sırası sorgunun ihtiyacıyla eşleşmiyor demektir.

Index’ler bedava değildir

SQLite’ın koruduğu her index, index’lenmiş bir sütuna dokunan her INSERT, UPDATE ya da DELETE’te güncellenmek zorundadır. Bir bütçeleme uygulamasındaki transactions gibi bir tablo için, yazmalar okumalara göre görece nadirdir — günde bir avuç ekleme, düzinelerce liste render’ına karşı — bu yüzden takas kolaydır. Sürekli yazılan ve nadiren okunan bir tablo için (bir olay günlüğü, bir senkronizasyon kuyruğu) ise, transactions tablosuna yardımcı olan aynı üç index, kimsenin fark etmeyeceği bir okuma faydası için yazmaları ölçülebilir şekilde yavaşlatabilir. Şemadaki her tabloyu değil, her ekran açılışında sorgulanan tabloları index’leyin.

Birincil anahtar zaten örtük bir index alır — bunu indices içinde tekrar dahil etmek gereksizdir. Aynısı @PrimaryKey işaretli bir sütun ya da başka bir index’te zaten unique = true ilan ettiğiniz bir sütun için de geçerlidir; SQLite destekleyen index’i otomatik olarak oluşturur.

Bunun gerçekte önemli olduğu yer

Bunu sıkıcı yoldan, Granyn üzerinde buldum: birkaç yüz satırda anında hissettiren bir işlem listesi, gerçek kullanım birkaç bini geçtiğinde kategoriye göre filtrelerken görünür bir gecikme almaya başladı. DAO sorgusunda EXPLAIN QUERY PLAN, tam bir SCAN gösterdi — category_id filtresinin kullanabileceği bir index yoktu. Yukarıdaki bileşik index’i eklemek, filtrelenmiş sorguyu tam tablo taramasından index sorgusuna geri döndürdü ve takılma ortadan kalktı. Mimari değişikliği yok, yeni kütüphane yok, sadece bir migration’da doğru iki satır.

Ders bu tek tablonun ötesine genelleniyor: yerel öncelikli bir uygulama yavaşladığında sayfalamaya, önbelleğe ya da yeniden yazmaya uzanmadan önce, gerçekten yavaş olan sorguda EXPLAIN QUERY PLAN çalıştırın. Çoğu zaman çözüm bir index’tir, küçüktür ve yanlış yaparsanız geri alınabilir.