- Published on
[Ultimate Guide] Mastering Exactly-Once Message Processing: End-to-End Guarantees with Spring Boot 4.0, Apache Kafka, and PostgreSQL
- Authors

- Name
- Maria
Mastering Exactly-Once Message Processing: End-to-End Guarantees with Spring Boot 4.0, Apache Kafka, and PostgreSQL
In the dynamic world of event-driven microservices, the promise of decoupling and resilience often comes with a significant challenge: ensuring that every message is processed exactly once, and never more. While Apache Kafka provides powerful "at-least-once" delivery guarantees, achieving true end-to-end "exactly-once" semantics—where a business event reliably impacts downstream systems without duplication—is a complex architectural feat. This guide dives deep into how to engineer such guarantees, transforming your Spring Boot 4.0 applications into robust, fault-tolerant event processors leveraging the full power of Apache Kafka and PostgreSQL.
TL;DR: Achieving "effectively once" processing end-to-end requires a layered approach. This guide details combining Kafka's transactional producers and consumer
isolation.levelwith the Transactional Outbox pattern on the producer side, and the Idempotent Consumer pattern with local database transactions on the consumer side, all orchestrated within Spring Boot, Kafka, and PostgreSQL.
The Elusive "Exactly-Once": Understanding the Real Goal
The concept of "exactly-once" message processing is often misunderstood. In a distributed system, especially one as prone to network partitions and crashes as a microservices architecture, guaranteeing true exactly-once processing (meaning a message is delivered to a consumer, processed, and its side effects are committed precisely once, with no possibility of duplication or loss) is practically impossible without severe performance and availability trade-offs.
Instead, what we strive for in real-world systems is "effectively once" or "idempotent processing." This means:
- At-most-once: A message might be lost, but never processed more than once. (Rarely acceptable for critical business events).
- At-least-once: A message is guaranteed to be delivered, but might be delivered multiple times. (Kafka's default guarantee, requiring consumers to handle duplicates).
- Effectively Once / Idempotent Processing: The system's side effects occur exactly once, even if the underlying message is delivered and processed multiple times. This is the holy grail for robustness and data integrity in event-driven architectures.
Our focus throughout this guide will be on achieving this "effectively once" semantic by combining Kafka's strong delivery guarantees with application-level patterns that ensure idempotent processing.
Pillar 1: Producer Guarantees - Sending Messages Reliably
The journey to "effectively once" begins at the source: the message producer. Ensuring a message is reliably published to Kafka, without loss or accidental duplication from the producer's perspective, is the first critical step.
Kafka Producer Acks and Retries
Kafka's producer client offers several configurations to enhance reliability:
acks=all: This setting ensures that a message is considered "committed" only when it has been replicated to all in-sync replicas (ISRs) for the topic's partition. This prevents data loss if the leader broker fails.min.insync.replicas: Often used in conjunction withacks=all, this broker-side setting defines the minimum number of ISRs required for a successful write. If fewer ISRs are available, the producer will receive an error.retries: The producer can be configured to automatically retry sending messages upon transient failures. While crucial for reliability, excessive retries can lead to duplicate messages if the original send actually succeeded but the acknowledgment was lost.enable.idempotence=true: This is a powerful Kafka 0.11+ feature. When enabled, the producer automatically assigns a unique Producer ID (PID) and a sequence number to each message. The Kafka brokers then use this information to deduplicate messages from the same producer instance that might be retried. This guarantees "exactly-once" delivery from a single producer instance to a single Kafka partition.
While enable.idempotence=true is a significant step, it has limitations: it only guarantees idempotence for retries from the same producer instance and doesn't solve the distributed transaction problem where a database write succeeds but the Kafka send fails before the producer process crashes.
Transactional Producers in Kafka
Kafka introduced transactional producers to allow atomic writes to multiple Kafka topics/partitions, and critically, to atomically commit consumer offsets with producer sends. This brings distributed transaction capabilities to Kafka:
// Spring Boot Kafka Transactional Producer Configuration
@Configuration
public class KafkaProducerConfig {
@Value("${spring.kafka.producer.bootstrap-servers}")
private String bootstrapServers;
@Bean
public ProducerFactory<String, String> producerFactory() {
Map<String, Object> configProps = new HashMap<>();
configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
configProps.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
configProps.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
configProps.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true"); // Ensures idempotence for retries within a transaction
configProps.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "myTransactionalProducer"); // Must be unique per application/instance
return new DefaultKafkaProducerFactory<>(configProps);
}
@Bean
public KafkaTransactionManager<String, String> kafkaTransactionManager(ProducerFactory<String, String> producerFactory) {
return new KafkaTransactionManager<>(producerFactory);
}
@Bean
public KafkaTemplate<String, String> kafkaTemplate(ProducerFactory<String, String> producerFactory) {
return new KafkaTemplate<>(producerFactory);
}
}
With KafkaTransactionManager and Spring's @Transactional annotation, you can coordinate database and Kafka operations. However, this still faces the "two-phase commit" problem where committing a database transaction and a Kafka transaction atomically is inherently difficult across different resource managers. If the database transaction commits but the application crashes before the Kafka transaction commits, you're back to a potential inconsistency.
The Transactional Outbox Pattern Revisited
This is where the Transactional Outbox Pattern shines as the most robust producer-side guarantee for "effectively once" delivery in event-driven microservices, especially when integrating with a local database. The pattern ensures that a message is published to Kafka only if the local business transaction (e.g., saving an order to PostgreSQL) successfully commits.
How it works:
- Atomic Write: Instead of directly sending a message to Kafka, the producer writes the business data and an event representation of that data (the "outbox message") into a dedicated
outboxtable within the same local database transaction as the business logic. - Separate Publisher: A separate process (e.g., a dedicated microservice, a scheduled poller, or Debezium/Kafka Connect) monitors the
outboxtable for new, unprocessed events. - Publish to Kafka: When new events are found, they are read and published to the appropriate Kafka topic.
- Mark as Processed: Once successfully published to Kafka, the
outboxentry is marked asprocessedor deleted within another local transaction.
This pattern circumvents the distributed transaction problem by making the critical step (business logic + event creation) atomic within a single database. If the application crashes after the database commit but before Kafka send, the outbox entry persists and will be picked up by the publisher when the service recovers.
// Simplified Outbox Table DDL (PostgreSQL)
// 메시지 아웃박스 테이블 (Message outbox table)
CREATE TABLE outbox_events (
id UUID PRIMARY KEY,
aggregate_type VARCHAR(255) NOT NULL,
aggregate_id VARCHAR(255) NOT NULL,
type VARCHAR(255) NOT NULL,
payload JSONB NOT NULL,
timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
processed BOOLEAN DEFAULT FALSE
);
// Spring Boot 4.0 Example: Producer with Transactional Outbox
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxEventRepository outboxEventRepository;
public OrderService(OrderRepository orderRepository, OutboxEventRepository outboxEventRepository) {
this.orderRepository = orderRepository;
this.outboxEventRepository = outboxEventRepository;
}
@Transactional // Ensures atomicity of order save and outbox event creation
public Order createOrder(Order order) {
Order savedOrder = orderRepository.save(order); // 주문 저장 (Save order)
OutboxEvent event = new OutboxEvent(
UUID.randomUUID(),
"Order",
savedOrder.getId().toString(),
"OrderCreated",
objectMapper.writeValueAsString(new OrderCreatedEvent(savedOrder.getId(), savedOrder.getCustomerId(), savedOrder.getAmount()))
);
outboxEventRepository.save(event); // 아웃박스 이벤트 저장 (Save outbox event)
return savedOrder;
}
}
// Outbox Poller (simplified for brevity, often a separate component)
@Service
public class OutboxEventPublisher {
private final OutboxEventRepository outboxEventRepository;
private final KafkaTemplate<String, String> kafkaTemplate;
public OutboxEventPublisher(OutboxEventRepository outboxEventRepository, KafkaTemplate<String, String> kafkaTemplate) {
this.outboxEventRepository = outboxEventRepository;
this.kafkaTemplate = kafkaTemplate;
}
@Scheduled(fixedDelay = 5000) // Poll every 5 seconds
@Transactional // Ensure marking as processed is atomic
public void publishOutboxEvents() {
List<OutboxEvent> unprocessedEvents = outboxEventRepository.findByProcessedFalse();
for (OutboxEvent event : unprocessedEvents) {
try {
// 메시지를 Kafka로 발행 (Publish message to Kafka)
kafkaTemplate.send("order-events", event.getAggregateId(), event.getPayload());
event.setProcessed(true); // 성공적으로 발행되면 처리됨으로 표시 (Mark as processed on successful publish)
outboxEventRepository.save(event); // 상태 업데이트 (Update status)
} catch (Exception e) {
// Log error, retry mechanism, or move to a dead-letter outbox
// 오류 로깅, 재시도 메커니즘 (Log error, retry mechanism)
}
}
}
}
The Transactional Outbox ensures that every event corresponding to a committed business transaction will eventually make it to Kafka. Combined with Kafka's internal producer idempotence (enable.idempotence=true), this forms a formidable layer of "effectively once" guarantees on the producer side.
Pillar 2: Kafka Broker Guarantees - Storing and Delivering Reliably
Kafka itself plays a crucial role in ensuring message durability and influencing delivery semantics.
Durability and Replication
- Replication Factor: Configuring a replication factor greater than 1 (
replication.factor=3is common) ensures that each partition has multiple copies across different brokers. This prevents data loss if a single broker fails. - In-Sync Replicas (ISRs): Brokers maintain a set of ISRs for each partition. When
acks=allis used, a message write is only considered successful if it's written to all ISRs, guaranteeing high durability.
Consumer isolation.level
For applications using Kafka's transactional producers, the consumer's isolation.level is paramount:
read_uncommitted(default): Consumers will read all messages, including those from transactions that were eventually aborted. This can lead to consumers processing "ghost" messages.read_committed: Consumers will only read messages that are part of successfully committed transactions. They will skip messages from aborted transactions, ensuring that they only see the final, consistent state. This is essential for achieving end-to-end "effectively once" with transactional producers.
# Spring Boot Kafka Consumer Configuration
spring.kafka.consumer.isolation-level=read_committed
Pillar 3: Consumer Guarantees - Processing Messages Robustly
Even with a robust producer and Kafka's strong guarantees, the consumer application is the final frontier for achieving "effectively once" semantics. Due to network issues, application crashes, or rebalances, Kafka can (and will) deliver messages multiple times. The consumer must be built to handle these duplicates gracefully.
The Idempotent Consumer Pattern
The Idempotent Consumer Pattern is the cornerstone of reliable message processing. An idempotent consumer is one that can process the same message multiple times without causing unintended side effects or corrupting data.
Key Principle: Track the processing of messages and prevent re-processing if a message has already been successfully handled.
Implementation Strategies:
Primary Key Constraint: If your business domain naturally has a unique identifier for the outcome of a message (e.g., an
order_idin anorderstable), you can use this as a primary key or unique constraint. If a duplicate message attempts to create the sameorder_idagain, the database will throw a unique constraint violation, effectively deduplicating.Separate Deduplication Table: For more general cases, or when the business logic doesn't directly map to a unique key, maintain a dedicated
processed_messagestable in your database. This table stores a unique identifier for each message once it has been successfully processed.-- Deduplication Table DDL (PostgreSQL) -- 메시지 중복 제거 테이블 (Message deduplication table) CREATE TABLE processed_messages ( message_id VARCHAR(255) PRIMARY KEY, -- Can be Kafka record's UUID, offset+partition, or domain event ID consumer_group_id VARCHAR(255) NOT NULL, -- Essential for multi-group scenarios processed_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, UNIQUE (message_id, consumer_group_id) -- Ensures uniqueness per consumer group );The
message_idcan be derived from the Kafka record (e.g., a combination oftopic-partition-offset) or, more robustly, a unique business identifier embedded in the message payload (e.g.,event_idfrom the outbox). Using a composite unique key(message_id, consumer_group_id)is crucial if different consumer groups might process the same event but perform different actions, each needing its own idempotency check.
Atomic Processing and Offset Commits:
The critical aspect here is to ensure that the message processing, updating the application's state in PostgreSQL, and marking the message as "processed" in the deduplication table all happen within a single, local database transaction. Only after this transaction commits successfully should the Kafka consumer's offset be committed.
Spring Boot's @Transactional annotation integrates beautifully with Kafka listeners for this purpose.
// Spring Boot 4.0 Example: Idempotent Consumer
@Service
public class OrderEventHandler {
private final OrderRepository orderRepository; // Assume this stores materialized views or aggregates
private final ProcessedMessageRepository processedMessageRepository;
private final KafkaTemplate<String, String> kafkaTemplate; // For sending internal follow-up events if needed
public OrderEventHandler(OrderRepository orderRepository,
ProcessedMessageRepository processedMessageRepository,
KafkaTemplate<String, String> kafkaTemplate) {
this.orderRepository = orderRepository;
this.processedMessageRepository = processedMessageRepository;
this.kafkaTemplate = kafkaTemplate;
}
@KafkaListener(topics = "order-events", groupId = "order-processing-group", containerFactory = "kafkaListenerContainerFactory")
@Transactional // Ensures atomicity of processing and idempotency check
public void handleOrderCreated(
@Payload String message,
@Headers MessageHeaders headers,
AcknowledgeableConsumerRecord<String, String> record // For manual offset management
) {
OrderCreatedEvent event = objectMapper.readValue(message, OrderCreatedEvent.class);
String eventId = event.getEventId().toString(); // Assuming event has a unique ID
String consumerGroupId = "order-processing-group"; // Or derive dynamically
// 1. Check for idempotency
if (processedMessageRepository.existsByMessageIdAndConsumerGroupId(eventId, consumerGroupId)) {
// 메시지 이미 처리됨. 중복 무시 (Message already processed. Ignoring duplicate.)
System.out.println("Duplicate message received and ignored for event ID: " + eventId);
record.acknowledge(); // Acknowledge the offset immediately
return;
}
try {
// 2. Process the message and update application state
// 예를 들어, 주문 상태 변경 또는 새로운 주문 생성 (e.g., update order status or create new order)
Order order = new Order(event.getOrderId(), event.getCustomerId(), event.getAmount(), OrderStatus.CREATED);
orderRepository.save(order); // 애플리케이션 상태 업데이트 (Update application state)
// 3. Mark message as processed
// 중복 방지를 위해 처리된 메시지 기록 (Record processed message for deduplication)
ProcessedMessage processedMessage = new ProcessedMessage(eventId, consumerGroupId);
processedMessageRepository.save(processedMessage);
// 4. If everything above succeeds, acknowledge the Kafka offset
record.acknowledge(); // Kafka 오프셋 커밋 (Commit Kafka offset)
} catch (DataIntegrityViolationException e) {
// This might catch cases where a unique business key constraint fires
// 데이터 무결성 위반 (Data integrity violation)
System.err.println("Data integrity violation, likely a business-level duplicate for event ID: " + eventId + ". Ignoring.");
record.acknowledge(); // Acknowledge and move on
} catch (Exception e) {
// Handle other processing errors. Re-throw to trigger transaction rollback and re-delivery.
// 다른 처리 오류 핸들링. 트랜잭션 롤백 및 재전송 트리거 (Handle other processing errors. Trigger rollback and re-delivery.)
System.err.println("Error processing message for event ID: " + eventId + ". Rolling back transaction. Error: " + e.getMessage());
throw new RuntimeException("Failed to process event " + eventId, e); // Spring will roll back the transaction
}
}
}
For the kafkaListenerContainerFactory to work with @Transactional, you need to configure a ChainedKafkaTransactionManager or simply ensure your KafkaTransactionManager is configured correctly if your processing doesn't involve multiple transactional resources. However, when combining local DB transactions with Kafka offset management, it's typically more robust to handle record.acknowledge() manually after the local transaction commits. Spring Boot's default ContainerProperties.AckMode.RECORD or MANUAL_IMMEDIATE_ASYNC when used with @Transactional will automatically commit the offset after the transaction, which is exactly what we want.
Multi-OS Setup for Local Development
To run these examples locally, you'll need Docker Compose for Kafka and PostgreSQL.
| Feature / Command | Windows (WSL2/Docker Desktop) | macOS (Docker Desktop) | Linux (Docker Engine) |
|---|---|---|---|
| Docker Compose File | docker-compose.yml (same content) | docker-compose.yml (same content) | docker-compose.yml (same content) |
| Start Services | docker-compose up -d | docker-compose up -d | docker-compose up -d |
| Stop Services | docker-compose down | docker-compose down | docker-compose down |
| View Logs | docker-compose logs -f | docker-compose logs -f | docker-compose logs -f |
| Kafka Client (e.g., CLI) | Use docker exec -it <kafka-broker-container-id> bash then /bin/kafka-topics.sh ... or a GUI client. | Use docker exec -it <kafka-broker-container-id> bash then /bin/kafka-topics.sh ... or a GUI client. | Use docker exec -it <kafka-broker-container-id> bash then /bin/kafka-topics.sh ... or a GUI client. |
| PostgreSQL Client (e.g., PSQL) | docker exec -it <pg-container-id> psql -U user -d dbname | docker exec -it <pg-container-id> psql -U user -d dbname | docker exec -it <pg-container-id> psql -U user -d dbname |
| Common Issue | Port conflicts, WSL2 network issues. | Port conflicts, Docker resources. | Port conflicts, user permissions (sudo). |
Example docker-compose.yml:
version: '3.8'
services:
zookeeper:
image: confluentinc/cp-zookeeper:7.5.0
container_name: zookeeper
hostname: zookeeper
ports:
- "2181:2181"
environment:
ZOOKEEPER_CLIENT_PORT: 2181
ZOOKEEPER_TICK_TIME: 2000
kafka:
image: confluentinc/cp-kafka:7.5.0
container_name: kafka
hostname: kafka
ports:
- "9092:9092"
- "9094:9094" # For internal communication within Docker network
depends_on:
- zookeeper
environment:
KAFKA_BROKER_ID: 1
KAFKA_ZOOKEEPER_CONNECT: 'zookeeper:2181'
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9094,PLAINTEXT_HOST://localhost:9092
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1
KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1
KAFKA_AUTO_CREATE_TOPICS_ENABLE: "true"
KAFKA_CONFLUENT_SUPPORT_METRICS_ENABLE: "false"
postgresql:
image: postgres:15.3-alpine
container_name: postgresql
hostname: postgresql
ports:
- "5432:5432"
environment:
POSTGRES_DB: event_db
POSTGRES_USER: user
POSTGRES_PASSWORD: password
volumes:
- pgdata:/var/lib/postgresql/data # 데이터 영속성 유지 (Persist data)
volumes:
pgdata:
Putting It All Together: An End-to-End Architecture for "Effectively Once"
Let's visualize the entire flow and how each component contributes to achieving "effectively once" semantics:
graph TD
subgraph Producer Service (Spring Boot)
A[Client Request] --> B(Business Logic: Create Order)
B --> C{DB Transaction: <br>1. Save Order <br>2. Insert Outbox Event}
C -- Commit --> D[PostgreSQL: orders table & outbox_events table]
end
subgraph Outbox Poller / Debezium Connector
E[Monitors outbox_events table]
E -- New Events --> F(Publishes to Kafka)
F -- Successful Publish --> G{DB Transaction: <br>Mark outbox_event processed}
G -- Commit --> D
end
subgraph Apache Kafka
H(Order Events Topic)
I(Kafka Brokers: <br>Replicated, Idempotent Producer, <br>read_committed Consumers)
end
subgraph Consumer Service (Spring Boot)
J[Kafka Listener: <br>Order Events Topic (read_committed)]
J --> K{DB Transaction: <br>1. Check processed_messages <br>2. Process Business Logic (e.g., Update Materialized View) <br>3. Insert into processed_messages}
K -- If not processed & Commit --> L[PostgreSQL: <br>materialized_views table & processed_messages table]
K -- If already processed OR error --> M[Rollback or Ignore Duplicate, <br>then Commit Kafka Offset]
end
D -- Data Flow --> E
F --> I
I --> H
H --> J
L -- Data Flow --> J[Next Event Processing]
M -- Acknowledge Offset --> H
Scenario Walkthrough for Robustness:
Producer Transaction Success: Client request comes in, order is saved, outbox event is created, local DB transaction commits. The outbox poller picks up the event, publishes it to Kafka, and marks it as processed. Kafka acknowledges. Consumer reads, processes, marks as processed in its local DB, and acknowledges. Effectively once.
Producer Crash After DB Commit, Before Kafka Send: The business transaction (order save + outbox entry) commits. The application crashes before the outbox poller publishes the message to Kafka. When the producer service restarts, the outbox poller will find the unprocessed event in the
outbox_eventstable and publish it. No data loss, effectively once.Kafka Broker Failure: Kafka's replication and ISRs ensure that as long as
min.insync.replicasare available, data is durable. If a broker fails, a new leader is elected, and existing data is still available. Producers and consumers automatically reconnect. No data loss, message delivery continues.Consumer Crash After Processing, Before Kafka Offset Commit: The consumer receives a message, processes it, updates its local DB, and inserts into
processed_messages. But before it can commit its Kafka offset, the application crashes. When the consumer restarts, Kafka redelivers the same message. The idempotent consumer checksprocessed_messages, finds theevent_id, and gracefully ignores the duplicate processing, then commits the offset. Effectively once.Consumer Crash During Processing: The consumer receives a message and begins processing. Halfway through, the application crashes. Because the processing happened within a local database transaction, the transaction will roll back on restart. When Kafka redelivers the message, the consumer will attempt to process it again, as no record of it being
processedwas committed. Effectively once.
Advanced Considerations & Caveats
While the described architecture provides a strong foundation for "effectively once" processing, it's essential to consider practical implications and edge cases.
Performance Implications:
- Deduplication Lookups: Frequent checks against
processed_messagescan add latency. Ensuremessage_idandconsumer_group_idare indexed properly in PostgreSQL. Consider caching recently processed message IDs for very high-throughput scenarios, but be mindful of cache consistency. - Transactional Overhead: Every message processing involves a database transaction. For extremely high-volume, low-latency scenarios where "at-least-once" is acceptable, you might relax these guarantees for certain message types.
- Batching: Processing messages in batches (e.g., using
KafkaListenerwithList<ConsumerRecord>) can amortize transactional overhead if the idempotent check can also be done in a batch. However, error handling becomes more complex with batching.
- Deduplication Lookups: Frequent checks against
State Management of
processed_messages:- The
processed_messagestable will grow indefinitely. Implement a cleanup strategy (e.g., a scheduled job) to remove entries older than a certain retention period (e.g., older than Kafka's topic retention). Ensure this retention period is sufficient to cover any potential consumer downtime and re-processing windows.
- The
Multi-Consumer Group Scenarios:
- As highlighted, using a composite
UNIQUE(message_id, consumer_group_id)is vital if multiple consumer groups process the same logical event but perform different actions. Each group needs its own deduplication scope.
- As highlighted, using a composite
External System Interactions:
- If your idempotent consumer interacts with external third-party APIs (e.g., payment gateways, notification services), those external calls must also be idempotent, or you'll need an Asynchronous Outbound Gateway pattern for the consumer itself. This means the consumer writes a "command" to its local outbox for the external system, and a separate poller/publisher handles the reliable, idempotent interaction with the external system. This adds another layer of complexity but is necessary for true end-to-end guarantees beyond your own services.
Monitoring and Alerting:
- Instrument your outbox poller and idempotent consumers with metrics (e.g., using Micrometer, Prometheus). Monitor
outbox_events.processed=falsecount, duplicate message detections by consumers, and transaction commit rates. Anomalies can indicate processing backlogs or issues with idempotency logic.
- Instrument your outbox poller and idempotent consumers with metrics (e.g., using Micrometer, Prometheus). Monitor
Testing with Testcontainers:
- Robustly testing these patterns requires a full environment. Testcontainers (as covered in a previous post) is invaluable for spinning up Kafka, PostgreSQL, and even Debezium connectors within your integration tests, allowing you to simulate failures and verify exactly-once behavior.
Java 25 and Virtual Threads:
- While Java 25's Virtual Threads (Project Loom) significantly improve the efficiency of I/O-bound operations and reduce context-switching overhead, they do not directly solve the exactly-once problem. They can, however, make your Spring Boot applications more performant when dealing with the blocking I/O of database transactions and Kafka communication, allowing you to scale out your transactional producers and idempotent consumers more effectively without increasing thread pool sizes drastically. The core logic of idempotency and transactional boundaries remains crucial, but Virtual Threads can make it cheaper to implement.
Troubleshooting / What if it doesn't work?
Even with the best intentions, distributed systems are tricky. Here are common issues and troubleshooting steps:
"My consumers are still processing duplicates!"
- Check
isolation.level: Ensure your Kafka consumer is configured withisolation-level=read_committedif your producers are transactional. - Review Idempotency Logic: Double-check your
processed_messagestable schema for unique constraints. Is themessage_idtruly unique for each logical event? Are you usingconsumer_group_idif necessary? - Transaction Boundaries: Is your
@Transactionalannotation correctly placed on the consumer'shandlemethod, encompassing both the business logic and theprocessed_messagesinsert? - Offset Commits: Are you explicitly calling
record.acknowledge()only after the database transaction successfully commits? If using Spring's automatic offset management, ensure theAckMode(e.g.,RECORDorMANUAL_IMMEDIATE_ASYNC) is compatible with your@Transactionalsetup. - Database Connectivity: Transient database connection issues can lead to transaction rollbacks, causing Kafka messages to be re-delivered. Ensure your database is stable and connection pooling is robust.
- Check
"My outbox messages aren't being sent to Kafka!"
- Outbox Poller Status: Is your
@Scheduledpoller running? Check application logs for errors in the poller's execution. - Database Transaction: Did the initial business transaction (e.g.,
createOrder) successfully commit the outbox event to PostgreSQL? Verify theoutbox_eventstable content. - Kafka Connectivity: Can your poller connect to Kafka? Check Kafka broker logs and network connectivity.
processedFlag: Ensure theprocessedflag is being correctly set toTRUEafter successful Kafka publish. If not, the poller will keep trying to send the same messages.
- Outbox Poller Status: Is your
"My application is too slow with all these transactions and lookups!"
- Indexing: Verify that
outbox_events.processedandprocessed_messages.message_id,consumer_group_idcolumns have appropriate database indexes. - Necessity: Do all messages require strict "effectively once"? Some analytics or non-critical events might tolerate "at-least-once" with simpler processing. Categorize your events.
- Batching (Carefully): For high-volume scenarios, consider processing Kafka messages in batches within a single transaction, then performing a bulk
UPSERTintoprocessed_messages. This significantly increases complexity for error handling within a batch, but can improve throughput. - Database Performance: Profile your PostgreSQL queries. Is the database itself a bottleneck? Optimize queries, consider faster storage, or scale your database.
- Indexing: Verify that
"What if my database connection drops during a transaction commit?"
- This is typically handled by the database itself. If a transaction commit fails mid-way, the database will roll back the transaction. For the producer, this means neither the business data nor the outbox event is saved. For the consumer, this means neither the business logic nor the
processed_messagesentry is recorded, leading to Kafka re-delivery. This scenario is generally safe, as the transaction manager handles atomicity at the database level.
- This is typically handled by the database itself. If a transaction commit fails mid-way, the database will roll back the transaction. For the producer, this means neither the business data nor the outbox event is saved. For the consumer, this means neither the business logic nor the
Conclusion
Mastering "exactly-once" message processing in a distributed environment is not about achieving a mythical true "exactly-once" state, but about strategically applying patterns to ensure "effectively once" semantics. By meticulously combining Kafka's powerful transactional producers and read_committed isolation with the Transactional Outbox Pattern on the producer side and the Idempotent Consumer Pattern on the consumer side, all meticulously orchestrated within Spring Boot 4.0 applications and PostgreSQL, you can build event-driven microservices that are truly resilient, consistent, and trustworthy.
This architectural blueprint empowers you to construct systems where every critical business event leaves its mark precisely once, eliminating data corruption and ensuring the integrity of your most valuable asset: your data. Embrace these patterns, test rigorously with tools like Testcontainers, and continuously monitor your systems to confidently navigate the complexities of modern backend engineering.
🔗 Recommended Articles for Further Reading
- [Previous Post] [2026 Deep Dive] Mastering Stateful Stream Processing: Real-time Analytics & Complex Event Processing with Kafka Streams, Spring Boot 4.0, and PostgreSQL
- [Next Post] Stay tuned! The next technical deep-dive is coming up shortly.
🔍 Deep-Dive Search Index & Tags
Developer Intent & Synonyms: Exactly-Once, Exactly-Once Processing, Kafka Exactly-Once, Spring Boot Kafka Exactly-Once, Event-Driven Architecture, Idempotent Consumer, Transactional Outbox Pattern, Apache Kafka, PostgreSQL, Distributed Transactions, Message Deduplication, Data Consistency, Microservices Reliability, Spring Boot 4.0, Java 25 Backend, End-to-End Guarantees, Kafka Streams, 메시지 정확히 한 번 처리, 카프카 멱등성, 스프링 부트 카프카, 분산 트랜잭션, 데이터 일관성, 마이크로서비스 신뢰성, 멱등성 컨슈머, 트랜잭션 아웃박스 패턴