Malka (Daniel Lemos) / Melhorando Latência no tratamento de JSON

Publicado em Tue, 22 Jul 2025 10:00:48 -0300 Atualizado em Wed, 23 Jul 2025 17:13:47 -0300

Imagem de um Livro aberto em cima de uma placa de chips - Gerado por IA

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?

  1. 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.
  2. Buffers e JSON: Uma combinação perfeita: O json.Encoder do Go foi projetado para escrever em qualquer io.Writer. Um bytes.Buffer é um io.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.
  3. 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:

  1. 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.
  2. 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.
  3. 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.