ב-Android יש תמיכה מובנית ב-SQLite, מסד נתונים יעיל של SQL. כדאי לפעול לפי השיטות המומלצות הבאות כדי לשפר את ביצועי האפליקציה, ולוודא שהיא תמשיך לפעול במהירות גם כשהנתונים יגדלו. השיטות המומלצות האלה גם עוזרות לצמצם את הסיכוי להיתקל בבעיות בביצועים שקשה לשחזר ולפתור.
כדי לשפר את הביצועים, כדאי לפעול לפי העקרונות הבאים:
קריאה של פחות שורות ועמודות: מבצעים אופטימיזציה של השאילתות כדי לאחזר רק את הנתונים הנחוצים. לצמצם את כמות הנתונים שנקראים ממסד הנתונים, כי אחזור נתונים עודפים יכול להשפיע על הביצועים.
העברת העבודה למנוע SQLite: מבצעים פעולות חישוב, סינון ומיון בתוך שאילתות ה-SQL. שימוש במנוע השאילתות של SQLite יכול לשפר משמעותית את הביצועים.
שינוי הסכימה של מסד הנתונים: תכננו את הסכימה של מסד הנתונים כך שתעזור ל-SQLite ליצור תוכניות לביצוע שאילתה יעילות וייצוגי נתונים. כדי לשפר את הביצועים, חשוב להוסיף לאינדקס טבלאות בצורה נכונה ולבצע אופטימיזציה של מבני הטבלאות.
בנוסף, אתם יכולים להשתמש בכלים הזמינים לפתרון בעיות כדי למדוד את הביצועים של מסד הנתונים ב-SQLite, וכך לזהות תחומים שצריך לבצע בהם אופטימיזציה.
מומלץ להשתמש בספריית Jetpack Room.
הגדרת מסד הנתונים לביצועים מיטביים
כדי להגדיר את מסד הנתונים לביצועים אופטימליים ב-SQLite, פועלים לפי השלבים שמפורטים בקטע הזה.
הפעלת כתיבה מראש ביומן (WAL)
SQLite מטמיע מוטציות על ידי הוספה שלהן ליומן, ומדי פעם דוחס אותן למסד הנתונים. הפעולה הזו נקראת כתיבה מראש ביומן (WAL).
מפעילים את WAL, אלא אם משתמשים ב-ATTACH DATABASE.
מפעילים את WAL דרך המסגרת במקום להריץ את PRAGMA journal_mode=WAL בעצמכם. Room מפעיל את WAL כברירת מחדל במכשירים שאינם מכשירים עם זיכרון RAM נמוך.
אם אין חדר, משתמשים באחת מהאפשרויות הבאות:
- ה-method
enableWriteAheadLogging - הדגל הפתוח
ENABLE_WRITE_AHEAD_LOGGING - השיטה
setWriteAheadLoggingEnabledשלSQLiteOpenHelper
כשהמסגרת מזהה שמסד הנתונים משתמש ב-WAL, היא פותחת מאגר של חיבורים כדי שפעולות קריאה יוכלו לפעול במקביל לפעולת כתיבה, ומפעילה את המצב הסינכרוני של WAL שמתואר בקטע הבא. אם תפעילו את PRAGMA journal_mode=WAL בעצמכם, המסגרת עדיין תתייחס למסד הנתונים כמסד נתונים של יומן החזרה למצב קודם. הוא משתמש בחיבור יחיד, והאישורים שומרים על מצב ברירת המחדל הסינכרוני FULL.
הקלה על מצב הסנכרון
ב-SQLite יש אפשרות לשלוט במצב הסינכרוני. במצב FULL, כל commit מנפיק fsync כדי לוודא שהנתונים מגיעים לדיסק. כך משפרים את עמידות הנתונים, אבל מאטים את ה-commit. עם WAL, מצב NORMAL לא מסתנכרן בכל commit ועדיין מגן על מסד הנתונים מפני פגיעה.
כשמפעילים את WAL דרך ה-framework, מערכת Android מחילה את המצב הסינכרוני של WAL שמוגדר כברירת מחדל במכשיר, שהוא NORMAL ב-AOSP ל-Android מגרסה 9 ואילך.
יצרני המכשירים יכולים לשנות את ברירת המחדל הזו, ומסדי נתונים שלא משתמשים ב-WAL מוגדרים כברירת מחדל ל-FULL. כדי לוודא שמסד הנתונים משתמש ב-NORMAL, צריך להגדיר אותו כשפותחים את מסד הנתונים:
// 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")
בהגדרה הזו, יכול להיות שפעולת commit תוחזר לפני שהנתונים יאוחסנו בדיסק. אם המכשיר נכבה, למשל בגלל הפסקת חשמל או תגובה לשגיאת ליבה קריטית, יכול להיות שהנתונים שנשמרו יאבדו. עם זאת, בגלל הרישום ביומן, מסד הנתונים לא נפגם.
אם רק האפליקציה קורסת, הנתונים עדיין מגיעים לדיסק. ברוב האפליקציות, ההגדרה הזו משפרת את הביצועים ללא עלות משמעותית.
ניהול החיבורים והזיכרון
כל חיבור פתוח למסד נתונים תופס זיכרון SQLite: מטמון דפים, מקצה lookaside ומטמון של הצהרות מקומפלות. הדפים של קובץ מסד הנתונים נשארים גם במטמון הדפים של הליבה. כדי לשלוט בזיכרון הזה בלי להאט את השאילתות, כדאי לפעול לפי השיטות שבקטע הזה.
פותחים כל מסד נתונים פעם אחת
השתמשו במופע יחיד של SQLiteOpenHelper או RoomDatabase לכל קובץ של מסד נתונים בכל התהליך, לדוגמה על ידי הפיכתו ל-singleton. לכל מכונה יש מאגר חיבורים, מטמוני דפים ומטמוני הצהרות משלה, ושתי מכונות שפותחות את אותו קובץ לא יכולות לשתף אותם. בבדיקה אחת, איחוד של שני רכיבי עזר שפתחו את אותו קובץ לרכיב אחד, הפחית את השימוש בזיכרון המטמון של דפי SQLite כמעט בחצי, וקיצר את זמן האחזור של שאילתות p95 ביותר מחצי.
כדי למצוא מופעים כפולים, בודקים ב-dumpsys dbinfo אם אותו נתיב של מסד נתונים מופיע ביותר מכותרת Connection pool for אחת.
לא לבצע שינויים בסכימה מחוץ ל-callback הפתוח
הקריאה החוזרת (callback) onOpen של SQLiteOpenHelper פועלת בכל פעם שמסד הנתונים נפתח, וכך גם הקריאה החוזרת onOpen של RoomDatabase.Callback. הציבו הצהרות כמו CREATE TABLE IF NOT EXISTS ו-CREATE INDEX IF NOT EXISTS ב-onCreate וב-onUpgrade, או בהעברות של Room. אל תקראו ל-setVersion ב-onOpen, כי SQLiteOpenHelper כבר מגדיר את הגרסה אחרי onCreate ו-onUpgrade. גם אם הסכימה כבר קיימת, ההצהרות האלה עדיין פועלות בכל פתיחה, ו-setVersion כותב לקובץ של מסד הנתונים.
הגדרת הגודל של מטמון ההצהרות המוכנות
מערכת Android שומרת במטמון הצהרות מקומפלות לכל חיבור. כברירת מחדל, המטמון מכיל 25 הצהרות, ואפשר להגדיל אותו עד ל-MAX_SQL_CACHE_SIZE (100) באמצעות setMaxSqlCacheSize. אם מסד נתונים מריץ יותר מ-25 הצהרות נפרדות בנתיבים החמים שלו, המטמון מתרוקן ו-SQLite מקמפל את אותן הצהרות שוב ושוב.
קודם כול, מגדירים פרמטרים לשאילתות. שום גודל מטמון לא יכול לעזור ל-SQL שנבנה עם שרשור מחרוזות, כי כל מחרוזת היא הצהרה חדשה. לאחר מכן בודקים את מציאות והחמצות מטמון ההצהרות בקטע POOL STATS ב-dumpsys meminfo. לדוגמה, למסד הנתונים phenotype.db בפלט לדוגמה של אותו קטע יש 147,921 פגיעות ו-89,616 החטאות, כלומר שיעור החטאות של 38%. הגדלת נפח המטמון במסד נתונים כזה יכולה להסיר כמעט את כל הפספוסי מטמון. כל הצהרה ששמורה במטמון משתמשת בכמה קילובייטים של זיכרון.
כדי להגדיל את המטמון, צריך לשנות את onConfigure במחלקת המשנה SQLiteOpenHelper:
override fun onConfigure(db: SQLiteDatabase) {
db.setMaxSqlCacheSize(SQLiteDatabase.MAX_SQL_CACHE_SIZE)
}
אם אתם משתמשים ב-Room, צריך להפעיל את אותה שיטה ב-SupportSQLiteDatabase בקריאה החוזרת (callback) של onOpen ב-RoomDatabase.Callback.
סגירת מסדי נתונים לא פעילים, אבל השארת מסדי נתונים פעילים פתוחים
מסד נתונים פתוח שומר על החיבורים, המטמונים ותיאורי הקבצים שלו גם כשהוא לא פעיל. בבדיקה אחת, מסד נתונים של Room במצב WAL שהיה פתוח ולא פעיל החזיק כ-678KB של זיכרון מטמון של דפי SQLite ו-5 תיאורי קבצים. אם אתם משתמשים במסד נתונים רק מדי פעם, למשל במהלך סנכרון יומי, סגרו אותו כשתסיימו.
כדי לסגור מסדי נתונים לא פעילים בצורה בטוחה, צריך לפעול לפי ההנחיות הבאות:
- Room יכול לסגור בשבילכם מסד נתונים לא פעיל באמצעות
setAutoCloseTimeout, שהוא API ניסיוני. Room פותח מחדש את מסד הנתונים בגישה הבאה ומריץ שוב את הקריאה החוזרת (callback) שלonOpen, אז צריך להגדיר שם את החיבור. - הימנעו מהשיטות
setIdleConnectionTimeoutשלSQLiteOpenHelperו-SQLiteDatabase.OpenParams.Builder. הן הוצאו משימוש ברמת API 29 כי הן סוגרות חיבורים בלי לשחזר את ההגדרות שהחלתם באמצעות הצהרותPRAGMA. - אל תסגרו מסד נתונים שאתם שולחים אליו שאילתות כל כמה שניות. אם זמן הקצוב לתפוגה קצר יותר מהמרווח בין השאילתות, מסד הנתונים ייסגר וייפתח שוב ושוב, מה שיגרום למחיקת המטמון של הדפים והמטמון של ההצהרות בכל פעם, ויאט את השאילתה הבאה.
חשבון לזיכרון SQLite
יכול להיות שיהיה קשה לזהות את הזיכרון של SQLite. מטמון הדפים ומטמון ההצהרות של SQLite הם הקצאות זיכרון בערימה (heap allocation) מקוריות. אבל Android לא מפעיל את ה-I/O של SQLite שממופה לזיכרון כברירת מחדל, ולכן SQLite קורא קובצי מסד נתונים באמצעות קריאות קבצים רגילות, ותוכן הקובץ שהליבה שומרת במטמון הוא דפי קבצים לא ממופים. הם לא נכללים ב-PSS של האפליקציה ב-dumpsys meminfo. במכשירים שמשתמשים ב-cgroup v2 לניהול חשבונות זיכרון, הליבה עדיין מחייבת את הדפים האלה ב-cgroup של הזיכרון של האפליקציה. כדי לצמצם את טביעת הרגל הנסתרת הזו, קראו פחות דפים באמצעות אינדקסים וקראו רק את השורות והעמודות שאתם צריכים. סגירת מסדי נתונים שלא צריך
גם מפנה את המטמונים שלהם ב-SQLite.
הגדרת סכימות יעילות לטבלאות
כדי לשפר את הביצועים ולצמצם את צריכת הנתונים, הגדירו סכימת טבלה יעילה. SQLite יוצר תוכניות יעילות לביצוע שאילתות ונתונים, וכך מאפשר לאחזר נתונים מהר יותר. בקטע הזה מפורטות שיטות מומלצות ליצירת סכימות של טבלאות.
כדאי לשקול INTEGER PRIMARY KEY
בדוגמה הזו, מגדירים ומאכלסים טבלה באופן הבא:
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');
הפלט של הטבלה אמור להיראות כך:
| rowid | id | שם | עיר |
|---|---|---|---|
| 1 | 456 | ג'ון לנון | ליברפול, אנגליה |
| 2 | 123 | מייקל ג'קסון | גארי, אינדיאנה |
| 3 | 789 | דולי פרטון | מחוז סבייר, טנסי |
העמודה rowid היא אינדקס ששומר על סדר ההוספה. שאילתות שמסננות לפי rowid מיושמות כחיפוש מהיר בעץ B, אבל שאילתות שמסננות לפי id הן סריקת טבלה איטית.
אם אתם מתכננים לבצע חיפושים לפי id, אתם יכולים להימנע מאחסון העמודה rowid כדי להפחית את כמות הנתונים באחסון ולשפר את מהירות מסד הנתונים באופן כללי:
CREATE TABLE Customers(
id INTEGER PRIMARY KEY,
name TEXT,
city TEXT
);
עכשיו הטבלה נראית כך:
| id | שם | עיר |
|---|---|---|
| 123 | מייקל ג'קסון | גארי, אינדיאנה |
| 456 | ג'ון לנון | ליברפול, אנגליה |
| 789 | דולי פרטון | מחוז סבייר, טנסי |
מכיוון שאין צורך לאחסן את העמודה rowid, השאילתות של id מהירות. שימו לב שהטבלה ממוינת עכשיו לפי id במקום לפי סדר ההוספה.
האצת שאילתות באמצעות אינדקסים
SQLite משתמש באינדקסים כדי להאיץ שאילתות. כשמסננים (WHERE), ממיינים (ORDER BY) או צוברים (GROUP BY) עמודה, אם לטבלה יש אינדקס לעמודה, השאילתה מואצת.
בדוגמה הקודמת, כדי לסנן לפי city צריך לסרוק את כל הטבלה:
SELECT id, name
WHERE city = 'London, England';
באפליקציה עם הרבה שאילתות על ערים, אפשר להאיץ את השאילתות האלה באמצעות אינדקס:
CREATE INDEX city_index ON Customers(city);
אינדקס מיושם כטבלה נוספת, שממוינת לפי עמודת האינדקס וממופה ל-rowid:
| עיר | rowid |
|---|---|
| גארי, אינדיאנה | 2 |
| ליברפול, אנגליה | 1 |
| מחוז סבייר, טנסי | 3 |
שימו לב שעלות האחסון של העמודה city הוכפלה, כי היא נמצאת גם בטבלה המקורית וגם באינדקס. מכיוון שאתם משתמשים באינדקס, עלות האחסון הנוסף שווה את היתרון של שאילתות מהירות יותר.
עם זאת, אל תתחזקו אינדקס שאתם לא משתמשים בו, כדי להימנע מתשלום על עלות האחסון בלי לשפר את ביצועי השאילתות.
יצירת אינדקסים עם כמה עמודות
אם השאילתות משלבות כמה עמודות, אתם יכולים ליצור אינדקסים של כמה עמודות כדי להאיץ את השאילתה באופן מלא. אפשר גם להשתמש באינדקס בעמודה החיצונית ולתת לחיפוש הפנימי להתבצע כסריקה לינארית.
לדוגמה, נניח שזו השאילתה:
SELECT id, name
WHERE city = 'London, England'
ORDER BY city, name
אפשר להאיץ את השאילתה באמצעות אינדקס של כמה עמודות באותו סדר שצוין בשאילתה:
CREATE INDEX city_name_index ON Customers(city, name);
עם זאת, אם יש לכם אינדקס רק ב-city, הסדר החיצוני עדיין מואץ, אבל הסדר הפנימי דורש סריקה לינארית.
השיטה הזו פועלת גם עם שאילתות קידומת. לדוגמה, אינדקס ON Customers (city, name) גם מאיץ את הסינון, הסידור והקיבוץ לפי city, כי טבלת האינדקסים של אינדקס עם כמה עמודות מסודרת לפי האינדקסים הנתונים בסדר הנתון.
כדאי לשקול WITHOUT ROWID
כברירת מחדל, SQLite יוצר עמודת rowid לטבלה, כאשר rowid הוא INTEGER PRIMARY KEY AUTOINCREMENT משתמע. אם כבר יש לכם עמודה מסוג INTEGER PRIMARY KEY, העמודה הזו הופכת לשם חלופי של rowid.
בטבלאות עם מפתח ראשי שהוא לא INTEGER או שילוב של עמודות, כדאי להשתמש ב-WITHOUT ROWID.
אחסון נתונים קטנים כ-BLOB ונתונים גדולים כקובץ
אם רוצים לשייך נתונים גדולים לשורה, כמו תמונה ממוזערת של תמונה או תמונה של איש קשר, אפשר לאחסן את הנתונים בעמודה BLOB או בקובץ, ואז לאחסן את הנתיב בעמודה.
בדרך כלל, גודל הקבצים מעוגל כלפי מעלה במרווחים של 4KB. בקבצים קטנים מאוד, שבהם שגיאת העיגול משמעותית, יעיל יותר לאחסן אותם במסד הנתונים כ-BLOB. SQLite מצמצם את הקריאות למערכת הקבצים, ובמקרים מסוימים הוא מהיר יותר ממערכת הקבצים הבסיסית.
שיפור ביצועי השאילתות
כדי לשפר את ביצועי השאילתות ב-SQLite, כדאי לפעול לפי השיטות המומלצות הבאות, שמצמצמות את זמני התגובה ומייעלות את העיבוד.
קריאה רק של השורות שצריך
מסננים מאפשרים לצמצם את התוצאות על ידי ציון קריטריונים מסוימים, כמו טווח תאריכים, מיקום או שם. מגבלות מאפשרות לכם לשלוט במספר התוצאות שמוצגות:
db.rawQuery("""
SELECT name
FROM Customers
LIMIT 10;
""".trimIndent(),
null
).use { cursor ->
while (cursor.moveToNext()) {
// Process cursor data
}
}
קריאה רק של העמודות שצריך
אל תבחרו עמודות שלא צריך, כי זה יכול להאט את השאילתות ולבזבז משאבים. במקום זאת, בחרו רק את העמודות שבהן אתם משתמשים.
בדוגמה הבאה, בוחרים את id, name ו-phone:
// 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
}
}
עם זאת, אתם צריכים רק את העמודה name:
db.rawQuery("""
SELECT name
FROM Customers;
""".trimIndent(),
null
).use { cursor ->
while (cursor.moveToNext()) {
val name = cursor.getString(0)
// Further processing
}
}
הוספת פרמטרים לשאילתות
מחרוזת השאילתה יכולה לכלול פרמטר שידוע רק בזמן הריצה, כמו:
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
}
}
}
בקוד שלמעלה, כל שאילתה יוצרת מחרוזת שונה, ולכן לא נהנית מהמטמון של ההצהרות. כל קריאה מחייבת את SQLite לקמפל אותה לפני שהיא יכולה לפעול. במקום זאת, אפשר להחליף את הארגומנט id בפרמטר ולקשר את הערך באמצעות selectionArgs:
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
}
}
}
עכשיו אפשר לקמפל את השאילתה פעם אחת ולשמור אותה במטמון. השאילתה המקומפלת משמשת שוב בין הפעלות שונות של getNameById(long).
חזרה על פעולות ב-SQL, לא בקוד
השתמשו בשאילתה אחת שמחזירה את כל התוצאות המטורגטות, במקום לולאה פרוגרמטית שחוזרת על שאילתות SQL כדי להחזיר תוצאות נפרדות. הלולאה הפרוגרמטית איטית פי 1,000 בערך משאילתת SQL אחת.
שימוש במשתנה DISTINCT לערכים ייחודיים
שימוש במילת המפתח DISTINCT יכול לשפר את הביצועים של השאילתות, כי הוא מצמצם את כמות הנתונים שצריך לעבד. לדוגמה, אם רוצים להחזיר רק את הערכים הייחודיים מעמודה, משתמשים ב-DISTINCT:
db.rawQuery("""
SELECT DISTINCT name
FROM Customers;
""".trimIndent(),
null
).use { cursor ->
while (cursor.moveToNext()) {
// Only iterate over distinct names in Kotlin
// Process distinct name
}
}
השתמשו בפונקציות צבירה ככל האפשר
כדי לקבל תוצאות מצטברות בלי נתוני שורות, צריך להשתמש בפונקציות צבירה. לדוגמה, הקוד הבא בודק אם יש לפחות שורה אחת תואמת:
// 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
}
כדי לאחזר רק את השורה הראשונה, אפשר להשתמש ב-EXISTS() כדי להחזיר את 0 אם אין שורה תואמת, ואת 1 אם יש שורה תואמת אחת או יותר:
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
}
}
משתמשים בפונקציות צבירה של SQLite בקוד של האפליקציה:
-
COUNT: סופרת כמה שורות יש בעמודה. -
SUM: חיבור כל הערכים המספריים בעמודה. MINאוMAX: קובע את הערך הנמוך ביותר או הגבוה ביותר. הפונקציה פועלת על עמודות מספריות, על סוגיDATEועל סוגי טקסט.-
AVG: מוצא את הערך המספרי הממוצע. -
GROUP_CONCAT: משרשר מחרוזות עם מפריד אופציונלי.
שימוש ב-COUNT() במקום ב-Cursor.getCount()
בדוגמה הבאה, הפונקציה Cursor.getCount() קוראת את כל השורות ממסד הנתונים ומחזירה את כל הערכים של השורות:
// 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
}
לעומת זאת, אם משתמשים ב-COUNT(), מסד הנתונים מחזיר רק את הספירה:
db.rawQuery("""
SELECT COUNT(*)
FROM Customers;
""".trimIndent(),
null
).use { cursor ->
cursor.moveToFirst()
val count = cursor.getInt(0)
// Use count
}
הטמעת שאילתות במקום קוד
אפשר להרכיב שאילתות SQL והן תומכות בשאילתות משנה, באיחודים ובאילוצים של מפתחות זרים. אתם יכולים להשתמש בתוצאה של שאילתה אחת בשאילתה אחרת בלי לעבור דרך קוד האפליקציה. כך לא צריך להעתיק נתונים מ-SQLite, והמנוע של מסד הנתונים יכול לבצע אופטימיזציה של השאילתה.
בדוגמה הבאה, אתם יכולים להריץ שאילתה כדי למצוא באיזו עיר יש הכי הרבה לקוחות, ואז להשתמש בתוצאה בשאילתה אחרת כדי למצוא את כל הלקוחות מאותה עיר:
// 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
}
}
}
}
כדי לקבל את התוצאה בחצי מהזמן של הדוגמה הקודמת, משתמשים בשאילתת SQL אחת עם הצהרות בתצוגת עץ:
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
אם צריך להכניס שורה רק אם ערך של עמודה מסוימת הוא ייחודי בטבלה, יכול להיות שיהיה יעיל יותר לאכוף את הייחודיות הזו כאילוץ של עמודה.
בדוגמה הבאה, שאילתה אחת מופעלת כדי לאמת את השורה שצריך להוסיף, ושאילתה אחרת מופעלת כדי להוסיף אותה בפועל:
// 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
)
)
במקום לבדוק את האילוץ הייחודי ב-Kotlin, אפשר לבדוק אותו ב-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 לבדוק את האילוץ:
try {
db.execSQL(
"INSERT INTO Customers VALUES (?, ?, ?)",
arrayOf(customer.id.toString(), customer.name, customer.username)
)
} catch(e: SQLiteConstraintException) {
throw AddCustomerException(customer, e)
}
SQLite תומך באינדקסים ייחודיים עם כמה עמודות:
CREATE TABLE table(...);
CREATE UNIQUE INDEX unique_table ON table(column1, column2, ...);
SQLite מאמת אילוצים מהר יותר ועם פחות תקורה מאשר קוד Kotlin. מומלץ להשתמש ב-SQLite במקום בקוד של האפליקציה.
הוספה של כמה פריטים באצווה בעסקה אחת
טרנזקציה מבצעת כמה פעולות, מה שמשפר לא רק את היעילות אלא גם את הדיוק. כדי לשפר את עקביות הנתונים ולהאיץ את הביצועים, אפשר להוסיף נתונים באצווה:
db.beginTransaction()
try {
customers.forEach { customer ->
db.execSQL(
"INSERT INTO Customers VALUES (?, ?, ?)",
arrayOf(customer.id.toString(), customer.name, "customerValue")
)
}
db.setTransactionSuccessful()
} finally {
db.endTransaction()
}
מחוץ לטרנזקציה מפורשת, כל כתיבה פועלת בטרנזקציה משתמעת משלה ומשלמת את העלות המלאה של commit, כולל fsync במצב סינכרוני של FULL. כשמשתמשים באצווה, העלות הזו משולמת פעם אחת, מה שיכול להפוך אצווה של כתיבות למהירה יותר בסדר גודל אחד או יותר.
שימוש בכלים לפתרון בעיות
ב-SQLite יש את הכלים הבאים לפתרון בעיות כדי לעזור למדוד את הביצועים.
שימוש בהנחיה האינטראקטיבית של SQLite
להריץ את SQLite במחשב כדי להריץ שאילתות וללמוד.
בגרסאות שונות של פלטפורמת Android נעשה שימוש בגרסאות שונות של SQLite. כדי להשתמש באותו מנוע שקיים במכשיר Android, צריך להשתמש ב-adb shell ולהריץ את sqlite3 במכשיר היעד.
אתם יכולים לבקש מ-SQLite למדוד את משך הזמן של השאילתות:
sqlite> .timer on
sqlite> SELECT ...
Run Time: real ... user ... sys ...
EXPLAIN QUERY PLAN
אתם יכולים לבקש מ-SQLite להסביר איך הוא מתכוון לענות על שאילתה באמצעות EXPLAIN QUERY PLAN:
sqlite> EXPLAIN QUERY PLAN
SELECT id, name
FROM Customers
WHERE city = 'Paris';
QUERY PLAN
`--SCAN Customers
בדוגמה הקודמת, כדי למצוא את כל הלקוחות מפריז, צריך לסרוק את כל הטבלה בלי אינדקס. זה נקרא מורכבות לינארית. SQLite צריך לקרוא את כל השורות ולשמור רק את השורות שתואמות ללקוחות מפריז. כדי לפתור את הבעיה, אפשר להוסיף אינדקס:
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=?
אם אתם משתמשים במעטפת האינטראקטיבית, אתם יכולים לבקש מ-SQLite להסביר תמיד את התוכניות לביצוע שאילתות:
sqlite> .eqp on
מידע נוסף זמין במאמר תכנון שאילתות.
SQLite Analyzer
ב-SQLite יש ממשק שורת פקודה (CLI) של sqlite3_analyzer כדי להציג מידע נוסף שיכול לעזור לפתור בעיות בביצועים. כדי להתקין, נכנסים לדף ההורדה של SQLite.
אתם יכולים להשתמש ב-adb pull כדי להוריד קובץ של מסד נתונים ממכשיר יעד לתחנת העבודה שלכם לצורך ניתוח:
adb pull /data/data/<app_package_name>/databases/<db_name>.db
דפדפן SQLite
אפשר גם להתקין את כלי ה-GUI SQLite Browser מדף ההורדות של SQLite.
רישום ביומן ב-Android
מערכת Android מודדת את הזמן של שאילתות SQLite ומתעדת אותן ביומן:
# Enable query time logging
$ adb shell setprop log.tag.SQLiteTime VERBOSE
# Disable query time logging
$ adb shell setprop log.tag.SQLiteTime ERROR
ב-Android מגרסה 13 ואילך, אפשר גם לתעד ביומן רק את הפעולות האיטיות. מגדירים סף באלפיות השנייה, ואז מפעילים מחדש את האפליקציה כדי שהיא תקרא את תג היומן:
$ adb shell setprop log.tag.SQLiteSlowQueries VERBOSE
$ adb shell setprop db.log.slow_query_threshold 100
$ adb shell am force-stop <package-name>
פעולות איטיות מופיעות ב-logcat בתג SQLiteConnection עם משך הזמן, ה-SQL ונתיב מסד הנתונים שלהן. כדי להגדיר את הסף לאפליקציה אחת, משתמשים ב-db.log.slow_query_threshold.<uid>, כאשר <uid> הוא מזהה המשתמש של האפליקציה.
מעקב באמצעות Perfetto
כשמגדירים את Perfetto, מוסיפים את הקטגוריה database atrace כדי לכלול פעולות SQLite במעקב. כדי לראות מה כל שרשור עושה בזמן שהוא מריץ SQL, צריך להוסיף גם את אירועי המתזמן:
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 עוקב אחרי כל פעולה במסד הנתונים כפרוסה שנקראת על שם הפעולה וה-SQL שלה, לדוגמה executeForCursorWindow SELECT * FROM message WHERE body LIKE ?. ה-SQL שומר על הפלייסהולדרים של ? ונחתך ל-256 תווים. הארגומנטים של הקישור לא נכללים. הפעולות כוללות את open, close, prepare, execute והווריאציות של executeFor. פרוסה של prepare לא אומרת שהייתה אי מציאה במטמון של ההצהרות.
פריסת המסלול תלויה בגרסת Android:
- ב-Android מגרסה 17 ואילך, לכל חיבור יש טראק אסינכרוני בתהליך של האפליקציה שנקרא
SQLiteConnection-ואחריו מספר החיבור. הטרנזקציות מופיעות כפרוסותTRANSACTION-EXCLUSIVE,TRANSACTION-IMMEDIATEאוTRANSACTION-DEFERRED, וההצהרות שהופעלו בהן מקוננות מתחתיהן. השרשור שמריץ פעולה מקבל גם אירועים מיידיים שלSTART-ו-END-במסלול השרשור שלו, לכל טרנזקציה ולכל הצהרה מחוץ לטרנזקציה. השמות של האירועים האלה מסתיימים בשם של מסלול החיבור, לדוגמה-SQLiteConnection-0. - ב-Android בגרסה 16 ומטה, לכל הצהרת SQL נפרדת יש טראק אסינכרוני משלה בתהליך של האפליקציה, והטרנזקציות לא נמדדות.
מספרי החיבורים מתחילים מ-0 בכל מסד נתונים, כך שהחיבורים הראשיים של כל מסדי הנתונים בתהליך משתפים את הטראק SQLiteConnection-0. אם האפליקציה משתמשת ביותר ממסד נתונים אחד בו-זמנית, צריך למדוד את משך הזמן באמצעות האירועים START- ו-END- בטראקים של השרשורים.
איור 1. סריקה מלאה של טבלה בשרשור הראשי ב-Android 17.
אפשר לנתח את הפרוסות האלה באמצעות PerfettoSQL בממשק המשתמש של Perfetto או באמצעות trace_processor. השאילתה הבאה מוצאת את פעולות SQLite הנפוצות ביותר בכל תהליך. השאילתה הזו פועלת בכל הגרסאות של Android:
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;
השאילתה הבאה מציגה פעולות SQLite שנמשכו יותר ממילישנייה אחת בשרשור ראשי ב-Android מגרסה 17 ואילך:
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;
איור 2. פעולות SQLite שנמשכות יותר מ-1 אלפית השנייה ב-thread ראשי.
השאילתה הבאה מוצאת שרשורים שכותבים מחוץ לטרנזקציה מפורשת, שבה כל כתיבה משלמת על ה-commit שלה, ב-Android מגרסה 17 ואילך:
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> מדפיס נתונים סטטיסטיים שקשורים לשימוש של האפליקציה בזיכרון, כולל פרטים על SQLite בקטע DATABASES. לדוגמה, הפלט הזה נלקח מהפלט של adb shell dumpsys meminfo com.google.android.gms.persistent במכשיר של מפתח:
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 ואילך, הטבלאות PER CONNECTION STATS ו-POOL STATS שמוצגות בדוגמה מודפסות. ב-Android מגרסה 13 ומטה מודפסת רק הטבלה לכל חיבור, והנתונים הסטטיסטיים של המטמון משולבים בעמודה אחת של cache בתור hits/misses/size.
בקטע PER CONNECTION STATS יופיעו:
-
pgsz: הגודל של דף אחד במסד הנתונים, ב-KB. -
dbsz: הגודל של קובץ מסד הנתונים כולו, ב-KB. -
Lookaside(b): מספר משבצות הזיכרון של lookaside שבהן החיבור משתמש. למרות הכותרת(b), מדובר בספירה של משבצות ולא של בייטים. -
cache hits,cache missesו-cache size: נתונים סטטיסטיים של מטמון ההצהרות המוכנות של החיבור. הערכים האלה סופרים הצהרות SQL מקומפלות, ולא דפים של מסד נתונים. -
Dbname: הנתיב לקובץ של מסד הנתונים. לחיבורים נוספים לאותו מסד נתונים יתווסף מספר בסוגריים, למשל(1). ב-WAL, למסד נתונים יכולים להיות כמה חיבורים, כך שפעולות קריאה יכולות לפעול במקביל.
בקטע POOL STATS מופיעים הנתונים הסטטיסטיים של המטמון של ההצהרות המוכנות לכל מאגר חיבורים:
-
cache hits: SQLite שומר במטמון הצהרות מוכנות ומנסה להשתמש בהן שוב בזמן הרצת שאילתות, כדי לחסוך מאמץ וזיכרון בהידור של הצהרות SQL. זהו מספר הפעמים שהנתונים נמצאו במטמון של ההצהרות. -
cache misses: מספר אי המציאות במטמון של הצהרות. -
cache size: ב-Android 16 QPR2 ואילך, מספר ההצהרות המוכנות במטמון, מסוכמות על פני החיבורים הלא פעילים של המאגר. ב-Android מגרסה 14 עד גרסה 16 QPR1, הערך הזה הוא סכום ההתאמות וההחמצות, והוא לא מייצג את גודל המטמון.
אתם יכולים להשתמש בנתונים הסטטיסטיים האלה כדי להחליט מה לתקן:
- שיעור גבוה של פספוסים אומר ש-SQLite מקמפל את אותן הצהרות שוב ושוב.
קודם כל, חפשו SQL שנבנה באמצעות שרשור מחרוזות והגדירו לו פרמטרים. אם השאילתות כבר מוגדרות עם פרמטרים, והערך של
cache sizeלכל חיבור הגיע למגבלה של 25, הגדילו את גודל המטמון. - אם נתיב של מסד נתונים מופיע יותר מפעם אחת בלי סיומת בסגנון
(1), זה אומר שהאפליקציה פתחה את אותו קובץ פעמיים. פותחים כל מסד נתונים פעם אחת. - מסד נתונים שהאפליקציה משתמשת בו לעיתים רחוקות עדיין תופס מקום בזיכרון כשהוא פתוח. בסיום, סוגרים את החלון.
dumpsys dbinfo
הפקודה adb shell dumpsys dbinfo <package-name> מדפיסה את המצב של כל מאגר חיבורים באפליקציה. אפשר להוסיף את -v כדי להציג גם את ההצהרות במטמון ההצהרות המוכנות של כל חיבור. הפלט לדוגמה הבא הוא מ-Android 17, עם נתיבים ושורות ארוכות מקוצרות:
** 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=...
...
מחפשים את הדברים הבאים:
-
Configuration: מצב היומן והמצב הסינכרוני של המאגר. ב-Android מגרסה 13 ואילך, בשורה הזו מוצגים המצבים שהמסגרת הפעילה, כולל ברירות המחדל של הפלטפורמה. בדוגמה,events.dbמשתמש ביומן החזרה למצב קודם עם מצב סינכרוניFULL, כך שכל commit ממתין לסנכרון מלא. השורה הזו לא משקפת את הצהרות ה-PRAGMAשאתם מריצים בעצמכם. -
Max connections: ערך של 1 למסד נתונים שניתן לכתיבה פירושו שה-framework לא מחשיב את מסד הנתונים כמצב WAL, כך שפעולות קריאה וכתיבה חולקות חיבור יחיד. -
Total execution time,Total statements executedו-Average time per statement: זמינים ב-Android מגרסה 13 ואילך. השתמשו בהם כדי למצוא את מסדי הנתונים העמוסים ביותר. -
Most recently executed operations: 20 הפעולות האחרונות בכל חיבור, עם משך הזמן שלהן ו-SQL. חפשו דפוסים כמו אותה שאילתה שפועלת בלולאה, או הרבה כתיבות ברצף מחוץ לטרנזקציה. ב-Android 15 QPR1 ואילך, הרשימה הזו כוללת גם טרנזקציות מפורשות. -
totalLongOperationsו-Operations exceeding 2000ms: זמינים ב-Android 15 QPR1 ואילך. הם מציגים כמה פעולות ועסקאות נמשכו יותר מ-2 שניות, ומפרטים את 10 האחרונות שבהן. -
Acquired connectionsו-Connection waiters: חיבורים שנמצאים בשימוש ושרשורים שממתינים לחיבור. המתנות מצביעות על תחרות, כמו טרנזקציית כתיבה ארוכה שחוסמת כתיבות אחרות. -
Prepared statement cache: הודפס באמצעות-v. הרבה רשומות ששונות רק בערכים המילוליים מצביעות על SQL שנבנה באמצעות שרשור מחרוזות. - אם אותו נתיב מופיע מתחת ליותר מכותרת
Connection pool forאחת, סימן שהאפליקציה פתחה את אותו קובץ פעמיים.
מקורות מידע נוספים
צפייה בתוכן
מומלץ בשבילכם
- הערה: טקסט הקישור מוצג כש-JavaScript מושבת
- הרצת נקודות השוואה באינטגרציה רציפה (CI)
- פריימים קפואים
- יצירה ומדידה של פרופילי Baseline בלי Macrobenchmark