· 8 min de leitura

Rinha de Backend 2026: o problema real não é a API

Primeiro artigo da minha jornada na Rinha de Backend 2026. Rust, busca vetorial, 350 MB de memória e o porquê de começar pelo brute force.

Cartão de crédito com cadeado, capa oficial da Rinha de Backend 2026
Capa oficial da Rinha de Backend 2026. Crédito: zanfranceschi/rinha-de-backend-2026.

Começando

Esse é o primeiro de uma série de artigos sobre como estou encarando a Rinha de Backend 2026. A ideia não é contar a solução perfeita depois que tudo der certo. É mostrar a jornada com os erros, decisões revertidas e os números que aparecem no caminho.

Pra quem não conhece, a Rinha é um desafio organizado pelo Francisco Zanfranceschi onde você sobe sua API em Docker, com limites apertados de CPU e memória, e a engine oficial roda um benchmark unificado contra todo mundo. A graça está nas restrições.

O desafio parece pequeno até você olhar a memória

Na superfície, o contrato da API de 2026 é simples: expor GET /ready e POST /fraud-score, receber uma transação de cartão e devolver:

{
  "approved": false,
  "fraud_score": 0.8
}

O detalhe é que a decisão não vem de uma regra de negócio comum. Cada payload precisa virar um vetor de 14 dimensões. Depois, a API tem que buscar os 5 vetores mais próximos em um dataset de 3 milhões de referências rotuladas.

O score nasce daí:

fraud_score = fraudes_entre_os_5_vizinhos / 5
approved = fraud_score < 0.6

Então a API em si não é o centro do problema. O centro é fazer busca vetorial rápida, com duas instâncias rodando, dentro de 1 CPU e 350 MB somando todos os serviços. O dataset descomprimido sozinho já tem por volta de 284 MB. Se duas APIs carregarem uma cópia da estrutura na memória, o orçamento estoura antes de qualquer linha de lógica.

Diagrama do orçamento de memória da Rinha 2026: cenário de risco com duas cópias do dataset estourando 350 MB versus alvo com mmap compartilhado pelo kernel
Por que mmap não é detalhe: duas cópias residentes não cabem no orçamento.

Por que Rust

Eu escolhi Rust por três motivos, nessa ordem.

Primeiro, é quase pessoal: eu queria sair da zona de conforto. Se a ideia é documentar a jornada, faz sentido escolher uma stack que me obrigue a tomar decisões explícitas sobre layout de memória, alocação e custo de runtime.

Segundo, o problema combina bem com o que Rust entrega de fábrica: binário pequeno, representação compacta e processamento numérico previsível. Rust não resolve nada sozinho, mas reduz a magia entre o código e o que acontece no processo.

Terceiro, e o que mais pesou: o limite de 350 MB não perdoa. Em qualquer linguagem com runtime gordo, eu já começo perdendo orçamento antes de escrever a primeira linha útil.

Eu não forkei o repo oficial

Foi a primeira decisão que me peguei reconsiderando. O caminho mais comum é clonar o repositório oficial e adaptar dali. Eu fui pelo lado oposto: comecei do zero.

A submissão da Rinha aponta para o seu repositório público via participants/<usuario>.json. Em nenhum lugar pedem fork. Clonar o oficial traria muitos arquivos que não têm nada a ver com a solução e ia poluir histórico, branches e CI.

O que eu fiz foi mais cirúrgico: o repo oficial fica como referência. Copio resources e test quando preciso, mas o código da API mora em um repo limpo. Primeiro commit no dia 03 de maio:

24661a9 chore: initial project setup

Junto, já deixei o agent-md configurado para Codex, Claude Code, Cursor e Windsurf, com verificações determinísticas no agent-md.toml:

cargo check --locked
cargo fmt --check
cargo clippy --locked --all-targets --all-features -- -D warnings
cargo test --locked

Pode parecer overkill para um projeto pessoal. Mas é justamente em projeto pessoal que costuma faltar disciplina. Quero que cada commit já entre com lint e teste passando, sem depender de eu lembrar de rodar.

Antes de pensar em índice sofisticado

A tentação de pular direto para HNSW, IVF, VP-tree ou qualquer ANN é gigante. Mas se eu não tiver um baseline correto, não consigo medir o erro que essas técnicas introduzem. E a Rinha pune erro de detecção com peso real.

Meu plano inicial é mais bobo do que parece:

  1. Implementar a API mínima e validar o contrato.
  2. Transformar o payload em vetor de 14 dimensões seguindo os exemplos oficiais.
  3. Criar uma busca exata simples como baseline local.
  4. Preprocessar o dataset para um formato binário compacto.
  5. Comparar qualquer otimização contra o baseline.

O ponto não é vencer com brute force. É usar brute force como verdade local, para saber exatamente o que estou perdendo quando a busca ficar mais esperta.

O dataset precisa sair do JSON antes do runtime

O fluxo que eu não quero é este:

startup -> abrir references.json.gz -> descomprimir -> parsear JSON
       -> montar estrutura -> atender requests

Isso é ruim para startup, ruim para memória e péssimo para previsibilidade. Cada serde_json em payload de 284 MB cobra um pedaço do orçamento que eu não tenho. A direção melhor é empurrar trabalho para build-time:

references.json.gz
  -> preprocessador
  -> references.f32.bin
  -> labels.bin
  -> metadata.json
  -> runtime com mmap

No caminho competitivo, a API deveria mapear arquivos prontos, validar metadados e começar a responder. Nada de parsear JSON gigante no caminho quente. Se der, a versão final usa mmap com float16 ou até quantização inteira; mas isso fica para depois de medir o brute force em float32.

Diagrama do pipeline de dados separando build da imagem (preprocessador faz streaming gzip e gera binários) do runtime (mmap read-only valida metadata sem descompressão)
Tudo que é caro vai pro build. O runtime só mapeia, valida e responde.

O erro HTTP custa 5x mais que um falso positivo

Esse foi o número que mudou minha forma de pensar a API. A avaliação tem peso diferente para cada tipo de erro:

ErroPeso
Falso positivo1
Falso negativo3
Erro HTTP (5xx, timeout)5

E tem um cliff: se a soma de FP, FN e erros HTTP passar de 15% das requisições, o score de detecção vai direto para -3000. A latência tem seu próprio cliff: p99 acima de 2000 ms também trava em -3000.

Isso muda completamente a postura de tratamento de falha. Em produção, um payload inválido normalmente vira 4xx. Aqui, na dúvida, devolver:

{ "approved": true, "fraud_score": 0.0 }

provavelmente custa menos do que devolver 5xx. Não é bonito e não é uma regra que eu levaria para um produto real. Mas dentro do score, transformar uma falha recuperável em erro de infraestrutura é o pior negócio possível.

Dentro do score, HTTP 200 com fallback conservador costuma sair mais barato que 503 honesto.

A arquitetura que eu quero defender

Duas APIs, um load balancer fazendo round-robin puro (sem lógica de detecção), rede bridge, sem host nem privileged. Cada API com acesso local aos artefatos via mmap. O caminho quente fica assim:

POST /fraud-score
  -> parse
  -> vetorização 14D (com clamp e MCC default 0.5)
  -> top-5 nas referências mapeadas
  -> fraud_score
  -> HTTP 200

Cada seta dessas tem custo. Parse pesa CPU. Vetorização precisa estar correta até na hora UTC e no last_transaction: null (que vira -1 nos índices 5 e 6). A busca top-k provavelmente vai dominar o p99. E qualquer estrutura auxiliar precisa caber no mesmo orçamento.

Fluxograma do POST /fraud-score: parse válido segue por vetorização 14D, busca top-5 nas referências mmap e cálculo do score; parse inválido cai no fallback conservador retornando approved=true e fraud_score=0.0, ambos terminando em HTTP 200
Toda falha recuperável termina em HTTP 200 com fallback conservador.

O que vou medir antes de otimizar

MedidaPor que importa
Tamanho dos artefatos preprocessadosDecide se duas APIs cabem em 350 MB
Tempo de startupAfeta confiabilidade do bench
Memória por containerMostra se a arquitetura cabe
p99 por versãoAlimenta o score de latência direto
FP, FN e erros HTTPMostra se a otimização destruiu detecção

Cada 10x de melhoria no p99 vale +1000 pontos no score (saturando em +3000 quando p99 <= 1ms). Sem esses números do meu lado, qualquer escolha entre brute force, IVF ou HNSW vira preferência pessoal.

Onde estou agora

Repositório criado, agent-md rodando, hooks de Git e Codex ativos, CLAUDE.md sincronizado, working tree limpo. Próximo passo: escolher o runtime HTTP mínimo em Rust e fechar a Slice 1, com GET /ready, POST /fraud-score parseando o contrato e um smoke test rodando via k6 run test/smoke.js.

Minha meta nesta fase não é ter a solução perfeita. É ter uma base mensurável:

  • contrato da API funcionando;
  • vetorização validada contra os exemplos oficiais;
  • dataset preprocessado;
  • baseline exato para servir de referência;
  • scripts de teste reproduzíveis;
  • espaço claro para trocar o mecanismo de busca depois.

Com essa base, qualquer otimização vira comparação numérica contra o brute force. Sem ela, é chute.

No próximo artigo: como vetorizar essas 14 dimensões sem errar na hora UTC nem no last_transaction: null. Depois: o preprocessamento do dataset e o brute force que vai servir de baseline. Se eu chegar lá, ANN com erro mensurado.