有助提升 SQLite 性能的最佳实践

SQLite 是一种高效的 SQL 数据库,Android 内置了对它的支持。遵循以下最佳实践可以优化您的应用的性能,确保随着数据的增多,您的应用仍能以可预测的速度快速运行。通过运用这些最佳实践,您还可以降低遇到难以重现且难以排查的性能问题的可能性。

要想实现更快的性能,请遵循以下性能原则:

  • 减少读取的行数和列数:优化您的查询,以便仅检索必要的数据。最大限度地减少从数据库读取的数据量,因为过多的数据检索可能会影响性能。

  • 将工作推送到 SQLite 引擎:在 SQL 查询中执行计算、过滤和排序操作。使用 SQLite 的查询引擎可以显著提升性能。

  • 修改数据库架构:设计数据库架构,帮助 SQLite 构建高效的查询计划和数据表示法。适当地为表添加索引并优化表结构,以便提升性能。

此外,您还可以使用可用的问题排查工具来衡量 SQLite 数据库的性能,以便确定需要优化的方面。

我们建议使用 Jetpack Room 库。

配置数据库以提升性能

请按照本部分中的步骤配置您的数据库,以便在 SQLite 中实现最佳性能。

启用预写式日志记录

SQLite 能够通过将变更附加到日志来实现变更,而相应日志偶尔会压缩到数据库中。这称为预写式日志记录 (WAL)。

请启用 WAL,除非您使用 ATTACH DATABASE。

通过框架启用 WAL,而不是自行运行 PRAGMA journal_mode=WAL。默认情况下,Room 会在非低 RAM 设备上启用 WAL。 如果不使用 Room,请使用以下任一方法:

当框架知道数据库使用 WAL 时,它会打开一个连接池,以便读取操作可以与写入操作并发运行,并应用下一部分中所述的 WAL 同步模式。如果您自行运行 PRAGMA journal_mode=WAL,框架仍会将数据库视为回滚日志数据库。它使用单个连接,并且提交操作会保持默认的 FULL 同步模式。

放宽同步模式

SQLite 有一个用于控制同步模式的选项。在 FULL 模式下,每次提交都会发出 fsync,以便确保数据到达磁盘。这可以提高数据持久性,但会降低提交速度。使用 WAL 时,NORMAL 模式不会在每次提交时同步,但仍可保护数据库免遭损坏。

当您通过框架启用 WAL 时,Android 会应用设备的默认 WAL 同步模式,在 Android 9 及更高版本的 AOSP 中,该模式为 NORMAL。 设备制造商可以更改此默认设置,不使用 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")

采用此设置时,提交操作可以在数据存储到磁盘之前返回。如果设备关机(例如在断电或内核崩溃时),已提交的数据可能会丢失。但由于启用了日志记录,您的数据库未损坏。

如果只有您的应用崩溃,您的数据仍会到达磁盘。对于大多数应用而言,此设置可以在不增加材料成本的情况下提高性能。

管理连接和内存

每个打开的数据库连接都会占用 SQLite 内存:页面缓存、后备分配器和已编译语句的缓存。数据库文件页面也会保留在内核页面缓存中。请遵循本部分中的做法,在不减慢查询速度的情况下控制此内存。

每个数据库打开一次

在整个进程中,每个数据库文件使用一个 SQLiteOpenHelper 或 RoomDatabase 实例,例如将其设为单例。每个实例都有自己的连接池、页面缓存和语句缓存,打开同一文件的两个实例无法共享这些缓存。在一项基准测试中,将打开同一文件的两个辅助进程合并为一个,可将 SQLite 页面缓存内存减少近一半,并将 p95 查询延迟时间缩短一半以上。

如需查找重复的实例,请在多个 Connection pool for 标题下检查同一数据库路径的 dumpsys dbinfo。

不要在打开回调中更改架构

每次打开数据库时,SQLiteOpenHelper 的 onOpen 回调都会运行,RoomDatabase.Callback 的 onOpen 回调也会运行。将 CREATE TABLE IF NOT EXISTS 和 CREATE INDEX IF NOT EXISTS 等语句放入 onCreate 和 onUpgrade 中,或放入 Room 迁移中。请勿在 onOpen 中调用 setVersion,因为 SQLiteOpenHelper 已在 onCreate 和 onUpgrade 之后设置版本。即使架构已存在,这些语句仍会在每次打开时运行,并且 setVersion 会写入数据库文件。

调整预处理语句缓存的大小

Android 会缓存每个连接的已编译语句。默认情况下,缓存可容纳 25 条语句,您可以使用 setMaxSqlCacheSize 将其提高到 MAX_SQL_CACHE_SIZE (100)。如果数据库在其热路径中运行超过 25 个不同的语句,缓存就会发生抖动,SQLite 会一遍又一遍地编译相同的语句。

首先,将查询参数化。任何缓存大小都无法帮助通过字符串串联构建的 SQL,因为每个字符串都是一个新语句。然后,在 dumpsys meminfo 中检查 POOL STATS 下的语句缓存命中和未命中情况。例如,该部分示例输出中的 phenotype.db 数据库有 147,921 次命中和 89,616 次未命中,未命中率为 38%。在这样的数据库上提高缓存大小几乎可以消除所有未命中。每个缓存的语句都会使用几千字节的内存。

如需增加缓存大小,请在 SQLiteOpenHelper 子类中替换 onConfigure:

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

如果您使用 Room,请在 RoomDatabase.Callback 的 onOpen 回调中对 SupportSQLiteDatabase 调用相同的方法。

关闭空闲数据库,但保持繁忙数据库处于打开状态

打开的数据库即使处于空闲状态,也会保留其连接、缓存和文件描述符。在一项基准测试中,处于 WAL 模式且保持打开状态的空闲 Room 数据库占用了大约 678 KB 的 SQLite 页面缓存内存和 5 个文件描述符。如果您只是偶尔使用数据库(例如在每日同步期间),请在使用完毕后将其关闭。

如需安全地关闭空闲数据库,请遵循以下准则:

  • Room 可以使用 setAutoCloseTimeout(一个实验性 API)为您关闭空闲数据库。Room 会在下次访问时重新打开数据库,并再次运行 onOpen 回调,因此请将所有连接配置放在此处。
  • 避免使用 SQLiteOpenHelper 和 SQLiteDatabase.OpenParams.Builder 的 setIdleConnectionTimeout 方法。它们在 API 级别 29 中已废弃,因为它们会关闭连接,而不会恢复您使用 PRAGMA 语句应用的设置。
  • 请勿关闭每隔几秒查询一次的数据库。如果超时时间短于查询之间的间隔,则会导致数据库反复关闭和重新打开,从而每次都会舍弃其页面缓存和语句缓存,并导致下一次查询速度变慢。

考虑 SQLite 内存

SQLite 内存可能很难发现。SQLite 页面缓存和语句缓存是原生堆分配。但 Android 默认不启用 SQLite 的内存映射 I/O,因此 SQLite 会通过常规文件读取来读取数据库文件,并且内核缓存的文件内容是未映射的文件页面。它们不会计入 dumpsys meminfo 中应用的 PSS。在采用 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 name city
1 456 John Lennon Liverpool, England
2 123 Michael Jackson Gary, IN
3 789 Dolly Parton Sevier County, TN

rowid 列是用于保存插入顺序的索引。对于按 rowid 过滤的查询,会执行快速的 B 树搜索,而对于按 id 过滤的查询,则会执行缓慢的表扫描。

如果您打算按 id 进行查找,可以避免存储 rowid 列,以便减少存储的数据,并提高数据库的总体速度:

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

您的表现在如下所示:

id name city
123 Michael Jackson Gary, IN
456 John Lennon Liverpool, England
789 Dolly Parton Sevier County, TN

由于您不需要存储 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:

city rowid
Gary, IN 2
Liverpool, England 1
Sevier County, TN 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 列中,也可以将数据存储在文件中,然后将路径存储在该列中。

文件通常会按 4 KB 的增量向上舍入。对于非常小的文件(舍入误差会非常大),将其作为 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 查询来返回单个结果。程序化循环比单个 SQL 查询慢大约 1000 倍。

对唯一值使用 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
    )
)

您可以在定义表时在 SQL 中检查唯一性约束条件,而不是在 Kotlin 中检查:

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, ...);

与 Kotlin 代码相比,SQLite 能够更快地验证约束条件,并且开销更小。最佳实践是使用 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()
}

在显式事务之外,每个写入操作都在其自己的隐式事务中运行,并支付提交的全部费用,包括 FULL 同步模式下的 fsync。批处理只需支付一次该费用,这可以使一批写入操作的速度提高一个数量级或更多。

使用问题排查工具

SQLite 提供了以下问题排查工具来帮助您衡量性能。

使用 SQLite 的交互式提示

在您的机器上运行 SQLite,以便运行查询,并了解相关情况。Android 平台版本不同,使用的 SQLite 修订版本也不同。如需使用 Android 设备上所使用的同一引擎,请在目标设备上使用 adb shell,并运行 sqlite3。

您可以要求 SQLite 记录查询时间:

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

EXPLAIN QUERY PLAN

您可以使用 EXPLAIN QUERY PLAN 让 SQLite 说明它计划如何回应查询:

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

上面的示例要求在不使用索引的情况下进行全表扫描,以便找出巴黎的所有客户。这称为“线性复杂扫描”。SQLite 需要读取所有行,并且仅保留与 Paris 的客户匹配的行。如需修复此问题,您可以添加索引:

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=?

如果您使用的是交互式 shell,则可以要求 SQLite 始终说明查询计划:

sqlite> .eqp on

如需了解详情,请参阅查询计划。

SQLite 分析器

SQLite 提供了 sqlite3_analyzer 命令行界面 (CLI),用于转储可用于排查性能问题的其他信息。如需安装,请访问 SQLite 下载页面。

您可以使用 adb pull 将数据库文件从目标设备下载到工作站以进行分析:

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

SQLite 浏览器

您还可以从 SQLite 下载页面安装 GUI 工具 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> 是应用的 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- 事件来衡量时长。

Perfetto 中的主线程和 SQLiteConnection-0 轨迹,显示在主线程上扫描整个表 12 毫秒的 LIKE 查询

图 1. 在 Android 17 中,在主线程上执行全表扫描。

您可以在 Perfetto 界面中使用 PerfettoSQL 或 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;

以下查询列出了在 Android 17 及更高版本中,主线程上耗时超过 1 毫秒的 SQLite 操作:

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;

Perfetto 查询结果,其中列出了测试应用主线程上耗时超过 1 毫秒的 SQLite 操作

图 2. 主线程上耗时超过 1 毫秒的 SQLite 操作。

以下查询会查找在 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> 会输出与应用的内存用量相关的统计信息,包括 DATABASES 部分中有关 SQLite 的详细信息。例如,以下内容取自开发者设备上的 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):连接使用的旁视内存槽数量。尽管标头中包含 (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 到 Android 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 同步模式的回滚日志,因此每次提交都会等待完整同步。 此行不反映您自己运行的 PRAGMA 语句。
  • Max connections:对于可写数据库,值为 1 表示框架不认为数据库处于 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 标题下出现相同的路径,则表示您的应用打开了同一文件两次。

其他资源

查看内容