Práticas recomendadas para a performance do SQLite

O Android oferece suporte integrado ao SQLite, um banco de dados SQL eficiente. Siga estas práticas recomendadas para otimizar a performance do seu app, garantindo que ele permaneça rápido e previsivelmente rápido à medida que seus dados aumentam. Ao usar estas práticas recomendadas, você também reduz a possibilidade de encontrar problemas de performance difíceis de reproduzir e resolver.

Para ter uma performance mais rápida, siga estes princípios:

  • Ler menos linhas e colunas: otimize suas consultas para extrair apenas os dados necessários. Minimize a quantidade de dados lidos no banco de dados, porque a extração excessiva de dados pode afetar a performance.

  • Enviar o trabalho para o mecanismo SQLite: realize operações de cálculos, filtragem e classificação nas consultas SQL. O uso do mecanismo de consulta do SQLite pode melhorar significativamente a performance.

  • Modificar o esquema do banco de dados: projete seu esquema de banco de dados para ajudar o SQLite a construir planos de consulta e representações de dados eficientes. Faça a indexação de tabelas de forma adequada e otimize estruturas das tabelas para melhorar a performance.

Além disso, você pode usar as ferramentas de solução de problemas disponíveis para medir a performance do seu banco de dados SQLite para identificar áreas que exigem otimização.

Recomendamos usar a biblioteca Room do Jetpack.

Configurar o banco de dados para performance

Siga as etapas desta seção para configurar seu banco de dados para ter a performance ideal no SQLite.

Ativar o registro prévio de escrita

O SQLite implementa mutações anexando-as a um registro, que é compactado ocasionalmente no banco de dados. Isso é chamado de registro prévio de escrita (WAL) (link em inglês).

Ative o WAL, exceto se você estiver usando ATTACH DATABASE (link em inglês).

Ative o WAL pelo framework em vez de executar PRAGMA journal_mode=WAL por conta própria. O Room ativa o WAL por padrão em dispositivos que não têm pouca RAM. Sem o Room, use uma das seguintes opções:

Quando o framework sabe que o banco de dados usa o WAL, ele abre um pool de conexões para que as leituras possam ser executadas simultaneamente com uma gravação e aplica o modo síncrono do WAL descrito na próxima seção. Se você executar PRAGMA journal_mode=WAL por conta própria, o framework ainda vai tratar o banco de dados como um banco de dados de diário de reversão. Ele usa uma única conexão, e as confirmações mantêm o modo síncrono FULL padrão.

Reduzir o modo de sincronização

O SQLite tem uma opção para controlar o modo síncrono (link em inglês). No modo FULL, cada confirmação emite um fsync para garantir que os dados cheguem ao disco. Isso melhora a durabilidade dos dados, mas torna as confirmações mais lentas. Com o WAL, o modo NORMAL não é sincronizado em todas as confirmações e ainda protege o banco de dados contra corrupção.

Quando você ativa o WAL pelo framework, o Android aplica o modo síncrono padrão do dispositivo, que é NORMAL no AOSP para o Android 9 e versões mais recentes. Os fabricantes de dispositivos podem mudar esse padrão, e os bancos de dados que não usam o WAL usam FULL por padrão. Para garantir que o banco de dados use NORMAL, defina-o ao abrir o banco de dados:

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

Nessa configuração, uma confirmação pode ser retornada antes que os dados sejam armazenados em um disco. Se o dispositivo for desligado por qualquer motivo, por exemplo, falta de energia ou em caso de kernel panic, os dados confirmados poderão ser perdidos. No entanto, devido à geração de registros, seu banco de dados não fica corrompido.

Se apenas o app falhar, os dados ainda chegarão ao disco. Na maioria dos apps, essa configuração produz melhorias de performance sem custo significativo.

Gerenciar conexões e memória

Cada conexão de banco de dados aberta contém memória do SQLite: um cache de página, um alocador lookaside e um cache de instruções compiladas. As páginas do arquivo de banco de dados também permanecem no cache de páginas do kernel. Siga as práticas desta seção para manter essa memória sob controle sem desacelerar as consultas.

Abrir cada banco de dados uma vez

Use uma única instância de SQLiteOpenHelper ou RoomDatabase por arquivo de banco de dados para todo o processo, por exemplo, transformando-a em um Singleton. Cada instância tem o próprio pool de conexões, caches de página e caches de instrução, e duas instâncias que abrem o mesmo arquivo não podem compartilhá-los. Em um comparativo de mercado, a consolidação de dois auxiliares que abriram o mesmo arquivo em um cache de memória de página do SQLite reduziu quase pela metade a latência de consulta p95.

Para encontrar instâncias duplicadas, verifique se há o mesmo caminho de banco de dados em mais de um cabeçalho Connection pool for em dumpsys dbinfo.

Manter as mudanças de esquema fora do callback aberto

O callback onOpen de SQLiteOpenHelper é executado sempre que o banco de dados é aberto, assim como o callback onOpen de RoomDatabase.Callback. Coloque instruções como CREATE TABLE IF NOT EXISTS e CREATE INDEX IF NOT EXISTS em onCreate e onUpgrade ou em migrações do Room. Não chame setVersion em onOpen, porque SQLiteOpenHelper já define a versão depois de onCreate e onUpgrade. Mesmo quando o esquema já existe, essas instruções ainda são executadas em cada abertura, e o setVersion grava no arquivo do banco de dados.

Dimensionar o cache de instruções preparadas

O Android armazena em cache as instruções compiladas para cada conexão. O cache contém 25 instruções por padrão, e é possível aumentar esse número para até MAX_SQL_CACHE_SIZE (100) com setMaxSqlCacheSize. Se um banco de dados executar mais de 25 instruções distintas nos caminhos de acesso frequente, o cache vai sofrer thrashing e o SQLite vai compilar as mesmas instruções repetidamente.

Primeiro, parametrize suas consultas. Nenhum tamanho de cache pode ajudar o SQL criado com concatenação de strings, porque cada string é uma nova instrução. Em seguida, verifique as ocorrências em cache e as ausências no cache da instrução em POOL STATS em dumpsys meminfo. Por exemplo, o banco de dados phenotype.db na saída de amostra dessa seção tem 147.921 ocorrências e 89.616 ausências, o que representa uma taxa de ausência de 38%. Aumentar o tamanho do cache em um banco de dados como este pode remover quase todas as falhas. Cada instrução em cache usa alguns kilobytes de memória.

Para aumentar o tamanho do cache, substitua onConfigure na subclasse SQLiteOpenHelper:

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

Se você usa o Room, chame o mesmo método em SupportSQLiteDatabase no callback onOpen de RoomDatabase.Callback.

Fechar bancos de dados inativos, mas manter os ocupados abertos

Um banco de dados aberto mantém as conexões, os caches e os descritores de arquivos, mesmo quando está inativo. Em um comparativo de mercado, um banco de dados do Room inativo no modo WAL que permaneceu aberto manteve cerca de 678 KB de memória de cache de página do SQLite e cinco descritores de arquivo. Se você usa um banco de dados apenas ocasionalmente, por exemplo, durante uma sincronização diária, feche-o quando terminar.

Para fechar bancos de dados inativos com segurança, siga estas diretrizes:

  • O Room pode fechar um banco de dados inativo para você com setAutoCloseTimeout, que é uma API experimental. O Room reabre o banco de dados no próximo acesso e executa o callback onOpen novamente. Portanto, coloque qualquer configuração de conexão lá.
  • Evite os métodos setIdleConnectionTimeout de SQLiteOpenHelper e SQLiteDatabase.OpenParams.Builder. Eles foram descontinuados no nível 29 da API porque fecham conexões sem restaurar as configurações aplicadas com instruções PRAGMA.
  • Não feche um banco de dados que você consulta a cada poucos segundos. Um tempo limite menor que o intervalo entre as consultas faz com que o banco de dados seja fechado e reaberto repetidamente, o que descarta o cache de páginas e o cache de instruções todas as vezes e torna a próxima consulta mais lenta.

Considerar a memória do SQLite

A memória do SQLite pode ser difícil de detectar. O cache de página e o cache de instrução do SQLite são alocações de heap nativas. No entanto, o Android não ativa a E/S mapeada em memória do SQLite por padrão. Portanto, o SQLite lê arquivos de banco de dados com leituras de arquivos normais, e o conteúdo do arquivo que o kernel armazena em cache são páginas de arquivos não mapeadas. Eles não contam para o PSS do app em dumpsys meminfo. Em dispositivos que usam a contabilização de memória do cgroup v2, o kernel ainda cobra essas páginas do cgroup de memória do app. Para reduzir essa pegada oculta, leia menos páginas usando índices e lendo apenas as linhas e colunas necessárias. Fechar bancos de dados desnecessários também libera os caches do SQLite.

Definir esquemas de tabela eficientes

Para otimizar a performance e minimizar o consumo de dados, defina um esquema de tabela eficiente. O SQLite constrói planos de consulta e dados eficientes, o que acelera a extração de dados. Esta seção fornece práticas recomendadas para criar esquemas de tabelas.

Considerar INTEGER PRIMARY KEY

Para este exemplo, defina e preencha uma tabela desta forma:

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');

A saída da tabela será esta:

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

A coluna rowid (link em inglês) é um índice que preserva a ordem da inserção. As consultas que filtram por rowid são implementadas como uma pesquisa rápida em árvore B, mas as consultas que filtram por id são uma verificação de tabela lenta.

Se você planeja realizar pesquisas por id, pode evitar o armazenamento da coluna rowid para ter menos dados no armazenamento e um banco de dados mais rápido no geral:

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

A tabela ficará assim:

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

Como você não precisa armazenar a coluna rowid, as consultas id são rápidas. A tabela agora está classificada com base em id, em vez de na ordem de inserção.

Acelerar consultas com índices

O SQLite usa índices (link em inglês) para acelerar consultas. Ao filtrar (WHERE), classificar (ORDER BY) ou agregar (GROUP BY) uma coluna, se a tabela tiver um índice para a coluna, a consulta será acelerada.

No exemplo anterior, a filtragem por city exige a verificação da tabela inteira:

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

Para um app com muitas consultas de cidades, é possível acelerar essas consultas com um índice:

CREATE INDEX city_index ON Customers(city);

Um índice é implementado como uma tabela extra, classificada pela coluna de índice e mapeado para rowid:

city rowid
Gary, IN 2
Liverpool, England 1
Sevier County, TN 3

O custo de armazenamento da coluna city agora é duplo, porque ela está na tabela original e no índice. Como você está usando o índice, o custo do armazenamento extra compensa o uso de consultas mais rápidas. No entanto, não mantenha um índice que você não esteja usando para evitar pagar o custo de armazenamento sem nenhum ganho de performance da consulta.

Criar índices de várias colunas

Se as consultas combinarem várias colunas, crie índices de várias colunas para acelerar a consulta. Também é possível usar um índice em uma coluna externa e deixar a pesquisa interna ser feita como uma verificação linear.

Por exemplo, considerando esta consulta:

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

É possível acelerar a consulta com um índice de várias colunas na mesma ordem especificada na consulta:

CREATE INDEX city_name_index ON Customers(city, name);

No entanto, se você tiver apenas um índice em city, a ordem externa ainda será acelerada, embora a ordenação interna exija uma leitura linear.

Isso também funciona com consultas de prefixo. Por exemplo, um índice ON Customers (city, name) também acelera a filtragem, a ordenação e o agrupamento por city, já que a tabela de um índice de várias colunas é ordenada pelos índices especificados em uma determinada ordem.

Considerar WITHOUT ROWID

Por padrão, o SQLite cria uma coluna rowid para a tabela, em que rowid é um INTEGER PRIMARY KEY AUTOINCREMENT implícito. Se você já tem uma coluna INTEGER PRIMARY KEY, ela se tornará um alias de rowid.

Para tabelas com uma chave primária diferente de INTEGER ou um composto de colunas, considere WITHOUT ROWID (link em inglês).

Armazenar dados pequenos como um BLOB e dados grandes como um arquivo

Para associar grandes quantidades de dados a uma linha, por exemplo, a miniatura de uma imagem ou a foto de um contato, armazene os dados em uma coluna BLOB ou em um arquivo e armazene o caminho na coluna.

Os arquivos geralmente são arredondados para até 4 KB. É melhor armazenar arquivos muito pequenos, em que o erro de arredondamento é significativo, no banco de dados como um BLOB. O SQLite minimiza as chamadas do sistema de arquivos e é mais rápido que o sistema de arquivos (link em inglês) em alguns casos.

Melhorar a performance da consulta

Siga estas práticas recomendadas para melhorar a performance da consulta no SQLite, minimizando os tempos de resposta e maximizando a eficiência do processamento.

Ler somente as linhas necessárias

Os filtros permitem restringir os resultados, especificando determinados critérios, como período, local ou nome. Os limites permitem controlar o número de resultados exibidos:

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

Ler somente as colunas necessárias

Evite selecionar colunas desnecessárias, porque isso pode diminuir a velocidade das suas consultas e desperdiçar recursos. Em vez disso, selecione apenas as colunas usadas.

No exemplo abaixo, você seleciona id, name e 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
    }
}

No entanto, você só precisa da coluna name:

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

Parametrizar consultas

A string de consulta pode incluir um parâmetro que só é conhecido no ambiente de execução, como o seguinte:

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

No código anterior, cada consulta cria uma string diferente e, portanto, não se beneficia do cache de instruções. Cada chamada exige que o SQLite a compile antes de ser executada. Em vez disso, substitua o argumento id por um parâmetro e vincule o valor a 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
        }
    }
}

Agora, a consulta pode ser compilada uma vez e armazenada em cache. A consulta compilada é reutilizada entre diferentes invocações de getNameById(long).

Iterar em SQL, não em código

Use uma única consulta que retorne todos os resultados segmentados, em vez de um loop programático iterando em consultas SQL para retornar resultados individuais. O loop programático é cerca de 1.000 vezes mais lento do que uma única consulta SQL.

Usar DISTINCT para valores exclusivos

O uso da palavra-chave DISTINCT pode melhorar a performance das consultas, reduzindo a quantidade de dados que precisam ser processados. Por exemplo, para retornar apenas os valores exclusivos de uma coluna, use 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
    }
}

Usar funções de agregação sempre que possível

Use funções de agregação para receber resultados sem dados de linha. Por exemplo, o código abaixo verifica se há pelo menos uma linha correspondente:

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

Para buscar apenas a primeira linha, use EXISTS(), para retornar 0 se uma linha correspondente não existir, e 1, se uma ou mais linhas corresponderem:

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

Use funções de agregação do SQLite (link em inglês) no código do app:

  • COUNT: conta quantas linhas há em uma coluna.
  • SUM: adiciona todos os valores numéricos em uma coluna.
  • MIN ou MAX: determina o valor mais baixo ou mais alto. Funciona para colunas numéricas, tipos de DATE e tipos de texto.
  • AVG: encontra o valor numérico médio.
  • GROUP_CONCAT: concatena strings com um separador opcional.

Usar COUNT() em vez de Cursor.getCount()

No exemplo abaixo, a função Cursor.getCount() lê todas as linhas do banco de dados e retorna todos os valores das linhas:

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

No entanto, ao usar COUNT(), o banco de dados retorna apenas a contagem:

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

Aninhar consultas em vez de código

O SQL é um elemento combinável e oferece suporte a subconsultas, junções e restrições de chave externa. É possível usar o resultado de uma consulta em outra sem passar pelo código do app. Isso reduz a necessidade de copiar dados do SQLite e permite que o mecanismo do banco de dados otimize sua consulta.

No exemplo abaixo, você pode executar uma consulta para descobrir qual cidade tem mais clientes e, em seguida, usar o resultado em outra consulta para encontrar todos os clientes dessa cidade:

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

Para obter o resultado na metade do tempo do exemplo anterior, use uma única consulta SQL com instruções aninhadas:

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

Verificar exclusividade no SQL

Se uma linha só puder ser inserida se um valor de coluna específico for exclusivo na tabela, talvez seja mais eficiente impor essa exclusividade como uma restrição de coluna.

No exemplo abaixo, uma consulta é executada para validar a linha a ser inserida e outra para realmente inserir:

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

Em vez de verificar a restrição exclusiva no Kotlin, você pode fazer essa verificação no SQL ao definir a tabela:

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

O SQLite faz o mesmo desta forma:

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

Agora você pode inserir uma linha e deixar que o SQLite verifique a restrição:

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

O SQLite oferece suporte a índices exclusivos com várias colunas:

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

O SQLite valida restrições mais rapidamente e com menos sobrecarga do que o código Kotlin. Uma prática recomendada é usar o SQLite em vez do código do app.

Agrupar várias inserções em uma única transação

Uma transação confirma várias operações, o que melhora não só a eficiência, mas também a precisão. Para melhorar a consistência dos dados e acelerar a performance, faça inserções em lote:

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

Fora de uma transação explícita, cada gravação é executada na própria transação implícita e paga o custo total de uma confirmação, incluindo um fsync no modo síncrono FULL. O loteamento paga esse custo uma vez, o que pode tornar um lote de gravações uma ordem de magnitude ou mais rápido.

Usar ferramentas de solução de problemas

O SQLite oferece as ferramentas de solução de problemas abaixo para medir a performance.

Usar uma solicitação interativa do SQLite

Execute o SQLite na sua máquina para realizar consultas e aprender. Diferentes versões da Plataforma Android usam revisões diferentes do SQLite. Para usar o mesmo mecanismo de um dispositivo Android, use adb shell e execute sqlite3 no dispositivo de destino.

É possível solicitar consultas de tempo ao SQLite:

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

EXPLAIN QUERY PLAN

Você pode perguntar ao SQLite como ele pretende responder a uma consulta, usando EXPLAIN QUERY PLAN (link em inglês):

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

O exemplo anterior requer uma verificação completa da tabela sem um índice para encontrar todos os clientes de Paris. Isso é chamado de complexidade linear. O SQLite precisa ler todas as linhas e manter apenas aquelas que correspondem aos clientes de Paris. Para corrigir isso, adicione um índice:

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

Se você estiver usando o shell interativo, peça ao SQLite para sempre explicar os planos de consulta:

sqlite> .eqp on

Para mais informações, consulte Plano de consulta.

SQLite Analyzer

O SQLite oferece a interface de linha de comando (CLI) sqlite3_analyzer para despejar mais informações que podem ser usadas para resolver problemas de performance. Para instalar, acesse a página de download do SQLite (link em inglês).

Use adb pull para fazer o download de um arquivo de banco de dados de um dispositivo de destino para sua estação de trabalho para análise:

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

SQLite Browser

Você também pode instalar a ferramenta GUI do Navegador SQLite na página Downloads do SQLite (links em inglês).

Geração de registros do Android

O Android marca o tempo das consultas SQLite e as registra para você:

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

No Android 13 e versões mais recentes, também é possível registrar apenas as operações lentas. Defina um limite em milissegundos e reinicie o app para que ele leia a tag de registro:

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

As operações lentas aparecem no Logcat na tag SQLiteConnection com a duração, o SQL e o caminho do banco de dados. Para definir o limite de um único app, use db.log.slow_query_threshold.<uid>, em que <uid> é o UID do app.

Rastreamento do Perfetto

Ao configurar o Perfetto, adicione a categoria atrace database para incluir operações do SQLite no rastreamento. Para ver o que cada linha de execução está fazendo enquanto executa o SQL, adicione também os eventos do programador:

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

O Android rastreia cada operação de banco de dados como uma fração nomeada de acordo com a operação e o SQL dela, por exemplo, executeForCursorWindow SELECT * FROM message WHERE body LIKE ?. O SQL mantém os marcadores de posição ? e é truncado para 256 caracteres. Os argumentos de vinculação não estão incluídos. As operações incluem open, close, prepare, execute e as variantes executeFor. Uma fração prepare não significa que o cache de instruções não foi encontrado.

O layout da faixa depende da versão do Android:

  • No Android 17 e versões mais recentes, cada conexão tem uma faixa assíncrona no processo do app chamada SQLiteConnection- seguida por um número de conexão. As transações aparecem como frações TRANSACTION-EXCLUSIVE, TRANSACTION-IMMEDIATE ou TRANSACTION-DEFERRED, com as instruções executadas nelas aninhadas abaixo. A linha de execução que executa uma operação também recebe eventos instantâneos START- e END- na faixa de linha de execução, para cada transação e para cada instrução fora de uma transação. Os nomes desses eventos terminam com o nome da faixa de conexão, por exemplo, -SQLiteConnection-0.
  • No Android 16 e versões anteriores, cada instrução SQL distinta tem a própria faixa assíncrona no processo do app, e as transações não são rastreadas.

Os números de conexão são reiniciados em 0 para cada banco de dados. Assim, as conexões principais de todos os bancos de dados em um processo compartilham a faixa SQLiteConnection-0. Se o app usar mais de um banco de dados ao mesmo tempo, meça as durações com os eventos START- e END- nas faixas de linha de execução.

As faixas da linha de execução principal e do SQLiteConnection-0 no Perfetto, mostrando uma
consulta LIKE que verifica toda a tabela por 12 milissegundos na linha de execução
principal

Figura 1. Uma verificação completa da tabela na linha de execução principal no Android 17.

É possível analisar essas frações com o PerfettoSQL na interface do Perfetto ou com o trace_processor. A consulta a seguir encontra as operações SQLite mais frequentes em cada processo. Essa consulta funciona em todas as versões do 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;

A consulta a seguir lista as operações do SQLite que levaram mais de 1 ms em uma linha de execução principal no Android 17 e versões mais recentes:

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;

Um resultado de consulta do Perfetto que lista operações do SQLite com mais de 1
milissegundo na linha de execução principal de um app de
teste

Figura 2. Operações do SQLite com mais de 1 ms em uma linha de execução principal.

A consulta a seguir encontra linhas de execução que gravam fora de uma transação explícita, em que cada gravação paga pelo próprio commit, no Android 17 e versões mais recentes:

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> mostra estatísticas relacionadas ao uso da memória do app, incluindo detalhes sobre o SQLite na seção DATABASES. Por exemplo, isto foi retirado da saída de adb shell dumpsys meminfo com.google.android.gms.persistent no dispositivo de um desenvolvedor:

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

O Android 14 e versões mais recentes mostram as tabelas PER CONNECTION STATS e POOL STATS mostradas no exemplo. O Android 13 e versões anteriores mostram apenas a tabela por conexão, com as estatísticas de cache combinadas em uma única coluna cache como hits/misses/size.

Em PER CONNECTION STATS, você encontra:

  • pgsz: o tamanho de uma página do banco de dados, em KB.
  • dbsz: o tamanho do arquivo de banco de dados inteiro, em KB.
  • Lookaside(b): o número de slots de memória lookaside que a conexão usa. Apesar do (b) no cabeçalho, essa é uma contagem de slots, não de bytes.
  • cache hits, cache misses e cache size: estatísticas do cache de instruções preparadas da conexão. Esses valores contam instruções SQL compiladas, não páginas de banco de dados.
  • Dbname: o caminho para o arquivo do banco de dados. As conexões adicionais com o mesmo banco de dados têm um número entre parênteses, como (1). Com o WAL, um banco de dados pode ter várias conexões para que as leituras sejam executadas em paralelo.

Em POOL STATS, você vai encontrar as estatísticas de cache de instruções preparadas para cada pool de conexões:

  • cache hits: o SQLite armazena em cache as instruções preparadas e tenta reutilizá-las ao executar consultas para economizar esforço e memória na compilação de instruções SQL. É o número de ocorrências em cache de instruções.
  • cache misses: o número de ausências no cache de instruções.
  • cache size: no Android 16 QPR2 e versões mais recentes, o número de instruções preparadas no cache, somadas nas conexões inativas do pool. No Android 14 ao Android 16 QPR1, esse valor é a soma de acertos e erros e não representa o tamanho do cache.

Use essas estatísticas para decidir o que corrigir:

  • Uma alta taxa de ausência significa que o SQLite compila as mesmas instruções repetidamente. Primeiro, procure o SQL criado com concatenação de strings e parametrize-o. Se as consultas já estiverem parametrizadas e o cache size por conexão estiver no limite de 25, aumente o tamanho do cache.
  • Um caminho de banco de dados que aparece mais de uma vez sem um sufixo no estilo (1) significa que o app abriu o mesmo arquivo duas vezes. Abra cada banco de dados uma vez.
  • Um banco de dados que seu app raramente usa ainda retém memória enquanto está aberto. Feche-o quando terminar.

dumpsys dbinfo

adb shell dumpsys dbinfo <package-name> mostra o estado de cada pool de conexões no app. Adicione -v para listar também as instruções no cache de instruções preparadas de cada conexão. O exemplo de saída abaixo é do Android 17, com caminhos e linhas longas encurtados:

** 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=...
        ...

Procure o seguinte:

  • Configuration: o modo de diário e o modo síncrono do pool. No Android 13 e versões mais recentes, essa linha mostra os modos que o framework aplicou, incluindo os padrões da plataforma. No exemplo, events.db usa o diário de reversão com o modo síncrono FULL, de modo que cada confirmação aguarda uma sincronização completa. Essa linha não reflete as instruções PRAGMA que você executa.
  • Max connections: um valor de 1 para um banco de dados gravável significa que o framework não considera que o banco de dados está no modo WAL. Portanto, as leituras e gravações compartilham uma única conexão.
  • Total execution time, Total statements executed e Average time per statement: disponíveis no Android 13 e versões mais recentes. Use-as para encontrar os bancos de dados mais ocupados.
  • Most recently executed operations: as últimas 20 operações em cada conexão, com a duração e o SQL. Procure padrões como a mesma consulta em execução em um loop ou muitas gravações seguidas fora de uma transação. No Android 15 QPR1 e versões mais recentes, essa lista também inclui transações explícitas.
  • totalLongOperations e Operations exceeding 2000ms: disponíveis no Android 15 QPR1 e versões mais recentes. Eles mostram quantas operações e transações levaram mais de 2 segundos e listam as últimas 10.
  • Acquired connections e Connection waiters: conexões em uso e linhas de execução que estão aguardando uma conexão. Os waiters indicam contenção, como uma transação de gravação longa que bloqueia outras gravações.
  • Prepared statement cache: impresso com -v. Muitas entradas que diferem apenas em valores literais apontam para SQL criado com concatenação de strings.
  • O mesmo caminho em mais de um cabeçalho Connection pool for significa que o app abriu o mesmo arquivo duas vezes.

Outros recursos

Visualiza conteúdo