SQLite
Desempenho
iOS
macOS

10 Dicas de Desempenho do SQLite para Apps iOS e macOS

15 de outubro de 2025
Equipe HarborDB

O SQLite é a espinha dorsal do ecossistema Apple. Ele alimenta o Core Data, respalda o acesso direto típico a bancos de dados no iOS e macOS, e serve como formato de arquivo de aplicativo para inúmeros apps. Embora o SQLite seja famoso por sua "configuração zero", levá-lo de uma implementação básica para um mecanismo de alto desempenho capaz de lidar com gigabytes de dados e milhares de operações por segundo requer um entendimento profundo e um ajuste intencional.

Neste guia completo, mergulharemos nos aspectos internos do SQLite, explorando configurações avançadas (PRAGMAs), estratégias de indexação e padrões arquitetônicos especificamente adaptados para as restrições e capacidades dos dispositivos iOS e macOS.

1. Ativar o Modo Write-Ahead Logging (WAL)

A mudança mais eficaz que você pode fazer em um banco de dados SQLite padrão é ajudá-lo a lidar com a concorrência. Por padrão, o SQLite usa um "diário de reversão" (rollback journal) para garantir atomicidade e durabilidade. Quando ocorre uma transação de gravação, o SQLite copia as páginas originais para um arquivo de diário separado antes de escrever no arquivo de banco de dados principal. Durante esse processo, o banco de dados fica bloqueado para leitura. Isso significa que os leitores bloqueiam os escritores, e os escritores bloqueiam os leitores.

O modo WAL (Write-Ahead Logging) inverte isso. As alterações são gravadas em um arquivo "WAL" separado (geralmente terminando em -wal), e os leitores continuam lendo do arquivo de banco de dados principal (e do arquivo WAL) sem serem bloqueados pelo escritor.

Por Que o WAL Importa para Apps iOS

No iOS, a thread de interface do usuário (Main Thread) frequentemente precisa ler dados para popular table views ou collection views. Se uma thread em segundo plano estiver sincronizando dados de uma API e gravando no BD no modo Rollback Journal, sua Main Thread será bloqueada, causando travamentos na UI (drop frames). Com o modo WAL, sua sincronização em segundo plano pode escrever livremente enquanto sua thread de UI lê o snapshot confirmado mais recente.

Como Ativar

Execute isso através de um comando PRAGMA. Isso é persistente, então tecnicamente você só precisa fazer isso uma vez, mas é uma boa prática executá-lo ao abrir a conexão.

PRAGMA journal_mode = WAL;

Saída: wal

Aprofundando: Checkpoints

No modo WAL, o arquivo -wal cresce indefinidamente até que uma operação de "checkpoint" transfira as páginas WAL de volta para o banco de dados .sqlite principal. Por padrão, o SQLite cria um checkpoint automático quando o WAL atinge 1000 páginas (aprox. 4MB). No entanto, para cargas de trabalho de gravação pesadas, você pode querer ajustar o wal_autocheckpoint ou acioná-los manualmente usando PRAGMA wal_checkpoint(PASSIVE|FULL|RESTART|TRUNCATE).

2. Aproveite as Transações Explicitamente

É um equívoco comum pensar que instruções SQL individuais são rápidas o suficiente. Por padrão, o SQLite abre uma transação para cada instrução individual se uma não for iniciada manualmente. Isso significa que INSERT INTO table... na verdade faz:

  1. Abrir transação
  2. Criar arquivo de diário
  3. Gravar dados
  4. Sincronizar com o disco (fsync)
  5. Fechar transação
  6. Excluir/invalidar diário

Este fsync é caro, especialmente em armazenamento flash móvel.

A Abordagem em Lote

Agrupe suas operações:

// RUIM: 1000 fsyncs
for user in users {
    db.run("INSERT INTO users (name) VALUES (?)", user.name)
}

// BOM: 1 fsync
db.transaction {
    for user in users {
        db.run("INSERT INTO users (name) VALUES (?)", user.name)
    }
}

Os benchmarks geralmente mostram uma melhoria de velocidade de 100x a 1000x para inserções em massa ao usar transações explícitas.

3. Dominando os Índices: B-Trees, Covering e Índices Parciais

A indexação é uma arte. Poucos índices, e você escaneia a tabela inteira (O(n)). Muitos, e suas criações/atualizações ficam lentas (O(log n) por índice).

A Visualização da B-Tree

O SQLite usa B-Trees separadas para a tabela (baseada em rowid) e para cada índice.

  • Uma B-Tree de Tabela armazena os dados reais nos nós folha.
  • Uma B-Tree de Índice armazena a chave indexada + o rowid.

Índices de Cobertura (Covering Indexes)

Para consultar estritamente a partir do índice sem tocar na tabela principal (carregando páginas extras), inclua todas as colunas recuperadas no índice.

Cenário: Você tem uma tabela users com id, name, email, last_login. Consulta: SELECT email FROM users WHERE name = 'Alice'

Índice Padrão: CREATE INDEX idx_name ON users(name);

  1. Pesquisar 'Alice' em idx_name -> obter rowid.
  2. Buscar rowid na B-Tree de Tabela -> carregar página -> obter email.

Índice de Cobertura: CREATE INDEX idx_name_email ON users(name, email);

  1. Pesquisar 'Alice' em idx_name_email. O email está bem ali no nó folha do índice. O resultado é retornado instantaneamente. Zero pesquisas na tabela.

Índices Parciais

Se você consultar apenas um subconjunto de dados (por exemplo, usuários "ativos"), não indexe a tabela inteira para economizar espaço.

CREATE INDEX idx_active_users_email 
ON users(email) 
WHERE status = 'active';

Este índice é menor, mais rápido de atualizar e mais rápido de consultar.

4. Prepared Statements Não São Negociáveis

A análise de SQL consome muita CPU. SELECT * FROM users WHERE id = 1 e SELECT * FROM users WHERE id = 2 são strings completamente diferentes para o analisador do SQLite.

Uma Prepared Statement compila o plano de consulta SQL em código de bytes. Você pode então "vincular" parâmetros a ela e executá-la várias vezes.

Exemplo em Swift (usando GRDB ou SQLite.swift internamente)

Quando você usa uma biblioteca de forma eficaz, ela armazena em cache essas declarações. Use placeholders (?) em vez de interpolação de strings:

// RUIM - A compilação ocorre em cada iteração do loop
let sql = "INSERT INTO users (name) VALUES ('\(name)')"
try db.execute(sql)

// BOM - Compilar uma vez, vincular muitas
let stmt = try db.prepare("INSERT INTO users (name) VALUES (?)")
for name in names {
    try stmt.run(name)
}

Isso reduz significativamente a sobrecarga da CPU, o que também economiza bateria em dispositivos móveis.

5. Otimize Suas Configurações PRAGMA

Os padrões do SQLite são conservadores (destinados a hardware da era 2005). Os iPhones modernos têm armazenamento de classe NVMe e gigabytes de RAM.

temp_store

Por padrão, tabelas temporárias e índices são armazenados em disco. Mova-os para a RAM:

PRAGMA temp_store = MEMORY;

mmap_size

E/S mapeada em memória pode ser mais rápida do que chamadas de sistema de leitura/gravação padrão porque evita copiar dados entre o espaço do kernel e o espaço do usuário.

PRAGMA mmap_size = 268435456; -- 256MB

Nota: Tenha cuidado com mmap se tiver avisos de alta pressão de memória no iOS, mas para conjuntos de dados moderados, é um grande impulsionador de leitura.

synchronous

No modo WAL, o padrão é NORMAL. Isso é seguro o suficiente para a maioria dos apps (você pode perder a última transação em uma queda de energia, mas não corrupção do banco de dados). Evite FULL ou EXTRA a menos que esteja escrevendo software bancário onde a durabilidade aprimorada valha a pena a penalidade de velocidade de gravação de ~20%.

PRAGMA synchronous = NORMAL;

6. Entendendo o Planejador de Consultas (EXPLAIN QUERY PLAN)

Nunca adivinhe por que uma consulta é lenta. O SQLite diz a você. Prefixe sua consulta com EXPLAIN QUERY PLAN.

EXPLAIN QUERY PLAN SELECT * FROM orders WHERE user_id = 5 AND created_at > 1000;

Análise de Saída:

  • SCAN TABLE: Ruim. Está lendo cada linha.
  • SEARCH TABLE USING INDEX: Bom. Está usando uma pesquisa em B-Tree.
  • USE TEMP B-TREE: Aviso. Teve que construir um índice temporário (provavelmente para ordenação).

HarborDB (nosso produto) foca muito em visualizar esses planos, mas você pode ler a saída bruta em qualquer CLI.

7. Lide com SQLITE_BUSY com Graça

Mesmo no modo WAL, bloqueios compartilhados podem ocorrer. Se um escritor estiver finalizando um commit, um leitor pode ser bloqueado brevemente. Por padrão, o SQLite retorna SQLITE_BUSY imediatamente. No iOS, você deve definir um tempo limite para que o SQLite tente novamente internamente antes de desistir.

PRAGMA busy_timeout = 3000; -- 3000 milissegundos

Essa configuração simples previne 99% dos erros de "Banco de dados bloqueado" em aplicativos concorrentes.

8. Desnormalização para Velocidade Móvel

A normalização estrita (3NF) é ótima para eficiência de armazenamento e consistência, mas telas móveis frequentemente precisam de dados "unidos" instantaneamente. Joins são caros (O(M * log N)).

Se você tem uma lista de Posts que mostra o AuthorName e CommentCount, considere armazenar AuthorName e CommentCount diretamente na tabela Posts. Sim, você tem que atualizar a tabela Posts quando um autor muda seu nome ou um comentário é adicionado (Triggers podem automatizar isso!), mas sua velocidade de LEITURA (que ocorre 100x mais frequentemente que gravações) se torna O(1) por linha em vez de fazer JOINs massivos ao rolar a tela.

9. Paginação Eficiente (Evite OFFSET)

A paginação web padrão LIMIT 20 OFFSET 100 é tóxica no SQLite para grandes conjuntos de dados. Para chegar ao OFFSET 10000, o SQLite deve calcular as primeiras 10.000 linhas e descartá-las. Fica linearmente mais lento quanto mais profunda a página.

O Método Keysset (Seek): Lembre-se do último valor visto. Consulta: SELECT * FROM items WHERE created_at < ? ORDER BY created_at DESC LIMIT 20

Isso usa o índice para pular imediatamente para o local correto. É O(log N) independentemente da profundidade da página.

10. Vacuuming e Manutenção

Bancos de dados ficam fragmentados. Quando você exclui dados, o SQLite marca as páginas como "livres", mas não reduz o tamanho do arquivo.

  • VACUUM: Reconstrói o arquivo DB especializado do zero. Perfeitamente compacto. Lento. Bloqueio exclusivo.
  • PRAGMA auto_vacuum = INCREMENTAL: Permite recuperar espaço periodicamente sem bloquear tudo.

Para apps iOS, recomendamos:

  1. Usar auto_vacuum = NONE (Padrão) para desempenho.
  2. Executar um VACUUM completo apenas durante migrações importantes ou janelas de manutenção raras (por exemplo, atualização do app), pois requer espaço em disco (2x tamanho do BD) e tempo.

Conclusão

Otimizar o desempenho do SQLite/PostgreSQL é uma jornada de compreensão do hardware, do sistema de arquivos e das estruturas B-Tree. Ao ativar o modo WAL, usar índices adequados e entender o planejador de consultas, quase qualquer funcionalidade "lenta" no seu app iOS/macOS pode ser instantânea.

Para desenvolvedores que desejam ver essas métricas de desempenho em tempo real e visualizar seus planos de consulta, ferramentas nativas como o HarborDB fornecem uma vantagem significativa sobre as suposições da linha de comando.