Designing partition keys in Kafka: message ordering and load balancing

1. Overview

In Kafka, the partition key determines which partition a message falls into. From there, it determines two things: which messages maintain correct order, and how load is distributed evenly among consumers.

Choosing the wrong key usually leads to one of two consequences. The first is that events from the same order get processed out of order. The second is that one partition becomes “hot” and carries nearly all the traffic while other partitions sit idle.

After reading this article, you’ll understand how Kafka maps keys to partitions. You’ll also learn how to choose keys based on ordering requirements, how to handle hot keys, and how to configure producers and consumers in Spring Boot 3.x to avoid accidentally breaking order.

2. How Kafka selects a partition

2.1. Key is not null: hash then modulo

When a message has a key, the producer calculates the partition as follows:

partition = toPositive(murmur2(keyBytes)) % numPartitions

We can replicate this logic exactly using utility functions from 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));
        }
    }
}

Here’s what each part does:

  • The key is converted to bytes via StringSerializer, which means UTF-8. We use UTF-8 here to match that behavior.
  • Utils.murmur2 is the hash function that the default partitioner uses.
  • toPositive clears the sign bit so the result isn’t negative. The subsequent % operation gives the partition index.

Running this produces output like (the exact numbers depend on the key):

order-1001 -> partition 3
order-1002 -> partition 0
order-1001 -> partition 3

Key point: with the same key and same number of partitions, you always get the same partition. Kafka only guarantees order within a single partition. So if you want to maintain order for a group of messages, they must share the same key.

2.2. Key is null: no ordering guarantee

Starting from Kafka 3.3, messages without a key are distributed using a “uniform sticky” mechanism. The producer batches messages into one partition, then moves to another, taking into account the speed of each broker. Load is distributed fairly evenly, but two consecutive messages may land in different partitions. In that case, there’s no ordering between them.

Note: Hashing depends on the bytes of the key, not its logical value. If one service sends the key 1001 using LongSerializer and another sends "1001" using StringSerializer, the two messages may go to different partitions. Standardize the key serializer across your system.

3. Sending messages with a key in Spring Boot

Add the dependency:

<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 the producer in 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

What each setting does:

  • acks: all and enable.idempotence: true help retries avoid creating duplicate records.
  • More importantly, when idempotence is enabled, the broker rejects batches that arrive out of order. This keeps order within the partition intact, even with retries and max.in.flight up to 5.

The service that publishes events:

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());
                    }
                });
    }
}

The second parameter of send(topic, key, value) is the key. Here we use orderId. If we send CREATED, PAID, SHIPPED in order for the same order, the output looks like:

order-1001 CREATED -> p3@120
order-1001 PAID -> p3@121
order-1001 SHIPPED -> p3@122

All three events land in partition 3 with offsets increasing. The consumer will read them in exactly this order.

4. Choosing a key: start with ordering requirements

The first question isn’t “which key distributes load best”. The right question is: which messages must be processed in order? The key should be the identifier of the smallest group that meets that requirement.

Business requirement Suitable key Notes
Events for a single order must be ordered orderId Many keys, good load distribution
Customer wallet balance must increment/decrement in order customerId or walletId Easy to hit hot key if there’s a very large customer
Access logs, no ordering needed null Most even load distribution
Inventory by product sku “Hot” products can cause skewed load

Two principles to follow:

  1. The finer the key, the better the load distribution. orderId has millions of values so distribution is even. countryCode has only a few values so a few partitions will get nearly all of it.
  2. The number of distinct key values should be much larger than the number of partitions. If you only have 10 distinct keys but your topic has 12 partitions, some partitions will be empty. And there will likely be partitions receiving more than one heavy key.

5. Hot partitions and how to handle them

Suppose the key is customerId and one business customer creates 40% of the traffic. The partition holding that customer will fall behind. Adding more consumers won’t help, because a partition can only be read by one consumer in the group at any given time.

5.1. Salting: split hot keys across multiple buckets

The idea is to add a suffix to the key to spread hot keys across multiple 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;
    }
}

For normal customers, the key stays as customerId. For hot customers, the bucket is computed from orderId, so the key looks like cust-big#0 … cust-big#7.

Important consequences to understand:

  • All events for the same order still have the same key, so order is still maintained.
  • Order between different orders from the same customer is no longer guaranteed.

Salting only works when the business accepts that tradeoff. If you need absolute ordering by customer (like wallet balance), salting will cause data corruption.

Note: Don’t use random values or timestamps for the bucket. That way, events from the same order will fall into different partitions and order will be completely broken.

5.2. Testing your key strategy

This test checks two main behaviors: same order goes to same partition, and hot customers are spread across multiple 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");
    }
}

The test uses PartitionCalculator from section 2.1, which replicates the producer’s exact formula. So we don’t need to spin up a broker and can still verify where messages actually land.

6. Consumer side: don’t break order yourself

Keeping order on the producer side isn’t enough. Consumers can break it too.

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) {
        // Process sequentially in the listener's thread
        System.out.println(Thread.currentThread().getName() + " " + event);
    }
}

concurrency = "6" creates 6 consumer threads. Each partition is assigned to exactly one thread, so order within the partition is preserved. If the topic has 6 partitions, each thread gets one. If concurrency is set higher than the number of partitions, the extra threads sit idle.

Common consumer-side mistakes:

  • Pushing messages to a separate thread pool (executor.submit(...)) inside the listener. Two events from the same order could run in parallel, and PAID might finish before CREATED. If you need deeper parallelism than partition-level, parallelize by key—one sequential queue per key. Don’t parallelize per message.
  • Retrying by sending to a different topic (retry topic, non-blocking retry). The failed message is separated from the main flow, and messages after it with the same key are still processed. For flows requiring strict ordering, use blocking retry via DefaultErrorHandler with BackOff. This approach accepts temporary partition pauses.

7. Changing partition count: a silent trap

Because the formula uses % numPartitions, increasing partitions from 6 to 12 will move most keys to different partitions:

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

Older events for order-1001 sit in partition 3 and might not have finished processing. Newer events go into partition 9. Different consumers could process them at the same time, and order gets reversed.

Ways to reduce risk:

  • Estimate excess partition capacity upfront. Calculate based on target throughput divided by single-consumer throughput, then multiply by a safety factor.
  • If you must add partitions, stop the producer, wait for the consumer to catch up, increase partitions, then restart the producer.
  • Or create a new topic with more partitions and gradually shift traffic to it.

Note: Kafka doesn’t support reducing partition count. Over-provisioning partitions also has costs: extra broker resources, slower rebalancing, and more open file handles. Reserve capacity modestly—don’t set 1000 partitions “just to be safe”.

8. When you need a custom partitioner

You rarely need to write your own Partitioner. In most cases, choosing the right key is enough. A custom partitioner only makes sense when you need explicit mapping, like reserving partitions for VIP customers.

However, custom partitioners have drawbacks:

  • Every producer writing to that topic, including services in other languages, must use the same logic. If they don’t, order breaks.
  • Tools like Kafka Streams and Kafka Connect use murmur2 by default. They may not match your partitioning strategy.

So prioritize solving it through key design before considering a custom partitioner.

9. Conclusion

The partition key is an architectural decision, not just a parameter to send(). Main points to remember:

  • Kafka only guarantees order within a partition. Same key and same partition count means same partition, by the murmur2 modulo formula.
  • Choose the key as the identifier of the smallest group needing order. More distinct key values mean more even load distribution.
  • Handle hot keys with salting, but only if the business accepts losing order between buckets.
  • Enable idempotence on the producer. On the consumer, don’t arbitrarily push messages to a thread pool, and think carefully before using non-blocking retry.
  • Avoid increasing partitions on a running topic if the stream needs order. Estimate capacity upfront.

Master these points and you can design topics that are both ordered and scalable as traffic grows.

kafkaspring-bootpartitionmessagingjava