Skip to main content

The Distributed Transaction Problem

One Change, Two Owners

Pessimistic vs Optimistic Locking ended with a warning: every tool in it — SELECT … FOR UPDATE, version columns, deadlock detection — lives inside a single database. The moment the two things you need to change atomically live on different machines, all of it stops working. This lesson is about that wall.

Here's the whole problem in one sentence: transfer $100 from Alice's bank to Bob's bank. On a single database this is the easiest thing in the world — it's the BEGIN; debit; credit; COMMIT; of ACID, Precisely, and atomicity guarantees all-or-nothing for free. One write-ahead log, one commit, one undo. But Alice and Bob bank at different banks, and each bank runs its own database, with its own commit and its own failures. There is no shared BEGIN. There is no single COMMIT. The one logical change — move the money — now has to happen as two separate commits on two independent machines, and that is where the ground opens up.

(We'll keep saying "two banks" because it's the cleanest version, but this is the shape of an enormous class of real systems: any time one business operation must change data owned by two independent parties — two databases, a database and a payment provider, a database and a search index — you are here. In modern architectures the "two banks" are usually two services with two databases; the problem is identical, and we'll meet that framing later in the course.)

The two-banks story: one logical change across two independent databases, and the gap between them no transaction can span. A dark band states the one logical change — debit Alice minus $100, then credit Bob plus $100. Below, two bank panels. Left, Bank A in blue: Alice's own database, drawn as a cylinder, holding alice = $100, with its own durable commit. Right, Bank B in violet: Bob's own database, holding bob = $0, with its own commit. Between them a broken link with a lightning bolt labelled THE GAP — two independent databases, so no single transaction spans them. A red band at the bottom spells out the cost: a crash in the gap, and because no BEGIN…COMMIT spans two banks, Alice is debited and committed durably with no undo while Bob is never credited, so the total of $100 becomes $0 — $100 vanished, and it cannot be undone.

The Failure Window

The naive plan is obvious: debit Alice at Bank A, then credit Bob at Bank B. Let's actually run it — on two independent PostgreSQL instances (Bank A holds alice = 100, Bank B holds bob = 0; total money in the system: $100). Step one, Bank A does its own transaction and commits:

-- Bank A
UPDATE accounts SET balance = balance - 100 WHERE name = 'alice';   -- alice = 0, COMMITTED

That commit is durable — it survives anything now (ACID, Precisely again). Step two, we go to credit Bob at Bank B — but at that exact moment Bank B freezes (a crash, or the network drops between the two banks — from Bank A's side, the two are indistinguishable). Here is the real, verbatim result:

psql: error: connection to server at "bankb" (172.17.0.3), port 5432 failed: timeout expired

Bank A can only wait, and then time out. Now look at the world: **Bank A alice = 0, Bank B bob = 0 → total = 0.Westartedwith0.** We started with 100 and now have nothing. $100 has vanished — and here's the cruelty: Bank A already committed the debit. It's durable. There is no undo. You can't roll back a transaction that already returned COMMIT. The money didn't move; it evaporated, and no amount of cleverness at Bank A can bring it back.

That gap between the two commits — where one has happened and the other hasn't, and a failure can strike — is the failure window, and it is the beating heart of the distributed transaction problem. It cannot be closed by trying harder; as we'll see, it can only be managed. But first, let's kill the fixes you're already reaching for.

Why the Obvious Fixes Don't Work

Three fixes leap to mind. All three fail, and understanding why is half the lesson.

"Just wrap both in one transaction." There is no such transaction. A database transaction is bound to one connection to one database — we can prove it: open a transaction, then reconnect to another database and the original BEGIN is simply gone (You are now connected to database… and your uncommitted work vanished). Postgres has no syntax to BEGIN at Bank A and COMMIT at Bank B. The single atomic commit you're imagining does not exist across independent databases.

"Just retry until both succeed." Retrying is not just useless here — it's dangerous. If Bank A committed and Bank B failed, you can't retry the whole transfer, because that would debit Alice a second time — and you can't un-commit the first debit. And retrying just the credit runs into a worse trap we'll see in a moment: if Bank B did apply the credit but its reply was lost, your retry credits Bob twice. Retry without care turns "money lost" into "money duplicated." (The only thing that makes retry safe is idempotency — designing each step so applying it twice is the same as applying it once — a discipline we'll build later.)

"Just lock both rows." Locks are local (Pessimistic vs Optimistic Locking): Bank A's lock manager knows nothing about Bank B's. There is no shared lock manager across independent machines. And the moment you try to build one — hold a lock at both banks until both are ready — you've invented a coordination protocol whose failure modes are worse than the problem: if the thing coordinating the locks dies at the wrong instant, both banks sit holding locks forever, waiting for a decision that never comes. (That's a real protocol, and its blocking flaw is the whole of the next lesson.)

Every naive fix fails, and they fail for the same underlying reason — which is worth stating plainly.

It's an Agreement Problem

All-or-nothing across independent, failing nodes means every participant has to agree on the same outcome — everyone commits, or everyone aborts. And agreement over an unreliable network is provably imperfect. This isn't an engineering shortfall; it's a law.

The clean intuition is the Two Generals' Problem. Two allied generals must attack at the same time to win, but they can only coordinate by sending messengers across a valley the enemy patrols — messengers who might not make it. General A sends "attack at dawn." Did it arrive? A needs an acknowledgement. But B's acknowledgement might be lost too — so B needs an acknowledgement of the acknowledgement. It never ends: no finite exchange of messages can make both sides certain. The killer version, for us: after Bank A sends "credit Bob," and hears nothing back, it cannot tell the difference between "Bob was never credited" and "Bob was credited but the reply got lost." You saw that exact ambiguity in the timeout expired above — the request may have arrived and done its work; the silence tells you nothing. You can only ever reach probabilistic agreement, never certainty.

Theory nails the coffin shut twice more. The FLP impossibility result (Fischer, Lynch, and Paterson, 1985) proves that in a fully asynchronous network, no deterministic protocol can guarantee agreement if even a single node might crash — and "commit or abort?" is an agreement. FLP is in a sense stronger than the CAP theorem from CAP, Properly: CAP says you're in trouble during a network partition, but FLP says the mere possibility that one node crashes is enough to prevent guaranteed agreement — no partition required. And CAP itself frames the practical choice: to commit atomically, every participant must be reachable and must agree; when one isn't, you must pick — block and wait (sacrificing availability) or proceed without them (sacrificing atomicity). There is no third door.

So the distributed transaction problem is not a bug to be fixed. It's a fundamental limit: you cannot get perfect, atomic, all-or-nothing agreement across independent machines that fail and communicate over an unreliable network. Everything the rest of this section teaches is a way of choosing which imperfection to accept.

The Same Problem in Everyday Clothes: the Dual Write

You might think "two banks" is exotic — most of us aren't wiring money between institutions. But the identical problem shows up in the most ordinary system you'll ever build, the moment you do this: write to your database, then publish an event to a queue.

Save the order to Postgres, then publish an OrderPlaced message to Kafka so the warehouse ships and the customer gets an email. Two independent systems, two separate writes — a dual write. We ran it: saved the order (the app DB now has orders = 1), then the message queue froze, and the publish returned the same timeout expired. The result:

app DB orders: 1
queue events:  0

An order with no event. The customer was charged; the warehouse never hears about it; the email never sends. (Flip the failure and you get the mirror-image horror: the event fires but the DB write rolled back — now the warehouse ships an order that doesn't exist.) It's the two-banks transfer wearing a hoodie, and it is lurking in every event-driven system on earth. People hit it constantly without recognizing it as the distributed transaction problem — which is exactly why naming it matters. (The clean fix — write your data and your outgoing event in one local transaction, then relay the event separately — is a pattern we'll build in a later lesson.)

Break It Yourself

You now know the shape of the problem; the fastest way to believe it is to try every naive plan and watch each one fail. Below is the two-banks transfer as a live lab. Pick a plan — debit A first, credit B first, or debit-then-retry — and inject a failure: the second bank crashes before its write, or it writes but its reply is lost, or nothing fails at all. Run it and watch the money.

You'll find each plan has a way to break. Debit-first + a crash makes **100vanish.Creditfirst+acrashcreates100 vanish.** Credit-first + a crash **creates** 100 out of nothing. Retry + a lost reply credits Bob twice — money duplicated. And the lost-reply case always leaves the app in doubt: the money might be fine, but you cannot tell, which is its own kind of disaster (do nothing and hope, or retry and risk doubling). The 3×3 matrix fills in as you explore — and the point lands on its own: the only cell that's ever safe is "no failure," and you cannot guarantee no failure. There is no safe naive plan.

Move $100 from Alice (Bank A) to Bob (Bank B) — two independent databases, no shared commit. Pick a plan (debit first, credit first, or retry) and inject a failure (the second bank crashes, or its reply is lost, or nothing fails), then watch the money vanish, duplicate, or leave the app in doubt. A 3×3 matrix fills in as you explore every plan against every failure — and the punchline appears on its own: every plan breaks somewhere. There is no safe naive plan.

Your Three Moves

If the problem can't be solved, what do you actually do? You have exactly three moves, and the rest of this section is a tour of them.

  1. Coordinate. Appoint a coordinator that asks every participant to prepare ("can you commit? promise me you will") and, only once everyone has promised, tells them all to commit. This buys you real atomicity — all-or-nothing — which is why it's the classic answer. The price is that it blocks: while everyone is waiting on the coordinator's decision, they hold their locks, and if the coordinator picks the wrong moment to die, they wait forever. That's two-phase commit, and its promise and its blocking flaw are the very next lesson, Two-Phase Commit (and Why 3PC Exists).
  2. Compensate. Give up on atomicity and instead do each step for real, one at a time — and if a later step fails, run an action that undoes the earlier ones (refund the payment, restock the item). This keeps the system available (nobody blocks), at the cost of temporary inconsistency (for a moment, the money was debited before the refund) and a lot of careful bookkeeping. That's the Saga pattern — Saga: The Data-Consistency Mechanics.
  3. Avoid it. The move nobody teaches, and often the best one: arrange things so the change isn't distributed at all. If you can pull the two writes into a single owner with a single database, you're back to one boring BEGIN … COMMIT and the whole problem evaporates. A great deal of senior design is collapsing boundaries so that the operations that must be atomic together live together. The strongest engineers reach for a distributed transaction last, not first.

The takeaways:

  • One logical change across two independent stores cannot be made perfectly atomic. There is no transaction, no commit, and no lock that spans two independent databases (proven: reconnecting ends a transaction; measured: a naive transfer took 100to100 to 0 with no undo).
  • The hazard is partial failure, and its cruelest face is ambiguity: a timeout expired can't tell you whether the far side succeeded (the Two Generals problem). Naive fixes make it worse — retry double-applies, locks are local.
  • It's a fundamental limit, not a bug. Atomic commit is an agreement problem, and agreement over an unreliable network is provably imperfect (FLP; and CAP forces block-or-proceed under partition).
  • It's everywhere — the dual write (database + queue → an order with no event) is the same problem in every event-driven system.
  • You get three moves: coordinate (2PC — atomic, but blocks), compensate (Saga — available, but temporarily inconsistent), or avoid it (one owner, one transaction — often the best). Next — Two-Phase Commit (and Why 3PC Exists): we take the first move seriously — build a coordinator that makes everyone agree, and then watch the one failure that brings the whole thing to its knees.
Your three moves once one change must touch two independent owners, as three panels. COORDINATE, in blue: a coordinator points down to two databases, asking prepare then commit so everyone agrees first — plus, all-or-nothing; minus, it blocks on a stall; taught next as Two-Phase Commit. COMPENSATE, in amber: steps run pay, ship, then a red fail, with a dashed undo arrow looping back to reverse the earlier steps — plus, stays available; minus, not truly atomic; taught next as Saga. AVOID IT, in green and badged 'often best': two small databases merge with an arrow into one database with one commit, so the distributed transaction simply does not exist — plus, no distributed transaction; minus, not always doable; which takes you back to a single transaction. The header: three ways out, and the best is 'don't.'