DEV Community

Jason Y. (dev_in_the_fog)
Jason Y. (dev_in_the_fog)

Posted on Originally published at global-utils.com

Distributed Transactions in Practice: 2PC vs Saga Orchestration and Compensating Workflows

In monolithic architectures, ACID transactions are taken for granted. A single BEGIN TRANSACTION and COMMIT block guarantees atomicity across tables.

However, as systems decompose into microservices with private databases, atomic multi-service transactions become distributed coordination bottlenecks. While textbook literature often suggests Two-Phase Commit (2PC) / XA protocols, real-world high-throughput production systems collapse under 2PC coordinator failure modes, synchronous holding of database row locks, and cascading network timeouts.

In this deep architectural breakdown, we analyze why 2PC fails in modern cloud networks, how to build resilient Saga Orchestrators, and how to enforce strictly idempotent Compensating Transactions using the Transactional Outbox pattern.


1. Why Two-Phase Commit (2PC) Fails in Distributed Cloud Networks

Two-Phase Commit relies on an atomic coordinator across two distinct phases:

[Coordinator] ─── (1. Prepare / Vote) ───► [Service A: Lock Rows]
              ─── (1. Prepare / Vote) ───► [Service B: Lock Rows]
       │
 (Wait for Consensus)
       │
       ├─── (2. Commit / Rollback) ───► [Service A: Commit & Release]
       └─── (2. Commit / Rollback) ───► [Service B: Commit & Release]
Enter fullscreen mode Exit fullscreen mode

The Fatal Flaws in Production:

  1. Synchronous Lock Holding: Participants must hold database row locks between the Prepare and Commit phases. Under high concurrency, connection pool exhaustion and transaction lock queues trigger massive tail latency spikes.
  2. Coordinator Single Point of Failure (SPOF): If the coordinator crashes after participants vote "YES" but before issuing "COMMIT", participants remain blocked indefinitely in an unresolvable in-doubt state.
  3. Network Partitions (CAP Theorem): Under cloud network blips, 2PC sacrifices availability entirely for consistency.

2. The Saga Pattern: Choreography vs Orchestration

Instead of holding global locks, a Saga splits a distributed transaction into a sequence of local transactions. Each transaction updates data in a single service and publishes a message/event.

Orchestration vs Choreography

  • Choreography (Event-Driven): Services subscribe to each other's domain events. Clean for 2-3 services, but quickly degenerates into an unmaintainable "spaghetti dependency" graph when failure compensation requires circular backtracking.
  • Orchestration (State Machine): A dedicated Saga Orchestrator manages the workflow state machine, explicitly invoking forward actions and triggering backward compensating workflows upon failure.
[Client Checkout]
       │
       ▼
 [Saga Orchestrator] ──(1. Reserve Inventory)──► [Inventory Service] (✅ OK)
       │
       ├──(2. Process Payment)────► [Payment Gateway] (❌ Card Declined!)
       │
       ▼
 [Compensate Flow]  ──(Undo Step 1)─────────► [Inventory Service: Release Stock]
Enter fullscreen mode Exit fullscreen mode

3. Bulletproof Compensating Transactions & Idempotency

Compensating transactions are semantic undos, not storage engine rollbacks:

  1. Compensations Can Arrive Out of Order: If network jitter causes the Cancel event to arrive before the Create event, the system must record a tombstone to prevent phantom reservations.
  2. Strict Idempotency via Unique Saga IDs: Every step must record the saga_id and step_id in an idempotency table before committing the local transaction.
-- PostgreSQL Idempotent Transaction Guard
INSERT INTO saga_idempotency_log (saga_id, step_name, status, created_at)
VALUES ('saga_ord_98421_xyz', 'RESERVE_INVENTORY', 'COMPLETED', NOW())
ON CONFLICT (saga_id, step_name) DO NOTHING;
Enter fullscreen mode Exit fullscreen mode

4. The Transactional Outbox Pattern with CDC

To guarantee that local database updates and outbound Kafka/RabbitMQ events succeed or fail atomically without distributed locks, implement the Transactional Outbox Pattern:

BEGIN;
  -- 1. Mutate business entity
  UPDATE orders SET status = 'PROCESSING' WHERE id = 'ord_123';

  -- 2. Insert event into outbox in the SAME ACID transaction
  INSERT INTO outbox_events (aggregate_type, aggregate_id, event_type, payload)
  VALUES ('ORDER', 'ord_123', 'OrderProcessingStarted', '{"amount": 149.00}');
COMMIT;
Enter fullscreen mode Exit fullscreen mode

A Change Data Capture (CDC) worker (e.g. Debezium or WAL reader) tails the outbox_events table and streams events to the message broker with zero dual-write race conditions.


🛠️ Free Zero-Trust Engineering Utilities

If you are designing distributed systems, formatting complex SQL schemas, or generating secure tokens, check out our free browser-based developer utilities:

Originally published at NerdKit Engineering.

Top comments (6)

Collapse
 
kashif_manzer profile image
Kashif Manzer •

One detail worth making explicit on the idempotency guard: the guard write and the business write have to live in the same local transaction. If you insert into the idempotency table first and commit the business change separately, a crash between the two lets the retried step run twice, which is exactly the duplicate you were trying to prevent. The same rule applies to compensating steps. They get retried on failure too, so an unguarded compensation can double-release the very inventory you are trying to restore.

Collapse
 
dev_in_the_fog profile image
Jason Y. (dev_in_the_fog) •

100% agreed, Kashif! This is a critical nuance that deserves bold emphasis.\n\nIf the idempotency record and the state change aren't committed atomically within the same database transaction (or coordinated via transactional outbox when crossing datastores), a crash window exists right between them. We’ve seen this exact pitfall bite production systems where a retry creates duplicate state because the guard wasn't strictly atomic with the business write.\n\nAnd your point on compensating transactions is equally vital — people frequently overlook that compensations must be just as defensive and idempotent as forward steps, since network partitions during rollbacks will trigger retries there too. Thanks for adding such a sharp architectural detail to the discussion! 🚀

Collapse
 
dev_in_the_fog profile image
Jason Y. (dev_in_the_fog) •

Thanks for the great feedback, Arjun! You hit the nail on the head. In our experience, teams often underestimate how quickly choreography turns into a distributed game of telephone once failure compensation paths require circular rollbacks.

Having an explicit state machine might feel like adding an architectural dependency upfront, but when a production outage hits at 3 AM, being able to query the exact saga state and step history in a single table is an absolute lifesaver. Glad the out-of-order compensation point resonated with you as well!

Collapse
 
dev_in_the_fog profile image
Jason Y. (dev_in_the_fog) •

Couldn't agree more, Humam. Without the transactional outbox, dual-write drift between the local DB and the message broker is practically guaranteed over any meaningful volume.

One practical lesson we learned the hard way with outbox tables is making sure the relay worker (whether CDC via Debezium or polling) handles out-of-order publisher retries cleanly, and keeping the idempotency deduplication keys keyed with sensible TTLs so storage doesn't blow up. Appreciate you reading and sharing your thoughts!

Collapse
 
sgaggjhkjh profile image
Arjun Sharma •

The comparison of choreography and orchestration is the part that matches what I have seen in practice. Choreography looks clean with two or three services, then a failure somewhere in the chain leaves everyone guessing about what happened. Orchestration adds one more component, but having an explicit state machine pays for itself the first time you debug a stuck workflow. The note about compensations arriving out of order is also worth underlining. Idempotency tables sound like overhead until the first duplicate event shows up in production.

Collapse
 
humam_moin profile image
Humam Moin •

The part about idempotency and the transactional outbox is important. Handling retries and failed events properly is what makes these workflows reliable in production.

Some comments may only be visible to logged-in visitors. Sign in to view all comments.