Ack modes e commit de offset

TL;DR

O Spring Kafka desativa enable.auto.commit e assume o controle do commit de offset via AckMode. Os modos vão de automático-por-registro (RECORD) até totalmente manual (MANUAL_IMMEDIATE). A regra de ouro do at-least-once: processe primeiro, commite depois.

O que é

Offset é a posição de leitura de um consumer dentro de uma partição. Commit de offset é o ato de registrar no Kafka até qual mensagem o consumer já processou — equivale a dizer “processei até aqui, na próxima reinicialização pode começar do próximo”.

O Spring Kafka expõe esse controle através da enum AckMode, configurada no container do listener (ConcurrentKafkaListenerContainerFactory). Cada modo define quando o commit acontece.

Por que importa

Sem controle explícito de offset, o comportamento do consumer em caso de falha é imprevisível. Commitar cedo demais causa perda de mensagens (at-most-once); commitar tarde demais ou nunca causa reprocessamento (at-least-once). A escolha do AckMode é a principal alavanca para definir a semântica de entrega da sua aplicação.

Além disso, o enable.auto.commit nativo do Kafka commita em background a cada auto.commit.interval.ms — completamente alheio ao estado do processamento. Por isso o Spring Kafka o desativa por padrão (desde a versão 2.3) para assumir o controle.

Como funciona

O que é commit de offset

Cada partição Kafka mantém um cursor por consumer group. Quando o consumer chama commitSync() ou commitAsync(), ele grava no tópico interno __consumer_offsets a posição do próximo registro a ser lido.

Se o consumer reinicia antes de commitar, ele relê a partir do último offset commitado — podendo reprocessar mensagens já tratadas. Esse é o custo do at-least-once: duplicatas são possíveis, mas perdas não.

Quer entender como o Kafka armazena offsets internamente?

Veja Consumer Offsets (infra) — este documento foca no controle via Spring.

AckMode no Spring

O Spring Kafka oferece sete modos de acknowledgment. A tabela abaixo usa como referência a documentação oficial (Spring Kafka 4.1):

AckModeQuando commita
RECORDImediatamente após cada registro ser processado
BATCHApós todos os registros de um poll() serem processados (padrão)
TIMEApós o processamento, se ackTime ms já se passou desde o último commit
COUNTApós o processamento, se ackCount registros foram recebidos desde o último commit
COUNT_TIMESe qualquer condição de TIME ou COUNT for satisfeita
MANUALO listener chama acknowledge() manualmente; semântica equivalente ao BATCH
MANUAL_IMMEDIATEO commit ocorre imediatamente quando acknowledge() é chamado

Por que o Spring desativa o auto-commit?

A partir da versão 2.3, o Spring Kafka define enable.auto.commit=false incondicionalmente — a menos que você o sobrescreva explicitamente no ConsumerFactory. Isso é intencional: o auto-commit do Kafka roda em uma thread separada, sem conhecimento do estado do processamento, e tornaria todos os modos acima ineficazes.

At-least-once na prática

A regra central é: execute o processamento antes de commitar o offset.

poll() → processa registro → commit offset

Se a aplicação crashar entre o poll e o commit, o registro será relido no próximo reinício — gerando uma duplicata, mas nunca uma perda. Isso é at-least-once.

Commit síncrono vs assíncrono (syncCommits, padrão true):

  • Síncrono: bloqueia até o broker confirmar. Mais lento, mas garante que o commit foi efetivado antes de continuar.
  • Assíncrono: dispara o commit e segue em frente. Mais rápido, mas uma falha antes da confirmação pode reprocessar registros que pareciam commitados. Use commitCallback para tratar erros assíncronos.

Na prática

Configuração com AckMode.MANUAL_IMMEDIATE

# application.yml
spring:
  kafka:
    listener:
      ack-mode: manual_immediate
    consumer:
      group-id: meu-grupo
      auto-offset-reset: earliest
      # enable-auto-commit NÃO deve ser definido aqui — Spring gerencia
// Configuração do container factory
@Bean
public ConcurrentKafkaListenerContainerFactory<String, PedidoEvent> kafkaListenerContainerFactory(
        ConsumerFactory<String, PedidoEvent> consumerFactory) {
 
    var factory = new ConcurrentKafkaListenerContainerFactory<String, PedidoEvent>();
    factory.setConsumerFactory(consumerFactory);
    factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
    return factory;
}
// Listener com acknowledgment manual
@KafkaListener(topics = "pedidos", groupId = "meu-grupo")
public void processar(PedidoEvent evento, Acknowledgment ack) {
    try {
        processarPedido(evento);  // processa ANTES
        ack.acknowledge();        // commita DEPOIS
    } catch (Exception e) {
        // NÃO chama ack.acknowledge() — offset não é commitado
        // O registro será relido na próxima reinicialização
        log.error("Falha ao processar pedido {}", evento.id(), e);
        throw e;
    }
}

Interface AcknowledgingMessageListener

Para usar Acknowledgment, o listener deve receber o parâmetro na assinatura do método. O Spring injeta automaticamente quando o AckMode é MANUAL ou MANUAL_IMMEDIATE.

Quando usar cada modo

CenárioModo recomendado
Processamento simples, baixa criticidadeBATCH (padrão)
Controle fino por registro, sem transaçãoMANUAL_IMMEDIATE
Processamento em lote com commit ao fimMANUAL
Reduzir overhead de commits em alto volumeCOUNT ou TIME

Armadilhas

(1) enable.auto.commit ativado com processamento assíncrono

Se você sobrescrever enable.auto.commit=true no ConsumerFactory, o Kafka committará offsets em background a cada auto.commit.interval.ms — independentemente de o processamento ter terminado. Se o processamento for assíncrono (ex.: envio a outro serviço), o offset pode ser commitado antes da confirmação de entrega, quebrando a garantia de at-least-once silenciosamente.

Regra

Nunca ative enable.auto.commit quando o processamento tiver efeitos colaterais externos (banco, HTTP, outro tópico). Deixe o Spring gerenciar.

(2) Commitar ANTES de processar — at-most-once acidental

// ERRADO — at-most-once: se processarPedido() lançar exceção, a mensagem é perdida
@KafkaListener(topics = "pedidos")
public void processar(PedidoEvent evento, Acknowledgment ack) {
    ack.acknowledge();        // commit ANTES
    processarPedido(evento);  // processa DEPOIS — se crashar, perda garantida
}

Commitar antes de processar transforma at-least-once em at-most-once: uma falha pós-commit significa que o registro nunca será relido. Em sistemas financeiros ou de auditoria, isso equivale a perda de dados.

(3) AckMode.MANUAL sem chamar acknowledge()

// ERRADO — reprocesso infinito
@KafkaListener(topics = "pedidos")
public void processar(PedidoEvent evento, Acknowledgment ack) {
    processarPedido(evento);
    // ack.acknowledge() esquecido!
    // O offset nunca é commitado — na reinicialização, relê do início
}

Com MANUAL ou MANUAL_IMMEDIATE, esquecer de chamar acknowledge() faz com que o consumer nunca avance o offset. No reinício, ele relê todas as mensagens desde o último commit gravado. Em ambientes com retenção longa, isso pode significar reprocessar dias de histórico.

Em entrevista

Frase pronta (inglês)

  • “Spring Kafka takes over offset management by setting enable.auto.commit to false and exposing the AckMode abstraction.”
  • “With MANUAL_IMMEDIATE, the offset is committed right when acknowledge() is called, giving fine-grained control at the record level.”
  • “The golden rule for at-least-once is: process first, commit after — never the other way around.”
  • “Committing before processing accidentally turns at-least-once into at-most-once, which means data loss on crashes.”

Vocabulário

TermoSignificado resumido
AckModeEnum que controla quando o Spring commita offsets
enable.auto.commitPropriedade nativa do Kafka que o Spring desativa
at-least-onceSemântica que tolera duplicatas mas não perdas
at-most-onceSemântica que tolera perdas mas não duplicatas
AcknowledgmentInterface Spring para commit manual de offset
syncCommitsFlag que controla se o commit bloqueia até confirmação
__consumer_offsetsTópico interno do Kafka que armazena offsets commitados

Veja também

Referências