Chapter 14: Distributed Transactions
2PC, 3PC, Saga, TCC, and Consistency Patterns
1. The Problem: ACID Across Multiple Nodes
Why Distributed Transactions Are Hard
Single-node transactions rely on ACID guarantees enforced by the database. When operations span multiple nodes, services, or databases, these guarantees break down.
There is no global clock, no shared memory, and network failures are indistinguishable from slow responses. Any participant can fail at any time, and partial failures are the norm.
Failure Modes in Distributed Transactions
| Failure Mode | Description | Impact | Detection |
|---|---|---|---|
| Participant Crash | A service goes down during transaction | Uncertain state - did it commit? | Timeout or explicit error |
| Coordinator Crash | Central coordinator fails | Participants left in limbo | Timeout |
| Network Partition | Network split isolates nodes | Cannot reach consensus | Timeout (indistinguishable from crash) |
| Message Loss | Network drops packets | Operations not received | Timeout or missing ack |
| Partial Success | Some participants commit, others fail | Data inconsistency | Explicit rollback needed |
| Byzantine Failure | Node behaves maliciously or erratically | Corrupted state propagation | Verification, signatures |
Motivating Example: E-Commerce Order
Placing an order requires coordinating multiple operations across services:
Order Placement Transaction:
1. Inventory Service: Reserve product (decrement stock)
2. Payment Service: Charge customer's card
3. Order Service: Create order record
4. Shipping Service: Create shipping request
5. Notification Service: Send confirmation email
What happens if:
- Step 2 fails? (Must unreserve inventory)
- Step 3 fails? (Must refund payment AND unreserve)
- Network partitions after step 2? (Money charged, but order not created)
but what if we fail
AFTER charging?
The Dual-Write Problem
The classic anti-pattern that necessitates distributed transactions:
// Anti-pattern: Two separate writes
database.insert(order); // Write 1
messageQueue.publish(order); // Write 2
// What if:
// - DB insert succeeds, but message publish fails?
// - Message publish succeeds, but DB insert fails?
// - Process crashes between the two operations?
// Result: Inconsistency between DB and downstream consumers
CAP Theorem Implications
| Approach | Consistency | Availability | Partition Tolerance | Trade-off |
|---|---|---|---|---|
| 2PC/3PC | ✓ Strong | ✗ Blocks | ✗ Fails | CP - unavailable during partition |
| Saga | ~ Eventual | ✓ High | ✓ Handles | AP - temporary inconsistency |
| TCC | ✓ Strong | ~ Medium | ~ Partial | Hybrid - reserves resources |
| Event Sourcing | ~ Eventual | ✓ High | ✓ Handles | AP - compensate via events |
When asked about distributed transactions, immediately identify the consistency requirements. Strong consistency (2PC) vs. eventual consistency (Saga) is the first decision point. Then discuss failure modes and recovery strategies.
2. Two-Phase Commit (2PC)
Overview
2PC is a blocking, synchronous protocol that ensures all participants either commit or abort. It uses a coordinator to orchestrate a two-phase process.
Protocol Steps
| Phase | Coordinator Action | Participant Action | Guarantees |
|---|---|---|---|
| Phase 1: Prepare | Send PREPARE to all participants | Vote YES (can commit) or NO (abort) If YES: write to redo log, hold locks |
After YES vote, participant MUST be able to commit |
| Phase 2: Commit | If all YES: send COMMIT If any NO: send ABORT |
Execute COMMIT or ABORT Release locks, respond with ACK |
Decision is final and durable |
Success Case: All Participants Commit
Hold locks P1-->>Co: YES Note over P2: Write redo log
Hold locks P2-->>Co: YES Note over P3: Write redo log
Hold locks P3-->>Co: YES Note over Co: All YES received
Write COMMIT to log
Phase 2: COMMIT Co->>P1: COMMIT Co->>P2: COMMIT Co->>P3: COMMIT Note over P1: Apply changes
Release locks P1-->>Co: ACK Note over P2: Apply changes
Release locks P2-->>Co: ACK Note over P3: Apply changes
Release locks P3-->>Co: ACK Note over Co: Transaction complete
Failure Case: One Participant Votes NO
Write ABORT to log
Phase 2: ABORT Co->>P1: ABORT Co->>P2: ABORT Co->>P3: ABORT Note over P1: Rollback
Release locks P1-->>Co: ACK Note over P2: Rollback
Release locks P2-->>Co: ACK Note over P3: Rollback
Release locks P3-->>Co: ACK Note over Co: Transaction aborted
The Blocking Problem
2PC's critical flaw: if the coordinator crashes after participants vote YES but before sending COMMIT, participants are stuck holding locks indefinitely.
(before writing COMMIT decision) Note over P1: Waiting...
Locks held
Cannot commit or abort Note over P2: Waiting...
Locks held
Cannot commit or abort Note over P1,P2: BLOCKED until coordinator recovers
Participants cannot unilaterally decide to commit or abort after voting YES. They must wait for the coordinator. If the coordinator crashes, participants remain blocked, holding locks and preventing progress.
State Transitions
with COMMIT BLOCKED --> Aborted: Coordinator recovers
with ABORT }
Implementation Details
class TwoPhaseCommitCoordinator {
function beginTransaction(participants) {
txnId = generateId();
log.write("BEGIN", txnId);
// Phase 1: PREPARE
votes = [];
for (participant in participants) {
vote = participant.prepare(txnId);
votes.push(vote);
if (vote == NO || timeout) {
// Early abort
decision = ABORT;
break;
}
}
// Make decision (write to durable log FIRST)
if (all votes == YES) {
decision = COMMIT;
} else {
decision = ABORT;
}
log.write(decision, txnId); // CRITICAL: durable before sending
// Phase 2: COMMIT or ABORT
for (participant in participants) {
participant.finalize(txnId, decision);
}
// Wait for acks (optional, fire-and-forget also works)
waitForAcks(participants, txnId);
log.write("END", txnId);
return decision;
}
}
class TwoPhaseCommitParticipant {
function prepare(txnId) {
// Try to acquire locks and validate constraints
if (!canCommit(txnId)) {
return NO;
}
// Write tentative changes to redo log (durable)
log.write("PREPARED", txnId, changes);
// Hold locks until COMMIT or ABORT received
locks.hold(txnId);
return YES; // Promise to commit if asked
}
function finalize(txnId, decision) {
if (decision == COMMIT) {
// Apply changes from redo log
applyChanges(txnId);
log.write("COMMITTED", txnId);
} else {
// Discard redo log
rollback(txnId);
log.write("ABORTED", txnId);
}
// Release locks
locks.release(txnId);
return ACK;
}
function recover(txnId) {
// On restart, check log
state = log.read(txnId);
if (state == PREPARED) {
// Still waiting for coordinator decision
// Must contact coordinator or wait
decision = askCoordinator(txnId);
finalize(txnId, decision);
}
}
}
Recovery and Coordinator Failover
| Scenario | Coordinator State | Participant State | Recovery Action |
|---|---|---|---|
| Coordinator crashes before PREPARE | No log entry | No state | Abort (transaction never started) |
| Coordinator crashes during PREPARE | BEGIN logged, no decision | Some YES, some nothing | Abort (not all participants prepared) |
| Coordinator crashes after logging COMMIT | COMMIT logged | Some PREPARED, some COMMITTED | Send COMMIT to remaining participants |
| Coordinator crashes after logging ABORT | ABORT logged | Some PREPARED, some ABORTED | Send ABORT to remaining participants |
| Participant crashes after voting YES | Waiting for acks | PREPARED logged | Participant asks coordinator on restart |
| Network partition during Phase 2 | COMMIT logged | Some unreachable | Retry COMMIT until ack (or timeout and reconcile) |
Performance Characteristics
| Metric | Value | Explanation |
|---|---|---|
| Latency | 2+ network round trips | PREPARE + COMMIT phases, plus fsync for logs |
| Lock Duration | Entire transaction + 2 RTTs | Locks held from PREPARE to COMMIT ack |
| Throughput | Low (blocking) | Locks reduce concurrency, coordinator bottleneck |
| Availability | Poor (coordinator SPOF) | Single coordinator failure blocks all in-flight txns |
| Fault Tolerance | Limited | Cannot tolerate coordinator failure during commit phase |
Real-World Usage
| System | Use Case | Notes |
|---|---|---|
| XA Transactions | JDBC, JTA (Java) | Standard 2PC implementation for distributed DBs |
| MySQL Cluster | Multi-node commits | 2PC within cluster, not across clusters |
| PostgreSQL | PREPARE TRANSACTION | Manual 2PC support, rarely used in practice |
| MS DTC | Windows distributed transactions | Microsoft Distributed Transaction Coordinator |
| Oracle RAC | Cluster coordination | Modified 2PC with cache fusion |
- Recommending 2PC for microservices (too brittle, high latency)
- Not mentioning the blocking problem
- Ignoring lock duration impact on throughput
- Claiming 2PC is "highly available" (it's CP, not AP)
When to Use 2PC
- Strong consistency is non-negotiable (financial transactions)
- Participants are co-located (same data center, low latency)
- Transaction volume is low (not a high-throughput system)
- Participants support XA or similar protocol
- Acceptable to block during coordinator failure (e.g., batch jobs)
When to Avoid 2PC
- Microservices across multiple data centers (network latency kills performance)
- High-throughput systems (lock duration bottleneck)
- Need high availability during failures (coordinator SPOF)
- Participants don't all support 2PC
- Can tolerate eventual consistency (use Saga instead)
3. Three-Phase Commit (3PC)
Overview
3PC attempts to solve 2PC's blocking problem by adding a third phase: pre-commit. This allows participants to make progress even if the coordinator crashes, under certain assumptions.
The Three Phases
| Phase | Coordinator Action | Participant Action | Purpose |
|---|---|---|---|
| Phase 1: CanCommit | Send CAN-COMMIT query | Vote YES (can commit) or NO (abort) | Same as 2PC PREPARE, but lighter |
| Phase 2: PreCommit | If all YES: send PRE-COMMIT If any NO: send ABORT |
If PRE-COMMIT: ack and prepare to commit If ABORT: abort |
Participants know decision is coming |
| Phase 3: DoCommit | Send DO-COMMIT | Commit and release locks | Actual commit |
Success Case
Phase 2: PreCommit Co->>P1: PRE-COMMIT Co->>P2: PRE-COMMIT Note over P1: Prepare to commit
Know commit is coming P1-->>Co: ACK Note over P2: Prepare to commit
Know commit is coming P2-->>Co: ACK Note over Co: Phase 3: DoCommit Co->>P1: DO-COMMIT Co->>P2: DO-COMMIT Note over P1: Commit
Release locks P1-->>Co: ACK Note over P2: Commit
Release locks P2-->>Co: ACK Note over Co: Transaction complete
Non-Blocking on Coordinator Failure
Key improvement: if coordinator crashes after PRE-COMMIT, participants know the decision was commit and can proceed.
(before DO-COMMIT) Note over P1: Timeout waiting for DO-COMMIT
But I know decision was commit Note over P1: After timeout, assume COMMIT P1->>P1: COMMIT Note over P2: Timeout waiting for DO-COMMIT
But I know decision was commit Note over P2: After timeout, assume COMMIT P2->>P2: COMMIT Note over P1,P2: NON-BLOCKING:
Can proceed without coordinator
State Transitions
The Network Partition Problem
3PC's Achilles heel: it assumes a synchronous network with bounded delays. Network partitions break this assumption.
If a network partition occurs after PRE-COMMIT is sent but before some participants receive it, the system splits into two groups:
- Participants that received PRE-COMMIT assume COMMIT after timeout
- Participants that didn't receive PRE-COMMIT assume ABORT after timeout
- Result: Split-brain inconsistency
Never got PRE-COMMIT Note over P3: Assume ABORT (safe default) P3->>P3: ABORT Note over P1,P3: INCONSISTENCY!
P1,P2 committed
P3 aborted
Comparison: 2PC vs 3PC
| Aspect | 2PC | 3PC |
|---|---|---|
| Phases | 2 (PREPARE, COMMIT) | 3 (CAN-COMMIT, PRE-COMMIT, DO-COMMIT) |
| Latency | 2 round trips | 3 round trips (50% higher) |
| Blocking on Coordinator Crash | YES (after PREPARE) | NO (after PRE-COMMIT, participants assume commit) |
| Network Partition Handling | Blocks but stays consistent | Can split-brain (P1 commits, P2 aborts) |
| Consistency Guarantee | Strong (all or nothing) | Strong only in synchronous networks |
| Real-World Usage | Common (XA, databases) | Rare (theory only, too fragile) |
| Failure Model | Fail-stop (crash-recovery) | Assumes synchronous network (unrealistic) |
Why 3PC is Rarely Used
- Higher Latency: 50% more round trips than 2PC
- Network Partition Vulnerability: Can lead to split-brain inconsistency
- Synchronous Network Assumption: Real networks are asynchronous
- Timeout Complexity: Choosing correct timeout values is hard
- No Significant Benefit: In practice, coordinator crashes are rare enough that 2PC blocking is acceptable, or eventual consistency (Saga) is better
When discussing 3PC, emphasize that it's a theoretical improvement over 2PC but fails under network partitions. In practice, 2PC is preferred for strong consistency (accepting the blocking risk), or Saga for eventual consistency (avoiding blocking entirely).
Theoretical Interest vs. Practical Reality
| Aspect | Theory | Practice |
|---|---|---|
| Non-blocking | Participants can proceed after timeout | Only safe in synchronous networks (unrealistic) |
| Fault Tolerance | Survives coordinator failure | Only if no network partition occurs |
| Adoption | Promising improvement over 2PC | Almost never used (too complex, too fragile) |
| Partition Tolerance | Assumed not to happen (synchronous model) | Partitions are common in real networks |
Conclusion: 3PC is a textbook protocol that demonstrates the difficulty of achieving non-blocking distributed consensus. In practice, systems use 2PC for strong consistency (accepting blocking) or move to eventual consistency patterns (Saga, TCC) that embrace asynchrony.
4. Saga Pattern
Overview
Saga is an eventual consistency pattern that breaks a distributed transaction into a sequence of local transactions, each with a compensating transaction to undo it if needed. No distributed locks, no blocking.
Instead of locking resources across services (2PC), let each service commit locally, then compensate if something fails later. Trade strong consistency for availability and scalability.
Two Flavors: Choreography vs. Orchestration
| Aspect | Choreography | Orchestration |
|---|---|---|
| Coordination | Decentralized - services react to events | Centralized - orchestrator controls flow |
| Communication | Event-driven (pub/sub) | Request/response (RPC) |
| Complexity | Distributed (harder to debug) | Localized (easier to reason about) |
| Coupling | Services must know next step | Services are decoupled (orchestrator knows flow) |
| Visibility | Harder to track saga state | Orchestrator has full visibility |
| Best For | Simple workflows, loose coupling | Complex workflows, need observability |
Choreography: Event-Driven Saga
Each service publishes events, and other services react by executing their local transaction and publishing the next event.
Choreography: Failure and Compensation
Orchestration: Centralized Saga Coordinator
A dedicated orchestrator service controls the flow, calling each participant and handling failures.
Orchestration: Failure and Rollback
Compensating Transactions
Compensating transactions are NOT rollbacks - they are forward-correcting operations that semantically undo the effect.
| Forward Transaction | Compensating Transaction | Notes |
|---|---|---|
| Reserve inventory | Release reservation | Idempotent: safe to call multiple times |
| Charge payment | Issue refund | May require manual review if disputes occur |
| Send email | Send cancellation email | Cannot "unsend" original email |
| Create shipping label | Cancel shipment | May incur cancellation fee |
| Allocate budget | Deallocate budget | Must handle partial consumption |
| Update user profile | Revert to previous profile | Need to store old state |
Not all actions are compensable:
- Cannot compensate: Sending an email, publishing to external API, physical world actions
- Partial compensation: Refunds may have fees, shipment cancellation may have costs
- Time-sensitive: Compensation may fail if too much time passes (e.g., shipment already delivered)
Saga State Machine
Implementation: Orchestrator Pseudocode
class SagaOrchestrator {
function executeSaga(sagaDefinition, input) {
sagaId = generateId();
state = initializeState(sagaId, input);
try {
// Execute forward steps
for (step in sagaDefinition.steps) {
result = executeStep(step, state);
if (result.failed) {
throw new SagaFailure(step, result.error);
}
// Save state after each step (for recovery)
state = updateState(sagaId, step, result);
}
// All steps succeeded
markCompleted(sagaId);
return state;
} catch (error) {
// Saga failed - execute compensating transactions
compensate(sagaId, state, error);
throw error;
}
}
function compensate(sagaId, state, originalError) {
completedSteps = state.getCompletedSteps();
// Execute compensations in REVERSE order
for (step in reverse(completedSteps)) {
try {
compensatingTxn = step.getCompensation();
compensatingTxn.execute(state);
state = updateState(sagaId, "compensated_" + step.name, null);
} catch (compensationError) {
// Compensation failed - log and retry
log.error("Compensation failed for step " + step.name, compensationError);
retryCompensation(sagaId, step, state);
}
}
markFailed(sagaId, originalError);
}
function recover(sagaId) {
// On restart, load state from durable storage
state = loadState(sagaId);
if (state.status == "IN_PROGRESS") {
// Resume saga from last completed step
resumeSaga(sagaId, state);
} else if (state.status == "COMPENSATING") {
// Resume compensation
resumeCompensation(sagaId, state);
}
}
}
Saga Execution Guarantees (SEC)
| Property | Guarantee | Enforcement |
|---|---|---|
| Semantic Atomicity | Either all steps complete OR all compensations execute | Orchestrator must retry compensations on failure |
| Eventual Consistency | System reaches consistent state eventually | Compensations must succeed (retries + manual intervention) |
| Isolation | NOT guaranteed (dirty reads possible) | Use semantic locks or versioning if needed |
Saga's biggest weakness: intermediate states are visible. If Order Saga reserves inventory but payment fails, another client might see "low stock" during the compensation window. Mitigations:
- Semantic locks: Mark inventory as "pending" vs "reserved"
- Commutative operations: Design operations to be order-independent
- Versioning: Read-only transactions see consistent snapshots
- Accept anomalies: Document that temporary inconsistencies are expected
Real-World Example: Order Placement Saga
| Step | Service | Forward Transaction | Compensating Transaction |
|---|---|---|---|
| 1 | Inventory | Reserve items (set status=RESERVED) | Release reservation (set status=AVAILABLE) |
| 2 | Payment | Authorize card (hold funds) | Cancel authorization (release funds) |
| 3 | Payment | Capture payment (charge card) | Refund payment (return funds) |
| 4 | Loyalty | Allocate points (credit account) | Revoke points (debit account) |
| 5 | Shipping | Create shipment (generate label) | Cancel shipment (void label) |
| 6 | Order | Create order record (status=PLACED) | Mark order cancelled (status=CANCELLED) |
| 7 | Notification | Send confirmation email | Send cancellation email |
Saga Orchestration Tools
| Tool | Type | Features | Best For |
|---|---|---|---|
| Temporal | Workflow engine | Durable execution, automatic retries, versioning | Complex workflows, long-running sagas |
| Cadence | Workflow engine | Similar to Temporal (predecessor) | Uber's open-source version |
| Camunda | BPMN workflow | Visual workflow designer, BPMN standard | Enterprise workflows, compliance |
| AWS Step Functions | Managed orchestration | Serverless, visual workflow, retry policies | AWS-native applications |
| Apache Airflow | DAG-based workflow | Python-based, batch-oriented | Data pipelines, batch jobs |
| Netflix Conductor | Microservice orchestrator | JSON workflow definition, polyglot | Microservice coordination |
| Eventuate Tram Saga | Saga framework | Java library, event-driven | Spring Boot microservices |
When to Use Saga
- Distributed transactions across microservices
- Can tolerate eventual consistency (not immediate consistency)
- Each step can be compensated (or manual intervention is acceptable)
- Need high availability and throughput (no blocking)
- Long-running transactions (minutes to hours)
When to Avoid Saga
- Strong consistency required (use 2PC or single database)
- Steps cannot be compensated (e.g., physical actions)
- Isolation is critical (intermediate states must not be visible)
- Simple use case (overhead not justified)
When asked about microservice transactions, Saga is usually the right answer. Emphasize orchestration for complex workflows (better observability) and choreography for simple event-driven flows. Always discuss compensating transactions and lack of isolation.
Complete Saga Example: E-Commerce Order (All 5 Steps + All Compensations)
This end-to-end example demonstrates a complete order placement saga, including the exact sequence of forward transactions and compensating transactions when a step fails.
Forward Transactions (Success Path)
| Step | Service | Action | Result |
|---|---|---|---|
| 1 | Order Service | createOrder(items, user_id) |
order_id=ORD-123status=PENDING |
| 2 | Inventory Service | reserveItems(order_id, items) |
reservation_id=RES-456Stock marked RESERVED |
| 3 | Payment Service | chargeCard(order_id, amount=$99.50, idempotency_key=ORD-123-PAY) |
payment_id=PAY-789Card charged |
| 4 | Shipping Service | createShipment(order_id, address) |
shipment_id=SHIP-012Label generated |
| 5 | Notification Service | sendConfirmation(order_id, user_email) |
Email queued for delivery |
Failure Scenario: Shipping Service Down at Step 4
Problem: After successfully charging the customer's card (step 3), the shipping service API is down. The saga must compensate by refunding the payment, releasing inventory, and canceling the order.
Compensating Transactions (Executed in Reverse Order)
| Step | Service | Compensation Action | Result |
|---|---|---|---|
| 5 | Notification Service | Never executed (saga failed before this step) | No compensation needed |
| 4 | Shipping Service | Failed (API down) | ❌ Trigger compensation chain |
| 3 | Payment Service | refundPayment(payment_id=PAY-789, reason="shipping_failed") |
refund_id=REF-345$99.50 refunded to card |
| 2 | Inventory Service | releaseReservation(reservation_id=RES-456) |
Stock status restored to AVAILABLE |
| 1 | Order Service | cancelOrder(order_id=ORD-123, reason="shipping_unavailable") |
status=CANCELLEDUser notified |
Key Saga Rules
- Reverse Order: Compensations execute in reverse order (5 → 4 → 3 → 2 → 1) to maintain consistency
- Idempotency: Each compensation must be idempotent (safe to retry). If
refundPayment(PAY-789)is called twice, the second call is a no-op - Eventual Success: Compensations must EVENTUALLY succeed. If
releaseReservationfails (Inventory service down), retry forever with exponential backoff - Dead Letter Queue: After 10 retries (or configurable threshold), move failed compensation to DLQ for human intervention
- State Persistence: Saga state (completed steps, pending compensations) must be durably stored (database, workflow engine) to survive coordinator crashes
Saga Flow: Success and Failure Paths
Execute Compensations (Reverse Order) Note over Orch,Notif: Step 5 never executed - skip compensation Orch->>Pay: COMPENSATE 3: refundPayment(PAY-789, reason="shipping_failed") Pay-->>Orch: ✓ refund_id=REF-345 Orch->>Inv: COMPENSATE 2: releaseReservation(RES-456) Inv-->>Orch: ✓ Stock restored to AVAILABLE Orch->>Order: COMPENSATE 1: cancelOrder(ORD-123, reason="shipping_unavailable") Order-->>Orch: ✓ status=CANCELLED Note over Orch: Saga Compensated Successfully
Customer refunded, inventory released
Alternative: Success Path
Saga Succeeded Orch->>Order: updateOrderStatus(ORD-123, status=CONFIRMED) Order-->>Orch: ✓ Order confirmed
Handling Compensation Failures: Retry and DLQ
What if a compensation fails? Example: Payment service refunds the card successfully, but Inventory service is down and cannot release the reservation.
Compensation Retry Logic:
1. Attempt releaseReservation(RES-456)
2. If fails: Retry after 1s (exponential backoff: 1s, 2s, 4s, 8s, ...)
3. After 10 retries (or 5 minutes elapsed): Move to Dead Letter Queue
4. Alert operations team: "Saga ORD-123 compensation stuck: inventory reservation RES-456 not released"
5. Human intervention: Manually release reservation or wait for Inventory service recovery and retry
If compensation fails permanently, the system is in an inconsistent state: customer refunded but inventory still marked RESERVED. This is why saga compensations must be designed to eventually succeed (either by fixing the service or manual intervention). Unlike forward transactions (which can fail and trigger compensation), compensations cannot fail permanently.
Idempotency Example: Refund Payment
Saga orchestrator might call refundPayment(PAY-789) multiple times due to retries or crashes. The payment service must ensure idempotency:
function refundPayment(payment_id, reason) {
// Check if refund already exists (idempotency)
existing_refund = db.query("SELECT * FROM refunds WHERE payment_id = ?", payment_id);
if (existing_refund) {
return existing_refund; // Already refunded, return existing refund
}
// First time - execute refund
refund_id = stripe.refund(payment_id, reason);
db.insert("INSERT INTO refunds (refund_id, payment_id, reason, created_at) VALUES (?, ?, ?, NOW())", refund_id, payment_id, reason);
return { refund_id: refund_id, status: "success" };
}
Result: Calling refundPayment(PAY-789) 10 times results in exactly 1 refund, not 10 refunds.
Real-World Production Details
| Aspect | Implementation |
|---|---|
| State Storage | PostgreSQL table: saga_instances (saga_id, current_step, status, created_at, updated_at)saga_step_log (saga_id, step_num, service, action, result, timestamp) |
| Retry Policy | Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 64s (max) Max retries: 10 attempts or 5 minutes total |
| Timeout | Each step: 30s timeout Entire saga: 5 minutes timeout (prevents infinite hangs) |
| Observability | Metrics: saga_started_total, saga_completed_total, saga_failed_total, saga_compensation_total Traces: Each saga is a single trace with spans for each step Logs: Structured logs with saga_id, step, service, action, result |
| Dead Letter Queue | SQS/Kafka topic: saga-compensation-dlqAlert on message arrival → PagerDuty escalation |
| Idempotency Keys | Format: {saga_id}-{step_num}-{service}Example: ORD-123-3-payment (ensures step 3 payment is executed exactly once) |
Start with the high-level saga flow (forward transactions → failure → compensations in reverse). If the interviewer asks "How do you ensure compensations succeed?" or "What if a compensation fails?", dive into retry logic, idempotency, and DLQ. This demonstrates production-readiness, not just theoretical knowledge.
5. TCC Pattern (Try-Confirm-Cancel)
Overview
TCC is a three-phase distributed transaction pattern that reserves resources upfront (Try), then either commits (Confirm) or releases (Cancel) them. It's a middle ground between 2PC and Saga.
The Three Phases
| Phase | Purpose | Participant Action | Guarantee |
|---|---|---|---|
| Try | Reserve resources | Lock or mark resources as "pending" Validate constraints Return success/failure |
If Try succeeds, resources are reserved and Confirm WILL succeed |
| Confirm | Commit the transaction | Execute business logic with reserved resources Release locks |
Must be idempotent (may be retried) |
| Cancel | Abort and release resources | Release reserved resources Rollback any Try phase changes |
Must be idempotent (may be retried) |
TCC Flow: Success Case
Create reservation record I-->>Co: Success (reservation ID) Co->>P: Try: Hold funds Note over P: Authorize card
Create hold record P-->>Co: Success (hold ID) Co->>O: Try: Create order Note over O: Create order with status=PENDING
Store order ID O-->>Co: Success (order ID) Note over Co: All Try succeeded
Phase 2: CONFIRM Co->>I: Confirm: Commit reservation Note over I: Change RESERVED → SOLD
Delete reservation record I-->>Co: Confirmed Co->>P: Confirm: Capture payment Note over P: Capture held funds
Delete hold record P-->>Co: Confirmed Co->>O: Confirm: Finalize order Note over O: Change PENDING → CONFIRMED O-->>Co: Confirmed Note over Co: Transaction complete
TCC Flow: Failure Case (Cancel)
Phase 3: CANCEL Co->>I: Cancel: Release reservation Note over I: Delete reservation
Mark stock as AVAILABLE I-->>Co: Cancelled Note over Co: Transaction cancelled
How TCC Differs from Saga
| Aspect | Saga | TCC |
|---|---|---|
| Resource Reservation | No - commits immediately | Yes - reserves in Try phase |
| Isolation | None (dirty reads possible) | Better (reserved resources not visible) |
| Consistency Window | Eventual (compensation takes time) | Shorter (Try → Confirm is fast) |
| Complexity | Moderate (2 operations per service) | High (3 operations per service) |
| Performance | Fast (each service commits independently) | Slower (extra Try phase) |
| Use Case | General microservice transactions | Financial systems, inventory mgmt |
TCC Implementation: Inventory Service Example
class InventoryService {
// Try: Reserve stock
function tryReserve(productId, quantity, txnId) {
// Check if enough stock available
available = db.query("SELECT quantity FROM inventory WHERE product_id = ?", productId);
if (available < quantity) {
return { success: false, error: "Insufficient stock" };
}
// Create reservation record (tentative hold)
reservation = {
id: generateId(),
txnId: txnId,
productId: productId,
quantity: quantity,
status: "RESERVED",
expiresAt: now() + 10minutes // Timeout for Try phase
};
db.insert("reservations", reservation);
// Decrement available inventory (but don't commit sale yet)
db.update("inventory SET reserved = reserved + ? WHERE product_id = ?", quantity, productId);
return { success: true, reservationId: reservation.id };
}
// Confirm: Commit the reservation
function confirm(txnId) {
reservation = db.query("SELECT * FROM reservations WHERE txn_id = ?", txnId);
if (!reservation) {
// Already confirmed or cancelled - idempotent
return { success: true };
}
// Move from reserved to sold
db.update("inventory SET sold = sold + ?, reserved = reserved - ? WHERE product_id = ?",
reservation.quantity, reservation.quantity, reservation.productId);
// Delete reservation record
db.delete("reservations WHERE txn_id = ?", txnId);
return { success: true };
}
// Cancel: Release the reservation
function cancel(txnId) {
reservation = db.query("SELECT * FROM reservations WHERE txn_id = ?", txnId);
if (!reservation) {
// Already cancelled - idempotent
return { success: true };
}
// Release reserved inventory
db.update("inventory SET reserved = reserved - ? WHERE product_id = ?",
reservation.quantity, reservation.productId);
// Delete reservation record
db.delete("reservations WHERE txn_id = ?", txnId);
return { success: true };
}
// Background job: Clean up expired reservations
function cleanupExpiredReservations() {
expired = db.query("SELECT * FROM reservations WHERE expires_at < ?", now());
for (reservation in expired) {
cancel(reservation.txnId);
}
}
}
TCC Coordinator
class TCCCoordinator {
function executeTransaction(participants, txnId) {
tryResults = [];
try {
// Phase 1: TRY on all participants
for (participant in participants) {
result = participant.try(txnId);
if (!result.success) {
throw new TryFailure(participant, result.error);
}
tryResults.push({ participant, result });
}
// All Try succeeded - log COMMIT decision
log.write("TCC_COMMIT", txnId);
// Phase 2: CONFIRM on all participants
for (entry in tryResults) {
entry.participant.confirm(txnId);
}
log.write("TCC_COMPLETED", txnId);
return { success: true };
} catch (error) {
// Try phase failed - log CANCEL decision
log.write("TCC_CANCEL", txnId, error);
// Phase 3: CANCEL on all participants (including ones that succeeded)
for (entry in tryResults) {
try {
entry.participant.cancel(txnId);
} catch (cancelError) {
// Cancel must succeed - retry
retryCancel(entry.participant, txnId);
}
}
log.write("TCC_CANCELLED", txnId);
return { success: false, error };
}
}
function recover(txnId) {
state = log.read(txnId);
if (state == "TCC_COMMIT") {
// Coordinator decided COMMIT - resume Confirm phase
resumeConfirm(txnId);
} else if (state == "TCC_CANCEL") {
// Coordinator decided CANCEL - resume Cancel phase
resumeCancel(txnId);
}
}
}
Idempotency Requirements
All TCC operations (Try, Confirm, Cancel) MUST be idempotent because the coordinator may retry them.
| Operation | Idempotency Strategy | Example |
|---|---|---|
| Try | Check if reservation already exists for txnId | If reservation exists, return existing reservation ID |
| Confirm | Check if already confirmed (reservation record deleted) | If no reservation found, assume already confirmed, return success |
| Cancel | Check if already cancelled (reservation record deleted) | If no reservation found, assume already cancelled, return success |
TCC State Diagram
Real-World Use Cases
| Domain | Use Case | Try | Confirm | Cancel |
|---|---|---|---|---|
| E-Commerce | Order placement | Reserve inventory, authorize payment | Commit sale, capture payment | Release inventory, void authorization |
| Banking | Money transfer | Hold funds in source account | Debit source, credit destination | Release hold |
| Travel Booking | Flight + hotel | Reserve flight seat, reserve hotel room | Book flight, book hotel | Release seat, release room |
| Cloud Resources | Multi-resource allocation | Reserve VM, reserve storage, reserve IP | Provision VM, provision storage, assign IP | Release reservations |
| Ticket Sales | Concert tickets | Hold tickets for 10 minutes | Sell tickets | Release held tickets |
Advantages of TCC
- Better Isolation: Reserved resources not visible to other transactions
- Predictable: If Try succeeds, Confirm will succeed (no surprises)
- Shorter Inconsistency Window: Faster than Saga's eventual consistency
- No Rollback Complexity: Cancel is simpler than Saga compensation
Disadvantages of TCC
- High Complexity: Every service must implement 3 operations (Try, Confirm, Cancel)
- Resource Locking: Reservations hold resources (availability impact)
- Timeout Management: Must handle expired Try phase reservations
- Extra Round Trip: Try phase adds latency
- Idempotency Required: All operations must be idempotent (implementation burden)
When to Use TCC
- Financial transactions where isolation matters (e.g., prevent double-spending)
- Inventory management with strict availability guarantees
- Resource allocation systems (cloud, tickets, reservations)
- Need better isolation than Saga but can't afford 2PC blocking
When to Avoid TCC
- Services don't naturally support reservations (implementation burden too high)
- Long-running transactions (holding reservations for hours is costly)
- Isolation not critical (Saga is simpler)
- Need true ACID guarantees (use 2PC or single database)
TCC is the "smart candidate" answer for financial systems and inventory problems. It shows you understand the trade-off between Saga (eventual consistency, no isolation) and 2PC (strong consistency, blocking). Emphasize the reservation mechanism and idempotency requirements.
9. Comparison Matrix
Overview Table: All Patterns
| Pattern | Consistency | Availability | Latency | Complexity | Isolation | Scalability |
|---|---|---|---|---|---|---|
| 2PC | ✅ Strong | ❌ Low (blocks) | ❌ High (2 RTTs + locks) | 🟡 Medium | ✅ Full (locks) | ❌ Low (coordinator bottleneck) |
| 3PC | 🟡 Strong* (partition breaks) | 🟡 Medium | ❌ Very High (3 RTTs) | ❌ High | ✅ Full (locks) | ❌ Low |
| Saga | 🟡 Eventual | ✅ High | ✅ Low (parallel commits) | 🟡 Medium | ❌ None | ✅ High |
| TCC | ✅ Strong (with reservations) | 🟡 Medium | 🟡 Medium (3 phases) | ❌ High (3 ops/service) | 🟡 Partial (reserved state) | 🟡 Medium |
| Outbox | ✅ Atomic (DB + event) | ✅ High | 🟡 Medium (relay delay) | 🟡 Medium | N/A (not a transaction protocol) | ✅ High |
Failure Handling Comparison
| Pattern | Coordinator Crash | Participant Crash | Network Partition | Recovery Complexity |
|---|---|---|---|---|
| 2PC | ❌ Blocks participants | 🟡 Coordinator waits (timeout) | ❌ Blocks or aborts | 🟡 Medium (coordinator log) |
| 3PC | 🟡 Non-blocking* (if no partition) | 🟡 Timeout and proceed | ❌ Can split-brain | ❌ High (complex state machine) |
| Saga | ✅ Orchestrator recovers from log | ✅ Retry or compensate | ✅ Eventually consistent | 🟡 Medium (idempotent steps) |
| TCC | ✅ Recovers from decision log | ✅ Retry Confirm/Cancel | 🟡 Timeout reservations | ❌ High (timeout management) |
| Outbox | ✅ Relay restarts and resumes | ✅ Event persisted in DB | ✅ Tolerant (eventual) | ✅ Low (simple retry) |
Use Case Fit
| Scenario | Best Pattern | Why | Avoid |
|---|---|---|---|
| Financial transactions (strong consistency required) | 2PC or TCC | Need atomicity, isolation critical | Saga (no isolation, eventual) |
| Microservices (independent teams, loosely coupled) | Saga (choreography) | Decentralized, event-driven, scales well | 2PC (coupling, blocking) |
| E-commerce order (cart → payment → shipping) | Saga (orchestration) | Complex workflow, need observability | 3PC (unnecessary complexity) |
| Inventory reservation (prevent overbooking) | TCC | Need isolation via reservations | Saga (dirty reads of inventory) |
| Event-driven architecture (DB + publish event) | Outbox | Atomic DB + event, no dual-write | Direct publish (message loss risk) |
| Single datacenter, low latency, XA support | 2PC | Network reliable, strong consistency | Saga (eventual consistency overkill) |
| Multi-region, high throughput, eventual consistency OK | Saga + Outbox | Scalable, partition-tolerant | 2PC (latency, blocking) |
| Payment processing (no double-charge) | TCC or 2PC + Idempotency keys | Isolation critical, idempotent retries | Saga without semantic locks |
Decision Tree
XA Transactions] C -->|No| F[TCC
Reservations] D -->|Yes| G[TCC
Semantic Locks] D -->|No| H{Complex workflow?} H -->|Yes| I[Saga
Orchestration] H -->|No| J[Saga
Choreography] style E fill:#FFC107 style F fill:#FF9800 style G fill:#FF9800 style I fill:#4CAF50 style J fill:#4CAF50
Performance Characteristics
| Pattern | Throughput | P99 Latency | Lock Duration | Network Hops | Coordinator Load |
|---|---|---|---|---|---|
| 2PC | Low (blocking) | High (100-500ms) | Entire transaction | 2 per participant | High (all txns) |
| 3PC | Very Low | Very High (150-750ms) | Entire transaction | 3 per participant | Very High |
| Saga | High (no blocking) | Low (10-100ms per step) | None (local commits) | 1 per step | Low (orchestrator only tracks state) |
| TCC | Medium | Medium (50-200ms) | Try → Confirm window | 3 per participant | Medium |
| Outbox | High (async) | Low (DB write latency) | None (event publish async) | 0 (async relay) | None (relay is separate) |
Implementation Complexity
| Pattern | Service Changes | Infrastructure | Testing Complexity | Debugging |
|---|---|---|---|---|
| 2PC | Low (XA support) | Transaction manager (DTC, JTA) | Low (synchronous) | Easy (coordinator log) |
| 3PC | High (custom impl) | Custom coordinator | Very High (timeouts, partitions) | Hard (distributed state) |
| Saga | High (compensations) | Orchestrator (Temporal, Conductor) | High (async, eventual) | Medium (workflow visibility) |
| TCC | Very High (3 ops each) | Coordinator + timeout mgmt | Very High (try/confirm/cancel) | Hard (reservation leaks) |
| Outbox | Medium (outbox table) | Relay or CDC (Debezium) | Medium (idempotency) | Easy (outbox table inspection) |
Start with clarifying questions:
- What consistency level is required? (Strong → 2PC/TCC, Eventual → Saga)
- What's the failure tolerance? (Network partition → avoid 2PC)
- What's the scale? (High throughput → Saga, Low → 2PC OK)
- Do services support XA? (Yes → 2PC option, No → Saga)
Then recommend pattern with trade-offs, don't just pick one blindly.
10. Real-World Patterns
Uber: Trip Lifecycle with Saga
Architecture
Trip Request Saga (Orchestrated with Cadence)
| Step | Service | Action | Compensation | Timeout |
|---|---|---|---|---|
| 1 | Rider Service | Validate rider (credit check) | N/A (read-only) | 5s |
| 2 | Map Service | Calculate route, estimate fare | N/A (read-only) | 10s |
| 3 | Payment Service | Pre-authorize card (hold funds) | Cancel authorization | 30s |
| 4 | Driver Service | Find and assign driver | Unassign driver | 60s |
| 5 | Trip Service | Create trip record (ACTIVE) | Cancel trip | 5s |
| 6 | Notification Service | Send push to rider & driver | Send cancellation push | 10s |
Why Cadence?
- Durable Execution: Workflow state persisted, survives failures
- Automatic Retries: Failed steps retry with exponential backoff
- Timeout Management: Each activity has timeout, workflow can compensate
- Versioning: Can deploy new workflow versions without breaking in-flight trips
- Observability: Full visibility into workflow execution (Cadence UI)
Stripe: Payment Idempotency and Webhooks
Architecture
Idempotency Key Flow
POST https://api.stripe.com/v1/charges
Headers:
Authorization: Bearer sk_test_...
Idempotency-Key: 7f3d9c8b-4e2a-4c9f-8e1a-5b6d7c8e9f0a
Body:
amount=1000
currency=usd
source=tok_visa
description=Order #12345
// Stripe's handling:
1. Check if Idempotency-Key exists in `idempotency_keys` table
2. If exists and status=completed → return cached response
3. If exists and status=processing → return 409 Conflict
4. If new → process payment, cache result with key
5. Return response
// Client can safely retry on network failure
// Same Idempotency-Key = same result (no double charge)
Webhook Delivery (Outbox Pattern)
| Step | Description | Guarantees |
|---|---|---|
| 1. Payment Processing | Stripe processes payment, writes to DB | ACID transaction |
| 2. Event Creation | Insert event into outbox table (same txn) | Atomic with payment |
| 3. Event Publishing | Background worker publishes from outbox | At-least-once delivery |
| 4. Webhook Delivery | POST to merchant's webhook URL | Retry with exponential backoff (up to 3 days) |
| 5. Idempotent Handling | Merchant checks event ID (deduplication) | Exactly-once semantics (at merchant) |
{
"id": "evt_1J3Q4R2eZvKYlo2C0X5Y6Z7A", // Event ID for deduplication
"type": "charge.succeeded",
"created": 1625097600,
"data": {
"object": {
"id": "ch_1J3Q4R2eZvKYlo2C0X5Y6Z7A",
"amount": 1000,
"currency": "usd",
"status": "succeeded"
}
}
}
// Merchant's webhook handler (idempotent):
function handleWebhook(event) {
// Check if already processed
exists = db.query("SELECT 1 FROM processed_stripe_events WHERE event_id = ?", event.id);
if (exists) {
return { status: 200, message: "Already processed" };
}
// Process event
db.beginTransaction();
updateOrder(event.data.object.id);
db.insert("processed_stripe_events", { event_id: event.id });
db.commit();
return { status: 200 };
}
DoorDash: Order Fulfillment Saga
Architecture
Order Placement Saga
| Step | Service | Action | Compensation | SLA |
|---|---|---|---|---|
| 1 | Payment | Pre-authorize card | Cancel authorization | 5s |
| 2 | Restaurant | Submit order to restaurant (POS) | Cancel order at restaurant | 30s |
| 3 | Dasher Dispatch | Find and assign Dasher | Unassign Dasher | 10min |
| 4 | Payment | Capture payment (charge card) | Refund payment | 10s |
| 5 | Notification | Send confirmation (SMS, push) | Send cancellation notice | 5s |
Temporal Workflow Code (Simplified)
@WorkflowMethod
public void fulfillOrder(Order order) {
try {
// Step 1: Pre-authorize payment
PaymentResult preAuth = activities.preAuthorizePayment(order.paymentMethod, order.total);
workflow.sleep(Duration.ofSeconds(1)); // Wait for auth to settle
// Step 2: Submit to restaurant
RestaurantResult restaurant = activities.submitToRestaurant(order.restaurantId, order.items);
// Step 3: Assign Dasher (with timeout)
DasherResult dasher = activities.assignDasher(
order.restaurantLocation,
order.deliveryLocation,
Duration.ofMinutes(10)
);
// Step 4: Capture payment
activities.capturePayment(preAuth.authId);
// Step 5: Send notifications
activities.sendNotification(order.customerId, "Order confirmed!");
activities.sendNotification(dasher.dasherId, "New delivery!");
// Mark workflow complete
activities.completeOrder(order.id);
} catch (Exception e) {
// Compensate in reverse order
compensate(order, e);
}
}
private void compensate(Order order, Exception originalError) {
try {
// Cancel in reverse order
activities.cancelDasherAssignment(order.id);
activities.cancelRestaurantOrder(order.id);
activities.cancelPaymentAuth(order.id);
activities.sendNotification(order.customerId, "Order cancelled: " + originalError.getMessage());
activities.markOrderFailed(order.id, originalError);
} catch (Exception compensationError) {
// Log and alert - manual intervention needed
log.error("Compensation failed for order " + order.id, compensationError);
alertOncall("Compensation failure", order.id, compensationError);
}
}
Why Temporal?
- Long-Running Workflows: Dasher assignment can take 10+ minutes
- Automatic Retries: Activity failures retry automatically
- Durable Timers:
workflow.sleep()survives process restarts - Versioning: Can change workflow logic without breaking in-flight orders
- Visibility: Temporal UI shows all in-flight orders, stuck workflows
Netflix: Event Sourcing for User Activity
Architecture
Event Sourcing Pattern
All state changes are stored as immutable events. Current state is derived by replaying events.
| Event Type | Payload | Consumers |
|---|---|---|
| VideoStarted | { userId, videoId, timestamp } | View history, Recommendations, Analytics |
| VideoProgress | { userId, videoId, position, timestamp } | Resume playback, Watch time tracking |
| VideoCompleted | { userId, videoId, timestamp } | Recommendations, Completion rate |
| VideoRated | { userId, videoId, rating, timestamp } | Recommendations, Content quality |
| ProfileCreated | { userId, profileId, name } | User service, Billing |
Guarantees
- Immutable Event Log: Events never deleted (Kafka retention = forever for critical topics)
- Ordering Per User: Kafka partition key = userId (all user events in order)
- Replay Capability: Can rebuild any view by replaying events
- Schema Evolution: Use Avro with schema registry (backward/forward compatible)
- Idempotent Consumers: All consumers handle duplicate events (event ID deduplication)
When discussing real-world systems, mention specific tools (Temporal, Cadence, Kafka) and patterns (Outbox, Event Sourcing). Show you understand trade-offs: Uber uses Cadence for observability, Stripe uses Outbox for webhook reliability, Netflix uses Event Sourcing for replayability.
11. When to Avoid Distributed Transactions
Alternative: Eventual Consistency
Many problems that seem to require distributed transactions can be solved with eventual consistency and domain modeling.
Example: Bank Transfer
| Approach | Implementation | Trade-offs |
|---|---|---|
| 2PC (Strong Consistency) | Lock both accounts, debit source, credit destination, commit | ✅ Atomic ❌ Blocks both accounts ❌ Low throughput |
| Saga (Eventual Consistency) | Debit source → emit event → credit destination Compensate if destination fails |
✅ High throughput ❌ Temporary inconsistency 🟡 Need compensating transaction |
| Event Sourcing (Eventual Consistency) | Log "TransferInitiated" event → async processors update accounts | ✅ Audit trail ✅ Replayable ❌ Complexity ❌ Eventual consistency |
Alternative: Single Database
If possible, keep related data in the same database. Use microservices when needed, but don't split data unnecessarily.
A well-designed monolith with a single database is simpler, faster, and more consistent than distributed microservices. Only split when you have clear scaling or organizational reasons (team autonomy, independent deployment).
When to Keep Data Together
- Strong consistency required: Financial transactions, inventory management
- Frequent joins: Queries span multiple entities (splitting causes N+1 problem)
- Low scale: Single DB can handle load (splitting adds complexity without benefit)
- ACID guarantees: Need rollback, foreign keys, constraints
Alternative: Accepting Inconsistency
Some business domains naturally tolerate inconsistency. Document and communicate these trade-offs.
Examples of Acceptable Inconsistency
| Domain | Inconsistency | Mitigation |
|---|---|---|
| E-Commerce Inventory | Overselling (more orders than stock) | Apologize, refund, offer discount for next purchase |
| Social Media Likes | Like count slightly off (cached) | Accept approximation (exact count not critical) |
| Analytics Dashboards | Metrics delayed by minutes | Label as "Last updated 5 min ago" |
| Recommendations | Recommendations stale (not reflecting latest views) | Accept staleness (recommendations are probabilistic anyway) |
| Leaderboards | Rankings update with delay | Accept eventual consistency (users refresh to see latest) |
Alternative: Reconciliation Jobs
Instead of preventing inconsistency, detect and fix it after the fact.
Reconciliation Pattern
// Run nightly to detect and fix inconsistencies
function reconcileOrders() {
// Find orders that are PAID but not SHIPPED after 24 hours
stuckOrders = db.query(`
SELECT o.id, o.status, p.status as payment_status, s.status as shipping_status
FROM orders o
JOIN payments p ON o.id = p.order_id
LEFT JOIN shipments s ON o.id = s.order_id
WHERE o.status = 'PAID'
AND (s.status IS NULL OR s.status = 'PENDING')
AND o.created_at < NOW() - INTERVAL 24 HOUR
`);
for (order in stuckOrders) {
if (order.payment_status == 'COMPLETED' && order.shipping_status == NULL) {
// Payment succeeded but shipment never created - create it now
log.warn("Reconciliation: Creating missing shipment for order", order.id);
shippingService.createShipment(order.id);
} else if (order.payment_status == 'FAILED' && order.shipping_status != NULL) {
// Payment failed but shipment exists - cancel shipment
log.warn("Reconciliation: Canceling shipment for failed payment", order.id);
shippingService.cancelShipment(order.id);
orderService.updateStatus(order.id, 'CANCELLED');
}
}
}
Reconciliation Use Cases
- Financial Systems: Daily balance reconciliation (sum of transactions = account balance)
- Inventory: Physical count vs. system count (adjust discrepancies)
- Distributed Caches: Compare cache vs. DB, invalidate stale entries
- Multi-Region: Compare data across regions, flag differences
Alternative: CQRS (Command Query Responsibility Segregation)
Separate write model (commands) from read model (queries). Accept that read model is eventually consistent.
Customer View)] Projector --> ReadDB2[(Read DB 2
Reporting)] Projector --> ReadDB3[(Read DB 3
Search)] Client -->|Read| Query[Query Handler] Query --> ReadDB1 Query --> ReadDB2 Query --> ReadDB3 style Command fill:#f44336 style Query fill:#4CAF50 style EventBus fill:#FF9800
| Aspect | Write Side | Read Side |
|---|---|---|
| Consistency | Strong (single write DB) | Eventual (async projection) |
| Model | Normalized, enforces business rules | Denormalized, optimized for queries |
| Scalability | Limited (single write DB) | High (multiple read DBs) |
| Technology | PostgreSQL, MySQL (relational) | ElasticSearch, MongoDB, Redis (optimized for reads) |
Decision Framework: Do I Need Distributed Transactions?
ACID guarantees
Simple] B -->|No| D{Strong consistency required?} D -->|Yes| E{Same datacenter?} D -->|No| F[Use Saga
Eventual consistency
High availability] E -->|Yes| G[Use 2PC
Accept blocking
XA transactions] E -->|No| H[Use TCC
Reservations
Accept complexity] D --> I{Can I accept temporary inconsistency?} I -->|Yes| J[Use Saga + Reconciliation
Fix inconsistencies later] I -->|No| K[Reconsider architecture
Maybe split is wrong] style C fill:#4CAF50 style F fill:#2196F3 style G fill:#FFC107 style H fill:#FF9800 style J fill:#4CAF50 style K fill:#f44336
Anti-Patterns to Avoid
| Anti-Pattern | Problem | Alternative |
|---|---|---|
| Distributed Transactions Everywhere | Every microservice operation requires 2PC | Consolidate related data into same service/DB |
| Saga for Simple Workflows | 3-step process doesn't need Saga complexity | Use single database transaction |
| 2PC Across WAN | High latency, network partition risk | Use Saga or TCC (eventual consistency) |
| No Idempotency | Retries cause duplicate side effects | Always design for idempotent operations |
| Ignoring Compensation Failures | Saga compensation fails, stuck in inconsistent state | Implement retry + alerting + manual intervention |
| No Observability | Cannot debug distributed transactions | Use orchestrator (Temporal) or distributed tracing |
The KISS Principle
Keep It Simple, Stupid. Distributed transactions add immense complexity. Avoid them if possible.
Every distributed transaction pattern (2PC, Saga, TCC) consumes your team's complexity budget. Use it wisely:
- Start with a monolith and single database (simple, fast, consistent)
- Split only when you have clear scaling or organizational reasons
- When you split, accept eventual consistency where possible (Saga)
- Use strong consistency (2PC/TCC) only for critical business requirements
- Invest in observability (Temporal, distributed tracing) to manage complexity
End your answer by acknowledging trade-offs: "I'd start with Saga for eventual consistency and high availability. If business requires strong consistency, I'd consider TCC for isolation or 2PC for same-datacenter deployments. But first, I'd question if we can avoid distributed transactions entirely by consolidating related data."