Leer en español · Lire en français · Auf Deutsch lesen
Section 6.7 of 6.8 · Advanced · 30 min read
Distributed transactions, sagas and the outbox pattern
Video lesson
5 min · narrated, with captionsListen only (audio)
Read the transcript
When one business action spans several services, no single transaction can cover it. In this lesson you'll learn why two-phase commit is avoided between services, how sagas keep processes correct instead, and how to send messages reliably.
Booking a holiday means booking a flight, a hotel and a hire car, from three different companies. Nobody can make all three happen at exactly the same instant. So you book them one at a time. If the hire car turns out to be unavailable, you cancel the hotel and the flight, perhaps paying a cancellation fee. That's a saga: separate steps, each with a planned way to undo it. The skill is choosing the order of the steps, and planning the cancellations before anything goes wrong.
The classic way to make changes in several databases all-or-nothing is two-phase commit, run by a coordinator. In phase one, it asks each participant whether it can commit. Each makes its changes durable, keeps its locks, and votes yes. By voting yes, it gives up the right to abort on its own. If every vote is yes, the coordinator logs its decision: commit. In phase two, it tells every participant, and they apply the outcome and release their locks. But if the coordinator crashes after the votes, the participants can't decide alone, so they keep their locks until it recovers. Between independently owned services, that's a poor fit. Availability multiplies down, locks are held across the network, deployments become coupled, and many participants, such as a payment provider, can't join at all.
The alternative is a saga: a sequence of local transactions, each in one service's own database, linked by messages. Each step is paired with a compensating transaction that undoes it in a business sense. Either all the steps complete, or the completed ones are compensated in reverse order. What a saga doesn't give you is isolation: between steps, others can see the intermediate state.
Here's the food-delivery order saga, run by the order service as orchestrator. First, it records the order as pending, which tells others the order is in flux. Second, it asks the payment service to authorise payment: a cheap hold on the card, easy to void. Third, it asks dispatch to reserve a courier, the step most likely to fail at busy times. This time, no courier is available. So the orchestrator compensates the completed steps, newest first. It voids the payment authorisation. Then it marks the order rejected, keeping the row for audit, and tells the customer before anything was cooked or charged. Had a courier been found, step four, confirming to the restaurant, would be the pivot. Once a kitchen starts cooking, the saga can only go forward, so later steps are retried until they succeed.
A rollback makes it as if nothing happened. A compensation is a new transaction that counteracts something that did happen, and may have been seen. It can void the card hold, but it can't unsend an email. So compensations must be idempotent, because their trigger may arrive twice, and retriable until they succeed, or raise an alert for a person. They may even arrive before the step they undo. The payment service should then remember the void, and refuse the late authorisation. And steps that can't be undone, such as cooking the food, belong after the pivot, or need a business compensation, such as a voucher or a refund.
Every saga step updates its database and sends a message. Doing those separately is the dual-write problem: crash in between, and the message is lost, or announces something that never happened. The transactional outbox fixes it with one local transaction. The order service writes the order row and the outgoing message, as a row in an outbox table, together. Both commit, or neither does. A separate relay reads the outbox, by polling it or by reading the database's change log, and publishes each message to the broker. A relay can crash before noting what it sent, so a message may go out twice. So the receiver keeps an inbox of processed message IDs, saved with its change, and skips duplicates.
So how do you choose? Prefer one local transaction where you can. If a service split cuts through one consistency boundary, the cheaper fix is often to move the boundary. Use a saga when the steps can be compensated or retried. Choreography, where services simply react to each other's events, suits two or three steps. For longer flows with branching and timeouts, most practitioners prefer an orchestrator. And order the steps deliberately: likely failures before the pivot, and anything irreversible after it.
The full section has the complete step table, and the countermeasures for sagas' lack of isolation. Next, in Section six point eight, we look at serverless and space-based architectures. See you there.
What you need to know
- Two-phase commit gives atomicity across participants through prepare and commit phases, but it blocks while the coordinator is down and holds locks across the network; XA (X/Open, 1991) standardises it.
- Across independently owned services, 2PC multiplies unavailability, holds locks remotely, couples deployments and is unsupported by most APIs and many brokers, so it is generally avoided.
- A saga (Garcia-Molina and Salem, 1987) is a sequence of local transactions, each with a compensating transaction; either all steps complete or the completed ones are compensated in reverse order.
- Compensations are new forward transactions, not rollbacks: intermediate states may have been seen, and compensations must be idempotent, retriable and able to arrive out of order.
- Sagas lack isolation; countermeasures described by Chris Richardson include semantic locks, commutative updates, pessimistic view, reread value, version files and choosing by value.
- The transactional outbox writes the business change and its message in one local transaction, and a relay (polling or change data capture such as Debezium) publishes it at least once; idempotent receivers with an inbox remove duplicates.
- Prefer one local transaction where possible, redesign boundaries when the split is accidental, and use sagas when steps can be compensated or retried.
The problem: one business action, several databases
Inside one database, a transaction makes several changes atomic: the order row, its items and the payment reference are all saved or none are (Section 0.5). Once data is split across services that each own their database, as microservices and database per service recommend, a single business action such as placing a food order touches several databases owned by different services: the order service's, the payment service's and the dispatch service's. No single local transaction can cover them all.
There are two families of answer. One is to make many resource managers act as one through a distributed atomic commit protocol, of which two-phase commit is the classic. The other is to accept that the action is a sequence of local transactions and design for the moments in between: the saga. A third answer, often the best, is to notice that the transaction boundary and the service boundary disagree and to move one of them. This section covers all three and ends with a guide for choosing.
Two-phase commit and XA
Two-phase commit (2PC) is a protocol, dating from the late 1970s, that gives all-or-nothing atomicity across several participants such as two databases or a database and a message broker. A coordinator (the transaction manager) runs it. In phase 1, prepare, the coordinator asks every participant whether it can commit. A participant that says yes must first make the changes durable and keep the relevant locks, because by voting yes it gives up the right to abort on its own. If every vote is yes, the coordinator writes its decision, COMMIT, to its own log; if any vote is no or a participant does not answer, the decision is ABORT. In phase 2, commit, it tells every participant the outcome, and they apply it and release their locks.
The weakness is that 2PC is a blocking protocol. A participant that has voted yes is in doubt: it does not know the outcome and cannot safely guess, because another participant may already have committed. If the coordinator crashes at that moment and stays down, the in-doubt participants keep their locks until it recovers, and other transactions touching those rows wait too. Operators can force a heuristic decision to release the locks, at the risk of breaking atomicity.
The XA specification, published by X/Open in 1991, standardised the interface between a transaction manager and resource managers so that, for example, a Java application server can commit a database update and a message send atomically. Many relational databases and some message brokers support it.
Two-phase commit, and where it blocks
Why 2PC is avoided across microservices
2PC works and is still used inside a single system's boundary. Across independently owned services it is usually a poor fit, for reasons that compound each other.
Availability multiplies down. Every participant and the coordinator must be up for any transaction to commit, so the transaction's availability is the product of theirs, the same arithmetic as a synchronous call chain (see why distribute). Locks are held across the network for at least two round trips, and much longer if anything is slow, which cuts throughput and raises contention. Coupling returns: all participants must speak the same protocol, share a transaction manager and agree on timeouts, which undermines the independent deployability that justified splitting the services. Many participants cannot join: most HTTP APIs, third-party services such as payment providers, many NoSQL databases and several popular brokers do not support XA. And the payment provider's API will never hold a lock for your coordinator.
The picture is different inside distributed databases. Systems such as Google Spanner run two-phase commit internally between groups of replicas that are themselves kept consistent by consensus, so a crashed coordinator is replaced rather than awaited. That makes cross-partition transactions within one database practical; it does not make transactions across separately owned services and their different technologies practical.
Sagas: a sequence of local transactions
Hector Garcia-Molina and Kenneth Salem introduced the saga in a 1987 SIGMOD paper about long-lived transactions, ones that would hold database locks for hours. Their idea was to split such a transaction into a sequence of ordinary transactions T1, T2 … Tn, each of which commits on its own, and to pair each with a compensating transaction Ci that semantically undoes it. The system then guarantees that either the whole sequence completes, or the completed steps are followed by their compensations in reverse order: T1 … Tj, Cj … C1.
Microservice architectures adopted the idea because it fits their constraints exactly: each step is a local ACID transaction in one service's own database, and steps are linked by messages. A saga gives atomicity in the eventual, business sense (all steps happen, or the effects are undone), but not isolation: between steps, other transactions can see the intermediate state.
It helps to classify the steps, as Chris Richardson does in Microservices Patterns (2018). Compensatable transactions can be undone by a compensation. The pivot transaction is the go/no-go point: once it commits, the saga will run to completion. Retriable transactions come after the pivot and must be designed so that they eventually succeed if retried. Placing steps that are likely to fail before the pivot, and steps that cannot be undone after it, is a key design decision.
Choreography or orchestration
A saga's steps must be coordinated somehow, and the two options mirror the choreography and orchestration discussed in event-driven architecture.
In a choreography-based saga there is no coordinator. Each service listens for events and reacts by doing its local transaction and publishing a new event: the order service publishes OrderCreated, the payment service reacts and publishes PaymentAuthorised, the dispatch service reacts to that, and so on; failure events trigger compensations in the services that listen for them. It is simple for two or three steps and keeps services loosely coupled. As steps grow, the flow exists only implicitly, scattered across services, so it is hard to see, test and change; and cyclic event dependencies between services creep in.
In an orchestration-based saga a single orchestrator, usually in the service that owns the business process, sends commands to participants and reacts to their replies. The orchestrator is a persistent state machine: it records which step it is on in its own database, so it can resume after a crash. The flow is explicit and testable in one place, and participants need not know about each other. The risk is that the orchestrator accumulates business logic that belongs in the participants. Workflow engines such as Temporal, Camunda and AWS Step Functions provide durable orchestration as a platform, at the cost of another dependency. For more than about three steps, or for any flow with branching and timeouts, most practitioners prefer orchestration.
Worked example: the order saga
The food-delivery platform's order saga is orchestrated by the order service. The order of steps is deliberate: the payment authorisation is a cheap hold that is easy to void, the courier reservation is the step most likely to fail at busy times, and confirming to the restaurant is the pivot, because once a kitchen has started cooking the order cannot simply be undone.
The order saga, step by step
| Step | Service and local transaction | Kind | Compensation if a later step fails |
|---|---|---|---|
| 1. Create order | Order service inserts the order with state PENDING (a semantic lock). | Compensatable | C1: set state to REJECTED and record the reason. The row is kept for audit, not deleted. |
| 2. Authorise payment | Payment service asks the card provider to place a hold for the total, using the order ID as idempotency key. | Compensatable | C2: void the authorisation, releasing the hold. The customer may briefly see a pending amount on their banking app. |
| 3. Reserve courier | Dispatch service reserves a nearby courier for the pickup window. | Compensatable | C3: release the courier so they can take other jobs. |
| 4. Confirm order | Order service sets state to CONFIRMED and sends the ticket to the restaurant. | Pivot | None: from here the saga must go forward. Problems later are handled by new business processes (refunds, vouchers). |
| 5. Notify customer; capture payment on pickup | Notification service sends "Order confirmed"; payment service captures the held amount when the courier collects the food. | Retriable | None: these steps are retried, idempotently, until they succeed or are escalated to a person. |
The order saga when no courier is available
Compensation is not rollback
A database rollback makes it as if the transaction never happened: nobody else ever saw its changes. A compensation is a new, forward transaction that semantically counteracts an earlier one that did happen and may have been seen. In the order saga, the customer's bank may already show a pending hold, and an email may have said "payment authorised". The compensation cannot unsend the email; it can only void the hold and send another message. Some actions cannot be compensated at all, such as food already cooked or a notification already read; those belong after the pivot, or need a business compensation such as a voucher or a refund.
Compensations therefore need the same care as forward steps. They must be idempotent, because the message that triggers them may be delivered twice. They must be retriable until they succeed, because a compensation that fails permanently leaves the system in a state nobody designed; if they truly cannot succeed, they must raise an alert for a person to resolve. They may need to be commutative with the forward step: if a VoidAuthorisation arrives before the AuthorisePayment it undoes (possible after timeouts and retries), the payment service should remember the void and refuse the late authorisation rather than fail. And they should be visible to the business: "rejected, no courier" is information the operations team wants on a dashboard.
Living without isolation: anomalies and countermeasures
Because each step commits on its own, sagas lack the I in ACID, and concurrent sagas can interfere. Richardson describes three resulting anomalies. A lost update occurs when one saga overwrites a change made by another without reading it, such as a cancellation saga marking an order cancelled and the order saga then setting it confirmed. A dirty read occurs when a saga reads data another saga has changed but may yet compensate, such as a credit-limit check that counts a hold that is about to be voided. A fuzzy or non-repeatable read occurs when two steps of one saga read the same data and get different values because another saga changed it in between.
He also catalogues countermeasures, design techniques that reduce the anomalies, drawing on earlier research on transactions across multiple databases. The most widely used are these.
Countermeasures for the lack of isolation in sagas (after Richardson)
| Countermeasure | Idea | Food-delivery example |
|---|---|---|
| Semantic lock | A compensatable step sets an application-level flag, usually a *_PENDING state, telling other transactions that the record is in flux; they wait, fail or treat it specially. | While an order is PENDING, a cancellation request is queued or refused with "order is being processed", not applied halfway through. |
| Commutative updates | Design updates so that they give the same result in any order, removing lost updates. | The courier's earnings ledger records credits and debits as separate entries rather than overwriting a balance. |
| Pessimistic view | Reorder steps to minimise the business risk of a dirty read, putting risky updates into retriable steps after the pivot. | Increase a restaurant's "available capacity" only in a retriable step after cancellation is certain, never in a step that might be compensated. |
| Reread value | Before updating, re-read the record and check it has not changed since it was read (optimistic locking); if it has, abort and possibly restart the saga. | Before confirming, the orchestrator checks the order's version is still the one it created; if a cancellation changed it, it compensates instead. |
| Version file | Record the operations applied to a record so that operations arriving out of order can be reordered or recognised. | The payment service records that a void arrived for an authorisation it has not yet seen, and rejects the late authorisation. |
| By value | Choose the coordination mechanism per request by its business risk. | Ordinary orders use the saga; a high-value corporate catering order might go through a stricter, slower process. |
Publishing reliably: the transactional outbox
Every saga step does two things: it updates its own database and it sends a message (an event or a reply). Doing them separately creates the dual-write problem. If the service commits and then crashes before publishing, the rest of the saga never hears about the step. If it publishes first and the commit then fails, other services act on something that never happened. Wrapping the database and the broker in 2PC is exactly what we wanted to avoid.
The transactional outbox solves it with one local transaction. The service writes the business change and the outgoing message, as a row in an outbox table, in the same database transaction; either both commit or neither does. A separate relay then reads the outbox and publishes each message to the broker. Two relay designs are common. A polling publisher queries the outbox table for unpublished rows every so often and marks them sent: simple, but it adds load and latency. Transaction log tailing uses change data capture (CDC): a tool reads the database's own replication log (the PostgreSQL write-ahead log or the MySQL binary log) and turns new outbox rows into messages. Debezium, an open-source CDC platform usually run with Kafka Connect, is the best-known example and includes an outbox event router that routes each row to a topic based on a column such as the aggregate type.
The relay can crash after publishing and before recording that it did, so it will sometimes publish a message twice. The outbox therefore gives at-least-once delivery, and consumers must cope with duplicates. Publishing order is preserved per aggregate if the relay publishes rows in commit order and uses the aggregate ID as the message key.
BEGIN;
INSERT INTO orders (id, customer_id, state, total_pence)
VALUES ('1042', 'c-77', 'PENDING', 2350);
INSERT INTO outbox (id, aggregate_type, aggregate_id, type, payload)
VALUES ('e-9f1c', 'order', '1042', 'OrderCreated',
'{"orderId":"1042","customerId":"c-77","totalPence":2350}');
COMMIT;
-- A relay (poller or CDC such as Debezium) later publishes e-9f1c to the broker.Outbox on the way out, inbox on the way in
Receiving reliably: idempotent receivers and the inbox
Because the outbox, broker retries and saga retries all produce duplicates, every participant must be an idempotent receiver, the name Gregor Hohpe and Bobby Woolf gave the pattern in Enterprise Integration Patterns (2003). Some operations are naturally idempotent ("set order 1042 to CONFIRMED"); others need a duplicate check. The usual implementation is an inbox: a table of processed message IDs, written in the same local transaction as the business change, so that "process the message" and "remember that it was processed" commit together. A redelivered message finds its ID already present and is acknowledged without doing anything. The consumer-side code appears in event-driven architecture.
Together, outbox, at-least-once delivery and idempotent receivers give the practical result people usually mean by "exactly once": each business effect happens once, even though messages may travel more than once.
Decision guide
Decision guide: one transaction, a saga, or new boundaries?
Use this when a business action needs to change data in more than one place.
- Is all the data that must change together owned by one service and stored in one database?YesUse one local ACID transaction. It is the simplest and strongest option; do not distribute it.NoGo to the next question.
- Do the parts that must change together nearly always change together, belong to one team and share a bounded context, so that the split is accidental?YesRedesign the boundary: merge the services, or move the data, so the action becomes one local transaction. Sagas are not a fix for a wrong split.NoGo to the next question.
- Must every participant see all changes at the same instant, with no intermediate state ever visible (for example, a ledger that must always balance)?YesKeep those records in one database, or use a distributed database that provides cross-partition transactions internally. Use 2PC only inside one system's boundary, with participants you control.NoGo to the next question.
- Can every step either be compensated, or be placed after a pivot and retried until it succeeds?YesUse a saga: orchestrated if it has more than about three steps or any branching, choreographed if it is short and stable. Add an outbox, idempotent receivers and countermeasures such as semantic locks.NoReorder the steps or change the business process (for example, reserve before charging, or add a manual review) until it can; otherwise reconsider the boundaries.
Check your understanding
Why can a participant in two-phase commit not simply commit on its own if the coordinator disappears after it voted yes?
Because it does not know the decision: another participant may have voted no, so the coordinator may have decided to abort; guessing could break atomicity.
In the order saga, why is confirming the order to the restaurant the pivot rather than the first step?
Once the kitchen starts cooking, the step cannot be undone, so it must come after the steps that might fail (payment authorisation, courier reservation), which can be compensated cheaply.
What problem does the transactional outbox solve, and what guarantee does it give?
It removes the dual write between the database and the broker by committing the change and the message together; the relay then delivers the message at least once, so duplicates are possible.
Name two ways a compensation differs from a database rollback.
It is a new transaction that runs after the original committed, and others may already have seen or acted on the original; it may also be only a semantic undo (a refund, a void) rather than restoring the exact previous state.
Sources and further reading
Have a question about this section? ArchitectBot, the course’s AI study assistant, answers from the course first.
Ask: “Our checkout calls payment, then stock, then shipping, and sometimes we charge customers for items we cannot ship. How should we redesign it as a saga?”