Two‑phase commit
Backend Fundamentals · Distributed Systems · 10 min read
1. What problem does this solve?
When a business operation touches multiple independent data stores (e.g., a PostgreSQL order table and a Redis inventory cache), we want the whole operation to either succeed completely or fail completely.
Without a coordination protocol, each store could commit its part while the others fail, leaving the system in an inconsistent state.
Why it matters
- Data integrity – an order must not be created if inventory cannot be reserved.
- Auditability – downstream services rely on a consistent view of data.
- Regulatory compliance – financial systems cannot allow partial updates.
What happens if it’s not used
- Partial commits → orphaned records, duplicated inventory, broken business rules.
- Manual rollbacks become error‑prone and hard to automate.
- Auditing and debugging become difficult because the system state is ambiguous.
Real‑world usage
- Banking systems that update multiple ledgers atomically.
- E‑commerce platforms that write to a relational DB and a search index.
- Distributed microservices that need strong consistency for a single transaction.
2. Mental model
Analogy – The “Two‑step handshake”
Imagine two friends, Alice and Bob, want to exchange gifts.
Alice says, “I’ll give you my gift if you give me yours.”
Bob replies, “I’ll give you mine if you give me yours.”
They both agree to exchange simultaneously; if one backs out, neither gets a gift.
Engineering model – Two‑phase commit (2PC)
- Prepare phase – each participant (resource manager) is asked to prepare to commit.
It checks local constraints, locks rows, writes a “prepared” log entry, and replies YES or NO. - Commit phase – if all participants answered YES, the coordinator tells everyone to COMMIT.
If any answered NO, the coordinator tells everyone to ABORT.
The coordinator guarantees that either all resources commit or none do, preserving atomicity across the distributed system.
3. Deep explanation
Internals
| Component | Role |
|---|---|
| Coordinator | Orchestrates the transaction, collects votes, decides commit/abort. |
| Resource Manager (RM) | Each database or service that participates. Handles local transaction, writes to a log, and locks resources. |
| Log / Write‑Ahead Log (WAL) | RMs persist a prepared state before voting YES to survive crashes. |
Protocol steps
- Begin – Coordinator starts a global transaction ID (GID).
- Prepare – Coordinator sends
PREPARE(GID)to each RM.
RM checks constraints, locks, writesPREPARED(GID)to its log, repliesYES. - Decision – Coordinator collects votes.
If all YES → COMMIT; else → ABORT. - Commit/Abort – Coordinator sends
COMMIT(GID)orABORT(GID)to each RM.
RM finalizes (writes commit record or rolls back) and releases locks. - Completion – Coordinator acknowledges completion to the application.
Important terminology
- Two‑phase – the protocol has two distinct phases: prepare and commit/abort.
- Vote – RM’s response (
YESorNO) to the prepare request. - Coordinator – the process that decides the outcome.
- Resource Manager – a database or service that can participate in a distributed transaction.
- Prepared state – a durable log entry indicating the RM is ready to commit.
Trade‑offs
| Aspect | Benefit | Drawback |
|---|---|---|
| Strong consistency | Guarantees atomicity across resources. | Requires locking and coordination, leading to latency. |
| Durability | RMs persist prepared state, survive crashes. | Log growth, potential for long recovery times. |
| Simplicity | Well‑defined protocol. | Not suitable for high‑throughput, latency‑sensitive services. |
| Scalability | Works across any number of RMs. | Coordinator becomes a bottleneck; failure of coordinator stalls all transactions. |
Failure modes
| Failure | Impact | Recovery |
|---|---|---|
RM crashes after YES | RM may be stuck in prepared state. | On restart, RM reads log, asks coordinator for decision. |
| Coordinator crashes | RMs may be stuck waiting for decision. | RMs can retry or use a timeout to abort. |
| Network partition | Some RMs may not receive decision. | RMs can time out and abort; eventual consistency restored. |
Limitations
- Blocking – RMs hold locks until commit/abort, blocking other transactions.
- Coordinator bottleneck – single point of failure and throughput limit.
- Not suited for microservices – modern architectures favor eventual consistency (Saga pattern).
- Complexity in heterogeneous systems – each RM must implement 2PC support.
Where it fits in a backend architecture
- Monolithic legacy systems – often built around a single database; 2PC is rarely needed.
- Hybrid systems – a PostgreSQL order table plus a Redis cache; 2PC can keep them in sync.
- Distributed databases – some NoSQL stores expose 2PC APIs for cross‑node consistency.
Interaction with related concepts
- Sagas – alternative to 2PC for long‑running, eventually consistent workflows.
- Idempotency keys – useful for retrying 2PC steps safely.
- Distributed logging / tracing – essential to debug 2PC failures.
- Circuit breakers – protect the coordinator from cascading failures.
4. How it works
sequenceDiagram
participant App as Application
participant Coord as Coordinator
participant RM1 as Resource Manager 1
participant RM2 as Resource Manager 2
App->>Coord: BEGIN_TRANSACTION
Coord->>RM1: PREPARE(GID)
Coord->>RM2: PREPARE(GID)
RM1-->>Coord: YES
RM2-->>Coord: YES
Coord->>RM1: COMMIT(GID)
Coord->>RM2: COMMIT(GID)
RM1-->>Coord: ACK
RM2-->>Coord: ACK
Coord->>App: COMMIT_SUCCESS
Step‑by‑step flow
- Application starts a global transaction and sends
BEGIN_TRANSACTIONto the coordinator. - Coordinator generates a unique GID and sends
PREPARE(GID)to each RM. - Each RM:
- Checks local constraints.
- Locks necessary rows.
- Writes a
PREPARED(GID)entry to its log. - Responds
YES(orNOif it cannot proceed).
- Coordinator collects votes:
- If all
YES→ sendsCOMMIT(GID)to all RMs. - If any
NO→ sendsABORT(GID)to all RMs.
- If all
- Each RM finalizes:
- On
COMMIT, writes aCOMMITTED(GID)entry, releases locks. - On
ABORT, rolls back changes, releases locks.
- On
- Coordinator acknowledges completion to the application.
5. Practical implementation
Below is a minimal Node.js/TypeScript example that demonstrates a 2PC coordinator coordinating a PostgreSQL database and a Redis cache.
The example uses pg for PostgreSQL and ioredis for Redis.
// twoPhase.ts
import { Client as PgClient } from 'pg';
import Redis from 'ioredis';
import { v4 as uuidv4 } from 'uuid';
interface RM {
prepare(gid: string): Promise<boolean>;
commit(gid: string): Promise<void>;
abort(gid: string): Promise<void>;
}
class PostgresRM implements RM {
private client: PgClient;
constructor(client: PgClient) { this.client = client; }
async prepare(gid: string): Promise<boolean> {
// 1. Start local transaction
await this.client.query('BEGIN');
// 2. Perform business logic (e.g., insert order)
await this.client.query(
'INSERT INTO orders (id, gid, status) VALUES ($1, $2, $3)',
[uuidv4(), gid, 'PREPARED']
);
// 3. Log prepared state
await this.client.query(
'INSERT INTO tx_log (gid, state) VALUES ($1, $2)',
[gid, 'PREPARED']
);
// 4. Lock rows (implicit with transaction)
return true; // vote YES
}
async commit(gid: string): Promise<void> {
await this.client.query(
'UPDATE orders SET status = $1 WHERE gid = $2',
['COMMITTED', gid]
);
await this.client.query(
'UPDATE tx_log SET state = $1 WHERE gid = $2',
['COMMITTED', gid]
);
await this.client.query('COMMIT');
}
async abort(gid: string): Promise<void> {
await this.client.query('ROLLBACK');
await this.client.query(
'DELETE FROM tx_log WHERE gid = $1',
[gid]
);
}
}
class RedisRM implements RM {
private redis: Redis.Redis;
constructor(redis: Redis.Redis) { this.redis = redis; }
async prepare(gid: string): Promise<boolean> {
// 1. Reserve inventory in a transaction
const tx = this.redis.multi();
tx.set(`inventory:${gid}`, 'RESERVED', 'NX', 'EX', 60);
const results = await tx.exec();
// 2. Log prepared state
await this.redis.set(`tx:${gid}:state`, 'PREPARED');
return results?.[0]?.ok ?? false; // vote YES if key set
}
async commit(gid: string): Promise<void> {
await this.redis.set(`tx:${gid}:state`, 'COMMITTED');
// In a real system, we would also decrement inventory permanently
}
async abort(gid: string): Promise<void> {
await this.redis.del(`inventory:${gid}`);
await this.redis.del(`tx:${gid}:state`);
}
}
class TwoPhaseCoordinator {
private rms: RM[];
constructor(rms: RM[]) { this.rms = rms; }
async runTransaction(): Promise<void> {
const gid = uuidv4();
// Prepare phase
const votes = await Promise.all(this.rms.map(rm => rm.prepare(gid)));
const allYes = votes.every(v => v);
// Decision
if (allYes) {
await Promise.all(this.rms.map(rm => rm.commit(gid)));
console.log('Transaction committed');
} else {
await Promise.all(this.rms.map(rm => rm.abort(gid)));
console.log('Transaction aborted');
}
}
}
// Usage
(async () => {
const pg = new PgClient({ connectionString: process.env.DATABASE_URL });
await pg.connect();
const redis = new Redis(process.env.REDIS_URL);
const coordinator = new TwoPhaseCoordinator([
new PostgresRM(pg),
new RedisRM(redis),
]);
await coordinator.runTransaction();
await pg.end();
redis.disconnect();
})();
Key points
- Each RM writes a prepared state before voting
YES. - The coordinator collects votes and decides.
- On
COMMIT, each RM finalizes changes; onABORT, it rolls back. - The example is intentionally simple; real systems need robust error handling, timeouts, and recovery logic.
6. Production considerations
| Concern | What to watch for | Mitigation |
|---|---|---|
| Scalability | Coordinator becomes a bottleneck. | Use sharding, multiple coordinators per tenant, or switch to Saga for high throughput. |
| Reliability | Coordinator crash stalls all transactions. | Persist coordinator state; implement leader election (e.g., using ZooKeeper or etcd). |
| Security | Sensitive data in logs. | Encrypt logs, restrict access, use role‑based permissions. |
| Observability | Hard to trace distributed commits. | Add correlation IDs, emit events to a tracing system (OpenTelemetry). |
| Performance | Locks held for the whole transaction. | Keep transactions short, use optimistic locking where possible. |
| Error handling | Partial failures during commit. | Idempotent commit/abort operations, retry logic. |
| Resource usage | Log growth, lock contention. | Archive old logs, tune lock timeouts. |
| Operational concerns | Need for manual intervention on stuck transactions. | Build a monitoring dashboard, alert on long‑running prepared states. |
| Failure recovery | RMs in prepared state after crash. | On restart, RMs read log, query coordinator for decision, or abort after timeout. |
| Deployment | Consistent versions across services. | Use semantic versioning, automated rollback on coordinator failure. |
7. Common mistakes
| Mistake | Why it’s bad |
|---|---|
| Using 2PC for long‑running business flows | Locks are held for minutes, blocking other work and hurting throughput. |
| Ignoring coordinator failure | A single crash stalls all transactions; no fallback path. |
| Not persisting prepared state | RMs lose their vote on crash, leading to orphaned locks. |
| Assuming all RMs support 2PC | Some databases (e.g., MongoDB) do not expose 2PC; using them breaks the protocol. |
| Hard‑coding GID format | Collisions or lack of uniqueness can cause data corruption. |
| Not handling network partitions | RMs may wait indefinitely; need timeouts and abort logic. |
| Over‑locking | Locking entire tables instead of specific rows leads to contention. |
| Skipping idempotency | Retrying a commit can double‑apply changes if not idempotent. |
8. Senior engineer perspective
What changes at scale?
- Coordinator design – must be highly available (leader election), horizontally scalable, and able to handle thousands of concurrent transactions.
- Resource manager integration – each service must expose a lightweight 2PC API; otherwise, you risk coupling or performance penalties.
- Monitoring – need to surface metrics: number of prepared transactions, abort rates, lock wait times, coordinator latency.
- Cost – holding locks and persisting logs increases storage and CPU usage; evaluate whether the consistency guarantees outweigh the operational cost.
Design decisions
| Decision | Trade‑off |
|---|---|
| Single vs. multiple coordinators | Single coordinator simplifies logic but limits throughput; multiple coordinators require sharding logic. |
| Synchronous vs. asynchronous commit | Synchronous ensures consistency but increases latency; asynchronous (e.g., Saga) reduces latency but sacrifices strong consistency. |
| Log retention policy | Long retention aids recovery but consumes storage; short retention reduces cost but may hinder crash recovery. |
| Timeout strategy | Short timeouts reduce lock hold time but risk aborting legitimate commits; long timeouts increase resource usage. |
| Use of distributed consensus | Adding Raft/ZooKeeper for coordinator election adds complexity but improves reliability. |
When NOT to use 2PC
- Microservice architectures favor eventual consistency; use Saga or compensating actions instead.
- High‑throughput, low‑latency services (e.g., real‑time analytics) where blocking locks are unacceptable.
- When the business can tolerate partial failures or can implement domain‑specific consistency guarantees.
9. Interview questions
1BeginnerWhat is the purpose of the “prepare” phase in two‑phase commit?
2BeginnerWhy is a coordinator necessary in 2PC?
3SeniorDescribe a scenario where 2PC could become a bottleneck and how you would mitigate it.
4SeniorExplain how you would recover from a coordinator crash when some resource managers are in the prepared state.
5Scenario/DebuggingDuring a 2PC run, one RM returns YES but never receives the COMMIT message, and the transaction remains stuck. What could be the cause and how would you investigate?
10. Hands‑on task
Task:
Extend the provided twoPhase.ts example to add a timeout for the prepare phase. If any RM does not reply within 5 seconds, the coordinator should abort the transaction automatically.
Steps
- Modify the
runTransactionmethod to usePromise.racewith a timeout promise for eachprepare. - Log which RM timed out.
- Ensure that the abort logic still runs correctly.
- Write a unit test (using Jest) that simulates a slow RM and verifies that the transaction is aborted.
11. Quick revision
- 2PC guarantees atomicity across multiple resources by coordinating a prepare and commit/abort phase.
- The coordinator collects votes; all
YES→ commit, anyNO→ abort. - RMs persist a prepared state to survive crashes.
- Locking during the transaction blocks other work; keep transactions short.
- Coordinator is a single point of failure; use leader election or sharding.
- 2PC is heavy; consider Sagas for high‑throughput, eventually consistent systems.
- Failure modes: RM crash, coordinator crash, network partition; each requires recovery logic.
- Production concerns: scalability, observability, security, performance, and operational tooling.
- Common mistakes: ignoring coordinator failure, using 2PC for long flows, not persisting prepared state.
- Senior engineers weigh consistency vs. latency, cost vs. reliability, and decide when 2PC is appropriate.