Skip to content

필사 모드: Distributed Transactions: 2PC vs the Saga Pattern

English
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

Introduction — When a Transaction Crosses Service Boundaries

Inside a single database, a transaction is a trusty friend. Begin with BEGIN, end with COMMIT, and every change inside takes effect entirely or is entirely undone. But split the system into several services and that trust wobbles. Once the order service, payment service, and inventory service each own their own database, a single logical operation — "create an order, process a payment, decrement stock" — spans three physically separate databases.

Now the scary question returns: what if the payment succeeds but the stock decrement fails? There's no single COMMIT that finalizes all three databases at once. This is the distributed transaction problem, an unavoidable difficulty for microservices. This post contrasts the two classic approaches — two-phase commit and the Saga pattern — and lays out what we lose and gain in between.

If you want to see the messaging that connects to these ideas, you can visualize asynchronous message flow in this site's Message Queue Playground.

Why ACID Across Services Is Hard

A single-database transaction is powerful because one system controls all the data. One transaction manager takes locks, writes to a WAL, and finalizes the commit atomically. With everything under one roof, "all or nothing" is easy to guarantee.

Split across services and that premise breaks.

  • There is no shared transaction manager. Each service's database only knows its own transactions. By default, there is no higher authority to bundle service A's commit with service B's commit into one.
  • The network intrudes. Inter-service communication rides the network, which is slow, drops messages, delays them, reorders them, and above all disconnects. "The request went out but no response came back" leaves the sender unable to tell success from failure.
  • Partial failure is routine. In a single process, it's closer to "the whole thing lives or the whole thing dies," but in a distributed system the in-between state where "some succeeded and some failed" is common.

Because of these three, reproducing across services the atomicity that was obvious in a single database is fundamentally hard. There are two branches of solution. One is two-phase commit — "implement atomicity anyway, by force." The other is Saga — "give up atomicity and go with eventual consistency instead."

Two-Phase Commit (2PC) — Forcing Atomicity

Two-phase commit (2PC) is the classic protocol for bundling the commits of multiple participants into one. At its center is a coordinator, and as the name says it proceeds in two phases.

Phase 1 — prepare (voting). The coordinator asks every participant, "are you ready to commit?" Each participant does not actually commit its changes, but makes every preparation so that it could commit (acquiring locks, writing logs) and votes "yes (prepared)" or "no." If it answered "yes," that participant must be able to commit later if the coordinator so orders.

Phase 2 — commit or abort. If all participants voted "yes," the coordinator orders everyone to commit. If even one said "no," or failed to respond, it orders everyone to abort.

  Phase 1 (prepare):
    coordinator --"ready?"--> participants
    participants --"yes/no"--> coordinator

  Phase 2 (commit/abort):
    if all said "yes":
      coordinator --"commit!"--> participants (all commit)
    if any said "no"/no reply:
      coordinator --"abort!"--> participants (all roll back)

Logically it's tidy. Since it commits only after confirming everyone is prepared, atomicity appears to hold. But 2PC has serious weaknesses.

The blocking problem. This is 2PC's biggest flaw. After a participant votes "yes (prepared)," if the coordinator dies right before issuing the phase-2 order, the participant is stranded. It has heard neither "commit" nor "abort," and it cannot decide on its own (it promised "yes," after all). This participant waits indefinitely, holding its locks, until the coordinator recovers. Other transactions blocked on those locks stall along with it.

Other failure modes. The prepare phase passes but the commit order reaches only some participants; a network partition splits the coordinator from some participants — there are many combinations of partial failure. Variants exist to address these (3PC and others), but none is a complete fix, and they only add complexity.

So 2PC gives atomicity, but at the cost of availability and performance. The coordinator becomes a single point of failure, and holding locks across the prepare-commit gap drops throughput. That's why 2PC is rarely used in microservice environments with many services and high latency. (Conversely, it's still used in specific settings like a well-controlled single data center or XA transactions.)

The Saga Pattern — Trading Atomicity for Compensation

If 2PC "keeps atomicity but tolerates blocking," the Saga pattern inverts the idea: give up atomicity, commit each step as an individual local transaction, and if something goes wrong, clean up with compensating transactions that undo what was already done.

The core concept is the compensating transaction. If a later step fails after an earlier step has committed, you run a separate transaction that "semantically cancels" what was already committed. Where a rollback undoes something not yet committed, a compensation reverses something already committed and reflected in the world.

Seen through an order example:

  Happy path (each step commits independently):
    1. create order    (commit)
    2. charge payment  (commit)
    3. decrement stock  (commit)
    4. reserve shipping (commit)

  If step 3 fails on insufficient stock -> compensate in reverse:
    compensate 2: refund payment
    compensate 1: cancel order

Some important properties to note here:

  • A compensation may not be a perfect inverse of the original operation. The compensation for "charge payment" is "refund," but a refund is not exactly identical to a state where the charge never happened (a transaction record remains, and fees may apply). Compensation is semantic undoing, not physical undoing.
  • Intermediate state is exposed externally. While a Saga is in progress, other observers can see the in-between state "payment done but no shipping yet." Unlike 2PC's isolation, a Saga guarantees no isolation. So the application must be designed to tolerate such intermediate states.
  • Compensations themselves can fail. So compensations must be retryable and idempotent, and in the worst case you need a path for human intervention.

By giving up atomicity and isolation, a Saga gains high availability without blocking and loose coupling between services. That's why the Saga has become the de facto standard in microservices.

Choreography vs Orchestration

There are broadly two ways to actually implement a Saga. The fork is: who decides "move to the next step?"

Choreography — a dance with no central conductor. When each service finishes its work it publishes an event, and other services subscribe to that event and carry on with their own step. There is no central coordinator. It's like dancers continuing a dance by reacting to each other's moves, with no conductor.

  order service: publishes "order created" event
       |
       v (subscribe)
  payment service: process payment -> publishes "payment done" event
       |
       v (subscribe)
  inventory service: decrement stock -> publishes "stock confirmed" event
       |
       v (subscribe)
  shipping service: reserve shipping

The upside is loose coupling between services, and it's easy to slot in a new service just by subscribing to events. The downside is that the whole flow is scattered across services and hard to grasp at a glance. To know "which step is this order at now?" you have to dig through several services, and if events loop or get tangled unintentionally, debugging is tricky.

Orchestration — a performance with a conductor. A central orchestrator conducts the whole Saga. The orchestrator calls each step in order — "now charge," get a response, "now decrement stock" — and if a step fails, directs the compensating steps.

  ┌───────────────── orchestrator ─────────────────┐
  │  1. ask payment service to "charge" -> success  │
  │  2. ask inventory service to "decrement" -> fail!│
  │  3. compensate: ask payment service to "refund" │
  │  4. compensate: ask order service to "cancel"   │
  └─────────────────────────────────────────────────┘

The upside is that the whole flow lives in one place and is easy to understand and trace. Since the orchestrator holds the Saga's state, "which step it's at" is clear. The downside is that the orchestrator becomes the center of the logic and grows complex, and if done poorly it can become a single bottleneck or single point of failure again.

The choice between them depends on scale and complexity. When steps are few and simple, choreography is lighter; when steps are many, the flow is complex, and visibility matters, orchestration is easier to manage.

The Dual-Write Problem — Why You Need an Outbox

Whether Saga or any event-driven architecture, there is one trap you will inevitably hit in practice: the dual-write problem.

Here's the situation. When a service does some work, it often must do two things: (1) update its own database, and (2) publish an event/message announcing that fact. The problem is that these two are different systems (the local DB and the message broker) and cannot be bundled into one transaction.

  Dangerous order:
    1. save order to DB  (commit succeeds)
    2. publish "order created" to Kafka  <- what if the process dies here?

  Result: the order is in the DB, but the event was never published.
          downstream services never learn of this order. (inconsistent!)

Reversing the order doesn't help. If you publish the event first and the DB save fails, an "order created" event is loose in the world while no order exists in the DB. Whichever you do first, dying in between leaves the DB state and the published event out of sync.

The standard solution to this problem is the outbox pattern.

The Outbox Pattern — Bundling into One Local Transaction

The core idea of the outbox pattern is this: store the event you want to publish into an "outbox" table in the same database as the business data, within the same transaction. Then "data change" and "event record" live inside one local transaction, so the two commit atomically together or fail together. The dual write becomes a single write.

  One local transaction:
    BEGIN
      INSERT INTO orders (...)         -- business data
      INSERT INTO outbox (event, ...)  -- event to publish
    COMMIT   -- both commit or both roll back

  Then a separate process:
    polls the outbox table (or tails the DB log)
      -> publishes new events to the message broker
      -> on successful publish, marks/removes them from the outbox

There are two common ways to push events out to the actual broker. One is a polling publisher, where a separate process periodically reads the outbox table and publishes not-yet-sent events. The other is transaction log tailing (CDC), which reads the database's change log (e.g., PostgreSQL's WAL) to detect rows inserted into the outbox and publish them (tools like Debezium work this way).

An important point here: the outbox guarantees that events are published at least once, not exactly once. If the publisher dies after publishing but before "marking as processed," the same event can be published again. So the receiving side (the consumer) must be idempotent — same result even if it receives the same event twice. This idempotency and deduplication are the heart of distributed-system reliability, which is also the subject of the next post.

Eventual Consistency — What It Means in Practice

When you give up 2PC and go with Saga and outbox, you are accepting eventual consistency in place of strong consistency. Let's state what this phrase means without misunderstanding.

Eventual consistency is the guarantee that "eventually the state of all replicas and services will agree." But in the meantime, inconsistency may be observed. There can be a short window where the payment is processed but the order's status hasn't flipped to "paid" yet — because the Saga is still in progress, or the event is still propagating.

What this means in practice is concrete:

  • The UI and API must tolerate intermediate states. Showing the user a "processing" status is honest and safe. Don't promise a final state immediately.
  • Your own write may not be visible right after reading. You might query something you just created and not find it yet ("read-your-writes" is not guaranteed by default). If you need it, secure strong consistency separately for just that part.
  • The business must define the intermediate states. In a situation like "payment done but stock reservation failed," what to show the user and how to compensate is a business decision, not a technical one.

The key is that eventual consistency is not "no consistency" but "delayed consistency." Acknowledging this delay and reflecting that window in your design is the practical sense of handling distributed transactions.

Practical Guidance

Let's compress everything.

Avoid distributed transactions altogether when you can. If some data must be bundled into one transaction, the best design is a boundary that keeps it in the same service and same database. The lines along which you split services should respect transaction boundaries.

If you truly must cross services, prefer Saga over 2PC. 2PC's blocking and single point of failure conflict with the availability goals of microservices. Saga's eventual consistency and compensation are usually more realistic.

Choreography if the flow is simple; orchestration if it's complex and visibility matters. The more steps and the more complex the failure handling, the more valuable a central orchestrator's traceability becomes.

If you publish events, use the outbox pattern by default. Dual-write inconsistency is a classic bug that quietly drifts your data out of sync. Use an outbox to bundle "data change and event record" into one local transaction.

Make consumers idempotent. Whether outbox or any broker, "at least once" is the reality. If you don't design assuming duplicate delivery, you will inevitably get duplicate-processing bugs.

Wrapping Up

A single-database transaction was powerful because one system controlled everything. The moment you split the system into several services, that control scatters, the network and partial failure intrude, and reproducing "all or nothing" as-is becomes fundamentally hard.

The two branches of solution are 2PC and Saga. 2PC keeps atomicity but tolerates blocking and a single point of failure; Saga gives up atomicity but gains high availability through compensating transactions and eventual consistency. And the dual-write trap of the event-driven world is tamed by the outbox pattern — with idempotency always underlying it as a premise.

In the end, distributed transactions are a choice between "the ideal of perfect atomicity" and "the reality of an available system." In most cases we accept a little delayed consistency and wrap that window in design. When you handle that trade-off deliberately, transactions in distributed systems become not a fear but an engineering problem you can manage.

References

현재 단락 (1/104)

Inside a single database, a transaction is a trusty friend. Begin with `BEGIN`, end with `COMMIT`, a...

작성 글자: 0원문 글자: 13,551작성 단락: 0/104