backendperformance

ETag no Lima: por que o timestamp do envelope matava o cache

· 5 min de leitura

Aqui na MoWave One a gente vive otimizando a performance do app Lima. Queremos uma experiência rápida, que responde na hora. Uma parte importante desse trabalho é o cache condicional de HTTP. O plano era implementar o mecanismo de ETag com If-None-Match nos nossos endpoints GET mais quentes: /dashboard, /insights, /subscription e /entitlements. São rotas acessadas o tempo todo, então devolver um 304 Not Modified quando o cache do cliente ainda está válido corta bastante tráfego de rede e carga no servidor.

O problema: cache falhando em silêncio

Quando ativamos o cache condicional, esperávamos ver uma queda visível nas respostas 200 OK completas em requisições repetidas para esses caminhos quentes. A ideia era simples: se o header If-None-Match do cliente trouxesse um ETag igual ao estado atual do servidor, devolveríamos 304 Not Modified, avisando que a resposta em cache continuava válida. Só que o monitoramento mostrou outra coisa. Respostas 304 praticamente não existiam. A camada de cache HTTP estava ligada, mas era peso morto, falhando em silêncio sem entregar benefício nenhum. Toda requisição, mesmo para dados que não tinham mudado, voltava como 200 OK com o payload inteiro, como se o cache nunca tivesse sido habilitado.

A causa técnica: um timestamp no envelope

Para entender o motivo, precisamos olhar como as respostas da nossa API são estruturadas e como os ETags eram gerados. A API do Lima envelopa toda resposta num formato padrão: {success, data, timestamp}. O campo data carrega o payload de negócio de verdade, success é um boolean e timestamp recebe Instant.now() em cada resposta. Por natureza, esse timestamp é único por requisição: ele marca o momento exato em que a resposta foi gerada.

Nossa primeira abordagem usava o ShallowEtagHeaderFilter, que já vem no Spring. Esse filtro, como o nome entrega e a implementação confirma, calcula o hash do corpo inteiro da resposta. Como o timestamp do envelope mudava a cada requisição, o hash do corpo inteiro também mudava sempre. Resultado: o ETag gerado nunca se repetia entre duas requisições consecutivas, mesmo com o data idêntico. O If-None-Match enviado pelo cliente jamais batia com o ETag recém-gerado no servidor, e o 304 Not Modified nunca disparava. O filtro era peso morto porque estava incluindo no hash informação volátil que não fazia parte do payload.

A correção: deep ETag e serialização canônica

A solução pedia algo mais fino: um “deep ETag” que ignorasse de propósito o campo efêmero timestamp. Criamos um componente auxiliar, o HttpConditional, responsável por calcular esse deep ETag. A ideia central é gerar o ETag apenas a partir do payload de data. Assim, enquanto o dado de negócio não mudar, o ETag permanece o mesmo.

O deep ETag é um hash SHA-256 calculado só sobre o payload de data. Para garantir consistência, principalmente em estruturas de dados mais complexas, forçamos serialização canônica: nosso ObjectWriter usa a feature ORDER_MAP_ENTRIES_BY_KEYS. Isso é crítico em endpoints como /entitlements, onde o data é um Map. Sem essa configuração, a ordem de iteração das entradas do map durante a serialização pode variar entre execuções da JVM, ou até dentro da mesma execução, produzindo bytes diferentes e, portanto, hashes diferentes, mesmo com o conteúdo lógico idêntico. Com a ordenação por chave, o mesmo payload sempre gera exatamente o mesmo ETag.

Além disso, o ETag tem namespace por usuário: o escopo dele é o userId. Esse detalhe importa porque evita o cenário em que duas contas diferentes acabam, por coincidência, com payloads de data byte a byte idênticos, o que poderia causar colisão de ETag entre usuários. O 304 Not Modified em si é devolvido direto no controller, depois que o HttpConditional calcula o ETag e valida contra o If-None-Match do cliente.

Um esboço conceitual de como o deep ETag é gerado:

// Simplified conceptual deep ETag generation within our HttpConditional helper
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import java.security.MessageDigest;
import java.util.Base64;

public class HttpConditional {

    private final ObjectMapper mapper;

    public HttpConditional() {
        this.mapper = new ObjectMapper();
        // Ensure consistent serialization for hashing complex objects like Maps
        this.mapper.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
    }

    public String generateDeepEtag(Object dataPayload, String userId) {
        try {
            // Serialize *only* the data payload into a canonical byte array
            byte[] payloadBytes = mapper.writer().writeValueAsBytes(dataPayload);

            // Compute SHA-256 hash of the canonical payload bytes
            MessageDigest digest = MessageDigest.getInstance("SHA-256");
            byte[] hash = digest.digest(payloadBytes);
            String base64Hash = Base64.getUrlEncoder().withoutPadding().encodeToString(hash);

            // Namespace the ETag per user to prevent cross-user collisions.
            // Using a weak ETag (W/) prefix as per RFC 7232 is often a good practice
            // for content that might have subtle, acceptable variations.
            return String.format("W/\"%s-%s\"", userId, base64Hash);
        } catch (Exception e) {
            // Log error, return null or throw a specific exception for handling upstream
            System.err.println("Error generating deep ETag: " + e.getMessage());
            return null;
        }
    }
}

Essa correção, combinada com o server.compression (gzip) que já tínhamos, derrubou drasticamente os bytes trafegados nesses caminhos quentes. Quando o servidor devolve 304 Not Modified, o corpo da resposta é vazio: zero bytes de payload. E o ganho é amplificado pelo fato de que a resposta 200 OK original já viria comprimida com gzip. Essa combinação entrega ganhos de performance bem perceptíveis para quem usa o Lima.

Principais lições

A experiência com o cache da API do Lima deixou uma lição clara: um filtro genérico de cache, como o ShallowEtagHeaderFilter do Spring, opera num nível que não entende nem se adapta ao design do envelope de resposta da sua aplicação. Se toda resposta carrega um timestamp fresco ou metadado volátil parecido, calcular o hash do corpo inteiro inviabiliza o cache HTTP. Concentre a geração do ETag no payload estável, o que interessa para o negócio. Serialização canônica é essencial para tipos de dados complexos gerarem hashes consistentes, e namespace por usuário adiciona uma camada importante de correção e prevenção de colisões. Quando o wrapper carrega informação dinâmica que não é payload, faça o hash do payload, não do wrapper.