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:
- O método
enableWriteAheadLogging - A flag aberta
ENABLE_WRITE_AHEAD_LOGGING - O método
setWriteAheadLoggingEnableddeSQLiteOpenHelper
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 callbackonOpennovamente. Portanto, coloque qualquer configuração de conexão lá. - Evite os métodos
setIdleConnectionTimeoutdeSQLiteOpenHelpereSQLiteDatabase.OpenParams.Builder. Eles foram descontinuados no nível 29 da API porque fecham conexões sem restaurar as configurações aplicadas com instruçõesPRAGMA. - 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.MINouMAX: determina o valor mais baixo ou mais alto. Funciona para colunas numéricas, tipos deDATEe 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çõesTRANSACTION-EXCLUSIVE,TRANSACTION-IMMEDIATEouTRANSACTION-DEFERRED, com as instruções executadas nelas aninhadas abaixo. A linha de execução que executa uma operação também recebe eventos instantâneosSTART-eEND-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.
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;
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 missesecache 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 sizepor 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.dbusa o diário de reversão com o modo síncronoFULL, de modo que cada confirmação aguarda uma sincronização completa. Essa linha não reflete as instruçõesPRAGMAque 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 executedeAverage 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.totalLongOperationseOperations 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 connectionseConnection 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 forsignifica que o app abriu o mesmo arquivo duas vezes.
Outros recursos
Visualiza conteúdo
Recomendados para você
- Observação: o texto do link aparece quando o JavaScript está desativado
- Executar comparativos na integração contínua
- Frames congelados
- Criar e medir perfis de referência sem a Macrobenchmark