Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 57

Two-Phase Commit: Implementation and Failure Modes

Recommended resources

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:

  1. The Atomic Commitment Problem: Why ACID's "Atomicity" is hard in a distributed world.
  2. The Two-Phase Commit Protocol: The mechanics of the "prepare" and "commit" phases.
  3. The Achilles' Heel - Blocking Failures: Analyzing how 2PC breaks and why it can compromise availability.
  4. Implementing 2PC: A Microservices Example: A practical Python implementation of the protocol.
  5. 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 by Service A) to a savings account (managed by Service B).

    1. Service A debits the checking account.
    2. Service B credits the savings account.

    What if Service A succeeds, but Service B crashes 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:
    1. Phase 1: Prepare (or Voting) Phase

      • The coordinator sends a PREPARE message 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').
    2. 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 a COMMIT message to all participants.
      • If any participant voted VOTE-ABORT or timed out: The coordinator decides to abort. It sends an ABORT message to all participants.
      • Upon receiving the final command (COMMIT or ABORT), 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).
  • 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.
    1. All participants have sent VOTE-COMMIT. They are now in a PREPARED state, holding locks on resources, and waiting for the coordinator's final command.
    2. The coordinator crashes before it can send COMMIT or ABORT.
    3. 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 to ABORT.
      • They cannot unilaterally ABORT, because they promised to commit, and the coordinator might have already decided to COMMIT and told another participant before crashing.
    4. 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.

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:

    1. OrderService starts a transaction.
    2. Prepare Phase: It calls FoodService.prepare_food() and DeliveryService.prepare_agent(). These methods check for availability, "reserve" the resources (e.g., set an is_reserved flag), and vote yes/no.
    3. Commit Phase: If both vote yes, OrderService calls FoodService.commit() and DeliveryService.commit(). These methods finalize the assignment (e.g., by setting an order_id). If either voted no, it calls abort() 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.

Distributed Transaction Patterns: Performance vs Consistency Trade-offs
This chart positions 2PC within the landscape of distributed transaction patterns. Note its high consistency guarantee but also its high latency and complexity compared to patterns like Saga, which favor performance and availability.
  • 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:

  1. AuthService: Creates user credentials.
  2. ProfileService: Creates a user profile.
  3. 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:

  1. 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.
  2. Tolerating Partial Failures: The requirement to handle the WelcomeEmailService being 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.
  3. 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 AuthService and ProfileService but 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 the AuthService fails) 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.