Voltar para artigos

O mito do "reescreve tudo em Go": seu gargalo quase nunca é a linguagem

Eu gosto de Go. É a linguagem que escolho para sistemas de alta concorrência, mantenho microsserviços em produção com ela há anos e a recomendo frequentemente para quem precisa de binários enxutos e controle fino de recursos. Mas eu já perdi a conta de quantas vezes vi a mesma comédia trágica se repetir no mercado.

O roteiro nunca muda. O produto cresce, ganha tração e o backend (seja ele em PHP, Python, Ruby ou Node.js) começa a suar frio com algumas centenas de requisições por segundo. O p99 de latência encosta nos dois segundos, o Grafana apita alertas no Slack e o clima na reunião técnica fica tenso.

É nesse momento que alguém, geralmente seduzido por vídeos sobre linguagens compiladas ou benchmarks sintéticos de "hello world", decreta o diagnóstico salvador: "Nossa stack atual não escala. Precisamos reescrever esse core inteiro em Go".

A diretoria técnica compra o discurso. O roadmap congela por seis meses. Gastam-se centenas de milhares de reais em horas de engenharia, deploys novos, refatoração de regras de negócio antigas e configuração de novos pipelines no CI/CD.

O dia do deploy chega. O tráfego bate no novo serviço compilado em Go.

E a latência média continua em 1.5 segundo.

A matemática do I/O-bound: onde o tempo realmente passa

Existe uma ilusão generalizada na nossa área de que a velocidade de execução da linguagem de programação define o tempo de resposta das aplicações web. Isso é verdade para quem constrói transcodificadores de vídeo, compiladores, bancos de dados ou engines gráficas. Mas para 95% dos sistemas comerciais, APIs REST e plataformas B2B, a realidade da CPU é irrelevante.

Pense na anatomia de uma requisição HTTP típica em um endpoint de listagem de pedidos:

  1. O framework recebe a requisição, valida rotas e faz parse do JSON de entrada: 3 ms.
  2. O código valida permissões de usuário e regras de negócio simples: 2 ms.
  3. A aplicação dispara uma query no PostgreSQL e aguarda os dados voltarem pelo socket: 790 ms.
  4. O código pega as linhas retornadas, monta o payload e serializa o JSON: 5 ms.

O tempo total de ponta a ponta foi de 800 ms.

Desses 800 ms, quanto tempo o processo passou executando código de fato na CPU da máquina? Exatamente 10 ms. Os outros 790 ms foram gastos com o processo ocioso, em estado de espera de I/O, aguardando os pacotes TCP voltarem do banco de dados.

Se você trocar PHP, Python ou Node por Go, você vai transformar aqueles 10 ms de CPU em 0.5 ms. Parabéns: seu endpoint agora leva 790.5 ms para responder em vez de 800 ms.

Você torrou meio ano de trabalho do time para economizar 9.5 milissegundos em uma chamada onde o usuário passa quase um segundo olhando para a tela esperando a resposta.

A linguagem compilada só fez o seu backend esperar a resposta do banco de dados com muito mais eficiência e tipagem estática.

A query assassina e a ilusão do ORM

Se a linguagem não é o gargalo, por que o sistema engasga sob carga? Quase sempre a resposta está em modelagem ingênua, ausência de índices adequados e abstrações perigosas criadas por ORMs.

No controller, o código parece inofensivo. No Laravel Eloquent ou no Prisma, escreve-se algo limpo e legível:

$orders = Order::query()
    ->where('customer_id', $customerId)
    ->where('status', 'pending')
    ->orderByDesc('created_at')
    ->limit(20)
    ->get();

Essa linha passa lisa no code review. Não tem nada de errado à primeira vista. Mas por baixo dos panos, o SQL gerado é este:

SELECT id, customer_id, amount, status, created_at 
FROM orders 
WHERE customer_id = 'c1a2b3c4-0000-0000-0000-000000000001' 
  AND status = 'pending' 
ORDER BY created_at DESC 
LIMIT 20;

Se a tabela tem 6 milhões de linhas e possui apenas um índice simples na coluna customer_id, o Postgres sofre. Rodando um EXPLAIN (ANALYZE, BUFFERS) nessa consulta com um cliente que possui histórico volumoso, a autópsia fica visível:

Limit  (cost=24150.12..24150.17 rows=20 width=72) (actual time=812.450..812.456 rows=20 loops=1)
  Buffers: shared hit=412 read=18920
  ->  Sort  (cost=24150.12..24175.80 rows=10272 width=72) (actual time=812.448..812.451 rows=20 loops=1)
        Sort Key: created_at DESC
        Sort Method: top-N heapsort  Memory: 27kB
        Buffers: shared hit=412 read=18920
        ->  Bitmap Heap Scan on orders  (cost=210.45..23820.10 rows=10272 width=72) (actual time=14.200..795.120 rows=125000 loops=1)
              Recheck Cond: (customer_id = 'c1a2b3c4-0000-0000-0000-000000000001'::uuid)
              Filter: (status = 'pending'::text)
              Rows Removed by Filter: 110000
              Buffers: shared hit=412 read=18920
              ->  Bitmap Index Scan on idx_orders_customer_id  (cost=0.00..207.88 rows=10272 width=0) (actual time=12.100..12.100 rows=125000 loops=1)
                    Index Cond: (customer_id = 'c1a2b3c4-0000-0000-0000-000000000001'::uuid)
                    Buffers: shared hit=28
Planning Time: 0.280 ms
Execution Time: 812.510 ms

O Postgres precisou de mais de 800 milissegundos para executar a consulta. O campo Buffers: shared hit=412 read=18920 entrega o crime: foram quase 19 mil blocos lidos do disco (cerca de 150 MB) e mais de 110 mil linhas filtradas em memória que compartilhavam o mesmo customer_id, mas não tinham o status pending. Para completar, o banco ainda precisou ordenar tudo por created_at.

Se você executar essa query pelo Eloquent, pelo Prisma, pelo GORM ou pelo driver nativo database/sql do Go, o tempo de execução no banco será o mesmo: 812 milissegundos. O Postgres não sabe e não se importa com qual linguagem abriu o socket TCP.

Agora, aplique a correção que deveria ter sido feita antes de qualquer reunião de arquitetura:

CREATE INDEX CONCURRENTLY idx_orders_customer_status_created 
ON orders (customer_id, status, created_at DESC);

Executando o mesmo EXPLAIN (ANALYZE, BUFFERS) com o índice composto:

Limit  (cost=0.43..28.10 rows=20 width=72) (actual time=0.045..0.068 rows=20 loops=1)
  Buffers: shared hit=4
  ->  Index Scan using idx_orders_customer_status_created on orders  (cost=0.43..14210.00 rows=10272 width=72) (actual time=0.044..0.064 rows=20 loops=1)
        Index Cond: ((customer_id = 'c1a2b3c4-0000-0000-0000-000000000001'::uuid) AND (status = 'pending'::text))
        Buffers: shared hit=4
Planning Time: 0.150 ms
Execution Time: 0.088 ms

O tempo caiu de 812 milissegundos para 0.088 milissegundos. A leitura pesada de 150 MB de disco virou uma consulta direta a 4 blocos na memória RAM.

A consulta ficou quase dez mil vezes mais rápida sem mudar uma vírgula no código da aplicação. Nem no PHP, nem no Node, nem no Go.

O mito das 100 mil goroutines e o colapso do pool de conexões

Quem começa com Go costuma se deslumbrar com a concorrência leve. Cada goroutine inicia com um stack de apenas 2 KB, permitindo subir dezenas de milhares delas concorrentemente na mesma máquina sem sufocar a memória RAM.

O perigo começa quando o desenvolvedor acha que pode repassar essa concorrência irrestrita diretamente para o banco de dados relacional.

Em Go, a biblioteca padrão gerencia um pool interno de conexões via sql.DB. Conexões com o PostgreSQL não são goroutines: são processos pesados no sistema operacional. Cada conexão ativa aloca buffers de memória dedicados no Linux e consome locks no catálogo do banco.

Se você configurar seu pool assim:

db, err := sql.Open("postgres", dsn)
if err != nil {
    log.Fatal(err)
}

db.SetMaxOpenConns(25)
db.SetMaxIdleConns(10)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(1 * time.Minute)

E receber 500 requisições simultâneas em goroutines, você terá 500 tarefas rodando, mas apenas 25 conexões reais com o Postgres. As outras 475 goroutines vão ficar bloqueadas aguardando um slot livre no pool.

Se a aplicação estiver rodando queries lentas (como aquela sem índice do exemplo anterior), o pool trava por completo. As goroutines vão acumulando na fila até que o contexto estoure: context deadline exceeded.

Para inspecionar esse gargalo em Go sem adivinhações, basta expor os dados reais do pool:

stats := db.Stats()
fmt.Printf("Abertas: %d | Em uso: %d | Aguardando: %d | Tempo total de espera: %v\n",
    stats.OpenConnections, stats.InUse, stats.WaitCount, stats.WaitDuration)

Se stats.WaitCount e stats.WaitDuration estão crescendo de forma contínua, o seu problema não é falta de poder computacional: é contenção de conexões.

Quando o time não entende isso, o reflexo imediatista é aumentar o SetMaxOpenConns para 300. Multiplique isso por quatro contêineres rodando em paralelo e você terá 1.200 conexões tentando bater em uma instância RDS que aguenta 150 antes de sofrer contenção violenta de CPU por troca de contexto. O resultado: o banco cai por exaustão de recursos.

Ironicamente, em aplicações PHP tradicionais com PHP-FPM, a diretiva pm.max_children atuava como um disjuntor natural para a infraestrutura. Ao migrar para Go sem dominar dimensionamento de pool e controle de concorrência, muitos times conseguem derrubar o banco de dados com muito mais rapidez e eficiência do que antes.

O que rodar antes de cogitar uma reescrita

Antes de abrir uma discussão sobre reescrever código em outra linguagem, consulte o histórico real que o próprio banco de dados registra.

A extensão pg_stat_statements no PostgreSQL é a ferramenta mais objetiva para eliminar achismos em reuniões técnicas. Se ela ainda não estiver habilitada no seu cluster, habilite:

CREATE EXTENSION IF NOT EXISTS pg_stat_statements;

Depois de acumular algumas horas ou dias de tráfego comum de produção, rode esta consulta:

SELECT 
    round((total_exec_time / 1000)::numeric, 2) AS total_seconds,
    calls,
    round((mean_exec_time)::numeric, 2) AS mean_ms,
    query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5;

O resultado costuma revelar dois padrões claros:

  1. Queries com mean_ms altíssimo (ex: 800 ms a 3.000 ms) e poucas chamadas: São as consultas que estão fazendo varredura sequencial em tabelas grandes por falta de índice ou agrupamento inadequado.
  2. Queries com mean_ms baixo (ex: 1 ms), mas com milhões de calls: Esse é o clássico problema de N+1 gerado por loops ingênuos em ORMs, onde a aplicação faz centenas de viagens pela rede para montar uma única tela.

Em ambos os cenários, a linguagem é irrelevante. Consertar a query assassina ou agrupar as chamadas N+1 em um único WHERE IN resolve o problema de performance na hora, sem mexer na infraestrutura.

Onde Go realmente faz sentido (e por que continuo usando)

Afirmar que a reescrita de APIs de CRUD é quase sempre desperdício de dinheiro não significa que Go não tenha seu lugar. Eu continuo escolhendo Go como ferramenta primária em problemas onde os gargalos são genuinamente causados por concorrência ou limitações de runtime:

  1. Conexões de longa duração e tráfego persistente: Servidores de WebSocket, proxies reversos, gateways gRPC e brokers de telemetria IoT. Manter 50 mil conexões TCP ativas em PHP ou Python exige um consumo inviável de processos e memória. Em Go, o runtime gerencia esse volume consumindo poucas centenas de megabytes.
  2. Processamento concorrente e pipelines de streaming: Sistemas que precisam consumir filas em lotes paralelos, realizar computação em CPU, decodificar formatos binários ou processar arquivos pesados sem despejar tudo em disco.
  3. Serviços com restrição severa de footprint: Microsserviços e daemons de infraestrutura que rodam em contêineres com limites rígidos de memória (64 MB a 128 MB) e precisam inicializar em frações de segundo.
  4. Ferramentas de linha de comando (CLIs): A facilidade de compilar um único binário estático sem depender de interpretadores ou dependências instaladas no host é imbatível para automação e ferramentas internas.

Se o seu sistema se encaixa em algum desses casos, Go é uma escolha técnica fantástica. Mas se o seu sistema é uma API de CRUD que recebe requisições HTTP, consulta o Postgres e cospe um JSON para o cliente, a linguagem de programação raramente é a culpada pela lentidão.

Resumo da ópera

Trocar de linguagem de programação para tentar resolver latência provocada por I/O e consultas mal modeladas é o equivalente na engenharia a comprar uma Ferrari para andar no engarrafamento da Marginal Tietê. O motor pode ser capaz de girar a 8.000 rotações por minuto com tecnologia de ponta, mas você vai continuar parado exatamente atrás do mesmo caminhão.

Reescritas completas têm custos altíssimos, introduzem regressões sutis em regras de negócio que já estavam consolidadas e alimentam o ego técnico do time à custa da saúde financeira da empresa.

Antes de queimar seis meses de orçamento de engenharia para reescrever um serviço em Go, abra o APM, estude o EXPLAIN (ANALYZE, BUFFERS) das suas consultas mais frequentes e analise seu banco de dados. Na imensa maioria dos casos, o seu gargalo não é a linguagem. É engenharia básica de dados.

Gostou deste artigo? Compartilhe com outros desenvolvedores ou na sua rede.