שיטות מומלצות לשיפור הביצועים של SQLite (תצוגות)

מושגים והטמעה של Jetpack Compose

ב-Android יש תמיכה מובנית ב-SQLite, מסד נתונים יעיל של SQL. כדאי לפעול לפי השיטות המומלצות הבאות כדי לשפר את ביצועי האפליקציה, ולוודא שהיא תמשיך לפעול במהירות גם כשהנתונים יגדלו. השיטות המומלצות האלה גם עוזרות לצמצם את הסיכוי להיתקל בבעיות בביצועים שקשה לשחזר ולפתור.

כדי לשפר את הביצועים, כדאי לפעול לפי העקרונות הבאים:

  • קריאה של פחות שורות ועמודות: מבצעים אופטימיזציה של השאילתות כדי לאחזר רק את הנתונים הנחוצים. לצמצם את כמות הנתונים שנקראים ממסד הנתונים, כי אחזור נתונים עודפים יכול להשפיע על הביצועים.

  • העברת העבודה למנוע SQLite: מבצעים פעולות חישוב, סינון ומיון בתוך שאילתות ה-SQL. שימוש במנוע השאילתות של SQLite יכול לשפר משמעותית את הביצועים.

  • שינוי הסכימה של מסד הנתונים: תכננו את הסכימה של מסד הנתונים כך שתעזור ל-SQLite ליצור תוכניות לביצוע שאילתה יעילות וייצוגי נתונים. כדי לשפר את הביצועים, חשוב להוסיף לאינדקס טבלאות בצורה נכונה ולבצע אופטימיזציה של מבני הטבלאות.

בנוסף, אתם יכולים להשתמש בכלים הזמינים לפתרון בעיות כדי למדוד את הביצועים של מסד הנתונים ב-SQLite, וכך לזהות תחומים שצריך לבצע בהם אופטימיזציה.

מומלץ להשתמש בספריית Jetpack Room.

הגדרת מסד הנתונים לביצועים מיטביים

כדי להגדיר את מסד הנתונים לביצועים אופטימליים ב-SQLite, פועלים לפי השלבים שמפורטים בקטע הזה.

הקלה על מצב הסנכרון

ב-SQLite יש אפשרות לשלוט במצב הסינכרוני. במצב FULL, כל commit מנפיק fsync כדי לוודא שהנתונים מגיעים לדיסק. כך משפרים את עמידות הנתונים, אבל מאטים את ה-commit. עם WAL, מצב NORMAL לא מסתנכרן בכל commit ועדיין מגן על מסד הנתונים מפני פגיעה.

כשמפעילים את WAL דרך ה-framework, מערכת Android מחילה את המצב הסינכרוני של WAL שמוגדר כברירת מחדל במכשיר, שהוא NORMAL ב-AOSP ל-Android מגרסה 9 ואילך. יצרני המכשירים יכולים לשנות את ברירת המחדל הזו, ומסדי נתונים שלא משתמשים ב-WAL מוגדרים כברירת מחדל ל-FULL. כדי לוודא שמסד הנתונים משתמש ב-NORMAL, צריך להגדיר אותו כשפותחים את מסד הנתונים:

Kotlin

// 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")

Java

// When opening the database
SQLiteDatabase.OpenParams params = new 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");

בהגדרה הזו, יכול להיות שפעולת commit תוחזר לפני שהנתונים יאוחסנו בדיסק. אם המכשיר נכבה, למשל בגלל הפסקת חשמל או תגובה לשגיאת ליבה קריטית, יכול להיות שהנתונים שנשמרו יאבדו. עם זאת, בגלל הרישום ביומן, מסד הנתונים לא נפגם.

אם רק האפליקציה קורסת, הנתונים עדיין מגיעים לדיסק. ברוב האפליקציות, ההגדרה הזו משפרת את הביצועים ללא עלות משמעותית.

שיפור ביצועי השאילתות

כדי לשפר את ביצועי השאילתות ב-SQLite, כדאי לפעול לפי השיטות המומלצות הבאות, שמצמצמות את זמני התגובה ומייעלות את העיבוד.

קריאה רק של השורות שצריך

מסננים מאפשרים לצמצם את התוצאות על ידי ציון קריטריונים מסוימים, כמו טווח תאריכים, מיקום או שם. מגבלות מאפשרות לכם לשלוט במספר התוצאות שמוצגות:

Kotlin

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

Java

try (Cursor cursor = db.rawQuery("""
    SELECT name
    FROM Customers
    LIMIT 10;
    """, null)) {
  while (cursor.moveToNext()) {
    // Process cursor data
  }
}

קריאה רק של העמודות שצריך

אל תבחרו עמודות שלא צריך, כי זה יכול להאט את השאילתות ולבזבז משאבים. במקום זאת, בחרו רק את העמודות שבהן אתם משתמשים.

בדוגמה הבאה, בוחרים את id, name ו-phone:

Kotlin

// 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
    }
}

Java

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

try (Cursor cursor = db.rawQuery("""
    SELECT id, name, phone
    FROM customers;
    """, null)) {
  while (cursor.moveToNext()) {
    String name = cursor.getString(1);
    // Further processing
  }
}

עם זאת, אתם צריכים רק את העמודה name:

Kotlin

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

Java

try (Cursor cursor = db.rawQuery("""
    SELECT name
    FROM Customers;
    """, null)) {
  while (cursor.moveToNext()) {
    String name = cursor.getString(0);
    // Further processing
  }
}

הוספת פרמטרים לשאילתות

מחרוזת השאילתה יכולה לכלול פרמטר שידוע רק בזמן הריצה, כמו:

Kotlin

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
        }
    }
}

Java

@Nullable
public String getNameById(long id) {
  try (Cursor cursor = db.rawQuery(
      "SELECT name FROM customers WHERE id=" + id, null)) {
    if (cursor.moveToFirst()) {
      return cursor.getString(0);
    } else {
      return null;
    }
  }
}

בקוד שלמעלה, כל שאילתה יוצרת מחרוזת שונה, ולכן לא נהנית מהמטמון של ההצהרות. כל קריאה מחייבת את SQLite לקמפל אותה לפני שהיא יכולה לפעול. במקום זאת, אפשר להחליף את הארגומנט id בפרמטר ולקשר את הערך באמצעות selectionArgs:

Kotlin

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
        }
    }
}

Java

@Nullable
public String getNameById(long id) {
  try (Cursor cursor = db.rawQuery("""
          SELECT name
          FROM customers
          WHERE id=?
      """, new String[] {String.valueOf(id)})) {
    if (cursor.moveToFirst()) {
      return cursor.getString(0);
    } else {
      return null;
    }
  }
}

עכשיו אפשר לקמפל את השאילתה פעם אחת ולשמור אותה במטמון. השאילתה המקומפלת משמשת שוב בין הפעלות שונות של getNameById(long).

שימוש במשתנה DISTINCT לערכים ייחודיים

שימוש במילת המפתח DISTINCT יכול לשפר את הביצועים של השאילתות, כי הוא מצמצם את כמות הנתונים שצריך לעבד. לדוגמה, אם רוצים להחזיר רק את הערכים הייחודיים מעמודה, משתמשים ב-DISTINCT:

Kotlin

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

Java

try (Cursor cursor = db.rawQuery("""
    SELECT DISTINCT name
    FROM Customers;
    """, null)) {
  while (cursor.moveToNext()) {
    // Only iterate over distinct names in Java
    // Process distinct name
  }
}

השתמשו בפונקציות צבירה ככל האפשר

כדי לקבל תוצאות מצטברות בלי נתוני שורות, צריך להשתמש בפונקציות צבירה. לדוגמה, הקוד הבא בודק אם יש לפחות שורה אחת תואמת:

Kotlin

// 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
}

Java

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

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

כדי לאחזר רק את השורה הראשונה, אפשר להשתמש ב-EXISTS() כדי להחזיר את 0 אם אין שורה תואמת, ואת 1 אם יש שורה תואמת אחת או יותר:

Kotlin

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
    }
}

Java

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

משתמשים בפונקציות צבירה של SQLite בקוד של האפליקציה:

  • ‫COUNT: סופרת כמה שורות יש בעמודה.
  • ‫SUM: חיבור כל הערכים המספריים בעמודה.
  • MIN או MAX: קובע את הערך הנמוך ביותר או הגבוה ביותר. הפונקציה פועלת על עמודות מספריות, על סוגי DATE ועל סוגי טקסט.
  • ‫AVG: מוצא את הערך המספרי הממוצע.
  • ‫GROUP_CONCAT: משרשר מחרוזות עם מפריד אופציונלי.

שימוש ב-COUNT() במקום ב-Cursor.getCount()

בדוגמה הבאה, הפונקציה Cursor.getCount() קוראת את כל השורות ממסד הנתונים ומחזירה את כל הערכים של השורות:

Kotlin

// 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
}

Java

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

try (Cursor cursor = db.rawQuery("""
    SELECT id
    FROM Customers;
    """, null)) {
  int count = cursor.getCount();
  // Use count
}

לעומת זאת, אם משתמשים ב-COUNT(), מסד הנתונים מחזיר רק את הספירה:

Kotlin

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

Java

try (Cursor cursor = db.rawQuery("""
    SELECT COUNT(*)
    FROM Customers;
    """, null)) {
  cursor.moveToFirst();
  int count = cursor.getInt(0);
  // Use count
}

הטמעת שאילתות במקום קוד

אפשר להרכיב שאילתות SQL והן תומכות בשאילתות משנה, באיחודים ובאילוצים של מפתחות זרים. אתם יכולים להשתמש בתוצאה של שאילתה אחת בשאילתה אחרת בלי לעבור דרך קוד האפליקציה. כך לא צריך להעתיק נתונים מ-SQLite, והמנוע של מסד הנתונים יכול לבצע אופטימיזציה של השאילתה.

בדוגמה הבאה, אתם יכולים להריץ שאילתה כדי למצוא באיזו עיר יש הכי הרבה לקוחות, ואז להשתמש בתוצאה בשאילתה אחרת כדי למצוא את כל הלקוחות מאותה עיר:

Kotlin

// 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
            }
        }
    }
}

Java

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

try (Cursor cursor = db.rawQuery("""
    SELECT city
    FROM Customers
    GROUP BY city
    ORDER BY COUNT(*) DESC
    LIMIT 1;
    """, null)) {
  if (cursor.moveToFirst()) {
    String topCity = cursor.getString(0);
    try (Cursor innerCursor = db.rawQuery("""
        SELECT name, city
        FROM Customers
        WHERE city = ?;
        """, new String[] {topCity})) {
        while (innerCursor.moveToNext()) {
          // Process inner cursor data
        }
    }
  }
}

כדי לקבל את התוצאה בחצי מהזמן של הדוגמה הקודמת, משתמשים בשאילתת SQL אחת עם הצהרות בתצוגת עץ:

Kotlin

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
    }
}

Java

try (Cursor cursor = db.rawQuery("""
    SELECT name, city
    FROM Customers
    WHERE city IN (
      SELECT city
      FROM Customers
      GROUP BY city
      ORDER BY COUNT(*) DESC
      LIMIT 1
    );
    """, null)) {
  while(cursor.moveToNext()) {
    // Process cursor data
  }
}

בדיקת הייחודיות ב-SQL

אם צריך להכניס שורה רק אם ערך של עמודה מסוימת הוא ייחודי בטבלה, יכול להיות שיהיה יעיל יותר לאכוף את הייחודיות הזו כאילוץ של עמודה.

בדוגמה הבאה, שאילתה אחת מופעלת כדי לאמת את השורה שצריך להוסיף, ושאילתה אחרת מופעלת כדי להוסיף אותה בפועל:

Kotlin

// 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
    )
)

Java

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

try (Cursor cursor = db.rawQuery("""
    SELECT EXISTS (
      SELECT null
      FROM customers
      WHERE username = ?
    );
    """, new String[] { customer.username })) {
  if (cursor.moveToFirst() && cursor.getInt(0) == 1) {
    throw new AddCustomerException(customer);
  }
}
db.execSQL(
    "INSERT INTO customers VALUES (?, ?, ?)",
    new String[] {
      String.valueOf(customer.id),
      customer.name,
      customer.username,
    });

במקום לבדוק את האילוץ הייחודי ב-Kotlin או ב-Java, אפשר לבדוק אותו ב-SQL כשמגדירים את הטבלה:

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

ב-SQLite מתבצעת פעולה דומה:

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

עכשיו אפשר להוסיף שורה ולתת ל-SQLite לבדוק את האילוץ:

Kotlin

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

Java

try {
  db.execSQL(
      "INSERT INTO Customers VALUES (?, ?, ?)",
      new String[] {
        String.valueOf(customer.id),
        customer.name,
        customer.username,
      });
} catch (SQLiteConstraintException e) {
  throw new AddCustomerException(customer, e);
}

‫SQLite תומך באינדקסים ייחודיים עם כמה עמודות:

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

SQLite מאמת אילוצים מהר יותר ועם פחות תקורה מאשר קוד Kotlin או Java. מומלץ להשתמש ב-SQLite במקום בקוד של האפליקציה.

הוספה של כמה פריטים באצווה בעסקה אחת

טרנזקציה מבצעת כמה פעולות, מה שמשפר לא רק את היעילות אלא גם את הדיוק. כדי לשפר את עקביות הנתונים ולהאיץ את הביצועים, אפשר להוסיף נתונים באצווה:

Kotlin

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

Java

db.beginTransaction();
try {
  for (Customer customer : customers) {
    db.execSQL(
        "INSERT INTO Customers VALUES (?, ?, ?)",
        new String[] {
          String.valueOf(customer.id),
          customer.name,
          "customerValue"
        });
  }
  db.setTransactionSuccessful();
} finally {
  db.endTransaction();
}