SQLite performansı için en iyi uygulamalar

Android, verimli bir SQL veritabanı olan SQLite için yerleşik destek sunar. Uygulamanızın performansını optimize etmek için bu en iyi uygulamaları takip edin. Böylece, verileriniz arttıkça uygulamanızın hızlı ve öngörülebilir şekilde hızlı kalmasını sağlayabilirsiniz. Bu en iyi uygulamaları kullanarak, yeniden oluşturulması ve sorunlarının giderilmesi zor olan performans sorunlarıyla karşılaşma olasılığını da azaltırsınız.

Daha hızlı performans elde etmek için aşağıdaki performans ilkelerini uygulayın:

  • Daha az satır ve sütun okuma: Sorgularınızı yalnızca gerekli verileri alacak şekilde optimize edin. Veritabanından okunan veri miktarını en aza indirin. Aşırı veri alma işlemi performansı etkileyebilir.

  • İşleri SQLite motoruna aktarın: SQL sorgularında hesaplama, filtreleme ve sıralama işlemleri yapın. SQLite'ın sorgu motorunu kullanmak performansı önemli ölçüde artırabilir.

  • Veritabanı şemasını değiştirme: Veritabanı şemanızı, SQLite'ın verimli sorgu planları ve veri temsilleri oluşturmasına yardımcı olacak şekilde tasarlayın. Performansı artırmak için tabloları doğru şekilde dizine ekleyin ve tablo yapılarını optimize edin.

Ayrıca, optimizasyon gerektiren alanları belirlemek için mevcut sorun giderme araçlarını kullanarak SQLite veritabanınızın performansını ölçebilirsiniz.

Jetpack Room kitaplığını kullanmanızı öneririz.

Veritabanını performans için yapılandırma

SQLite'ta optimum performans için veritabanınızı yapılandırmak üzere bu bölümdeki adımları uygulayın.

Write-Ahead Logging'i etkinleştirme

SQLite, mutasyonları bir günlüğe ekleyerek uygular ve bu günlüğü zaman zaman veritabanına sıkıştırır. Buna Write-Ahead Logging (WAL) denir.

ATTACH DATABASE kullanmıyorsanız WAL'yi etkinleştirin.

PRAGMA journal_mode=WAL komutunu kendiniz çalıştırmak yerine çerçeve üzerinden WAL'yi etkinleştirin. Room, düşük RAM'li olmayan cihazlarda WAL'yi varsayılan olarak etkinleştirir. Room olmadan aşağıdakilerden birini kullanın:

Çerçeve, veritabanının WAL kullandığını bildiğinde okuma işlemlerinin yazma işlemiyle eşzamanlı olarak çalışabilmesi için bir bağlantı havuzu açar ve sonraki bölümde açıklanan WAL eşzamanlı modunu uygular. PRAGMA journal_mode=WAL komutunu kendiniz çalıştırırsanız çerçeve, veritabanını yine de geri alma günlüğü veritabanı olarak değerlendirir. Tek bir bağlantı kullanır ve commit'ler varsayılan FULL eşzamanlı modunu korur.

Senkronizasyon modunu gevşetme

SQLite'ta eşzamanlı modu kontrol etme seçeneği vardır. FULL modunda, her kaydetme işlemi, verilerin diske ulaşmasını sağlamak için bir fsync yayınlar. Bu, veri dayanıklılığını artırır ancak kaydetme işlemlerinizi yavaşlatır. WAL ile NORMAL modu her kayıtta senkronize edilmez ve veritabanını bozulmaya karşı korur.

WAL'yi çerçeve üzerinden etkinleştirdiğinizde Android, cihazın varsayılan WAL eşzamanlı modunu uygular. Bu mod, Android 9 ve sonraki sürümler için AOSP'de NORMAL şeklindedir. Cihaz üreticileri bu varsayılanı değiştirebilir ve WAL kullanmayan veritabanları varsayılan olarak FULL değerini kullanır. Veritabanınızın NORMAL kullandığından emin olmak için veritabanını açarken bunu ayarlayın:

// When opening the database
val params = SQLiteDatabase.OpenParams.Builder()
    .addOpenFlags(SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING)
    // SYNC_MODE_NORMAL is available in API level 33 and higher.
    // On API levels 28 to 32, pass "NORMAL" instead.
    .setSynchronousMode(SQLiteDatabase.SYNC_MODE_NORMAL)
    .build()
// With SQLiteOpenHelper, call setOpenParams(params) in your constructor.

// Or: after having opened the database
db.execSQL("PRAGMA synchronous = NORMAL")

Bu ayarda, veriler bir diske depolanmadan önce bir commit döndürülebilir. Güç kaybı veya kernel panic gibi bir cihaz kapanması meydana gelirse kaydedilen veriler kaybolabilir. Ancak günlük kaydı sayesinde veritabanınız bozulmaz.

Yalnızca uygulamanız kilitlenirse verileriniz diske ulaşmaya devam eder. Çoğu uygulamada bu ayar, önemli bir maliyet olmadan performans iyileştirmeleri sağlar.

Bağlantıları ve hafızayı yönetme

Açık olan her veritabanı bağlantısı SQLite belleğini (sayfa önbelleği, lookaside ayırıcı ve derlenmiş ifadelerin önbelleği) tutar. Veritabanı dosyası sayfaları da çekirdek sayfa önbelleğinde kalır. Sorgularınızı yavaşlatmadan bu belleği kontrol altında tutmak için bu bölümdeki uygulamaları izleyin.

Her veritabanını bir kez açın

Tüm süreciniz için veritabanı dosyası başına tek bir SQLiteOpenHelper veya RoomDatabase örneği kullanın (ör. tekil yaparak). Her örneğin kendi bağlantı havuzu, sayfa önbellekleri ve ifade önbellekleri vardır. Aynı dosyayı açan iki örnek bunları paylaşamaz. Bir karşılaştırmada, aynı dosyayı açan iki yardımcıyı birleştirerek SQLite sayfa önbelleği belleğini neredeyse yarı yarıya, p95 sorgu gecikmesini ise yarıdan fazla azalttık.

Yinelenen örnekleri bulmak için birden fazla Connection pool for başlığının altında aynı veritabanı yolunu içeren dumpsys dbinfo öğesini kontrol edin.

Şema değişikliklerini açık geri çağırmanın dışında tutma

SQLiteOpenHelper sınıfının onOpen geri çağırma işlevi, veritabanı her açıldığında çalışır. RoomDatabase.Callback sınıfının onOpen geri çağırma işlevi de aynı şekilde çalışır. CREATE TABLE IF NOT EXISTS ve CREATE INDEX IF NOT EXISTS gibi ifadeleri onCreate ve onUpgrade içine veya Room taşıma işlemlerine yerleştirin. onOpen içinde setVersion çağrısı yapmayın. SQLiteOpenHelper, onCreate ve onUpgrade sonrasında sürümü zaten ayarlıyor. Şema zaten mevcut olsa bile bu ifadeler her açılışta çalışır ve setVersion, veritabanı dosyasına yazar.

Hazırlanan ifade önbelleğini boyutlandırma

Android, her bağlantı için derlenmiş ifadeleri önbelleğe alır. Önbellek varsayılan olarak 25 ifade tutar ve setMaxSqlCacheSize ile bu sayıyı MAX_SQL_CACHE_SIZE (100) değerine yükseltebilirsiniz. Bir veritabanı, etkin yollarında 25'ten fazla farklı ifade çalıştırırsa önbellek bozulur ve SQLite aynı ifadeleri tekrar tekrar derler.

Öncelikle sorgularınızı parametreleştirin. Her dize yeni bir ifade olduğundan, dize birleştirme ile oluşturulan SQL'e hiçbir önbellek boyutu yardımcı olamaz. Ardından, dumpsys meminfo içinde POOL STATS altındaki ifade önbelleği isabetlerini ve eksiklerini kontrol edin. Örneğin, bu bölümün örnek çıkışındaki phenotype.db veritabanında 147.921 isabet ve 89.616 isabetsizlik vardır. Bu da %38'lik bir isabetsizlik oranıdır. Bunun gibi bir veritabanında önbellek boyutunu artırmak, kaçırmaların neredeyse tamamını ortadan kaldırabilir. Önbelleğe alınan her ifade birkaç kilobayt bellek kullanır.

Önbellek boyutunu artırmak için SQLiteOpenHelper alt sınıfınızda onConfigure değerini geçersiz kılın:

override fun onConfigure(db: SQLiteDatabase) {
    db.setMaxSqlCacheSize(SQLiteDatabase.MAX_SQL_CACHE_SIZE)
}

Room kullanıyorsanız RoomDatabase.Callback öğesinin onOpen geri çağırma işlevinde SupportSQLiteDatabase üzerinde aynı yöntemi çağırın.

Boştaki veritabanlarını kapatma, ancak meşgul olanları açık tutma

Açık bir veritabanı, boşta olduğunda bile bağlantılarını, önbelleklerini ve dosya tanımlayıcılarını korur. Bir karşılaştırmada, WAL modunda açık kalan boş bir Room veritabanı, yaklaşık 678 KB SQLite sayfa önbelleği belleği ve 5 dosya tanımlayıcı tuttu. Veritabanını yalnızca ara sıra (ör. günlük senkronizasyon sırasında) kullanıyorsanız işiniz bittiğinde kapatın.

Boştaki veritabanlarını güvenli bir şekilde kapatmak için aşağıdaki yönergeleri uygulayın:

  • Room, deneysel bir API olan setAutoCloseTimeout ile boşta olan bir veritabanını sizin için kapatabilir. Room, bir sonraki erişimde veritabanını yeniden açar ve onOpen geri çağırma işlevinizi tekrar çalıştırır. Bu nedenle, bağlantı yapılandırmalarını buraya ekleyin.
  • SQLiteOpenHelper ve SQLiteDatabase.OpenParams.Builder'nin setIdleConnectionTimeout yöntemlerini kullanmaktan kaçının. PRAGMA ifadeleriyle uyguladığınız ayarları geri yüklemeden bağlantıları kapattıkları için API düzeyi 29'da kullanımdan kaldırılmıştır.
  • Birkaç saniyede bir sorguladığınız bir veritabanını kapatmayın. Sorgularınız arasındaki boşluktan daha kısa bir zaman aşımı, veritabanının tekrar tekrar kapanıp açılmasına neden olur. Bu da her seferinde sayfa önbelleğini ve ifade önbelleğini silerek bir sonraki sorguyu yavaşlatır.

SQLite belleğini hesaba katma

SQLite bellek sorunlarını tespit etmek zor olabilir. SQLite sayfa önbelleği ve ifade önbelleği, yerel yığın ayırmalardır. Ancak Android, SQLite'ın bellekle eşlenen G/Ç'sini varsayılan olarak etkinleştirmez. Bu nedenle SQLite, veritabanı dosyalarını normal dosya okuma işlemleriyle okur ve çekirdeğin önbelleğe aldığı dosya içerikleri, eşlenmemiş dosya sayfalarıdır. Bu kullanıcılar, uygulamanızın dumpsys meminfo için PSS'sine dahil edilmez. cgroup v2 bellek hesaplaması kullanan cihazlarda çekirdek, bu sayfaları uygulamanızın bellek cgroup'una eklemeye devam eder. Bu gizli ayak izini azaltmak için dizinleri kullanarak daha az sayfa okuyun ve yalnızca ihtiyacınız olan satır ve sütunları okuyun. İhtiyacınız olmayan veritabanlarını kapatmak, SQLite önbelleklerini de boşaltır.

Verimli tablo şemaları tanımlama

Performansı optimize etmek ve veri tüketimini en aza indirmek için verimli bir tablo şeması tanımlayın. SQLite, verimli sorgu planları ve veriler oluşturarak verilerin daha hızlı alınmasını sağlar. Bu bölümde, tablo şemaları oluşturmayla ilgili en iyi uygulamalar açıklanmaktadır.

INTEGER PRIMARY KEY kategorisini düşünün

Bu örnekte, bir tabloyu aşağıdaki gibi tanımlayın ve doldurun:

CREATE TABLE Customers(
  id INTEGER,
  name TEXT,
  city TEXT
);
INSERT INTO Customers Values(456, 'John Lennon', 'Liverpool, England');
INSERT INTO Customers Values(123, 'Michael Jackson', 'Gary, IN');
INSERT INTO Customers Values(789, 'Dolly Parton', 'Sevier County, TN');

Tablo çıkışı aşağıdaki gibidir:

rowid id ad şehir
1 456 John Lennon Liverpool, İngiltere
2 123 Michael Jackson Gary, IN
3 789 Dolly Parton Sevier County, TN

rowid sütunu, ekleme sırasını koruyan bir dizindir. rowid ile filtreleme yapan sorgular hızlı bir B-tree araması olarak uygulanır ancak id ile filtreleme yapan sorgular yavaş bir tablo taramasıdır.

id ile arama yapmayı planlıyorsanız depolama alanında daha az veri ve genel olarak daha hızlı bir veritabanı için rowid sütununu depolamaktan kaçınabilirsiniz:

CREATE TABLE Customers(
  id INTEGER PRIMARY KEY,
  name TEXT,
  city TEXT
);

Tablonuz artık aşağıdaki gibi görünür:

id ad şehir
123 Michael Jackson Gary, IN
456 John Lennon Liverpool, İngiltere
789 Dolly Parton Sevier County, TN

rowid sütununu depolamanız gerekmediğinden id sorguları hızlıdır. Tablonun artık kampanya siparişi yerine id ölçütüne göre sıralandığını görebilirsiniz.

Dizinlerle sorguları hızlandırma

SQLite, sorguları hızlandırmak için dizinleri kullanır. Bir sütunu filtrelerken (WHERE), sıralarken (ORDER BY) veya toplarken (GROUP BY) tabloda sütun için bir dizin varsa sorgu hızlandırılır.

Önceki örnekte, city ölçütüne göre filtreleme yapmak için tablonun tamamının taranması gerekir:

SELECT id, name
WHERE city = 'London, England';

Çok sayıda şehir sorgusu içeren bir uygulama için bu sorguları bir dizinle hızlandırabilirsiniz:

CREATE INDEX city_index ON Customers(city);

Dizin, dizin sütununa göre sıralanan ve rowid ile eşlenen ek bir tablo olarak uygulanır:

şehir rowid
Gary, IN 2
Liverpool, İngiltere 1
Sevier County, TN 3

city sütununun depolama maliyetinin artık iki katına çıktığını unutmayın. Bunun nedeni, sütunun hem orijinal tabloda hem de dizinde bulunmasıdır. Dizini kullandığınız için ek depolama alanının maliyeti, daha hızlı sorguların avantajına değer. Ancak sorgu performansı artışı olmadan depolama maliyeti ödememek için kullanmadığınız bir dizini saklamayın.

Çok sütunlu dizinler oluşturma

Sorgularınız birden fazla sütunu birleştiriyorsa sorguyu tamamen hızlandırmak için çok sütunlu dizinler oluşturabilirsiniz. Dış sütunda bir dizin kullanıp iç aramanın doğrusal tarama olarak yapılmasını da sağlayabilirsiniz.

Örneğin, aşağıdaki sorguyu ele alalım:

SELECT id, name
WHERE city = 'London, England'
ORDER BY city, name

Sorguda belirtilen sırayla çok sütunlu bir dizin kullanarak sorguyu hızlandırabilirsiniz:

CREATE INDEX city_name_index ON Customers(city, name);

Ancak yalnızca city üzerinde bir dizininiz varsa dış sıralama yine hızlandırılır, iç sıralama ise doğrusal tarama gerektirir.

Bu, önek sorgularında da geçerlidir. Örneğin, bir dizin ON Customers (city, name), city'ye göre filtreleme, sıralama ve gruplandırmayı da hızlandırır. Çünkü çok sütunlu bir dizinin dizin tablosu, verilen dizinlere göre verilen sırada sıralanır.

WITHOUT ROWID kategorisini düşünün

SQLite, varsayılan olarak tablonuz için bir rowid sütunu oluşturur. Burada rowid, örtük bir INTEGER PRIMARY KEY AUTOINCREMENT değeridir. Zaten INTEGER PRIMARY KEY olan bir sütununuz varsa bu sütun, rowid için bir takma ad olur.

INTEGER dışında bir birincil anahtarı veya sütun bileşimi olan tablolar için WITHOUT ROWID kullanabilirsiniz.

Küçük verileri BLOB, büyük verileri dosya olarak depolayın

Bir satırla büyük verileri (ör. bir resmin küçük resmi veya bir kişinin fotoğrafı) ilişkilendirmek istiyorsanız verileri bir BLOB sütununda veya bir dosyada depolayabilir, ardından yolu sütunda depolayabilirsiniz.

Dosyalar genellikle 4 KB'lık artışlarla yuvarlanır. Yuvarlama hatasının önemli olduğu çok küçük dosyalar için bunları veritabanında BLOB olarak depolamak daha verimlidir. SQLite, dosya sistemi çağrılarını en aza indirir ve bazı durumlarda temel dosya sisteminden daha hızlıdır.

Sorgu performansını iyileştirme

Yanıt sürelerini en aza indirip işleme verimliliğini en üst düzeye çıkararak SQLite'ta sorgu performansını iyileştirmek için aşağıdaki en iyi uygulamaları izleyin.

Yalnızca ihtiyacınız olan satırları okuma

Filtreler, tarih aralığı, konum veya ad gibi belirli ölçütleri belirterek sonuçlarınızı daraltmanıza olanak tanır. Sınırlar, gördüğünüz sonuç sayısını kontrol etmenizi sağlar:

db.rawQuery("""
    SELECT name
    FROM Customers
    LIMIT 10;
    """.trimIndent(),
    null
).use { cursor ->
    while (cursor.moveToNext()) {
        // Process cursor data
    }
}

Yalnızca ihtiyacınız olan sütunları okuma

Sorgularınızı yavaşlatabileceği ve kaynakları boşa harcayabileceği için gereksiz sütunları seçmekten kaçının. Bunun yerine, yalnızca kullanılan sütunları seçin.

Aşağıdaki örnekte id, name ve phone'yi seçersiniz:

// This is not the most efficient way of doing this.
// See the following example for a better approach.

db.rawQuery(
    """
    SELECT id, name, phone
    FROM customers;
    """.trimIndent(),
    null
).use { cursor ->
    while (cursor.moveToNext()) {
        val name = cursor.getString(1)
        // Further processing
    }
}

Ancak yalnızca name sütununa ihtiyacınız vardır:

db.rawQuery("""
    SELECT name
    FROM Customers;
    """.trimIndent(),
    null
).use { cursor ->
    while (cursor.moveToNext()) {
        val name = cursor.getString(0)
        // Further processing
    }
}

Sorguları parametreleştirme

Sorgu dizeniz, yalnızca çalışma zamanında bilinen bir parametre içerebilir. Örneğin:

fun getNameById(id: Long): String?
    db.rawQuery(
        "SELECT name FROM customers WHERE id=$id", null
    ).use { cursor ->
        return if (cursor.moveToFirst()) {
            cursor.getString(0)
        } else {
            null
        }
    }
}

Önceki kodda her sorgu farklı bir dize oluşturur ve bu nedenle ifade önbelleğinden yararlanmaz. Her çağrının yürütülebilmesi için SQLite'ın çağrıyı derlemesi gerekir. Bunun yerine, id bağımsız değişkenini bir parametre ile değiştirebilir ve değeri selectionArgs ile bağlayabilirsiniz:

fun getNameById(id: Long): String? {
    db.rawQuery(
        """
          SELECT name
          FROM customers
          WHERE id=?
        """.trimIndent(), arrayOf(id.toString())
    ).use { cursor ->
        return if (cursor.moveToFirst()) {
            cursor.getString(0)
        } else {
            null
        }
    }
}

Artık sorgu bir kez derlenip önbelleğe alınabilir. Derlenen sorgu, getNameById(long) öğesinin farklı çağrıları arasında yeniden kullanılır.

Kodda değil, SQL'de yineleme yapın

Tek tek sonuçlar döndürmek için SQL sorguları üzerinde yineleme yapan programatik bir döngü yerine, hedeflenen tüm sonuçları döndüren tek bir sorgu kullanın. Programatik döngü, tek bir SQL sorgusundan yaklaşık 1.000 kat daha yavaştır.

Benzersiz değerler için DISTINCT kullanın

DISTINCT anahtar kelimesini kullanmak, işlenmesi gereken veri miktarını azaltarak sorgularınızın performansını artırabilir. Örneğin, bir sütundaki yalnızca benzersiz değerleri döndürmek istiyorsanız DISTINCT işlevini kullanın:

db.rawQuery("""
    SELECT DISTINCT name
    FROM Customers;
    """.trimIndent(),
    null
).use { cursor ->
    while (cursor.moveToNext()) {
        // Only iterate over distinct names in Kotlin
        // Process distinct name
    }
}

Mümkün olduğunda topla işlevlerini kullanın

Satır verileri olmadan toplu sonuçlar için topla işlevlerini kullanın. Örneğin, aşağıdaki kod en az bir eşleşen satır olup olmadığını kontrol eder:

// This is not the most efficient way of doing this.
// See the following example for a better approach.

db.rawQuery("""
    SELECT id, name
    FROM Customers
    WHERE city = 'Paris';
    """.trimIndent(),
    null
).use { cursor ->
    if (cursor.moveToFirst()) {
        // At least one customer from Paris
        // Handle found
    } else {
        // No customers from Paris
        // Handle not found
}

Yalnızca ilk satırı getirmek için EXISTS() işlevini kullanarak eşleşen bir satır yoksa 0, bir veya daha fazla satır eşleşiyorsa 1 değerini döndürebilirsiniz:

db.rawQuery("""
    SELECT EXISTS (
        SELECT null
        FROM Customers
        WHERE city = 'Paris';
    );
    """.trimIndent(),
    null
).use { cursor ->
    if (cursor.moveToFirst() && cursor.getInt(0) == 1) {
        // At least one customer from Paris
        // Handle found
    } else {
        // No customers from Paris
        // Handle not found
    }
}

Uygulama kodunuzda SQLite toplama işlevlerini kullanma:

  • COUNT: Bir sütunda kaç satır olduğunu sayar.
  • SUM: Bir sütundaki tüm sayısal değerleri toplar.
  • MIN veya MAX: En düşük veya en yüksek değeri belirler. Sayısal sütunlar, DATE türleri ve metin türleri için kullanılabilir.
  • AVG: Ortalama sayısal değeri bulur.
  • GROUP_CONCAT: Dizeleri isteğe bağlı bir ayırıcıyla birleştirir.

Cursor.getCount() yerine COUNT() kullanın

Aşağıdaki örnekte, Cursor.getCount() işlevi veritabanındaki tüm satırları okur ve tüm satır değerlerini döndürür:

// This is not the most efficient way of doing this.
// See the following example for a better approach.

db.rawQuery("""
    SELECT id
    FROM Customers;
    """.trimIndent(),
    null
).use { cursor ->
    val count = cursor.getCount()
    // Use count
}

Ancak COUNT() kullanıldığında veritabanı yalnızca sayıyı döndürür:

db.rawQuery("""
    SELECT COUNT(*)
    FROM Customers;
    """.trimIndent(),
    null
).use { cursor ->
    cursor.moveToFirst()
    val count = cursor.getInt(0)
    // Use count
}

Kod yerine sorguları iç içe yerleştirme

SQL, birleştirilebilir ve alt sorguları, birleştirmeleri ve yabancı anahtar kısıtlamalarını destekler. Bir sorgunun sonucunu, uygulama kodunu kullanmadan başka bir sorguda kullanabilirsiniz. Bu sayede SQLite'tan veri kopyalama ihtiyacı azalır ve veritabanı motoru sorgunuzu optimize edebilir.

Aşağıdaki örnekte, en çok müşterinin hangi şehirde olduğunu bulmak için bir sorgu çalıştırabilir, ardından sonucu başka bir sorguda kullanarak o şehirdeki tüm müşterileri bulabilirsiniz:

// This is not the most efficient way of doing this.
// See the following example for a better approach.

db.rawQuery("""
    SELECT city
    FROM Customers
    GROUP BY city
    ORDER BY COUNT(*) DESC
    LIMIT 1;
    """.trimIndent(),
    null
).use { cursor ->
    if (cursor.moveToFirst()) {
        val topCity = cursor.getString(0)
        db.rawQuery("""
            SELECT name, city
            FROM Customers
            WHERE city = ?;
        """.trimIndent(),
        arrayOf(topCity)).use { innerCursor ->
            while (innerCursor.moveToNext()) {
                // Process inner cursor data
            }
        }
    }
}

Sonucu önceki örneğin yarısı kadar sürede almak için iç içe yerleştirilmiş ifadeler içeren tek bir SQL sorgusu kullanın:

db.rawQuery("""
    SELECT name, city
    FROM Customers
    WHERE city IN (
        SELECT city
        FROM Customers
        GROUP BY city
        ORDER BY COUNT (*) DESC
        LIMIT 1;
    );
    """.trimIndent(),
    null
).use { cursor ->
    if (cursor.moveToNext()) {
        // Process cursor data
    }
}

SQL'de benzersizliği kontrol etme

Bir satırın, tablodaki belirli bir sütun değeri benzersiz olmadığı sürece eklenmemesi gerekiyorsa bu benzersizliği sütun kısıtlaması olarak zorunlu kılmak daha verimli olabilir.

Aşağıdaki örnekte, eklenecek satırı doğrulamak için bir sorgu, ekleme işlemini gerçekleştirmek için başka bir sorgu çalıştırılır:

// This is not the most efficient way of doing this.
// See the following example for a better approach.

db.rawQuery(
    """
    SELECT EXISTS (
        SELECT null
        FROM customers
        WHERE username = ?
    );
    """.trimIndent(),
    arrayOf(customer.username)
).use { cursor ->
    if (cursor.moveToFirst() && cursor.getInt(0) == 1) {
        throw AddCustomerException(customer)
    }
}
db.execSQL(
    "INSERT INTO customers VALUES (?, ?, ?)",
    arrayOf(
        customer.id.toString(),
        customer.name,
        customer.username
    )
)

Benzersizlik kısıtlamasını Kotlin yerine SQL'de tabloyu tanımlarken kontrol edebilirsiniz:

CREATE TABLE Customers(
  id INTEGER PRIMARY KEY,
  name TEXT,
  username TEXT UNIQUE
);

SQLite, aşağıdakilerle aynı işlemi yapar:

CREATE TABLE Customers(...);
CREATE UNIQUE INDEX CustomersUsername ON Customers(username);

Şimdi bir satır ekleyip SQLite'ın kısıtlamayı kontrol etmesini sağlayabilirsiniz:

try {
    db.execSQL(
        "INSERT INTO Customers VALUES (?, ?, ?)",
        arrayOf(customer.id.toString(), customer.name, customer.username)
    )
} catch(e: SQLiteConstraintException) {
    throw AddCustomerException(customer, e)
}

SQLite, birden çok sütun içeren benzersiz dizinleri destekler:

CREATE TABLE table(...);
CREATE UNIQUE INDEX unique_table ON table(column1, column2, ...);

SQLite, kısıtlamaları Kotlin kodundan daha hızlı ve daha az ek yükle doğrular. Uygulama kodu yerine SQLite kullanmak en iyi uygulamadır.

Tek bir işlemde birden fazla ekleme işlemini toplu olarak gerçekleştirme

İşlem, birden fazla işlemi yürütür. Bu sayede hem verimlilik hem de doğruluk artar. Veri tutarlılığını artırmak ve performansı hızlandırmak için ekleme işlemlerini toplu olarak yapabilirsiniz:

db.beginTransaction()
try {
    customers.forEach { customer ->
        db.execSQL(
            "INSERT INTO Customers VALUES (?, ?, ?)",
            arrayOf(customer.id.toString(), customer.name, "customerValue")
        )
    }
    db.setTransactionSuccessful()
} finally {
    db.endTransaction()
}

Açık bir işlem dışında her yazma işlemi kendi örtük işleminde çalışır ve eşzamanlı modda FULL içinde fsync dahil olmak üzere bir kaydetme işleminin tüm maliyetini öder. Toplu işleme, bu maliyeti bir kez öder. Bu da bir yazma grubunu bir kat veya daha fazla hızlandırabilir.

Sorun giderme araçlarını kullanma

SQLite, performansı ölçmeye yardımcı olmak için aşağıdaki sorun giderme araçlarını sağlar.

SQLite'ın etkileşimli istemini kullanma

Sorgu çalıştırmak ve öğrenmek için makinenizde SQLite'ı çalıştırın. Farklı Android platformu sürümleri, SQLite'ın farklı düzeltmelerini kullanır. Android destekli bir cihazdaki motorun aynısını kullanmak için adb shell komutunu kullanın ve hedef cihazınızda sqlite3 komutunu çalıştırın.

SQLite'tan sorguları zamanlamasını isteyebilirsiniz:

sqlite> .timer on
sqlite> SELECT ...
Run Time: real ... user ... sys ...

EXPLAIN QUERY PLAN

SQLite'tan, EXPLAIN QUERY PLAN kullanarak bir sorguyu nasıl yanıtlamayı planladığını açıklamasını isteyebilirsiniz:

sqlite> EXPLAIN QUERY PLAN
SELECT id, name
FROM Customers
WHERE city = 'Paris';
QUERY PLAN
`--SCAN Customers

Önceki örnekte, Paris'teki tüm müşterileri bulmak için dizin olmadan tam tablo taraması yapılması gerekir. Buna doğrusal karmaşıklık denir. SQLite'ın tüm satırları okuması ve yalnızca Paris'teki müşterilerle eşleşen satırları tutması gerekir. Bu sorunu düzeltmek için bir dizin ekleyebilirsiniz:

sqlite> CREATE INDEX Idx1 ON Customers(city);
sqlite> EXPLAIN QUERY PLAN
SELECT id, name
FROM Customers
WHERE city = 'Paris';
QUERY PLAN
`--SEARCH test USING INDEX Idx1 (city=?

Etkileşimli kabuğu kullanıyorsanız SQLite'tan sorgu planlarını her zaman açıklamasını isteyebilirsiniz:

sqlite> .eqp on

Daha fazla bilgi için Sorgu Planlama bölümüne bakın.

SQLite Analyzer

SQLite, performans sorunlarını gidermek için kullanılabilecek ek bilgileri dökümlemek üzere sqlite3_analyzer komut satırı arayüzünü (KSA) sunar. Yüklemek için SQLite indirme sayfasını ziyaret edin.

Bir veritabanı dosyasını hedef cihazdan iş istasyonunuza indirip analiz etmek için adb pull aracını kullanabilirsiniz:

adb pull /data/data/<app_package_name>/databases/<db_name>.db

SQLite Browser

SQLite indirme sayfasından SQLite Browser GUI aracını da yükleyebilirsiniz.

Android günlüğü

Android, SQLite sorgularını zamanlar ve sizin için günlüğe kaydeder:

# Enable query time logging
$ adb shell setprop log.tag.SQLiteTime VERBOSE
# Disable query time logging
$ adb shell setprop log.tag.SQLiteTime ERROR

Android 13 ve sonraki sürümlerde yalnızca yavaş işlemleri de günlüğe kaydedebilirsiniz. Milisaniye cinsinden bir eşik belirleyin, ardından günlük etiketini okuması için uygulamanızı yeniden başlatın:

$ adb shell setprop log.tag.SQLiteSlowQueries VERBOSE
$ adb shell setprop db.log.slow_query_threshold 100
$ adb shell am force-stop <package-name>

Yavaş işlemler, logcat'te SQLiteConnection etiketi altında süreleri, SQL ve veritabanı yollarıyla birlikte gösterilir. Tek bir uygulamanın eşiğini ayarlamak için db.log.slow_query_threshold.<uid> komutunu kullanın. Burada <uid>, uygulamanın UID'sidir.

Perfetto izleme

Perfetto'yu yapılandırırken, SQLite işlemlerini izlemeye dahil etmek için database atrace kategorisini ekleyin. SQL çalıştırırken her bir iş parçacığının ne yaptığını görmek için zamanlayıcı etkinliklerini de ekleyin:

data_sources {
  config {
    name: "linux.ftrace"
    ftrace_config {
      ftrace_events: "sched/sched_switch"
      ftrace_events: "sched/sched_waking"
      ftrace_events: "sched/sched_blocked_reason"
      atrace_categories: "database"
    }
  }
}

Android, her veritabanı işlemini, işlemin ve SQL'inin adını taşıyan bir dilim olarak izler (ör. executeForCursorWindow SELECT * FROM message WHERE body LIKE ?). SQL, ? yer tutucularını korur ve 256 karaktere kısaltılır. Bağlama bağımsız değişkenleri dahil edilmez. İşlemler arasında open, close, prepare, execute ve executeFor varyantları yer alır. prepare dilimi, ifade önbelleğinin kaçırıldığı anlamına gelmez.

Kanal düzeni, Android sürümüne bağlıdır:

  • Android 17 ve sonraki sürümlerde her bağlantının, uygulama işleminde SQLiteConnection- ve ardından bağlantı numarasıyla adlandırılan eşzamansız bir kanalı vardır. İşlemler TRANSACTION-EXCLUSIVE, TRANSACTION-IMMEDIATE veya TRANSACTION-DEFERRED dilimleri olarak görünür ve içlerinde çalıştırılan ifadeler altlarında iç içe yerleştirilir. Bir işlemi çalıştıran iş parçacığı, her işlem ve işlem dışındaki her ifade için iş parçacığı izinde START- ve END- anlık etkinlikleri de alır. Bu etkinliklerin adları, bağlantı kanalı adıyla biter (ör. -SQLiteConnection-0).
  • Android 16 ve önceki sürümlerde her farklı SQL ifadesinin uygulama sürecinde kendi eş zamansız izi vardır ve işlemler izlenmez.

Bağlantı numaraları her veritabanı için 0'dan başlar. Bu nedenle, bir işlemdeki tüm veritabanlarının birincil bağlantıları SQLiteConnection-0 izini paylaşır. Uygulamanız aynı anda birden fazla veritabanı kullanıyorsa iş parçacığı izlerindeki START- ve END- etkinlikleriyle süreleri ölçün.

Perfetto'da ana iş parçacığı ve SQLiteConnection-0 izleri. Ana iş parçacığında 12 milisaniye boyunca tüm tabloyu tarayan bir LIKE sorgusu gösteriliyor.

Şekil 1. Android 17'de ana iş parçacığında tam tablo taraması.

Bu dilimleri Perfetto kullanıcı arayüzünde PerfettoSQL ile veya trace_processor ile analiz edebilirsiniz. Aşağıdaki sorgu, her işlemde en sık kullanılan SQLite işlemlerini bulur. Bu sorgu tüm Android sürümlerinde çalışır:

INCLUDE PERFETTO MODULE slices.with_context;

SELECT process_name, name AS operation, COUNT(*) AS count
FROM process_slice
WHERE name GLOB 'open *' OR name GLOB 'close *'
  OR name GLOB 'prepare *' OR name GLOB 'execute*'
  OR name GLOB 'TRANSACTION-*'
GROUP BY process_name, name
ORDER BY count DESC
LIMIT 10;

Aşağıdaki sorguda, Android 17 ve sonraki sürümlerde ana iş parçacığında 1 ms'den uzun süren SQLite işlemleri listelenmektedir:

INCLUDE PERFETTO MODULE slices.with_context;

WITH marks AS (
  SELECT process_name, utid, is_main_thread, ts, name,
    LEAD(ts) OVER (PARTITION BY utid ORDER BY ts) AS end_ts,
    LEAD(name) OVER (PARTITION BY utid ORDER BY ts) AS end_name
  FROM thread_slice
  WHERE name GLOB 'START-*' OR name GLOB 'END-*'
)
SELECT process_name, ts, ROUND((end_ts - ts) / 1e6, 1) AS dur_ms,
  substr(name, 7) AS operation
FROM marks
WHERE name GLOB 'START-*'
  AND end_name = 'END-' || substr(name, 7)
  AND is_main_thread
  AND end_ts - ts > 1e6
ORDER BY dur_ms DESC
LIMIT 10;

Bir test uygulamasının ana iş parçacığında 1 milisaniyeden uzun süren SQLite işlemlerini listeleyen Perfetto sorgu sonucu

Şekil 2. Ana iş parçacığında 1 ms'den uzun süren SQLite işlemleri.

Aşağıdaki sorgu, Android 17 ve sonraki sürümlerde açık bir işlemin dışında yazma işlemi yapan ve her yazma işleminin kendi kaydı için ödeme yaptığı ileti dizilerini bulur:

INCLUDE PERFETTO MODULE slices.with_context;

WITH ops AS (
  SELECT process_name, thread_name, utid, name,
    upper(ltrim(substr(name, instr(name, ' ') + 1))) AS sql
  FROM thread_slice
  WHERE name GLOB 'START-*'
)
SELECT process_name, thread_name,
  SUM(name GLOB 'START-TRANSACTION-*') AS transactions,
  SUM(name GLOB 'START-execute*' AND (sql GLOB 'INSERT *'
    OR sql GLOB 'UPDATE *' OR sql GLOB 'DELETE *' OR sql GLOB 'REPLACE *'))
    AS autocommit_writes
FROM ops
GROUP BY utid
HAVING autocommit_writes > 0
ORDER BY autocommit_writes DESC
LIMIT 10;

dumpsys meminfo

adb shell dumpsys meminfo <package-name>, uygulamanın bellek kullanımıyla ilgili istatistikleri yazdırır. DATABASES bölümünde SQLite ile ilgili ayrıntılar da yer alır. Örneğin, aşağıdaki çıkış bir geliştiricinin cihazında adb shell dumpsys meminfo com.google.android.gms.persistent komutunun çalıştırılmasıyla elde edilmiştir:

DATABASES
      pgsz     dbsz   Lookaside(b) cache hits cache misses cache size  Dbname
PER CONNECTION STATS
         4       52             45     8    41     6  /data/user/10/com.google.android.gms/databases/gaia-discovery
         4        8                    0     0     0    (attached) temp
         4       52             56     5    23     6  /data/user/10/com.google.android.gms/databases/gaia-discovery (1)
         4      252             95   233   124    12  /data/user_de/10/com.google.android.gms/databases/phenotype.db
         4        8                    0     0     0    (attached) temp
         4      252             17     0    17     1  /data/user_de/10/com.google.android.gms/databases/phenotype.db (1)
         4     9280            105 103169 69805    25  /data/user/10/com.google.android.gms/databases/phenotype.db
         4       20                    0     0     0    (attached) temp
         4     9280            108 13877  6394    25  /data/user/10/com.google.android.gms/databases/phenotype.db (2)
         4        8                    0     0     0    (attached) temp
         4     9280            105 12548  5519    25  /data/user/10/com.google.android.gms/databases/phenotype.db (3)
         4        8                    0     0     0    (attached) temp
         4     9280            107 18328  7886    25  /data/user/10/com.google.android.gms/databases/phenotype.db (1)
         4        8                    0     0     0    (attached) temp
         4       36             51   156    29     5  /data/user/10/com.google.android.gms/databases/mobstore_gc_db_v0
         4       36             97    47    27    10  /data/user/10/com.google.android.gms/databases/context_feature_default.db
         4       36             56     3    16     4  /data/user/10/com.google.android.gms/databases/context_feature_default.db (2)
         4      300             40  2111    24     5  /data/user/10/com.google.android.gms/databases/gservices.db
         4      300             39     3    17     4  /data/user/10/com.google.android.gms/databases/gservices.db (1)
         4       20             17     0    14     1  /data/user/10/com.google.android.gms/databases/gms.notifications.db
         4       20             33     1    15     2  /data/user/10/com.google.android.gms/databases/gms.notifications.db (1)
         4      120             40   143   163     4  /data/user/10/com.google.android.gms/databases/android_pay
         4      120            123    86    32    19  /data/user/10/com.google.android.gms/databases/android_pay (1)
         4       28             33     4    17     3  /data/user/10/com.google.android.gms/databases/googlesettings.db
POOL STATS
     cache hits  cache misses    cache size  Dbname
             13            68            81  /data/user/10/com.google.android.gms/databases/gaia-discovery
            233           145           378  /data/user_de/10/com.google.android.gms/databases/phenotype.db
         147921         89616        237537  /data/user/10/com.google.android.gms/databases/phenotype.db
            156            30           186  /data/user/10/com.google.android.gms/databases/mobstore_gc_db_v0
             50            57           107  /data/user/10/com.google.android.gms/databases/context_feature_default.db
           2114            43          2157  /data/user/10/com.google.android.gms/databases/gservices.db
              1            31            32  /data/user/10/com.google.android.gms/databases/gms.notifications.db
            229           197           426  /data/user/10/com.google.android.gms/databases/android_pay
              4            18            22  /data/user/10/com.google.android.gms/databases/googlesettings.db

Android 14 ve sonraki sürümler, örnekte gösterilen PER CONNECTION STATS ve POOL STATS tablolarını yazdırır. Android 13 ve önceki sürümler yalnızca bağlantı başına tabloyu yazdırır. Önbellek istatistikleri, tek bir cache sütununda hits/misses/size olarak birleştirilir.

PER CONNECTION STATS bölümünde şunları bulabilirsiniz:

  • pgsz: KB cinsinden bir veritabanı sayfasının boyutu.
  • dbsz: KB cinsinden tüm veritabanı dosyasının boyutu.
  • Lookaside(b): Bağlantının kullandığı lookaside bellek yuvalarının sayısı. Başlıktaki (b) ifadesine rağmen bu, bayt sayısını değil, yuva sayısını gösterir.
  • cache hits, cache misses ve cache size: Bağlantının hazırlanmış ifade önbelleğiyle ilgili istatistikler. Bu değerler, veritabanı sayfalarını değil, derlenmiş SQL ifadelerini sayar.
  • Dbname: Veritabanı dosyasının yolu. Aynı veritabanına yapılan ek bağlantıların sonuna parantez içinde bir sayı eklenir (ör. (1)). WAL ile bir veritabanının birden fazla bağlantısı olabilir. Böylece okuma işlemleri paralel olarak çalıştırılabilir.

POOL STATS bölümünde, her bağlantı havuzu için hazırlanmış ifade önbelleği istatistiklerini bulabilirsiniz:

  • cache hits: SQLite, SQL ifadelerini derlerken biraz çaba ve bellek tasarrufu sağlamak için hazırlanmış ifadeleri önbelleğe alır ve sorgu çalıştırırken bunları yeniden kullanmaya çalışır. Bu, talimat önbelleği isabetlerinin sayısıdır.
  • cache misses: Önbellekte bulunamayan ifade sayısı.
  • cache size: Android 16 QPR2 ve sonraki sürümlerde, önbellekteki hazırlanmış ifadelerin sayısı, havuzun boşta olan bağlantıları genelinde toplanır. Android 14'ten Android 16 QPR1'e kadar bu değer, isabetlerin ve ıskalamaların toplamıdır ve önbellek boyutunu temsil etmez.

Neleri düzelteceğinize karar vermek için bu istatistikleri kullanın:

  • Yüksek bir isabet etmeme oranı, SQLite'ın aynı ifadeleri tekrar tekrar derlediği anlamına gelir. Öncelikle dize birleştirme ile oluşturulmuş SQL'i bulun ve bunu parametreleştirin. Sorgularınız zaten parametreleştirilmişse ve bağlantı başına cache size 25 sınırındaysa önbellek boyutunu artırın.
  • (1) stili bir son ek olmadan birden fazla kez görünen veritabanı yolu, uygulamanızın aynı dosyayı iki kez açtığı anlamına gelir. Her veritabanını bir kez açın.
  • Uygulamanızın nadiren kullandığı bir veritabanı, açık olduğu sürece bellek kullanmaya devam eder. İşiniz bittiğinde kapatın.

dumpsys dbinfo

adb shell dumpsys dbinfo <package-name>, uygulamanızdaki her bağlantı havuzunun durumunu yazdırır. Her bağlantının hazırlanmış ifade önbelleğindeki ifadeleri de listelemek için -v ekleyin. Aşağıdaki örnek çıkış, Android 17'den alınmıştır. Yollar ve uzun satırlar kısaltılmıştır:

** Database info for pid 2705 [com.example.app] **
Connection pool for /data/user/0/com.example.app/databases/settings.db:
  Open: true
  Max connections: 4
  Total execution time (ms): 4963
  Total statements executed: 231239
  Average time per statement (ms): 0
  Configuration: openFlags=805306368, ..., journalMode=WAL, syncMode=NORMAL
  IsReadOnlyDatabase: false
  Available primary connection:
    <none>
  Available non-primary connections:
    <none>
  Acquired connections:
    <none>
  Connection waiters:
    <none>
Connection pool for /data/user/0/com.example.app/databases/events.db:
  Open: true
  Max connections: 1
  Total execution time (ms): 842
  Total statements executed: 840
  Average time per statement (ms): 1
  Configuration: openFlags=268435456, ..., journalMode=TRUNCATE, syncMode=FULL
  IsReadOnlyDatabase: false
  Available primary connection:
    Connection #0:
      isPrimaryConnection: true
      onlyAllowReadOnlyOperations: false
      totalLongOperations: 0
      Most recently executed operations:
        0: [10-26 04:22:32.133] executeForChangedRowCount took 0ms -
           succeeded, sql="DELETE FROM params WHERE app_id=?", path=...
        1: [10-26 04:22:32.133] prepare took 0ms - succeeded,
           sql="DELETE FROM params WHERE app_id=?", path=...
        ...

Aşağıdakileri arayın:

  • Configuration: Havuzun günlük modu ve eşzamanlı modu. Android 13 ve sonraki sürümlerde bu satır, platform varsayılanları da dahil olmak üzere çerçevenin uyguladığı modları gösterir. Örnekte events.db, FULL eşzamanlı moduyla geri alma günlüğünü kullanır. Bu nedenle, her kaydetme işlemi tam senkronizasyon için bekler. Bu satır, kendiniz çalıştırdığınız PRAGMA ifadelerini yansıtmaz.
  • Max connections: Yazılabilir bir veritabanı için 1 değeri, çerçevenin veritabanını WAL modunda kabul etmediği anlamına gelir. Bu nedenle, okuma ve yazma işlemleri tek bir bağlantıyı paylaşır.
  • Total execution time, Total statements executed ve Average time per statement: Android 13 ve sonraki sürümlerde kullanılabilir. En yoğun veritabanlarınızı bulmak için bu metrikleri kullanabilirsiniz.
  • Most recently executed operations: Her bağlantıdaki son 20 işlem, süreleri ve SQL'leri. Aynı sorgunun bir döngüde çalıştırılması veya bir işlem dışında arka arkaya birçok yazma işlemi yapılması gibi kalıplara dikkat edin. Android 15 QPR1 ve sonraki sürümlerde bu liste, açık işlemleri de içerir.
  • totalLongOperations ve Operations exceeding 2000ms: Android 15 QPR1 ve sonraki sürümlerde kullanılabilir. Bu grafikler, 2 saniyeden uzun süren işlem ve transferlerin sayısını gösterir ve son 10 tanesini listeler.
  • Acquired connections ve Connection waiters: Kullanımda olan bağlantılar ve bağlantı bekleyen ileti dizileri. Bekleyenler, diğer yazma işlemlerini engelleyen uzun bir yazma işlemi gibi çekişmeleri gösterir.
  • Prepared statement cache: -v ile yazdırıldı. Yalnızca değişmez değerleri farklı olan birçok giriş, dize birleştirme ile oluşturulmuş SQL'e işaret eder.
  • Birden fazla Connection pool for başlığı altında aynı yolun bulunması, uygulamanızın aynı dosyayı iki kez açtığı anlamına gelir.

Ek kaynaklar

İçerik görüntüleme