惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

F
Fortinet All Blogs
V2EX - 技术
V2EX - 技术
The Last Watchdog
The Last Watchdog
宝玉的分享
宝玉的分享
T
Tenable Blog
WordPress大学
WordPress大学
K
Kaspersky official blog
Microsoft Security Blog
Microsoft Security Blog
大猫的无限游戏
大猫的无限游戏
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Hacker News - Newest:
Hacker News - Newest: "LLM"
P
Palo Alto Networks Blog
Help Net Security
Help Net Security
V
Vulnerabilities – Threatpost
Know Your Adversary
Know Your Adversary
C
CXSECURITY Database RSS Feed - CXSecurity.com
A
Arctic Wolf
Forbes - Security
Forbes - Security
Microsoft Azure Blog
Microsoft Azure Blog
爱范儿
爱范儿
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
The Cloudflare Blog
Hugging Face - Blog
Hugging Face - Blog
H
Hacker News: Front Page
W
WeLiveSecurity
博客园 - 【当耐特】
G
Google Developers Blog
Martin Fowler
Martin Fowler
TaoSecurity Blog
TaoSecurity Blog
Hacker News: Ask HN
Hacker News: Ask HN
人人都是产品经理
人人都是产品经理
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
AI
AI
N
Netflix TechBlog - Medium
C
Cisco Blogs
I
Intezer
aimingoo的专栏
aimingoo的专栏
博客园 - 聂微东
G
GRAHAM CLULEY
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
Apple Machine Learning Research
Apple Machine Learning Research
月光博客
月光博客
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
www.infosecurity-magazine.com
www.infosecurity-magazine.com
小众软件
小众软件
Blog — PlanetScale
Blog — PlanetScale
MyScale Blog
MyScale Blog
L
Lohrmann on Cybersecurity
Engineering at Meta
Engineering at Meta

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Guaranteed Message Ordering in Apache Kafka: What It Really Takes in Production (Spring Boot)
Yogesh Kale · 2026-04-22 · via DEV Community

A senior engineer's deep dive into the guarantees, tradeoffs, and production gotchas of implementing strictly ordered Kafka consumers — and the honest conversation about when not to.


The Why

In my previous post, I built a zero-loss Kafka consumer using @RetryableTopic. But I deliberately called out one tradeoff that deserves its own conversation:

"Moving a message to a retry topic breaks ordering guarantees for that partition."

That sentence sent me down a rabbit hole. If retryable topics — one of Spring Kafka's most powerful resilience tools — inherently break ordering, what does it actually take to guarantee strict message order in a production Kafka system?

This post is everything I found: the real guarantees Kafka provides, the configuration decisions that break ordering silently, and the honest tradeoffs you need to make before choosing this path.


What Kafka Actually Guarantees (And What It Doesn't)

Let's start with what Kafka promises, because most ordering bugs come from misreading this.

Kafka guarantees ordering within a partition. It makes no guarantees across partitions.

That's it. Full stop. Not per topic. Not per consumer group. Per partition.

Before going deeper, here is where each ordering guarantee actually lives in the pipeline — because each layer can independently break the guarantee above it:

PRODUCER                    BROKER                      CONSUMER
─────────────────────────   ─────────────────────────   ─────────────────────────
Stable partition key        Single partition            concurrency=1 (or 1 per partition)
  → same key always           → messages stored         enable.auto.commit=false
    routes to same partition    in append order          AckMode.RECORD
enable.idempotence=true     Replication (acks=all)      No @Async hand-offs
max.in.flight=1               → no gaps from            CooperativeStickyAssignor
  → no broker reordering        leader failover          → rebalance-safe handoff
  from retried batches
                            ← Ordering guarantee lives across ALL three layers →
                            Breaking any one layer silently breaks end-to-end order

Enter fullscreen mode Exit fullscreen mode

This is also the map for debugging ordering incidents in production: work left to right. A gap in your offset sequence? Check the producer's max.in.flight setting. Out-of-order messages after a deployment? Check the rebalance assignor. Duplicates? Check idempotence and ack mode.

This has a direct consequence: if messages that must be processed in sequence land on different partitions, you have no ordering guarantee regardless of how well you configure your consumer. The ordering problem starts at the producer, not the consumer.

Consider an e-commerce order lifecycle:

Event 1: ORDER_PLACED     (orderId: 1001)
Event 2: PAYMENT_CAPTURED (orderId: 1001)
Event 3: INVENTORY_RESERVED (orderId: 1001)
Event 4: ORDER_SHIPPED    (orderId: 1001)

Enter fullscreen mode Exit fullscreen mode

All four events must be processed in sequence. If EVENT_3 is processed before EVENT_2, inventory is reserved against an unpaid order. If EVENT_4 arrives before EVENT_3, the shipment goes out against unreserved stock.

This is exactly the class of problem where Kafka ordering matters — and where getting it wrong causes real business damage.

Or consider a financial context (as a reference, not a recommendation to use Kafka for core ledger operations): a multi-leg settlement where debit must precede credit. Kafka can work here, but only with explicit ordering discipline. We will come back to where Kafka ordering fits and where it does not.


The Foundation: Partition Key Strategy

Since ordering is partition-scoped, the partition key is the most important architectural decision you will make for ordered consumers.

The rule: all messages that must be processed in sequence must share the same partition key. Kafka routes messages with the same key to the same partition using a consistent hash.

// Producer: always key by the entity whose events must be ordered
ProducerRecord<String, String> record = new ProducerRecord<>(
    "order-events",
    order.getOrderId(),   // ← This is the partition key
    eventPayload
);

Enter fullscreen mode Exit fullscreen mode

What goes wrong here in practice:

  • Null keys — Kafka round-robins null-key messages across all partitions. Order is immediately lost.
  • Random UUIDs as keys — same problem. Each message lands on a different partition.
  • Using producer instance ID or timestamp as the key — technically unique per message, which defeats the purpose entirely.
  • Changing partition count after go-live — Kafka's hash function maps keys to partitions based on partition count. Increasing partitions remaps keys to different partitions, breaking the ordering guarantee for in-flight and future messages.

Production rule: treat your partition count as immutable once consumers are live. Over-provision partitions at topic creation time rather than resizing later.


Consumer Concurrency: The Silent Ordering Killer

This is where the majority of ordering bugs I have seen are introduced — not at the architecture level, but in a configuration property that is easy to overlook.

@Bean
public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory() {
    ConcurrentKafkaListenerContainerFactory<String, String> factory =
        new ConcurrentKafkaListenerContainerFactory<>();
    factory.setConsumerFactory(consumerFactory());
    factory.setConcurrency(3); // ← This is the problem
    return factory;
}

Enter fullscreen mode Exit fullscreen mode

Setting concurrency > 1 means Spring Kafka spins up multiple consumer threads, and under normal operation each thread is assigned its own partition — a single partition is never served by two threads simultaneously. The real ordering risk is subtler: during a rebalance, a partition can be moved from one thread to another. The new thread can start consuming from that partition before the previous thread has finished processing its current batch. That window — old thread still running, new thread already polling — is where out-of-order processing occurs. It is easy to overlook precisely because it only surfaces under rebalance conditions, not in steady-state load testing.

For strict ordering, the rule is:

factory.setConcurrency(1); // One thread per listener container

Enter fullscreen mode Exit fullscreen mode

Or, if you want to scale with partition count:

// Set concurrency = number of partitions
// Each thread owns exactly one partition
factory.setConcurrency(partitionCount);

Enter fullscreen mode Exit fullscreen mode

The second option is correct but requires discipline: if you set concurrency=6 against a topic with 6 partitions, each thread owns one partition and ordering within each partition is preserved. The moment concurrency exceeds partition count, some partitions get no dedicated thread and ordering breaks.

Equally important: never use @Async inside a @KafkaListener method. The moment you hand processing off to another thread pool, the calling thread moves to the next message while the previous one is still being processed. Ordering is gone.

// This silently breaks ordering
@KafkaListener(topics = "order-events")
public void consume(ConsumerRecord<String, String> record) {
    asyncService.processAsync(record.value()); // returns immediately
}

// Processing must complete before the next message is touched
@KafkaListener(topics = "order-events")
public void consume(ConsumerRecord<String, String> record) {
    orderProcessingService.process(record.value()); // blocking, synchronous
}

Enter fullscreen mode Exit fullscreen mode


Offset Commit Strategy: Where Zero-Loss and Ordering Intersect

If you read my previous post on zero-loss consumers, you already know that enable.auto.commit=true is dangerous for reliability. For ordered consumers, it is doubly dangerous.

Auto-commit uses a time-based interval. It has no awareness of whether your processing logic has completed. In a high-throughput system, you can auto-commit offsets for messages your application is still processing — and if the application crashes, those messages are never reprocessed, silently breaking both your delivery guarantee and your ordering assumptions.

The correct configuration:

// application.yml
spring:
  kafka:
    consumer:
      enable-auto-commit: false
      auto-offset-reset: earliest

# In your ContainerFactory
factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.RECORD);

Enter fullscreen mode Exit fullscreen mode

AckMode.RECORD commits the offset after each record is fully processed. For strict ordering this is the safest mode — you never advance past a message until it has been handled.

If you need manual control (for example, batching acks across a transaction boundary):

factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);

Enter fullscreen mode Exit fullscreen mode

With MANUAL_IMMEDIATE, you must call acknowledgment.acknowledge() explicitly. The critical rule: acknowledge in order. Out-of-order acknowledgment creates offset gaps. When the consumer restarts, it will reprocess from the earliest unacknowledged offset, which may cause you to re-process messages you already handled.


Error Handling: The Hardest Tradeoff in Ordered Systems

This is where the real tension lives, and I want to be direct about it because most blog posts gloss over it.

The fundamental conflict: in an ordered consumer, a failed message blocks the partition. Every message behind it waits. But the standard tool for Kafka resilience — @RetryableTopic — resolves this by moving the failed message to a separate retry topic, which immediately breaks ordering for that partition.

You have to choose. There is no configuration that gives you both non-blocking retries and guaranteed ordering.

Option 1: Blocking Retry (Preserves Order, Risks Throughput)

@Bean
public DefaultErrorHandler errorHandler() {
    // Retry up to 3 times with exponential backoff
    ExponentialBackOffWithMaxRetries backOff = new ExponentialBackOffWithMaxRetries(3);
    backOff.setInitialInterval(1_000L);
    backOff.setMultiplier(2.0);
    backOff.setMaxInterval(10_000L);

    DefaultErrorHandler handler = new DefaultErrorHandler(
        new DeadLetterPublishingRecoverer(kafkaTemplate), backOff
    );

    // Never retry these — they will always fail
    handler.addNotRetryableExceptions(
        JsonParseException.class,
        ValidationException.class
    );

    return handler;
}

Enter fullscreen mode Exit fullscreen mode

With DefaultErrorHandler and no retry topic, retries happen in-place. The partition is blocked while retries are attempted. This is the correct choice when:

  • Your messages have business-level dependencies on each other (true ordering requirement)
  • A transient failure retrying for a few seconds is acceptable
  • Your downstream can recover quickly

When retries are exhausted, the message goes to the DLT via DeadLetterPublishingRecoverer. At that point, you have a decision: stop the consumer (strict ordering, no skipping) or skip to the next message (lose strict ordering, maintain throughput).

Option 2: DLT With Consumer Pause (Best of Both Under Prolonged Failure)

For scenarios where the downstream is fully down (not just a transient blip), blocking retries will pin your partition for a long time. A better pattern is to pause the consumer and let messages accumulate safely in the broker — they stay in their original partition order, untouched, ready to be processed in sequence once the dependency recovers.

The trigger for a pause is your error handler detecting consecutive exhausted retries within a time window — not a single failure. A single transient failure should exhaust the backoff and either recover or go to DLT. It is only when failures are sustained (indicating downstream unavailability rather than a bad message) that pausing the entire consumer is warranted.

@Component
public class OrderedConsumerController {

    private final KafkaListenerEndpointRegistry registry;

    // Called by your error handler after N consecutive DLT publishes within a window,
    // indicating the downstream service is unavailable — not just a bad message
    public void pauseConsumer(String listenerId) {
        MessageListenerContainer container = registry.getListenerContainer(listenerId);
        if (container != null && container.isRunning()) {
            container.pause();
            log.warn("Consumer [{}] paused due to sustained downstream failure", listenerId);
        }
    }

    public void resumeConsumer(String listenerId) {
        MessageListenerContainer container = registry.getListenerContainer(listenerId);
        if (container != null) {
            container.resume();
            log.info("Consumer [{}] resumed after downstream recovery", listenerId);
        }
    }
}

Enter fullscreen mode Exit fullscreen mode

Recovery monitoring is what completes this pattern. In my previous post on zero-loss consumers, I used a HeartbeatScheduler that periodically probed the downstream on a fixed interval and published a recovery event once the health check passed. The same approach applies here: a scheduled task that calls your downstream's health endpoint every 10–30 seconds, and calls resumeConsumer() on success. The key difference in an ordered consumer is that you must not resume until you are confident the dependency is stable — a premature resume that fails immediately will cause another pause cycle and risk committing a partial offset.


Producer-Side Guarantees: The Part Most Ordering Guides Skip

You can configure your consumer perfectly and still lose ordering at the broker level if your producer is misconfigured. Three settings matter:

spring:
  kafka:
    producer:
      acks: all                    # Wait for all in-sync replicas to acknowledge
      retries: 3
      properties:
        enable.idempotence: true
        max.in.flight.requests.per.connection: 1

Enter fullscreen mode Exit fullscreen mode

acks=all — without this, a message can be acknowledged by the leader but not yet replicated. If the leader fails before replication, the message is lost. Your consumer never sees it, and the sequence has a permanent gap.

enable.idempotence=true — producer retries can cause duplicate messages at the broker if the original request succeeded but the acknowledgment was lost in transit. With idempotence enabled, Kafka deduplicates using a producer ID and sequence number. This is a prerequisite for correct ordered delivery.

max.in.flight.requests.per.connection=1 — this one is subtle. When a producer has multiple in-flight requests, a retry of request N can overtake request N+1 if N+1 was acknowledged first. The result: messages arrive at the broker out of order. Setting this to 1 ensures only one batch is in-flight at a time. Note: with enable.idempotence=true, Kafka allows up to 5 in-flight requests without reordering risk, but setting it to 1 is the conservative and safest choice for strictly ordered workloads.


Rebalance Handling: The Ordering Disruption You Cannot Ignore

A consumer group rebalance — triggered by a consumer joining, leaving, or timing out — temporarily pauses all consumers in the group while partition ownership is reassigned. Any in-flight processing during a rebalance can complete out of order relative to the messages that follow.

Use the Cooperative Sticky Assignor:

spring:
  kafka:
    consumer:
      properties:
        partition.assignment.strategy: org.apache.kafka.clients.consumer.CooperativeStickyAssignor

Enter fullscreen mode Exit fullscreen mode

The default RangeAssignor is a stop-the-world rebalance: all consumers stop, all partitions are unassigned, then reassigned. CooperativeStickyAssignor only reassigns partitions that actually need to move. Consumers that keep their partition assignments continue processing uninterrupted. For ordered consumers on high-partition topics, this is a significant improvement.

Use Static Group Membership to reduce rebalance frequency:

spring:
  kafka:
    consumer:
      properties:
        group.instance.id: order-consumer-instance-1  # Unique per instance
        session.timeout.ms: 60000

Enter fullscreen mode Exit fullscreen mode

Without static membership, every application restart triggers a rebalance. With group.instance.id, Kafka recognises the consumer as the same instance rejoining and avoids a full rebalance if it comes back within the session timeout. For Kubernetes deployments where pods restart frequently, this is a material difference.

Implement ConsumerRebalanceListener for clean handoffs:

@Component
public class OrderedRebalanceListener implements ConsumerRebalanceListener {

    private final Set<TopicPartition> currentAssignment = new HashSet<>();

    @Override
    public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
        // Complete or checkpoint in-flight work before partitions are handed off
        log.info("Partitions revoked: {}. Completing in-flight processing.", partitions);
        // Flush any buffered state, commit offsets manually if using MANUAL ack mode
        currentAssignment.removeAll(partitions);
    }

    @Override
    public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
        log.info("Partitions assigned: {}", partitions);
        currentAssignment.addAll(partitions);
    }
}

Enter fullscreen mode Exit fullscreen mode


Idempotent Processing: Non-Negotiable

Rebalances cause redelivery. Network failures cause redelivery. Consumer restarts cause redelivery. Your consumer will process the same message more than once — the question is whether your business logic handles it correctly.

Every handler in an ordered consumer must be idempotent. Processing the same message twice must produce the same result as processing it once.

A simple deduplication pattern using the original offset and partition as a correlation key:

@Service
public class IdempotentOrderProcessor {

    private final OrderRepository orderRepository;
    private final ProcessedMessageRepository processedMessages;

    public void process(ConsumerRecord<String, String> record) {
        String deduplicationKey = record.topic() + "-" + record.partition() + "-" + record.offset();

        if (processedMessages.existsByKey(deduplicationKey)) {
            log.info("Duplicate message detected, skipping. Key: {}", deduplicationKey);
            return;
        }

        // Process the message
        OrderEvent event = deserialize(record.value());
        orderRepository.applyEvent(event);

        // Mark as processed (ideally in the same transaction as the business operation)
        processedMessages.save(new ProcessedMessage(deduplicationKey));
    }
}

Enter fullscreen mode Exit fullscreen mode

Important note on this pattern: if you are using a relational database, saving the processed message key and applying the business operation should be in the same database transaction. If the application crashes between the business operation and saving the key, you get a reprocessed message on restart. Transactional outbox or a unique constraint on the key column enforced by the database are both valid approaches.


Sequence Numbers: Defense in Depth

Kafka offsets tell you where a message sits within a partition. But they do not tell you the intended business sequence — which can differ if messages are produced out of order by the upstream system, or if a producer retry creates a gap.

For critical ordered consumers, embedding a sequence number in the message payload gives you an additional layer of defense:

public class OrderEvent {
    private String orderId;
    private String eventType;
    private long sequenceNumber;      // Monotonically increasing per orderId
    private Instant occurredAt;
    // ...
}

Enter fullscreen mode Exit fullscreen mode

In your consumer, validate before processing:

public void process(OrderEvent event) {
    long expectedSeq = orderSequenceTracker.getNext(event.getOrderId());

    if (event.getSequenceNumber() < expectedSeq) {
        log.warn("Duplicate or old event detected. orderId={}, seq={}, expected={}",
            event.getOrderId(), event.getSequenceNumber(), expectedSeq);
        return; // Idempotency: already processed
    }

    if (event.getSequenceNumber() > expectedSeq) {
        log.error("Gap in sequence detected. orderId={}, seq={}, expected={}",
            event.getOrderId(), event.getSequenceNumber(), expectedSeq);
        throw new SequenceGapException("Missing event in sequence for order: " + event.getOrderId());
    }

    applyEvent(event);
    orderSequenceTracker.advance(event.getOrderId());
}

Enter fullscreen mode Exit fullscreen mode

A SequenceGapException thrown here will trigger your error handler. Whether you block-retry, pause the consumer, or route to a DLT depends on your business tolerance — but at least you have detected the gap rather than processing out-of-order silently.


Observability: You Cannot Debug What You Cannot See

Ordered consumers fail in subtle ways. A rebalance redelivery processed twice. A gap in the offset sequence. A message processed 200ms before its predecessor completed. None of these show up as errors in your application logs without deliberate instrumentation.

Log partition and offset on every message:

@KafkaListener(topics = "order-events", groupId = "order-processor")
public void consume(
    ConsumerRecord<String, String> record,
    @Header(KafkaHeaders.RECEIVED_PARTITION) int partition,
    @Header(KafkaHeaders.OFFSET) long offset) {

    log.info("Processing message. topic={}, partition={}, offset={}, key={}",
        record.topic(), partition, offset, record.key());

    orderProcessingService.process(record);

    log.info("Completed message. topic={}, partition={}, offset={}", 
        record.topic(), partition, offset);
}

Enter fullscreen mode Exit fullscreen mode

Expose consumer lag as a metric. Consumer lag — the gap between the latest offset produced and the latest offset committed by your consumer — is the single most important health signal for an ordered consumer. A growing lag means messages are accumulating unprocessed, which eventually translates to ordering delays even if no failures have occurred.

With Micrometer and Spring Boot Actuator, consumer lag is exposed automatically if you include spring-kafka metrics. Ensure your Prometheus scrape config picks it up:

management:
  metrics:
    tags:
      application: order-consumer
  endpoints:
    web:
      exposure:
        include: health,metrics,prometheus

Enter fullscreen mode Exit fullscreen mode

Alert on: lag growing beyond your SLA threshold, DLT publish rate above zero, offset gap between consecutive commits, and rebalance frequency.


Where Kafka Ordering Fits — And Where It Does Not

After all of the above, the honest answer: Kafka ordering is appropriate for event-driven workflows where sequence matters but the domain can tolerate the complexity overhead.

It fits well for:

  • Order lifecycle events (placed → paid → shipped → delivered)
  • User action streams that must be replayed in order
  • Audit trails where chronological sequence is required
  • CDC (Change Data Capture) streams that must apply database changes in order

It is the wrong tool for:

  • Core financial ledger operations requiring ACID guarantees across multiple legs
  • Workflows where any ordering failure must immediately halt the entire pipeline — a relational database with proper transaction management is simpler and safer
  • Low-volume, low-latency scenarios where the operational complexity of Kafka outweighs its throughput benefits

If your requirement is "these four operations must all succeed or all fail, in order, atomically" — that is a distributed transaction problem. Look at the Saga pattern with orchestration, or a transactional outbox, before reaching for Kafka ordering.


The Full Picture: Configuration Reference

Every property below has a reason. The two most commonly misconfigured ones in ordered consumers are max.poll.interval.ms and max.poll.records — and getting either wrong causes silent ordering failures rather than hard errors.

spring:
  kafka:
    consumer:
      enable-auto-commit: false
      auto-offset-reset: earliest
      properties:
        partition.assignment.strategy: org.apache.kafka.clients.consumer.CooperativeStickyAssignor
        group.instance.id: ${HOSTNAME}-consumer   # Static membership — prevents rebalance on pod restart
        session.timeout.ms: 60000

        # Conservative ceiling: limits how many records a single poll() fetches.
        # If your processing logic takes 200ms per record, 50 records = 10s of work per poll.
        # This directly informs max.poll.interval.ms below — they must be sized together.
        max.poll.records: 50

        # Must exceed your worst-case processing time for a full poll batch.
        # Formula: max.poll.interval.ms > max.poll.records × max_processing_time_per_record_ms
        # With 50 records at 200ms each = 10,000ms. 300,000ms gives a 30× safety margin.
        # If this deadline is breached, Kafka treats the consumer as dead, triggers a rebalance,
        # and the in-flight batch may be reprocessed by another thread — breaking ordering.
        max.poll.interval.ms: 300000
    producer:
      acks: all
      retries: 3
      properties:
        enable.idempotence: true
        max.in.flight.requests.per.connection: 1

Enter fullscreen mode Exit fullscreen mode

@Bean
public ConcurrentKafkaListenerContainerFactory<String, String> kafkaListenerContainerFactory() {
    ConcurrentKafkaListenerContainerFactory<String, String> factory =
        new ConcurrentKafkaListenerContainerFactory<>();
    factory.setConsumerFactory(consumerFactory());
    factory.setConcurrency(1);  // Or = partition count with sticky assignment
    factory.getContainerProperties().setAckMode(ContainerProperties.AckMode.RECORD);

    ExponentialBackOffWithMaxRetries backOff = new ExponentialBackOffWithMaxRetries(3);
    backOff.setInitialInterval(1_000L);
    backOff.setMultiplier(2.0);
    backOff.setMaxInterval(10_000L);

    factory.setCommonErrorHandler(new DefaultErrorHandler(
        new DeadLetterPublishingRecoverer(kafkaTemplate), backOff
    ));

    return factory;
}

Enter fullscreen mode Exit fullscreen mode


Final Thoughts

Guaranteed message ordering in Kafka is achievable, but it is not a feature you turn on. It is a property that emerges from a set of consistent decisions made across the producer, the broker, and the consumer — and broken by any one decision made without understanding its implications.

The combination that holds in production:

  • Stable, business-meaningful partition keys set at the producer
  • enable.idempotence=true and max.in.flight.requests.per.connection=1 on the producer
  • enable.auto.commit=false with AckMode.RECORD on the consumer
  • concurrency=1 (or one thread per partition) with no async hand-offs
  • Blocking retry with DefaultErrorHandler — not @RetryableTopic, which trades ordering for throughput
  • Cooperative sticky assignor and static group membership for rebalance stability
  • Idempotent processing with deduplication keyed on topic + partition + offset
  • Sequence numbers in the payload for gap detection
  • Consumer lag and offset gap alerting as the primary health signals

I have not deployed this exact pattern to production — but I have run the zero-loss consumer from my previous post in production, and the foundation is the same: respect Kafka's actual guarantees, design for redelivery, and make your tradeoffs explicit rather than discovering them in an incident.

If you have implemented ordered consumers in production and found gaps in this — or made different tradeoff decisions — I would genuinely like to hear about it in the comments.


This is part of a series on production-grade Kafka with Spring Boot. The previous post covers zero-loss delivery with retryable topics.


Tags: #kafka #springboot #java #distributedsystems #eventdrivenarchitecture #microservices