Cloudflare D1
SQL
Performance
Índices
Otimização

Consultas lentas em D1: como diagnosticar e otimizar

Sem índices adequados, queries que parecem rápidas em desenvolvimento com 100 linhas tornam-se lentas em produção com 100 mil — e caras, porque D1 cobra por linhas lidas, não por linhas retornadas.

SQLite tem a reputação de ser um banco simples que "funciona e pronto." Essa reputação é merecida para os casos em que ele é usado como banco embutido em aplicações desktop e mobile — onde o conjunto de dados é pequeno e o query planner tem trabalho fácil. No D1, esse contexto muda: tabelas com centenas de milhares de linhas, múltiplas queries por request de usuário, e um modelo de pricing que cobra por cada linha que o banco lê durante a execução de uma query, não por linha retornada para a aplicação. Uma query mal otimizada em produção não é só lenta — é cara, e o custo aumenta linearmente com o volume de dados.

EXPLAIN QUERY PLAN: o diagnóstico antes da otimização

Antes de criar qualquer índice ou reescrever qualquer query, execute EXPLAIN QUERY PLAN contra a query problemática. No D1, você pode fazer isso remotamente via wrangler d1 execute NOME_DO_BANCO --remote --command "EXPLAIN QUERY PLAN SELECT ...", ou localmente com sqlite3 apontando para o arquivo .wrangler/state/v3/d1/.

O output é uma lista de operações que o query planner vai executar. "SCAN TABLE orders" é o sinal de alerta: significa que o banco vai percorrer todas as linhas da tabela. "SEARCH orders USING INDEX idx_orders_user_id (user_id=?)" é o que você quer ver: o banco usa o índice para localizar diretamente as linhas relevantes. "SEARCH orders USING INDEX idx_orders_user_date (user_id=? AND date>?)" indica que um índice composto está sendo aproveitado tanto para o filtro de igualdade quanto para o filtro de intervalo.

O erro mais comum é criar índices depois que os problemas aparecem em produção. O EXPLAIN QUERY PLAN deve ser parte do processo de desenvolvimento — rode contra cada query que toca tabelas com mais de alguns milhares de linhas antes do primeiro deploy. O custo de um índice desnecessário é espaço de armazenamento. O custo de uma query sem índice em produção é medido em dinheiro.

Os padrões que geram table scan em D1

Quatro situações recorrentes causam full table scan. A primeira é a mais óbvia: ausência de índice na coluna usada no WHERE. SELECT * FROM orders WHERE user_id = ? sem um índice em user_id lê todas as linhas da tabela para encontrar as do usuário. A correção é direta: CREATE INDEX idx_orders_user_id ON orders(user_id).

A segunda situação é a combinação de WHERE com ORDER BY não coberta por um índice composto. SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC pode usar o índice em user_id para filtrar, mas depois precisa ordenar o resultado em memória — uma operação chamada filesort. Um índice composto em (user_id, created_at) elimina o filesort porque os dados já estão ordenados por created_at dentro de cada user_id.

A terceira é o LIKE com wildcard inicial. WHERE title LIKE '%termo%' não pode usar nenhum índice: o wildcard no início da string impede que o banco use a ordenação do índice para descartar linhas. Para busca de texto com wildcard bilateral, use tabelas virtuais FTS5: CREATE VIRTUAL TABLE posts_fts USING fts5(title, content, content=posts). Queries FTS5 com MATCH são indexadas e escaláveis.

A quarta é a coerção de tipos. SQLite usa type affinity — uma coluna definida como TEXT pode armazenar inteiros, e uma comparação WHERE id = 42 contra uma coluna TEXT pode não usar o índice dependendo de como os valores foram inseridos. Manter consistência de tipos entre o schema, os valores inseridos e as queries é mais importante no SQLite do que em bancos com tipagem rígida.

N+1 em D1 e o papel do db.batch()

O problema de N+1 tem uma dimensão extra no D1: cada query é um subrequest, e subrequests têm limite de 1000 por invocação de Worker. Um endpoint que busca 50 pedidos e depois faz um SELECT para os itens de cada pedido individualmente executa 51 queries — 51 subrequests, com o custo de 51 round-trips ao banco, cada um adicionando latência de rede ao tempo total de resposta.

O db.batch() resolve isso agrupando múltiplas queries em um único subrequest. Todas as queries no batch executam em uma única ida e volta ao banco. O resultado é um array com um elemento por query, na mesma ordem em que foram enviadas. Para o padrão pedidos e itens, o batch contém: a query dos pedidos e uma query com IN cobrindo todos os IDs de pedido. Dois subrequests no total, independente de quantos pedidos forem retornados.

ORMs que suportam D1 — como o Drizzle ORM, que tem integração nativa — têm opções de eager loading que constroem automaticamente queries com JOIN ou batch em vez de N+1. O comportamento padrão da maioria dos ORMs, porém, gera N+1 a menos que você configure explicitamente o carregamento antecipado. Verificar o SQL gerado com console.log no ambiente de desenvolvimento antes de ir para produção é a forma mais direta de identificar esses padrões.

O custo real de não ter índice: um exemplo com números

Um banco D1 com 200 mil pedidos. A query SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at DESC LIMIT 20 sem índice em status executa um full table scan: 200 mil linhas lidas, 20 linhas retornadas. A $0,001 por milhão de leituras, cada execução dessa query custa $0,0002.

Com 100 mil execuções diárias desse endpoint — comum para um painel que atualiza via polling ou um dashboard de operações — o custo é $20 por dia, $600 por mês, apenas dessa query. O índice composto CREATE INDEX idx_orders_status_created ON orders(status, created_at DESC) muda completamente o plano: o banco lê apenas os registros com status = 'pending' usando o índice, já ordenados por created_at. Supondo 5 mil pedidos pendentes, a query lê 5 mil linhas, retorna 20, e custa $0,000005 por execução. A $100 mil execuções/dia, o custo cai para $0,50 por dia, $15 por mês.

O índice em si ocupa aproximadamente 5-10MB para 200 mil linhas. A $0,75/GB-mês, esse armazenamento custa menos de $0,01 por mês. A diferença entre $600/mês e $15/mês em custos de leitura, por um investimento de frações de centavo em armazenamento, é o tipo de otimização que nunca deve ser postergada para "depois que o tráfego crescer" — porque quando o tráfego crescer, o custo já vai estar acontecendo.

Leia também