Projetando partition key no Kafka: ordenação de mensagens e balanceamento
1. Visão geral
No Kafka, a partition key determina para qual partition uma mensagem vai. A partir disso, ela decide duas coisas: quais mensagens mantêm a ordenação correta e como a carga é distribuída entre os consumers.
Escolher a key incorretamente geralmente leva a uma de duas consequências. A primeira é que eventos de um mesmo pedido são processados fora de ordem. A segunda é que uma partition “quente” fica com quase todo o tráfego enquanto as outras partitions ficam ociosas.
Depois de ler este artigo, você entenderá como Kafka mapeia a key para uma partition. Você também saberá como escolher a key para preservar a ordenação, como lidar com hot keys, e como configurar producer/consumer no Spring Boot 3.x sem quebrar a ordenação acidentalmente.
2. Como Kafka seleciona uma partition
2.1. Key não nula: hash e depois módulo
Quando uma mensagem tem uma key, o producer calcula a partition segundo a fórmula a seguir:
partition = toPositive(murmur2(keyBytes)) % numPartitions
Podemos recriar essa lógica usando as funções utilitárias disponíveis em kafka-clients:
import org.apache.kafka.common.utils.Utils;
import java.nio.charset.StandardCharsets;
public final class PartitionCalculator {
private PartitionCalculator() {}
public static int partitionFor(String key, int numPartitions) {
byte[] keyBytes = key.getBytes(StandardCharsets.UTF_8);
return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
}
public static void main(String[] args) {
for (String orderId : new String[]{"order-1001", "order-1002", "order-1001"}) {
System.out.println(orderId + " -> partition " + partitionFor(orderId, 6));
}
}
}
Explicação de cada parte:
- A key é convertida em bytes usando
StringSerializer, ou seja, UTF-8. Usamos UTF-8 aqui para corresponder a esse comportamento. Utils.murmur2é a função hash que o partitioner padrão usa.toPositiveremove o bit de sinal para que o resultado não seja negativo. O operador%após isso retorna o índice da partition.
A execução produz um resultado como o seguinte (os números específicos dependem da key):
order-1001 -> partition 3
order-1002 -> partition 0
order-1001 -> partition 3
Ponto importante: a mesma key e o mesmo número de partitions sempre resultam na mesma partition. Kafka garante ordenação apenas dentro de uma partition. Portanto, para preservar a ordenação de um grupo de mensagens, todas elas devem usar a mesma key.
2.2. Key nula: sem garantia de ordenação
A partir do Kafka 3.3, mensagens sem key são distribuídas usando um mecanismo chamado “uniform sticky”. O producer agrupa um batch em uma partition, depois muda para outra, levando em conta a velocidade de cada broker. A carga é distribuída de forma bastante uniforme, mas duas mensagens consecutivas podem estar em partitions diferentes. Nesse caso, não há garantia de ordenação entre elas.
Nota: O hash depende dos bytes da key e não do seu valor lógico. Se um serviço envia a key
1001usandoLongSerializere outro serviço envia"1001"usandoStringSerializer, as duas mensagens podem ir para partitions diferentes. Padronize o serializer de key em todo o sistema.
3. Enviando mensagens com key usando Spring Boot
Adicione a dependência:
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka-test</artifactId>
<scope>test</scope>
</dependency>
Configure o producer em application.yml:
spring:
kafka:
bootstrap-servers: localhost:9092
producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
acks: all
properties:
enable.idempotence: true
max.in.flight.requests.per.connection: 5
Significado das configurações:
acks: alleenable.idempotence: trueajudam a evitar duplicatas ao fazer retry.- Mais importante ainda, com idempotence ativado, o broker rejeita batches que chegam fora de ordem. Assim, a ordenação dentro da partition é mantida, mesmo com retry e
max.in.flightchegando a 5.
Serviço que publica eventos:
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
public record OrderEvent(String orderId, String customerId, String type) {}
@Service
public class OrderEventPublisher {
private final KafkaTemplate<String, OrderEvent> kafkaTemplate;
public OrderEventPublisher(KafkaTemplate<String, OrderEvent> kafkaTemplate) {
this.kafkaTemplate = kafkaTemplate;
}
public void publish(OrderEvent event) {
kafkaTemplate.send("order-events", event.orderId(), event)
.whenComplete((result, ex) -> {
if (ex == null) {
var md = result.getRecordMetadata();
System.out.printf("%s %s -> p%d@%d%n",
event.orderId(), event.type(), md.partition(), md.offset());
}
});
}
}
O segundo parâmetro de send(topic, key, value) é a key. Aqui usamos orderId. Se enviarmos CREATED, PAID, SHIPPED sequencialmente para o mesmo pedido, a saída será algo como:
order-1001 CREATED -> p3@120
order-1001 PAID -> p3@121
order-1001 SHIPPED -> p3@122
Todos os três eventos estão na partition 3, com offsets crescentes. O consumer os lerá nessa ordem correta.
4. Escolhendo a key: comece pelos requisitos de ordenação
A primeira pergunta não é “qual key distribui a carga de forma mais uniforme”. A pergunta correta é: quais mensagens devem obrigatoriamente ser processadas em ordem? A key deve ser o identificador do menor grupo que atenda a esse requisito.
| Requisito de negócio | Key apropriada | Nota |
|---|---|---|
| Eventos de um pedido devem estar em ordem | orderId |
Muitas keys, boa distribuição |
| Saldo da carteira do cliente deve ser creditado/debitado em ordem | customerId ou walletId |
Fácil encontrar hot key se há clientes muito grandes |
| Logs de acesso, sem necessidade de ordenação | null |
Distribuição mais uniforme |
| Inventário por produto | sku |
Produtos “hot” podem causar desbalanceamento |
Dois princípios que acompanham isso:
- Quanto mais granular a key, melhor a distribuição.
orderIdtem milhões de valores e se distribui uniformemente.countryCodetem apenas poucos valores, então poucas partitions carregam quase tudo. - O número de valores de key deve ser significativamente maior que o número de partitions. Se há apenas 10 keys e o topic tem 12 partitions, com certeza haverá partitions vazias. Frequentemente também haverá partitions recebendo múltiplas keys pesadas.
5. Hot partitions e como tratá-las
Suponha que a key seja customerId e um cliente corporativo gera 40% do tráfego. A partition contendo esse cliente terá lag. Adicionar mais consumers não ajuda, porque uma partition só pode ser lida por um consumer do grupo de cada vez.
5.1. Salting: divida a hot key em múltiplos buckets
A ideia é adicionar um sufixo à key para espalhar a hot key entre múltiplas partitions:
import java.util.Set;
public class OrderKeyStrategy {
private final Set<String> hotCustomers;
private final int buckets;
public OrderKeyStrategy(Set<String> hotCustomers, int buckets) {
this.hotCustomers = hotCustomers;
this.buckets = buckets;
}
public String keyFor(OrderEvent event) {
if (!hotCustomers.contains(event.customerId())) {
return event.customerId();
}
int bucket = Math.floorMod(event.orderId().hashCode(), buckets);
return event.customerId() + "#" + bucket;
}
}
Para clientes normais, a key permanece como customerId. Para clientes hot, o bucket é calculado a partir de orderId, então a key tem a forma cust-big#0 … cust-big#7.
Consequências importantes:
- Todos os eventos de um mesmo pedido ainda têm a mesma key, então mantêm a ordenação.
- A ordenação entre pedidos diferentes do mesmo cliente não é mais garantida.
Salting só é viável quando o negócio aceita esse tradeoff. Se você precisa de ordenação absoluta por cliente (por exemplo, saldo de carteira), salting causará inconsistência de dados.
Nota: Não use valores aleatórios ou timestamp como bucket. Fazer isso causaria eventos do mesmo pedido em múltiplas partitions, quebrando completamente a ordenação.
5.2. Testando a estratégia de key
O teste abaixo verifica dois comportamentos principais: mesmo pedido vai para a mesma partition, e cliente hot é distribuído entre múltiplas partitions.
import org.junit.jupiter.api.Test;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
import static org.assertj.core.api.Assertions.assertThat;
class OrderKeyStrategyTest {
private static final int PARTITIONS = 12;
private final OrderKeyStrategy strategy = new OrderKeyStrategy(Set.of("cust-big"), 8);
@Test
void sameOrder_alwaysGoesToSamePartition() {
var created = new OrderEvent("order-1", "cust-big", "CREATED");
var paid = new OrderEvent("order-1", "cust-big", "PAID");
int p1 = PartitionCalculator.partitionFor(strategy.keyFor(created), PARTITIONS);
int p2 = PartitionCalculator.partitionFor(strategy.keyFor(paid), PARTITIONS);
assertThat(p1).isEqualTo(p2);
}
@Test
void hotCustomer_isSpreadAcrossPartitions() {
Set<Integer> partitions = IntStream.range(0, 1_000)
.mapToObj(i -> new OrderEvent("order-" + i, "cust-big", "CREATED"))
.map(e -> PartitionCalculator.partitionFor(strategy.keyFor(e), PARTITIONS))
.collect(Collectors.toSet());
assertThat(partitions).hasSizeGreaterThan(1);
}
@Test
void normalCustomer_keepsCustomerIdAsKey() {
var event = new OrderEvent("order-9", "cust-small", "CREATED");
assertThat(strategy.keyFor(event)).isEqualTo("cust-small");
}
}
O teste usa PartitionCalculator da seção 2.1, que recria a fórmula exata do producer. Portanto, não precisamos provisionar um broker e ainda assim podemos verificar onde as mensagens realmente caem.
6. No consumer: não quebre a ordenação você mesmo
O producer manter a ordenação correta não é suficiente. O consumer também pode quebrá-la.
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;
@Component
public class OrderEventListener {
@KafkaListener(topics = "order-events", groupId = "order-service", concurrency = "6")
public void onEvent(OrderEvent event) {
// Processa sequencialmente no thread do listener
System.out.println(Thread.currentThread().getName() + " " + event);
}
}
concurrency = "6" cria 6 consumer threads. Cada partition é atribuída a exatamente um thread, então a ordenação dentro da partition é mantida. Se o topic tem 6 partitions, cada thread recebe uma partition. Se você definir concurrency maior que o número de partitions, os threads extras ficarão ociosos.
Erros comuns do lado do consumer:
- Enviar mensagens para um thread pool separado (
executor.submit(...)) dentro do listener. Dois eventos do mesmo pedido podem rodar em paralelo, ePAIDpode terminar antes deCREATED. Se você precisa de paralelização mais profunda que o nível de partition, faça paralelização por key, com uma fila sequencial para cada key. Não faça paralelização por mensagem individual. - Retry enviando para outro topic (retry topic, non-blocking retry). A mensagem com erro é separada do fluxo principal, e as mensagens subsequentes da mesma key são processadas. Para fluxos que exigem ordenação rigorosa, use blocking retry com
DefaultErrorHandlereBackOff. Essa abordagem aceita que a partition pause temporariamente.
7. Alterando o número de partitions: uma armadilha silenciosa
Porque a fórmula usa % numPartitions, aumentar partitions de 6 para 12 fará a maioria das keys irem para partitions diferentes:
public class RepartitionDemo {
public static void main(String[] args) {
String key = "order-1001";
System.out.println("6 partitions -> " + PartitionCalculator.partitionFor(key, 6));
System.out.println("12 partitions -> " + PartitionCalculator.partitionFor(key, 12));
}
}
6 partitions -> 3
12 partitions -> 9
Os eventos antigos de order-1001 estão na partition 3 e podem não ter sido totalmente processados ainda. Eventos novos entram na partition 9. Dois consumers diferentes podem processá-los simultaneamente, e a ordenação é quebrada.
Maneiras de reduzir o risco:
- Estime partitions com folga desde o início. Calcule com base no throughput alvo dividido pelo throughput de um consumer, depois multiplique por um fator de margem.
- Se você precisar aumentar partitions, pause o producer, aguarde os consumers processarem todo o lag, aumente o número de partitions, depois retome o producer.
- Ou crie um novo topic com mais partitions e migre o fluxo gradualmente para o novo topic.
Nota: Kafka não suporta reduzir o número de partitions. Escolher muitas partitions também tem custos: consome recursos no broker, rebalances mais lentos, e mais arquivos abertos. Tenha folga suficiente, mas não defina 1000 partitions “só por precaução”.
8. Quando você precisa de um partitioner customizado
Raramente você precisa escrever seu próprio Partitioner. Na maioria dos casos, escolher a key correta é suficiente. Custom partitioner faz sentido apenas quando você precisa de um mapeamento explícito, como reservar algumas partitions para clientes VIP.
Em contrapartida, custom partitioner tem algumas desvantagens:
- Todo producer escrevendo nesse topic, incluindo serviços em outras linguagens, deve usar a mesma lógica. Se não, a ordenação é quebrada.
- Ferramentas como Kafka Streams e Kafka Connect usam murmur2 por padrão. Podem não corresponder ao seu esquema de particionamento.
Portanto, priorize resolver isso através de um design de key antes de pensar em partitioner customizado.
9. Conclusão
Partition key é uma decisão de design, não apenas um parâmetro de send(). Os pontos principais a lembrar:
- Kafka garante ordenação apenas dentro de uma partition. A mesma key e o mesmo número de partitions sempre resultam na mesma partition, seguindo a fórmula de murmur2 módulo número de partitions.
- Escolha a key como o identificador do menor grupo que precisa manter ordenação. Quanto mais valores de key, mais uniforme a distribuição.
- Trate hot keys usando salting, mas apenas quando o negócio aceita perder ordenação entre buckets.
- Ative idempotence no producer. No consumer, não envie mensagens para thread pools arbitrários, e pense bem antes de usar non-blocking retry.
- Evite aumentar partitions em um topic em execução se o fluxo exigir ordenação. Estime com folga desde o início.
Com uma compreensão sólida desses pontos, você pode projetar topics que mantêm a ordenação correta e podem escalar quando o tráfego aumenta.