Performance, antipadrões e decisão em produção
Como medir performance de busca
Métricas (latência P50/P95, rows examined, hit ratio), EXPLAIN e testes de carga realistas.
Nesta aula você vai
- Definir métricas de busca: latência P50/P95, rows examined e hit ratio
- Usar EXPLAIN/planos e telemetria para achar gargalos reais
- Desenhar testes de carga com consultas realistas, não só o caminho feliz
Como medir performance de busca
Objetivos
Nesta aula você vai:
- Definir métricas de busca: latência P50/P95, rows examined e hit ratio
- Usar EXPLAIN/planos e telemetria para achar gargalos reais
- Desenhar testes de carga com consultas realistas, não só o caminho feliz
Introdução
“A busca está lenta” não é um diagnóstico. Sem percentis, trabalho por query e carga representativa, você otimiza no escuro — criando índices errados ou trocando de motor cedo demais. Esta aula monta um kit mínimo de medição para FTS em SGBD e motores dedicados.
Conteúdo
Métricas essenciais
| Métrica | O que responde |
|---|---|
| Latência P50 | Experiência típica |
| Latência P95/P99 | Cauda que gera tickets |
| QPS / concorrência | Capacidade sob carga |
| Rows examined / docs scored | Trabalho interno por busca |
| Hit ratio de cache | Quão quente é o conjunto ativo de consultas |
| Error rate / timeouts | SLA quebrado |
| Index lag (se sync) | Atualidade vs fonte da verdade (SoR) |
| CPU/IO do nó de busca | Saturação de recurso |
Instrumente na borda da API (Worker/app) e, se possível, no motor (slow query log, _nodes/stats, pg_stat_statements, etc.).
Latência: meça percentis, não só média
// Histograma simples em memória (didático)
class LatencyHist {
constructor() {
this.samples = [];
}
observe(ms) {
this.samples.push(ms);
}
percentile(p) {
const s = [...this.samples].sort((a, b) => a - b);
if (!s.length) return 0;
const i = Math.min(s.length - 1, Math.floor((p / 100) * s.length));
return s[i];
}
}
Em produção, use Prometheus histograms / OpenTelemetry. Alarmes em P95 > orçamento (ex.: 200 ms na edge), não só em média.
Rows examined e planos
SQLite/D1:
EXPLAIN QUERY PLAN
SELECT rowid FROM articles_fts
WHERE articles_fts MATCH 'sqlite AND d1'
ORDER BY bm25(articles_fts)
LIMIT 20;
Busque evidência de uso do índice virtual FTS, não scan da tabela base inteira.
Postgres (referência): EXPLAIN (ANALYZE, BUFFERS) em @@ to_tsquery.
MySQL/MariaDB: EXPLAIN em MATCH ... AGAINST — confirme uso de FULLTEXT.
OpenSearch: profile API / slow logs; olhe took, shards, e custo de aggregations.
Se MATCH/$text examina milhões para devolver 20, a query é ampla demais ou o índice está errado.
Hit ratio
- Cache de resultados (KV/Cache API/Redis):
hits / (hits+misses). - OS page cache / buffer pool: IO esperado sob carga.
- Consultas únicas de cauda longa terão hit ratio baixo — normal; faça cache de prefixos e de consultas populares.
Testes de carga realistas
Evite só q=test em loop. Monte um query log sintético:
- Extraia top-N queries reais (anonimizadas) + amostra da cauda.
- Inclua vazias, 1 token, frases, prefixos de autocomplete.
- Misture filtros (status, tenant, data).
- Ramp-up gradual; meça P95 e erros.
- Separe leitura de busca de escrita/indexação se competem pelo mesmo DB.
# Exemplo k6 (esqueleto)
# scenarios: constant_arrival_rate com arquivo de queries
// Worker de probe (cron) — amostra de saúde
export async function scheduled(event, env) {
const queries = ['fts5', 'cloudflare d1', 'bm25 ranking'];
for (const q of queries) {
const t0 = Date.now();
await env.DB.prepare(
`SELECT rowid FROM articles_fts WHERE articles_fts MATCH ?1 LIMIT 20`
)
.bind(`"${q.replace(/"/g, '')}"`)
.all();
console.log({ q, ms: Date.now() - t0 });
}
}
Orçamentos sugeridos (ponto de partida)
| Superfície | P95 alvo (orientação) |
|---|---|
| Autocomplete | < 100 ms |
| Search page (lexical local) | < 300 ms |
| Search + agregações pesadas | < 800 ms (ou async) |
Ajuste ao produto; o importante é ter orçamento e medi-lo.
Exemplos práticos
-- SQLite: comparar custo com e sem LIMIT
EXPLAIN QUERY PLAN
SELECT snippet(articles_fts, 1, '', '', '…', 20)
FROM articles_fts
WHERE articles_fts MATCH 'fulltext*'
ORDER BY bm25(articles_fts)
LIMIT 10;
export async function timedSearch(db, match) {
const start = performance.now();
const { results } = await db
.prepare(
`SELECT rowid AS id, bm25(articles_fts) AS score
FROM articles_fts
WHERE articles_fts MATCH ?1
ORDER BY score LIMIT 20`
)
.bind(match)
.all();
return { results, ms: performance.now() - start, k: results.length };
}
Problemas e como resolver
| Problema | Causa | Mitigação |
|---|---|---|
| Média OK, usuários reclamam | Cauda P95/P99 | Percentis + slow log |
| Load test “passa” | Query irrealista | Replay de query log |
| EXPLAIN limpo, API lenta | Rede/serialização/N+1 | Trace ponta a ponta |
| Hit ratio baixo | Cauda longa | Cache seletivo; CDN só onde faz sentido |
Resumo
Meça P50/P95, trabalho examinado, cache e lag de índice; confirme planos com EXPLAIN; teste com queries reais. Sem isso, antipadrões passam despercebidos — tema da próxima aula.