Melhorando a Latência ao fazer tratamento de JSON (Marshalling)
No universo das transações financeiras, cada milissegundo conta. Em um sistema de pagamentos instantâneos como o Pix, a velocidade não é um luxo, é o núcleo do serviço. Quando um cliente aponta a câmera para um QR Code, a expectativa é uma: confirmação imediata. Uma demora de poucos segundos pode significar uma venda perdida ou a frustração do usuário.
Diariamente encontramos desafios que podem impactar nossos microsserviços ao processar um alto volume de requisições. Quando lidamos com JSON podemos ter picos de latência e dependendo do contexto isso pode ameaçar o Acordo de Nível de Serviço (SLA). Uma pequena mudança que não é uma bala de prata, mas pode auxiliar esses serviços está em uma otimização elegante utilizando a biblioteca padrão do Go.
Hoje vou detalhar como uma mudança de poucas linhas no tratamento de JSON (Marshalling) irá permitir seu serviço de suportar uma carga maior, reduzir a latência e garantir a estabilidade do sistema, mesmo em horários de pico.
Cenário fictício: Uma API de pagamentos Pix sob pressão
Imagine um microsserviço em Go que orquestra o processo de pagamento. Ele recebe uma requisição, valida o token de autenticação do lojista, consulta dados do usuário e da transação em um banco de dados ou cache (Redis), e por fim, gera uma resposta em formato JSON com os detalhes para a confirmação do pagamento.
O fluxo é comum, mas o volume é extremo. Em horários de pico, a aplicação processava milhares de solicitações por segundo. O sintoma era claro: o tempo de resposta começava a subir perigosamente, se aproximando do limite de SLA.
Uma análise com as ferramentas de profiling do Go (pprof
e go tool trace
) revelou um culpado inesperado: a serialização de JSON.
Nosso handler
HTTP parecia simples e idiomático:
// Versão Inicial - Ineficiente sob alta carga
func ProcessPixPaymentHandler(w http.ResponseWriter, r *http.Request) {
// 1. Validações e busca de dados (banco, Redis, etc.)
transactionData := getTransactionData(r)
// 2. Monta uma estrutura de resposta complexa
response := PixResponse{
TransactionID: transactionData.ID,
Status: "CONFIRMED",
Details: buildComplexDetailsStruct(transactionData), // Estrutura com muitos dados
}
// 3. Define o cabeçalho e codifica a resposta diretamente no ResponseWriter
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response) // <-- O gargalo está aqui!
}
Embora funcional, a linha json.NewEncoder(w).Encode(response)
estava causando um grande número de alocações de memória a cada chamada. Sob alta concorrência, isso gerava uma imensa pressão sobre o Garbage Collector (GC), o coletor de lixo do Go. O GC precisava pausar a execução da nossa aplicação com mais frequência para limpar a memória, adicionando preciosos milissegundos de latência a cada requisição.
A solução: Reciclando Buffers com sync.Pool
A dica que quero dar é utilizar uma estratégia bem simples: em vez de permitir que a biblioteca json
alocasse um novo buffer de memória para cada resposta, por que não reutilizar buffers que já foram usados? É como levar uma sacola reutilizável ao supermercado em vez de pegar uma nova a cada compra.
Para isso, usamos o sync.Pool
, um recurso do Go projetado exatamente para este tipo de otimização: armazenar e reutilizar objetos temporários de curta duração.
Veja a versão otimizada do nosso handler
:
// Pool de buffers para reutilização em toda a aplicação.
var bufferPool = sync.Pool{
New: func() interface{} {
return new(bytes.Buffer)
},
}
// Versão Otimizada
func ProcessPixPaymentHandler(w http.ResponseWriter, r *http.Request) {
transactionData := getTransactionData(r)
response := PixResponse{
TransactionID: transactionData.ID,
Status: "CONFIRMED",
Details: buildComplexDetailsStruct(transactionData),
}
// 1. Pega um buffer "reciclado" do pool.
buf := bufferPool.Get().(*bytes.Buffer)
buf.Reset() // Limpa o buffer para garantir que não tenha dados antigos.
defer bufferPool.Put(buf) // Devolve o buffer ao pool ao final da função.
// 2. Codifica a resposta JSON no nosso buffer reutilizável, não diretamente na resposta HTTP.
err := json.NewEncoder(buf).Encode(response)
if err != nil {
http.Error(w, "Erro ao codificar a resposta.", http.StatusInternalServerError)
return
}
// 3. Escreve o resultado do buffer para a resposta HTTP de uma só vez.
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write(buf.Bytes())
}
Essa pequena alteração pode gerar grandes impactos:
- Menos Alocações: Reduz-se drasticamente a quantidade de memória alocada por requisição.
- Menos Pressão no GC: Com menos lixo para coletar, o Garbage Collector roda com menos frequência e por menos tempo.
- Menos Pausas: A aplicação sofre menos “pausas”, resultando em uma latência mais baixa e consistente.
- Respostas Mais Rápidas: O resultado final normalmente (veja abaixo) é uma redução na latência do endpoint nos momentos de maior tráfego.
Visualizando a mudança na arquitetura
A mudança no fluxo de processamento pode ser resumida assim:
Antes: Requisição -> Alocar Novo Buffer de Memória -> Serializar JSON -> Coleta pelo GC (pausa) -> Escrever Resposta -> Fim
Depois:
Requisição -> Pegar Buffer do sync.Pool
-> Serializar JSON -> Escrever Resposta -> Devolver Buffer ao sync.Pool
-> Fim
Por que isso funciona tão bem?
sync.Pool
é otimizado para objetos de curta duração: Ele não é um cache genérico. Cada processador (P
no agendador do Go) mantém seu próprio pool local de objetos. Isso significa que goroutines rodando em processadores diferentes podem pegar e devolver objetos sem disputar travas (locks), tornando a operação extremamente rápida.- Buffers e JSON: Uma combinação perfeita: O
json.Encoder
do Go foi projetado para escrever em qualquerio.Writer
. Umbytes.Buffer
é umio.Writer
em memória, tornando-o o candidato ideal para essa técnica. Reutilizá-lo evita a alocação de memória e a sobrecarga da coleta de lixo. - Código amigo do GC é código amigo da Latência: O GC do Go é incrivelmente rápido, mas não é gratuito. Em sistemas de alta performance e baixa latência, como em um gateway de pagamentos, minimizar o trabalho do GC é fundamental para garantir a previsibilidade do tempo de resposta.
Quando NÃO Usar sync.Pool
?
Apesar de poderosa, essa técnica não é uma bala de prata. Use sync.Pool
apenas quando:
- O objeto tem vida curta: Perfeito para o escopo de uma única requisição HTTP, como um buffer.
- A criação do objeto é cara o suficiente: Justifica o custo de reutilizá-lo (buffers, estruturas grandes, etc.).
- O ciclo de vida é controlado: Você deve garantir que não existam referências ao objeto depois que ele for devolvido ao pool.
Evite usá-lo para:
- Conexões de banco de dados ou estado de longa duração.
- Objetos que são compartilhados de forma insegura entre goroutines.
E a regra de ouro: sempre chame Reset()
no objeto antes de reutilizá-lo. No nosso caso, buf.Reset()
garante que a resposta de um cliente não vaze para o próximo. Lembre-se sempre das 3 regras abaixo e será bem sucedido:
- Medir antes, medir depois: Ferramentas como o
pprof
não são opcionais; são essenciais para encontrar gargalos reais em vez de otimizar prematuramente. - Latência e GC andam de mãos dadas: Em sistemas sensíveis à latência, escrever código que aloca menos memória é uma das otimizações mais eficazes.
sync.Pool
é uma joia escondida: Para tarefas de serialização, I/O em memória e outros casos de uso com objetos temporários, é uma ferramenta incrivelmente poderosa.