paulserban.eu

Writing Edition

Paul Serban

Writing, snippets & book notes

November 30, 2023

Distributed Transactions Explained: Navigating the Shift from ACID to BASE

Understanding the Evolution of Transaction Models in Modern Distributed Systems

Introduction

For decades, the transaction was the bedrock of reliable software. A banking application could debit one account and credit another, and developers could trust that the database would either complete both operations or neither. This guarantee, formalized as ACID (Atomicity, Consistency, Isolation, Durability), shaped how an entire generation of engineers thought about correctness. It let teams reason about state changes as if they were happening on a single, orderly timeline, even when the underlying hardware was fallible.

But the systems we build today rarely fit inside a single database instance. Services are split across teams, data is sharded across regions, and a single business operation-placing an order, onboarding a user, processing a payment-often touches five or six independently deployed services, each with its own datastore. In this world, the clean guarantees of ACID become expensive, sometimes impossible, to preserve. This article traces why that shift happened, what BASE (Basically Available, Soft state, Eventually consistent) actually means in practice, and how experienced teams design systems that stay correct without pretending they're still running on a single mainframe.

The Problem: Why Distributed Systems Broke the ACID Model

The original promise of ACID transactions was built on an assumption that no longer holds in distributed architectures: that all the data involved in a transaction lives behind a single coordination point. A relational database can offer atomicity because it owns the entire write path-the write-ahead log, the lock manager, the buffer pool-all running on hardware it controls. When you split that write path across a payments service, an inventory service, and a notifications service, each with an independent database, there is no single component left that can unilaterally decide "commit" or "abort" for the whole operation.

This is where the CAP theorem, formalized by Eric Brewer and later proven by Seth Gilbert and Nancy Lynch, becomes unavoidable. CAP states that a distributed data system can provide at most two of three properties during a network partition: Consistency, Availability, and Partition tolerance. Since network partitions are a physical reality-not a hypothetical-every distributed system has already chosen, whether its designers realized it or not, to favor either consistency or availability when the network misbehaves. Traditional ACID systems implicitly favor consistency, often at the cost of availability during a partition.

The practical consequence is that distributed transaction coordination protocols-most notably Two-Phase Commit (2PC)-which attempt to preserve ACID semantics across nodes, come with serious costs. 2PC requires a coordinator to hold locks on every participant until all nodes acknowledge readiness to commit. If the coordinator crashes mid-protocol, participants can be left blocked indefinitely, holding locks and refusing other transactions. This blocking behavior is precisely what makes 2PC unattractive at internet scale, where a single slow or failed node can freeze an entire cluster's throughput.

Understanding ACID: The Guarantees We're Moving Away From

Before discussing what replaces ACID, it's worth being precise about what it actually guarantees, since the term is often used loosely. Atomicity means a transaction's operations are treated as a single indivisible unit-either every write succeeds or the entire transaction rolls back, leaving no partial state. Consistency ensures a transaction moves the database from one valid state to another, respecting all defined constraints, such as foreign keys or check constraints. Isolation governs how concurrent transactions interact, with isolation levels (read committed, repeatable read, serializable) determining how much one transaction can see of another's in-flight changes. Durability guarantees that once a transaction commits, its effects survive crashes, typically via write-ahead logging to persistent storage.

These four properties work beautifully within a single database engine because the engine has complete authority over the data and can enforce locking, logging, and recovery mechanisms in a tightly integrated way. PostgreSQL, MySQL's InnoDB engine, and Oracle Database all implement full ACID semantics for transactions scoped to a single instance. The trouble starts the moment "the transaction" needs to span multiple independently-operated systems, because none of the four guarantees can be enforced by any single party anymore-atomicity requires coordination, isolation requires shared locking across systems that don't trust each other's internals, and durability now depends on multiple independent storage subsystems all succeeding together.

Understanding BASE: A Different Set of Trade-offs

BASE was coined largely in reaction to these limits, and it isn't a rigorous specification in the way ACID is-it's better understood as a description of the design philosophy adopted by large-scale distributed systems, popularized in discussions by engineers like Dan Pritchett at eBay in the mid-2000s. Basically Available means the system remains operational and responsive even when parts of it are degraded or unreachable, prioritizing responding to requests over guaranteeing a fully up-to-date answer. Soft state acknowledges that a system's state may change over time even without new input, because replicas are still converging toward consistency in the background. Eventually consistent means that, given enough time without new updates, all replicas will converge to the same value-but there is no guarantee about how long "eventually" takes.

This is a genuinely different contract with the application developer. Under ACID, you can assume that a successful read immediately after a write reflects that write, everywhere. Under BASE, you cannot make that assumption without additional mechanisms-a read to a different replica might return stale data for some window of time. Amazon's Dynamo paper (DeCandia et al., 2007) and Werner Vogels' widely cited essay "Eventually Consistent" are foundational references here, both describing how systems can offer high availability and partition tolerance by relaxing strict consistency, and how application logic must be written to tolerate and often resolve conflicting writes rather than assuming the storage layer will prevent them.

It's worth emphasizing that BASE is not "no consistency"-it's a spectrum. Systems like Amazon DynamoDB, Apache Cassandra, and Riak allow developers to tune consistency levels per operation (for example, choosing quorum reads and writes), letting teams dial in stronger guarantees for critical paths while keeping the default posture optimized for availability and low latency.

Deep Technical Explanation: Consistency Models in Practice

Between the extremes of strict serializability and eventual consistency sits a spectrum of consistency models that distributed systems engineers need to understand precisely, because the differences have real operational consequences. Strong consistency (or linearizability) guarantees that any read after a write returns the latest value, as if all operations happened on a single timeline-this is what systems like Google Spanner achieve globally using synchronized atomic clocks (TrueTime) combined with Paxos-based replication. Causal consistency guarantees that operations which are causally related (a comment that replies to a post) are seen by all nodes in the same order, even if unrelated operations may appear in different orders on different replicas. Eventual consistency, the weakest common model, only guarantees convergence given no further writes and enough time.

Choosing where to sit on this spectrum is not a purely technical decision-it's a product decision disguised as an architecture decision. A social media "like" counter can tolerate eventual consistency because a brief undercount is invisible to users and self-corrects within milliseconds. A ledger recording a wire transfer cannot tolerate the same laxity, because a customer momentarily seeing an incorrect balance can trigger support tickets, regulatory scrutiny, or genuine financial harm. Mature engineering organizations don't pick one consistency model for the entire system; they choose per data type, often within the same service, which is why understanding these models precisely-rather than treating "eventual consistency" as a single monolithic concept-is one of the highest-leverage skills in distributed systems design.

Implementation Patterns: How Teams Actually Build This

In practice, teams rarely implement 2PC directly for cross-service transactions today; instead, they reach for patterns designed around BASE principles. The Saga pattern is the most widely adopted: instead of a single atomic transaction, a business process is broken into a sequence of local transactions, each with a corresponding compensating action that can undo its effect if a later step fails. Sagas can be coordinated in two ways-choreography, where each service listens for events and reacts independently, or orchestration, where a central coordinator explicitly directs each step.

Here is a simplified orchestrated saga for an order-placement flow, written in TypeScript, showing how compensations are tracked and triggered on failure:

type SagaStep = {
  name: string;
  execute: () => Promise<void>;
  compensate: () => Promise<void>;
};

class OrderSaga {
  private completedSteps: SagaStep[] = [];

  constructor(private steps: SagaStep[]) {}

  async run(): Promise<void> {
    for (const step of this.steps) {
      try {
        await step.execute();
        this.completedSteps.push(step);
      } catch (err) {
        console.error(`Step "${step.name}" failed, compensating...`, err);
        await this.rollback();
        throw new Error(`Saga aborted at step: ${step.name}`);
      }
    }
  }

  private async rollback(): Promise<void> {
    for (const step of this.completedSteps.reverse()) {
      try {
        await step.compensate();
      } catch (compErr) {
        console.error(`Compensation failed for "${step.name}"`, compErr);
        // In production: alert, log to a dead-letter queue for manual review
      }
    }
  }
}

const saga = new OrderSaga([
  {
    name: "reserve-inventory",
    execute: () => inventoryService.reserve(orderId, items),
    compensate: () => inventoryService.release(orderId, items),
  },
  {
    name: "charge-payment",
    execute: () => paymentService.charge(orderId, amount),
    compensate: () => paymentService.refund(orderId, amount),
  },
  {
    name: "schedule-shipment",
    execute: () => shippingService.schedule(orderId),
    compensate: () => shippingService.cancel(orderId),
  },
]);

await saga.run();

A second pattern that frequently accompanies sagas is the Transactional Outbox, which solves a subtler problem: how do you atomically update your own database and publish an event about that update, when the database write and the message publish are two separate systems? The outbox pattern writes the event into a table in the same local database transaction as the business data change, and a separate relay process (often built on Debezium's change-data-capture) reads that outbox table and publishes to a message broker like Kafka. This guarantees the event is never lost or duplicated relative to the database state, because both writes share the same local ACID transaction.

import json
import uuid
from datetime import datetime, timezone

def place_order(db_connection, order):
    """
    Writes the order and its corresponding outbox event
    in a single local ACID transaction.
    """
    with db_connection.transaction():
        order_id = db_connection.execute(
            """
            INSERT INTO orders (customer_id, total_amount, status)
            VALUES (%s, %s, 'PENDING')
            RETURNING id
            """,
            (order["customer_id"], order["total_amount"]),
        ).fetchone()[0]

        event_payload = {
            "order_id": order_id,
            "customer_id": order["customer_id"],
            "total_amount": order["total_amount"],
            "created_at": datetime.now(timezone.utc).isoformat(),
        }

        db_connection.execute(
            """
            INSERT INTO outbox (id, aggregate_type, event_type, payload, created_at)
            VALUES (%s, %s, %s, %s, %s)
            """,
            (
                str(uuid.uuid4()),
                "order",
                "OrderPlaced",
                json.dumps(event_payload),
                datetime.now(timezone.utc),
            ),
        )
    # A separate relay process (e.g. Debezium reading the outbox table's
    # write-ahead log) publishes this row to Kafka asynchronously.
    return order_id

Trade-offs and Pitfalls

Adopting saga-based and eventually consistent designs introduces failure modes that simply don't exist in single-database ACID transactions, and teams underestimate them at their peril. The most common pitfall is treating compensating transactions as a mirror image of the forward transaction, when in reality they often aren't true inverses. Refunding a payment is not the same as the payment never having happened-the customer may have already seen the charge on their statement, been sent a receipt email, or made subsequent decisions based on it. Compensations undo effects at the data layer, but real-world side effects (notifications, third-party API calls, human actions) often can't be cleanly reversed, which means saga design has to account for user-facing consequences, not just database rollback logic.

The second major pitfall is underestimating the operational burden of eventual consistency on the humans debugging the system. When a customer reports "my balance is wrong," and the true state is spread across four services with different replication lag, root-causing the issue requires distributed tracing, careful event ordering, and often manual reconciliation. Idempotency becomes non-negotiable rather than a nice-to-have: because sagas and message-driven architectures can retry steps or redeliver events, every operation triggered by an event needs to be safe to execute more than once, typically via idempotency keys stored alongside processed-event records.

Best Practices for Building on BASE Foundations

Given these trade-offs, experienced teams follow a handful of practices that make BASE-style systems tractable rather than chaotic. First, they scope consistency requirements per data type rather than applying one policy system-wide-financial ledgers get stronger guarantees (often via a dedicated ACID-compliant ledger service) while less critical, high-volume data (view counts, activity feeds) is allowed to be eventually consistent. Second, they invest early in idempotency keys and deduplication, since retries are inevitable in any system built on asynchronous messaging or at-least-once delivery semantics, which is the norm for brokers like Kafka and SQS.

Third, mature teams build observability specifically for distributed transaction state, not just general application logs. This typically means correlation IDs that flow through every service and message in a saga, dashboards showing in-flight saga status, and alerting on sagas that have been "stuck" mid-compensation for longer than expected. Fourth, they design compensating actions during initial development, not as an afterthought once a bug report arrives-a saga step without a corresponding, tested compensation is an incomplete implementation, not a shortcut. Finally, teams frequently adopt event sourcing or a similar audit-trail approach for critical business processes, since having an immutable log of every state transition makes reconciliation and debugging materially easier when the eventual-consistency window produces a temporary inconsistency that needs to be explained after the fact.

Key Takeaways

Analogies & Mental Models

A useful mental model for the ACID-to-BASE shift is the difference between a single orchestra conductor and a jazz ensemble. A conductor (the single ACID database) can halt the entire orchestra mid-piece if one section falls out of sync, then restart everyone together-true atomicity, because one entity has authority over all the players. A jazz ensemble has no conductor; each musician listens to the others and adjusts in near-real-time, occasionally playing a note that briefly clashes before the group self-corrects within the next few bars. The music is never perfectly synchronized in the way a conducted orchestra is, but it's far more resilient-one musician stumbling doesn't stop the whole performance, it just gets absorbed and corrected by the group.

Another helpful analogy is postal mail versus a phone call. A phone call (a synchronous ACID transaction) requires both parties to be present and available at the same moment, and if either side drops, the conversation fails entirely. Postal mail (an eventually consistent, message-driven system) tolerates delay: a letter can sit in transit, be temporarily "not yet delivered," and the system still works, provided both sender and receiver agree on how to handle a letter that never arrives-the equivalent of a saga's compensating action or a dead-letter queue.

The 80/20 Insight

If there's one concept that produces most of the practical benefit in this space, it's this: treat consistency as a per-operation decision, not a system-wide property. Teams that try to force an entire distributed system into either "fully ACID" or "fully BASE" tend to either drown in coordination overhead or accumulate correctness bugs that surface as painful production incidents. The teams that get the most leverage are the ones that identify the small number of operations-usually financial, security-related, or otherwise irreversible-that genuinely need strong consistency, isolate those behind a service or database boundary that can afford stronger guarantees (even at some cost to availability), and let everything else default to the more available, more scalable eventually-consistent model. Getting this 20% of decisions right removes the vast majority of both the correctness risk and the operational pain.

Conclusion

The move from ACID to BASE isn't a story of one model being obsolete and the other being correct-it's a story of the industry recognizing that a single set of guarantees can't serve every workload once systems cross machine and organizational boundaries. ACID remains the right tool within the boundary of a single database, and BASE-inspired patterns like sagas and eventual consistency are the right tools across those boundaries, where the physics of networks and the realities of independent failure domains make strict coordination too expensive or too fragile.

The engineers who navigate this well are the ones who stop asking "is this system ACID or BASE?" and start asking "which consistency guarantee does this specific operation actually need, and what am I willing to trade to get it?" That question, asked deliberately at the data-model and service-boundary level, is what separates distributed systems that stay debuggable at scale from ones that accumulate silent correctness gaps until they surface as very public incidents.

References

A quick flashcard preview - one example of each question type in this post. This is a demo of what you'll find in the full quiz app.

Flashcard preview - 1 / 9Multiple Choice

multiple choice - advanced - auto-graded

What problem does the Transactional Outbox pattern solve?

Choose an answer

Resources