Kafka producer: acks, idempotencia y batching
1. Descripción general
Enviar un mensaje a Kafka requiere una línea de send(). La parte difícil viene con las preguntas que la acompañan: ¿el mensaje realmente se ha guardado?, ¿se duplica al hacer reintentos?, ¿cómo enviar decenas de miles de mensajes por segundo sin sobrecargar el broker?
Este artículo cubre tres grupos de configuración que responden esas preguntas: acks, enable.idempotence y batching (batch.size, linger.ms, compression.type). Después de leerlo, sabrás cómo configurar un productor que sea seguro y rápido, tanto con Kafka client puro como con Spring Boot 3.x, y cómo probarlo.
2. Configuración
Con Java puro, solo necesitas kafka-clients:
<dependency>
<groupId>org.apache.kafka</groupId>
<artifactId>kafka-clients</artifactId>
<version>3.9.0</version>
</dependency>
Con Spring Boot 3.x, usa spring-kafka. La versión es gestionada por Spring Boot, y ya trae kafka-clients:
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
A lo largo del artículo, enviaremos eventos de pedidos:
public record Order(String id, String customerId, BigDecimal total) {
public String toJson() {
return """
{"id":"%s","customerId":"%s","total":%s}""".formatted(id, customerId, total);
}
}
3. acks: ¿cuándo se considera que un mensaje está enviado?
acks determina cuántos reconocimientos del broker debe esperar el productor antes de considerar que una solicitud es exitosa.
| Valor | Significado | Riesgo |
|---|---|---|
acks=0 |
No espera nada | Pierdes mensajes sin enterarte |
acks=1 |
Espera que el líder escriba | Pierdes mensajes si el líder falla antes de que el seguidor copie |
acks=all (-1) |
Espera que todas las réplicas en el ISR escriban | Latencia ligeramente mayor |
Ejemplo de configuración con client puro:
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.ACKS_CONFIG, "all");
try (var producer = new KafkaProducer<String, String>(props)) {
var order = new Order("o-1001", "c-42", new BigDecimal("250000"));
RecordMetadata meta = producer
.send(new ProducerRecord<>("orders", order.customerId(), order.toJson()))
.get();
System.out.printf("partition=%d offset=%d%n", meta.partition(), meta.offset());
}
Llamar a .get() convierte el envío en síncrono. Lo hacemos aquí solo para imprimir el resultado. Cuando acks=all, get() solo retorna después de que todas las réplicas en el ISR han escrito:
partition=2 offset=17
Usamos customerId como clave, así todos los pedidos de un mismo cliente van a la misma partición y mantienen el orden.
3.1. acks=all no es suficiente sin min.insync.replicas
acks=all significa “todas las réplicas actualmente en el ISR”. Supón que el ISR se reduce a solo el líder. Entonces acks=all no es diferente a acks=1. Necesitas configurar también en el topic o broker:
kafka-topics.sh --bootstrap-server localhost:9092 --create --topic orders \
--partitions 6 --replication-factor 3 \
--config min.insync.replicas=2
Con replication-factor=3 y min.insync.replicas=2, cada escritura debe tener al menos 2 copias. Si el ISR cae por debajo de 2, el productor recibe NotEnoughReplicasException en lugar de escribir silenciosamente una única copia.
Nota: No establezca
min.insync.replicasigual areplication-factor. Con eso, un simple reinicio de broker hace que el topic rechace todas las escrituras.
4. Idempotencia: reintenta sin duplicar
acks=all conlleva reintento. Considera este escenario: el broker escribió el mensaje pero la respuesta se pierde en el camino. El productor cree que falló y reenvía, quedando el mensaje escrito dos veces.
El productor idempotente resuelve exactamente este escenario. Cuando activas enable.idempotence=true:
- El broker asigna un ID de productor (PID) al productor.
- Cada lote enviado a una partición lleva un número de secuencia que aumenta.
- El broker descarta lotes con secuencias que ya ha visto, y rechaza lotes que salten la secuencia. Así se mantiene el orden aunque haya múltiples solicitudes en vuelo.
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
props.put(ProducerConfig.ACKS_CONFIG, "all");
props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5);
props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
props.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, 120_000);
Significado de cada línea:
ENABLE_IDEMPOTENCE_CONFIGactiva el mecanismo de PID y número de secuencia.- La idempotencia fuerza
acks=all. Si establecesacks=1junto conenable.idempotence=trueexplícitamente, el productor lanzaConfigExceptional inicializar. max.in.flight.requests.per.connectiondebe ser ≤ 5. Dentro de este límite, Kafka sigue garantizando el orden dentro de la partición.retriesdebe ser muy grande. El tiempo real de reintentos está limitado pordelivery.timeout.ms(2 minutos por defecto).
Desde Kafka 3.0, acks=all y enable.idempotence=true son el defecto. Aun así, deberías declararlos explícitamente. Si no lo haces, basta que alguien establezca acks=1 para que la idempotencia se desactive silenciosamente.
4.1. Lo que la idempotencia no puede hacer
Esta es la parte que frecuentemente se malentiende:
- Solo evita duplicados dentro de una sesión de productor, por partición. Si la aplicación se reinicia, el productor recibe un PID nuevo. Mensajes reenviados después del reinicio se duplicarán.
- No evita duplicados causados por tu código llamando
send()dos veces, por ejemplo cuando haces reintentos manuales en uncatch. - No garantiza escritura atómica en múltiples particiones.
Esos casos requieren transacciones (transactional.id) o consumidores que puedan deduplicar según orderId.
5. Batching: intercambiar latencia por throughput
El productor no envía mensaje a mensaje. send() solo coloca el registro en un buffer en memoria, organizado por partición. Un thread de fondo (sender) agrupa registros en lotes y los envía. Tres parámetros principales:
| Parámetro | Defecto | Rol |
|---|---|---|
batch.size |
16384 bytes | Tamaño máximo de un lote para una partición |
linger.ms |
0 (5 desde Kafka 4.0) | Tiempo de espera adicional para llenar el lote |
compression.type |
none |
Comprime el lote: gzip, snappy, lz4, zstd |
buffer.memory |
32 MB | Total de memoria para todos los lotes en espera |
Un lote se envía cuando alcanza batch.size o cuando expira linger.ms, lo que ocurra primero. Con linger.ms=0, los lotes casi siempre se envían inmediatamente, así que típicamente contienen solo unos pocos registros.
props.put(ProducerConfig.BATCH_SIZE_CONFIG, 64 * 1024);
props.put(ProducerConfig.LINGER_MS_CONFIG, 10);
props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4");
Esta configuración acepta hasta 10 ms de latencia adicional. A cambio, cada solicitud lleva 64 KB de datos comprimidos. Con datos JSON repetitivos como eventos de pedidos, lz4 o zstd típicamente reducen notablemente el ancho de banda de red y espacio en disco del broker. Comprimir por lotes es mucho más eficiente que comprimir mensaje a mensaje, porque lotes más grandes tienen más patrones para explotar.
Puedes verificarlo con las métricas del productor:
producer.metrics().forEach((name, metric) -> {
if (name.name().equals("batch-size-avg") || name.name().equals("records-per-request-avg")) {
System.out.println(name.name() + " = " + metric.metricValue());
}
});
Envía 10.000 pedidos seguidos con linger.ms=0, luego repite con linger.ms=10, y verás cómo records-per-request-avg aumenta notablemente:
# linger.ms=0
batch-size-avg = 1830.4
records-per-request-avg = 21.7
# linger.ms=10
batch-size-avg = 41022.9
records-per-request-avg = 486.2
Los números exactos dependen de tu máquina y carga, pero la tendencia siempre es así.
Nota: Si el buffer se llena (no por que el consumidor sea lento, sino porque el broker es lento o la red está congestionada),
send()bloqueará hastamax.block.ms(60 segundos por defecto) y luego lanzaráTimeoutException. No llamessend()en el thread que maneja requests HTTP sin considerar esta posibilidad.
Nota:
delivery.timeout.msdebe ser ≥linger.ms + request.timeout.ms. Si no, el productor rechaza inicializar.
6. Configuración en Spring Boot 3.x
Spring Boot tiene propiedades propias para parámetros comunes. Los demás van a través de properties:
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.apache.kafka.common.serialization.StringSerializer
acks: all
batch-size: 64KB
compression-type: lz4
buffer-memory: 64MB
properties:
enable.idempotence: true
linger.ms: 10
max.in.flight.requests.per.connection: 5
delivery.timeout.ms: 120000
El publicador usa KafkaTemplate. Desde Spring Kafka 3.0, send() retorna CompletableFuture:
@Service
public class OrderEventPublisher {
private static final Logger log = LoggerFactory.getLogger(OrderEventPublisher.class);
private final KafkaTemplate<String, String> kafkaTemplate;
public OrderEventPublisher(KafkaTemplate<String, String> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public CompletableFuture<SendResult<String, String>> publish(Order order) {
return kafkaTemplate.send("orders", order.customerId(), order.toJson())
.whenComplete((result, ex) -> {
if (ex != null) {
log.error("Fallo al enviar pedido {}", order.id(), ex);
} else {
var meta = result.getRecordMetadata();
log.info("Pedido {} -> partición {} offset {}",
order.id(), meta.partition(), meta.offset());
}
});
}
}
Ten en cuenta tres puntos:
- El callback se ejecuta en el thread sender del productor. No hagas trabajo pesado ni llamadas bloqueantes en
whenComplete, pues ralentizará todos los envíos subsecuentes. - Cuando el callback recibe un error, el productor ya ha reintentado todo dentro del límite de
delivery.timeout.ms. No reintentes de nuevo llamandosend()en el callback. Esa es una fuente de duplicados que la idempotencia no puede detener. - Devolver
CompletableFuturepermite que el llamador decida si esperar o no.
7. Pruebas del comportamiento del productor
kafka-clients proporciona MockProducer para probar sin broker. Para usarlo, extraemos la lógica a una clase que acepte la interfaz Producer:
public class OrderSender {
private final Producer<String, String> producer;
public OrderSender(Producer<String, String> producer) {
this.producer = producer;
}
public Future<RecordMetadata> send(Order order) {
return producer.send(new ProducerRecord<>("orders", order.customerId(), order.toJson()));
}
public static Properties reliableConfig(String bootstrap) {
Properties p = new Properties();
p.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrap);
p.put(ProducerConfig.ACKS_CONFIG, "all");
p.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
p.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5);
p.put(ProducerConfig.LINGER_MS_CONFIG, 10);
p.put(ProducerConfig.BATCH_SIZE_CONFIG, 64 * 1024);
p.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4");
return p;
}
}
Prueba con JUnit 5 y AssertJ:
class OrderSenderTest {
@Test
void whenSendingOrders_thenKeyIsCustomerId() {
var mock = new MockProducer<>(true, new StringSerializer(), new StringSerializer());
var sender = new OrderSender(mock);
sender.send(new Order("o-1", "c-42", new BigDecimal("100")));
sender.send(new Order("o-2", "c-42", new BigDecimal("200")));
assertThat(mock.history())
.hasSize(2)
.extracting(ProducerRecord::key)
.containsOnly("c-42");
}
@Test
void whenBrokerFails_thenFutureCompletesExceptionally() {
var mock = new MockProducer<>(false, new StringSerializer(), new StringSerializer());
var sender = new OrderSender(mock);
Future<RecordMetadata> future = sender.send(new Order("o-3", "c-7", BigDecimal.TEN));
mock.errorNext(new NotEnoughReplicasException("ISR < min.insync.replicas"));
assertThatThrownBy(future::get)
.hasCauseInstanceOf(NotEnoughReplicasException.class);
}
@Test
void reliableConfig_isAccepted() {
var props = OrderSender.reliableConfig("localhost:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
var config = new ProducerConfig(props);
assertThat(config.getString(ProducerConfig.ACKS_CONFIG)).isEqualTo("-1");
assertThat(config.getBoolean(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG)).isTrue();
}
}
Cada prueba verifica un comportamiento:
- Primera prueba usa
autoComplete=true, así que cadasend()tiene éxito inmediatamente.history()permite inspeccionar las claves, confirmando que el orden por cliente se mantiene. - Segunda prueba usa
autoComplete=falsey luegoerrorNext(...)para simular un broker rechazando la escritura. Es la forma de verificar que tu código maneja errores. - Última prueba inicializa
ProducerConfigpara que Kafka valide la combinación de configuraciones. Una combinación inválida, comoacks=1con idempotencia, hará fallar la prueba conConfigExceptionen lugar de fallar en producción. Kafka normalizaalla"-1", así que la aserción compara con"-1".
8. Cuándo relajar las restricciones
La configuración “maximalmente segura” no siempre es correcta:
- Métricas, logs, clickstreams: perder algunos registros es aceptable. Usa
acks=1,linger.msalto y compresiónzstdpara mejor throughput. - Sistemas que necesitan baja latencia por mensaje (ej: cotizaciones en tiempo real): mantén
linger.mspequeño (0–2 ms). Aquí no esperes lotes grandes. - Pagos, pedidos: siempre usa
acks=all, idempotencia ymin.insync.replicas=2. Si necesitas exactitud de lectura–proceso–escritura, considera transacciones.
Nota: Establecer
max.in.flight.requests.per.connection=1“para estar seguro” es innecesario cuando ya tienes idempotencia activa. Solo reduce el throughput.
9. Conclusión
Tres grupos de configuración resuelven tres problemas distintos:
acks=allcombinado conmin.insync.replicasgarantiza sin pérdida de mensajes.enable.idempotencegarantiza reintentos sin duplicados y mantiene el orden dentro de la partición. Su alcance es solo una sesión de productor.batch.size,linger.msycompression.typedeterminan el throughput, a costa de algo de latencia.
Declara explícitamente estos valores en lugar de confiar en los valores por defecto. Valídalos con pruebas para evitar sorpresas en producción. Y no hagas reintentos manuales de send() en tu código cuando el productor ya lo hace por ti.