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.

Intermediário 48 min 34 pontos Leitura 0%

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:

  1. Extraia top-N queries reais (anonimizadas) + amostra da cauda.
  2. Inclua vazias, 1 token, frases, prefixos de autocomplete.
  3. Misture filtros (status, tenant, data).
  4. Ramp-up gradual; meça P95 e erros.
  5. 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.