How to Implement Distributed Transactions in Microservices
In monolithic architectures backed by a single relational database, transactional data integrity is guaranteed by the database engine's ACID properties: if an operation fails mi...
In monolithic architectures backed by a single relational database, transactional data integrity is guaranteed by the database engine’s ACID properties: if an operation fails midway, an immediate SQL ROLLBACK restores previous state. In microservice architectures adhering to the Database per Service pattern, each service operates its own isolated datastore (PostgreSQL, MongoDB, Redis). There is no global transaction coordinator, and network calls can fail, time out, or experience network partitions. Consequently, distributed consistency must be engineered at the application layer through BASE principles and Eventual Consistency.
🟢 Junior Level
Why Monolithic ACID Fails in Microservices
Monolithic Architecture (Single ACID Boundary):
[Client] ──► [Application] ──► [Single Database] (BEGIN ──► INSERT/UPDATE ──► COMMIT / ROLLBACK)
Microservices Architecture (BASE / Eventual Consistency):
[Order Service] [Payment Service] [Warehouse Service]
│ │ │
▼ ▼ ▼
[(Order DB)] [(Payment DB)] [(Warehouse DB)]
When an operation requires coordinating updates across multiple independent services, three primary implementation strategies exist:
- Saga Pattern: A sequence of decoupled local transactions. Each microservice commits changes to its own database. If a downstream step fails, the system executes Compensating Transactions in reverse order to semantically undo earlier operations.
- TCC (Try-Confirm-Cancel): A two-phase business-level reservation protocol. Resources are temporarily held (
Try), then finalized upon global success (Confirm), or released upon failure (Cancel). - Two-Phase Commit (2PC / XA): A heavy, synchronous distributed locking protocol. A coordinator queries participants (“Prepare?”), and if all acknowledge readiness, issues a global “Commit”. In modern cloud microservices, 2PC is considered an anti-pattern due to synchronous locking, single-point-of-failure vulnerabilities, and incompatibility with NoSQL and message brokers.
Golden Architectural Rule: The best distributed transaction is the one you design away. If two domain entities must mutate strictly atomically at all times, they usually belong to the same Bounded Context and should be co-located within a single microservice.
🟡 Middle Level
1. The Dual-Write Problem
A common architectural trap is attempting to persist a database record and publish a message to a broker within the same method:
// ❌ CATASTROPHIC ANTI-PATTERN: The Dual-Write Problem!
@Transactional
public void createOrder(Order order) {
orderRepository.save(order); // Step 1: Database write
kafkaTemplate.send("orders", new OrderCreatedEvent(order.getId())); // Step 2: Network message
// If Kafka broker is down or network disconnects: Order is saved, but event is NEVER sent!
// If database commit fails after sending message: Event is sent, but order DOES NOT EXIST!
}
2. The Solution: Transactional Outbox Pattern
The business entity and the corresponding outbox event are saved within a single local ACID transaction in the relational database. A separate background process or Change Data Capture (CDC) engine reliably reads the outbox table and streams the events to the broker:
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
private final OutboxRepository outboxRepository;
private final ObjectMapper objectMapper;
@Transactional
public Order createOrder(CreateOrderCommand cmd) {
Order order = new Order(cmd.customerId(), cmd.items(), OrderStatus.PENDING);
orderRepository.save(order);
OutboxEvent event = new OutboxEvent(
UUID.randomUUID(),
"Order",
order.getId().toString(),
"OrderCreated",
objectMapper.writeValueAsString(new OrderCreatedPayload(order.getId(), order.getTotalAmount()))
);
outboxRepository.save(event); // Persisted ATOMICALLY with the order!
return order;
}
}
3. Comparison Matrix: Saga vs. TCC vs. 2PC
| Evaluation Parameter | Saga Pattern | TCC (Try-Confirm-Cancel) | 2PC (Two-Phase Commit) |
|---|---|---|---|
| Implementation Layer | Application (Domain Events/Commands) | Application (Business Reservations) | Infrastructure / DB Engine (XA) |
| Resource Locking | None; changes committed immediately | Soft lock (reserved_amount field) |
Hard database row locks until commit |
| Intermediate State Visibility | Dirty: Intermediate states visible | Semi-isolated: Held funds unavailable | Fully isolated: Invisible until commit |
| Complexity & Effort | Moderate | High (Each operation requires 3 methods) | Low in code, extremely fragile in ops |
| Network Communication | Asynchronous (Message Broker) | Synchronous or Asynchronous (RPC/REST) | Synchronous RPC |
| Throughput & Scalability | ⚡ High | 🟡 Moderate | 🔴 Low (Lock contention) |
🔴 Senior Level
1. Concurrency Anomalies in Saga and Semantic Countermeasures
Because each step in a Saga commits to its local database immediately, distributed transactions lack Isolation (the “I” in ACID), exposing the system to classical concurrency anomalies:
- Lost Updates: Saga A decrements warehouse inventory. Concurrent Saga B reads the modified quantity and updates it. When Saga A fails and applies a compensation, it overwrites Saga B’s update.
- Dirty Reads: Saga A debits a user balance. Saga B reads the reduced balance and rejects a withdrawal. Saga A subsequently fails on inventory booking and refunds the money. Saga B made an irreversible business decision based on dirty intermediate state.
Structural Countermeasures:
- Semantic Lock: Entities declare an intermediate status (e.g.,
OrderStatus.PENDING_APPROVAL,account.frozen_balance = 100). Downstream business logic inspects this flag and defers conflicting mutations. - Commutative Updates: Operations are structured so that ordering differences yield the identical final balance (e.g., credit and debit adjustments without rigid balance threshold checks).
- Three-Tier Saga Step Ordering:
```
Structure of a Robust Saga:
- Compensable Transactions ──► Can be rolled back via compensation if downstream steps fail
- Pivotal Transaction ──► Point of no return (if it succeeds, saga MUST run to completion)
- Retriable Transactions ──► Follow the pivotal step; guaranteed to succeed via idempotent retries ```
2. Transactional Outbox Delivery: Polling Publisher vs. CDC (Debezium)
- Polling Publisher (
SELECT ... FOR UPDATE SKIP LOCKED): A scheduled worker queries records withstatus = 'NEW'from the outbox table every $N$ milliseconds. Drawbacks: Creates significant database disk I/O, index bloat (especially with PostgreSQL MVCC needing aggressiveVACUUM), and introduces polling delay latency. - Change Data Capture (CDC via Debezium):
Debezium connects directly to the database replication stream (PostgreSQL WAL or MySQL binlog). It tails committed transaction logs asynchronously and streams events to Kafka in single-digit milliseconds without issuing
SELECTqueries against the primary database engine.
[Service] ──(Single Local ACID Tx)──► [PostgreSQL: orders + outbox]
│ (WAL Engine)
▼
[Debezium CDC]
│ (Streaming)
▼
[Apache Kafka]
3. Consumer Idempotency (The Idempotent Consumer Pattern)
Because distributed message brokers provide At-Least-Once delivery semantics, network retries and consumer group rebalances inevitably cause duplicate message consumption. Consumers must implement deduplication via an idempotent receiver table:
CREATE TABLE processed_messages (
message_id VARCHAR(64) PRIMARY KEY,
processed_at TIMESTAMP NOT NULL,
handler_name VARCHAR(100) NOT NULL
);
@Transactional
public void handle(OrderPaidEvent event) {
if (processedMessageRepo.existsById(event.eventId())) {
log.warn("Duplicate event detected: {}, skipping", event.eventId());
return;
}
processedMessageRepo.save(new ProcessedMessage(event.eventId(), Instant.now(), "OrderPaidHandler"));
deliveryService.scheduleDelivery(event.orderId());
}
4 Tricky Questions
1. Why is Two-Phase Commit (2PC) considered an anti-pattern in modern cloud microservices?
Answer:
- Synchronous Locking: During the Prepare phase, exclusive locks are held on database rows across all participating databases until the coordinator issues Commit. Under high concurrency, this causes massive lock contention, latency amplification, and connection pool exhaustion.
- Coordinator Single Point of Failure (In-Doubt State): If the coordinator crashes after issuing Prepare but before broadcasting Commit, participants remain stuck in an “In-Doubt” state. They cannot unilaterally commit or roll back, leaving database rows locked indefinitely until manual administrative intervention.
- Lack of Ecosystem Support: Modern distributed architectures rely on heterogeneous storage (MongoDB, Cassandra, DynamoDB) and message brokers (Kafka, RabbitMQ) that do not support the XA protocol.
2. What is the fundamental operational difference between the Saga Pattern and the TCC (Try-Confirm-Cancel) Pattern?
Answer:
- In Saga, each step performs a full, immediately committed local database modification. If a subsequent step fails, an independent compensating transaction is executed to revert the business effect. Intermediate mutated data is visible system-wide throughout execution.
- In TCC, operations are explicitly staged into two phases:
Try: Resources are not finalized; they are reserved (e.g., deducting fromavailable_balanceand adding tofrozen_balance).Confirm/Cancel: If all participants succeed duringTry,Confirmfinalizes the deduction fromfrozen_balance. If any participant fails,Cancelunfreezes the reserved balance. TCC eliminates dirty reads at the business layer, but requires significantly higher development complexity (writing three distinct methods for every operation).
3. What happens if a Compensating Transaction in a Saga fails?
Answer:
- A compensating transaction cannot fail due to business validation logic because there is no mechanism to “roll back a rollback.”
- In the event of transient technical failures (network timeouts, database unavailability), the orchestrator or participant must retry the compensation indefinitely using Exponential Backoff.
- If an unrecoverable failure occurs (e.g., account closed, persistent constraint violation):
- The transaction state is marked as
MANUAL_INTERVENTION_REQUIRED. - The failure event is published to a Dead Letter Queue (DLQ).
- SRE/Operations alerting is triggered immediately for manual ledger adjustment via an administrative dashboard.
- The transaction state is marked as
4. Why does a naive Polling Publisher (@Scheduled) for Transactional Outbox fail to scale under high production loads?
Answer:
- Lock Contention: Multiple application instances executing
SELECT ... FOR UPDATEagainst the same outbox table compete for table and index locks, creating severe database CPU overhead. - Table Bloat & MVCC Churn: Rapid
INSERT,UPDATE(status = PROCESSED), andDELETEcycles in relational databases (especially PostgreSQL) lead to severe dead-tuple bloat, degrading index performance and requiring continuous, resource-intensiveVACUUMprocesses. - End-to-End Latency: Event dispatch latency is bounded by the polling frequency (e.g., 500ms–1s). CDC engines (Debezium) resolve this by reading binary replication logs asynchronously without SQL query overhead.
🎯 Interview Cheat Sheet
30-Second Elevator Pitch
“Distributed transactions across microservices cannot rely on single-database ACID guarantees due to the Database per Service pattern. The industry standard is BASE (Eventual Consistency) implemented via the Saga Pattern (orchestrated or choreographed) or TCC (Try-Confirm-Cancel) for financial reservations. To avoid message loss during service-to-broker communication, the Transactional Outbox Pattern with Debezium CDC guarantees atomic database updates and event publishing. Because Sagas lack Isolation, systems must employ Semantic Locks (Pending statuses) and enforce Idempotent Consumers across all message listeners.”
Distributed Transaction Design Checklist
- Can this cross-service boundary be eliminated by merging related entities into a single Bounded Context?
- Is the Transactional Outbox pattern implemented to prevent the Dual-Write problem?
- Are all forward and compensating transactions designed to be strictly idempotent?
- Is the pivotal transaction clearly identified in the workflow sequence?
- Are Dead Letter Queues (DLQ) and operational alerts configured for failed compensations?
Red Flags (What NOT to Say)
- ❌ “We use Spring
@Transactionalto coordinate operations across multiple microservices via REST/HTTP.” —@Transactionalbinds to a single JDBC connection and cannot span network boundaries. - ❌ “Two-Phase Commit (2PC) is the standard approach for cloud-native microservices.” — 2PC holds blocking distributed locks, severely degrades throughput, and is incompatible with Kafka/NoSQL.
- ❌ “I send messages directly to Kafka inside a Spring
@Transactionalmethod.” — The Dual-Write anti-pattern; risks data loss or ghost messages if the database commit fails. - ❌ “Compensating transactions execute an SQL ROLLBACK on the remote service’s database.” — Remote transactions are already committed; compensations execute new corrective forward business actions.
Related Topics
- What is Saga Pattern and When to Use It — Core Saga mechanics
- What is the Difference Between Choreography and Orchestration in Saga — Saga coordination models
- What are Compensating Transactions — Designing compensations & idempotency
- What is Database per Service pattern — Microservice data ownership
- What is the difference between synchronous and asynchronous communication — Event-driven vs RPC