Hello! Welcome to your daily lesson on distributed systems.
In our last session, we explored how quorum-based strategies prevent the catastrophic "split-brain" failure by ensuring only a majority partition can make decisions. This principle of achieving agreement is a cornerstone of distributed consistency. Today, we shift our focus from agreeing on a single state (like who the leader is) to agreeing on the outcome of a multi-step operation: a distributed transaction.
Your goal for this lesson is to learn how to implement the two-phase commit (2PC) protocol and analyze its blocking failure modes. This protocol is a classic (though often debated) solution to ensuring atomicity for transactions that span multiple services or databases. Understanding 2PC is crucial because it highlights the fundamental trade-off between strong consistency and high availability, a central theme in system design interviews at companies like Google and Meta.
We will break down this topic into five interconnected concepts:
- The Atomic Commitment Problem: Why ACID's "Atomicity" is hard in a distributed world.
- The Two-Phase Commit Protocol: The mechanics of the "prepare" and "commit" phases.
- The Achilles' Heel - Blocking Failures: Analyzing how 2PC breaks and why it can compromise availability.
- Implementing 2PC: A Microservices Example: A practical Python implementation of the protocol.
- Elite Interview Deep Dive: 2PC vs. The Alternatives (Saga Pattern): Positioning 2PC in the broader landscape of distributed transaction patterns.
1. The Atomic Commitment Problem (Easy)
In a single-database system, transactions are blessed with ACID properties, where 'A' stands for Atomicity. This guarantees that all operations within a transaction complete successfully, or none of them do. But what happens when your transaction needs to change data across multiple independent systems, like different microservices or databases?
-
Technical Explanation:
A distributed transaction involves coordinating changes across two or more network-connected resources. Consider a classic example: transferring money from a checking account (managed byService A) to a savings account (managed byService B).Service Adebits the checking account.Service Bcredits the savings account.
What if
Service Asucceeds, butService Bcrashes before it can apply the credit? You've now violated atomicity: money has vanished from the system. The atomic commitment problem is the challenge of ensuring that all participating systems (or participants) agree on the final outcome of the transaction—either they all commit (make their changes permanent) or they all abort (roll back their changes).
Lecture 17: Distributed Transactions & Two-Phase Commit
To understand the core problem 2PC aims to solve, let's start with this lecture summary from MIT. It provides a clear definition and motivating examples.
Read the slide titled 'Distributed Transactions' and 'Problem with Distributed Transactions'. Focus on the examples and the core goal: achieving atomicity when a single 'COMMIT' command isn't enough because multiple machines are involved.
- Metaphor for a 5-Year-Old:
You want to trade your red toy car for a friend's blue toy car. You can't just hand yours over and hope they hand theirs back—they might run away! To make it atomic, you need a trusted parent (a "coordinator"). You both give your cars to the parent. Only when the parent has both cars in hand do they give the blue car to you and the red car to your friend. If one of you decides not to trade at the last second, the parent gives both cars back to their original owners. No one loses anything.
2. The Two-Phase Commit Protocol (Medium)
Two-Phase Commit is a protocol designed to solve the atomic commitment problem. It introduces a central coordinator that orchestrates the transaction across all participants. As the name implies, it operates in two distinct phases.
- Technical Explanation:
-
Phase 1: Prepare (or Voting) Phase
- The coordinator sends a
PREPAREmessage to all participants, asking them if they are ready to commit. - Upon receiving
PREPARE, each participant does all the work required for the transaction (e.g., validating data, acquiring locks, writing changes to a write-ahead log), but does not make the changes final yet. - If a participant can guarantee it can commit, it replies with a
VOTE-COMMIT(or 'YES') message. It is now in a prepared state. Crucially, by voting YES, a participant gives up its right to unilaterally abort; it is now bound to follow the coordinator's final decision. - If a participant cannot commit for any reason (e.g., a constraint violation), it replies with a
VOTE-ABORT(or 'NO').
- The coordinator sends a
-
Phase 2: Commit (or Decision) Phase
- The coordinator collects votes from all participants.
- If all participants voted
VOTE-COMMIT: The coordinator decides to commit. It logs this decision and sends aCOMMITmessage to all participants. - If any participant voted
VOTE-ABORTor timed out: The coordinator decides to abort. It sends anABORTmessage to all participants. - Upon receiving the final command (
COMMITorABORT), each participant makes the change permanent or rolls it back, and then releases any locks it was holding.
-
Distributed Systems 7.1: Two-phase commit
Martin Kleppmann provides a crystal-clear explanation of these two phases and the roles of the coordinator and participants.
Watch the segment from 06:34 to 09:54. Pay close attention to the promise a participant makes when it replies 'yes' to the prepare message—it's a binding contract.
-
Metaphor for a 5-Year-Old:
Imagine a wedding ceremony.- Phase 1 (Prepare): The officiant (coordinator) asks each partner (participant) individually, "Do you promise to marry this person?". Each partner must say "I do" (
VOTE-COMMIT). By saying "I do," they are now in a "prepared" state. They can't just change their mind and walk away; they have to wait for the officiant's final word. - Phase 2 (Commit): The officiant, having received an "I do" from both, makes the final decision and announces, "I now pronounce you married!" (
COMMIT). The marriage is now official. If either person had said "I don't" (VOTE-ABORT), the officiant would have declared the wedding cancelled (ABORT).
- Phase 1 (Prepare): The officiant (coordinator) asks each partner (participant) individually, "Do you promise to marry this person?". Each partner must say "I do" (
-
Python Code Snippet (Conceptual 2PC):
This code shows the basic logic of a coordinator and participants.class Participant: def __init__(self, name, can_commit=True): self.name = name self.state = "INIT" self.can_commit = can_commit # Simulate if this participant can succeed def prepare(self): if self.can_commit: self.state = "PREPARED" print(f"Participant {self.name}: VOTE-COMMIT") return True else: self.state = "ABORTED" print(f"Participant {self.name}: VOTE-ABORT") return False def commit(self): self.state = "COMMITTED" print(f"Participant {self.name}: Transaction COMMITTED.") def abort(self): self.state = "ABORTED" print(f"Participant {self.name}: Transaction ABORTED.") def run_2pc(participants): print("--- PHASE 1: PREPARE ---") votes = [] for p in participants: votes.append(p.prepare()) # If any participant voted to abort can_commit = all(votes) print("\n--- PHASE 2: COMMIT/ABORT ---") if can_commit: print("Coordinator: Decision is COMMIT.") for p in participants: p.commit() else: print("Coordinator: Decision is ABORT.") for p in participants: # Send abort to all, even those that were prepared if p.state != "ABORTED": p.abort() # Scenario 1: All succeed print("--- SCENARIO 1: All participants can commit ---") all_ok_participants = [Participant("Service A"), Participant("Service B")] run_2pc(all_ok_participants) # Scenario 2: One fails print("\n--- SCENARIO 2: One participant fails ---") one_fails_participants = [Participant("Service C"), Participant("Service D", can_commit=False)] run_2pc(one_fails_participants)
3. The Achilles' Heel - Blocking Failures (Hard)
2PC provides strong consistency, but this comes at a steep price: availability. The protocol is not fault-tolerant, and its primary weakness is that it is a blocking protocol.
- Technical Explanation:
The most critical failure mode occurs if the coordinator crashes after the prepare phase but before sending its final decision.- All participants have sent
VOTE-COMMIT. They are now in aPREPAREDstate, holding locks on resources, and waiting for the coordinator's final command. - The coordinator crashes before it can send
COMMITorABORT. - The participants are now blocked. They don't know the outcome of the transaction.
- They cannot unilaterally
COMMIT, because another participant might have failed, and the coordinator might have decided toABORT. - They cannot unilaterally
ABORT, because they promised to commit, and the coordinator might have already decided toCOMMITand told another participant before crashing.
- They cannot unilaterally
- The participants must wait, holding their locks and blocking other operations, until the coordinator recovers. If the coordinator is permanently lost, these resources could be locked forever. This kills system availability.
- All participants have sent
Lecture 17: Distributed Transactions & Two-Phase Commit
The MIT lecture slides provide a systematic and exhaustive analysis of all the different failure cases in 2PC. The blocking scenario is the most important one to understand.
Review the slides titled 'Failure Cases (1)' through '(8)'. Pay special attention to cases 4 and 5, which describe what happens when the coordinator crashes. This demonstrates why prepared workers must wait, leading to the blocking problem.
-
Metaphor for a 5-Year-Old:
Back to the wedding. The officiant asks both partners, and they both say "I do." Just as the officiant is about to make the final pronouncement, they faint and are rushed to the hospital.
The couple is now blocked. Are they married? Not officially. Can they just walk away and marry someone else? No, they've already said "I do" and are in a legally ambiguous "prepared" state. They must wait for the officiant to recover and finish the ceremony. All the wedding guests (other requests) are stuck waiting. -
Python Code Snippet (Simulating a Blocking Failure):
We'll modify the coordinator to simulate a crash.import time def run_2pc_with_crash(participants): print("--- PHASE 1: PREPARE ---") for p in participants: if not p.prepare(): # For simplicity, we assume prepare succeeds pass # All participants are now in 'PREPARED' state print("\n--- COORDINATOR CRASHES! ---") # Coordinator goes offline... # Now, participants are blocked time.sleep(2) # Simulate waiting for p in participants: print(f"Participant {p.name} state: {p.state}. Blocked, holding locks.") print("\n--- SCENARIO 3: Coordinator crashes after prepare phase ---") crash_participants = [Participant("Service E"), Participant("Service F")] run_2pc_with_crash(crash_participants)
4. Implementing 2PC: A Microservices Example (Harder)
Let's translate the theory into a more practical implementation, modeling a food delivery order that requires reserving items from a store and a delivery agent from a fleet.
-
Technical Explanation:
We will create three classes to simulate our microservices:OrderService: Acts as the coordinator. It orchestrates the transaction.FoodService: A participant. It manages food packet inventory.DeliveryService: A participant. It manages delivery agents.
The flow follows 2PC:
OrderServicestarts a transaction.- Prepare Phase: It calls
FoodService.prepare_food()andDeliveryService.prepare_agent(). These methods check for availability, "reserve" the resources (e.g., set anis_reservedflag), and vote yes/no. - Commit Phase: If both vote yes,
OrderServicecallsFoodService.commit()andDeliveryService.commit(). These methods finalize the assignment (e.g., by setting anorder_id). If either voted no, it callsabort()on both to release the reservations.
Implementing Distributed Transactions using Two Phase Commit Protocol
The following video implements this exact food delivery scenario in Go. While we will use Python, the architectural pattern, API design, and database logic are directly transferable and provide an excellent blueprint.
Watch from 11:36 to 19:43. Focus on the high-level architecture diagram and the sequence of API calls. Understand how 'reserving' a resource maps to the 'prepare' phase and 'assigning/booking' maps to the 'commit' phase.
-
Python Code Snippet (Microservice Simulation):
This script simulates the entire flow using in-memory dictionaries as databases.import uuid # --- Participant 1: FoodService --- class FoodService: def __init__(self): self.food_packets = {"pkt1": {"food_id": "burger", "order_id": None, "state": "AVAILABLE"}} self.tx_state = {} # transaction_id -> state def prepare(self, tx_id, food_id): for pkt_id, data in self.food_packets.items(): if data["food_id"] == food_id and data["state"] == "AVAILABLE": data["state"] = "RESERVED" self.tx_state[tx_id] = {"pkt_id": pkt_id, "old_state": "AVAILABLE"} print(f"FoodService: Reserved packet {pkt_id} for tx {tx_id}") return True return False def commit(self, tx_id, order_id): if tx_id in self.tx_state: pkt_id = self.tx_state[tx_id]["pkt_id"] self.food_packets[pkt_id]["state"] = "ASSIGNED" self.food_packets[pkt_id]["order_id"] = order_id print(f"FoodService: Committed packet {pkt_id} to order {order_id}") del self.tx_state[tx_id] def abort(self, tx_id): if tx_id in self.tx_state: pkt_id = self.tx_state[tx_id]["pkt_id"] self.food_packets[pkt_id]["state"] = self.tx_state[tx_id]["old_state"] print(f"FoodService: Aborted reservation for packet {pkt_id}") del self.tx_state[tx_id] # --- Participant 2: DeliveryService --- class DeliveryService: def __init__(self): self.agents = {"agent1": {"order_id": None, "state": "AVAILABLE"}} self.tx_state = {} def prepare(self, tx_id): for agent_id, data in self.agents.items(): if data["state"] == "AVAILABLE": data["state"] = "RESERVED" self.tx_state[tx_id] = {"agent_id": agent_id, "old_state": "AVAILABLE"} print(f"DeliveryService: Reserved agent {agent_id} for tx {tx_id}") return True return False def commit(self, tx_id, order_id): if tx_id in self.tx_state: agent_id = self.tx_state[tx_id]["agent_id"] self.agents[agent_id]["state"] = "ASSIGNED" self.agents[agent_id]["order_id"] = order_id print(f"DeliveryService: Committed agent {agent_id} to order {order_id}") del self.tx_state[tx_id] def abort(self, tx_id): if tx_id in self.tx_state: agent_id = self.tx_state[tx_id]["agent_id"] self.agents[agent_id]["state"] = self.tx_state[tx_id]["old_state"] print(f"DeliveryService: Aborted reservation for agent {agent_id}") del self.tx_state[tx_id] # --- Coordinator: OrderService --- class OrderService: def __init__(self, food_service, delivery_service): self.food_service = food_service self.delivery_service = delivery_service def place_order(self, food_id): tx_id = str(uuid.uuid4()) order_id = str(uuid.uuid4()) print(f"\nPlacing order {order_id} for {food_id} (tx: {tx_id})") # --- Phase 1: Prepare --- food_prepared = self.food_service.prepare(tx_id, food_id) agent_prepared = self.delivery_service.prepare(tx_id) # --- Phase 2: Commit/Abort --- if food_prepared and agent_prepared: print("Coordinator: Both participants prepared. Committing.") self.food_service.commit(tx_id, order_id) self.delivery_service.commit(tx_id, order_id) print(f"Order {order_id} placed successfully!") else: print("Coordinator: One or more participants failed to prepare. Aborting.") self.food_service.abort(tx_id) self.delivery_service.abort(tx_id) print(f"Order {order_id} failed.") # --- Run simulation --- food_svc = FoodService() delivery_svc = DeliveryService() order_svc = OrderService(food_svc, delivery_svc) # Success case order_svc.place_order("burger") # Failure case (no more agents available) order_svc.place_order("burger")
5. Elite Interview Deep Dive: 2PC vs. The Alternatives (Saga Pattern)
In an interview, a top-tier candidate must not only explain 2PC but also articulate why it's often avoided in modern, large-scale, high-availability systems and what the alternatives are.
-
Technical Explanation:
2PC prioritizes Consistency over Availability. Its blocking nature makes it a poor fit for services that require high uptime. The modern approach often favors availability and accepts weaker consistency guarantees.The primary alternative is the Saga pattern. A saga is a sequence of local transactions. Each transaction updates data within a single service and then triggers the next step. If any step fails, the saga executes a series of compensating transactions that undo the changes made by the preceding steps.
2PC vs. Saga:
- Atomicity: 2PC provides atomic commitment. Saga does not; it only guarantees eventual consistency after running compensating transactions.
- Availability: 2PC is blocking and has low availability if the coordinator fails. Saga is non-blocking and highly available; services can proceed with local transactions independently.
- Complexity: 2PC protocol logic is relatively simple. Saga logic can be very complex, as you must design and implement a compensating transaction for every step that could fail.
Because of its issues, 2PC is rarely used for long-running transactions across distributed microservices. It is, however, still found inside distributed databases (e.g., PostgreSQL, CockroachDB) that need to coordinate transactions across internal nodes, where network latency is low and failures are handled by robust, internal failover mechanisms.

- Metaphor (Saga vs. 2PC):
You're planning a vacation.- 2PC (The Package Deal): You go to a travel agent (coordinator) who puts a hold on a flight and a hotel. Only when both are confirmed available does the agent purchase both simultaneously. It's atomic, but if the agent disappears while the items are on hold, you're stuck.
- Saga (The DIY Traveler): You book and pay for the flight (first local transaction). If that succeeds, you book and pay for the hotel (second local transaction). If the hotel booking fails, you execute a compensating transaction: you cancel the flight you already booked. You might pay a small cancellation fee, but you are never blocked.
Test your understanding! (Interview-Style Question)
You are designing a user registration flow for a new social media platform. The flow involves three microservices:
AuthService: Creates user credentials.ProfileService: Creates a user profile.WelcomeEmailService: Sends a welcome email.
The business requires that registration be highly available and resilient to transient failures (e.g., the email service being temporarily down). Which pattern, 2PC or Saga, would you choose and why?
Show answer
The Saga pattern is the correct choice here.
Reasoning:
- Availability Requirement: The business demands high availability. 2PC's blocking nature is a direct threat to this. If a 2PC coordinator failed during registration, new users would be unable to sign up, which is unacceptable. A Saga is non-blocking and far more resilient.
- Tolerating Partial Failures: The requirement to handle the
WelcomeEmailServicebeing down is a classic use case for Sagas. With a Saga, creating the user and profile can succeed even if sending the email fails. The system can simply retry sending the email later. With 2PC, the entire registration transaction would have to abort if the email service voted 'NO' or timed out, resulting in a poor user experience. - Lack of True Atomic Need: While it's ideal for all three steps to happen, it's not a catastrophic data corruption if a user exists in the
AuthServiceandProfileServicebut hasn't received an email yet. The core data is consistent. This makes it a perfect candidate for the eventual consistency model of Sagas, where compensating transactions (like deleting the user and profile if theAuthServicefails) can clean up any true failures.
Conclusion
Today, you've dissected the Two-Phase Commit protocol. You learned its mechanics, implemented its logic, and critically analyzed its major drawback: the potential to block and reduce system availability.
Key Takeaways:
- 2PC solves the atomic commitment problem for distributed transactions using a coordinator and two phases: Prepare and Commit.
- In the Prepare Phase, participants vote on whether they can commit, and by voting YES, they promise to follow the coordinator's decision.
- The major flaw of 2PC is that it is a blocking protocol. If the coordinator fails after the prepare phase, participants are stuck holding locks, which kills availability.
- Due to its blocking nature, 2PC is often avoided in high-availability microservice architectures in favor of non-blocking patterns like Saga, which use compensating transactions to achieve eventual consistency.
Next Lesson Preview
We've now seen two major patterns for coordinating actions in distributed systems: Raft for consensus and 2PC for atomic commitment. Both are powerful but come with complexity and performance trade-offs. In our next lesson, we'll shift gears to a fundamental architectural pattern that helps manage this complexity at scale. We will explore how to apply the CQRS (Command Query Responsibility Segregation) pattern for scalable read/write separation, a technique used by many high-throughput systems to optimize performance by treating read and write operations differently.