#system-design#architecture#backend#distributed-systems

Designing Resilient Distributed Systems: EDA, CQRS, and Fault-Tolerant Patterns

"Distributed architectures exchange single-node simplicity for horizontal scalability, fault isolation, and autonomous team velocity. However, network partition"

By huud
0 views
~7 min read
Designing Resilient Distributed Systems: EDA, CQRS, and Fault-Tolerant Patterns

Distributed architectures exchange single-node simplicity for horizontal scalability, fault isolation, and autonomous team velocity. However, network partitions, partial failures, and asynchronous boundaries introduce critical failure modes: dual-write inconsistencies, cascading overloads, and out-of-order execution. Building resilient distributed backends requires combining architectural patterns: Event-Driven Architecture (EDA), Command Query Responsibility Segregation (CQRS), the Transactional Outbox pattern, distributed idempotency, adaptive rate limiting, and intelligent load balancing.


1. Asynchronous Backbone: EDA and CQRS

Synchronous RPC topologies chain availability risks: overall system availability equals the product of each downstream dependency (Asystem=AiA_{\text{system}} = \prod A_i). Under write-heavy workloads, relational lock contention degrades read performance. Event-Driven Architecture (EDA) decouples producers from consumers via immutable append-only logs, converting temporal dependencies into asynchronous message boundaries.

scss
[ Client ] ---> ( Command API ) ---> [ Write DB (PostgreSQL) ]
                       |
                 [ Outbox Table ]
                       |
                ( CDC / Debezium )
                       |
                       v
               [ Message Broker (Kafka) ]
                       |
                       +---> ( Projection Worker ) ---> [ Read Store (Elasticsearch) ]
                       +---> ( Payment Worker )    ---> [ Payment Gateway ]

Command Query Responsibility Segregation (CQRS) bifurcates mutation and retrieval paths:

  • Command Model: Enforces business invariants, executes domain logic, and writes to a normalized OLTP database (e.g., PostgreSQL). Optimized for transactional integrity.
  • Query Model: Read-only views hydrated asynchronously from domain events into specialized data stores (e.g., Elasticsearch for full-text search, Redis for key-value lookups). Optimized for sub-millisecond retrieval without joins.

2. Eliminating Dual Writes: The Transactional Outbox Pattern

Directly updating a database and publishing an event to a broker (e.g., Apache Kafka, RabbitMQ) creates an unsafe dual write. If the broker publish fails after database commit, downstream consumers miss state transitions. If the database transaction rolls back after broker publication, consumers process phantom data. Distributed 2-Phase Commit (2PC) protocols solve this theoretically, but introduce blocking latency and are rarely supported by modern brokers.

Implementation via Outbox Table

State mutations and outgoing domain events are committed atomically inside the same local relational transaction:

sql
BEGIN;

-- 1. Mutate domain state
UPDATE orders 
SET status = 'PAID', updated_at = NOW() 
WHERE id = 'ord_01HGB8' AND status = 'PENDING';

-- 2. Persist outbox event in same ACID transaction
INSERT INTO outbox_events (
    id, aggregate_type, aggregate_id, event_type, payload, created_at
) VALUES (
    gen_random_uuid(), 
    'Order', 
    'ord_01HGB8', 
    'OrderPaid', 
    '{"order_id": "ord_01HGB8", "amount": 99.50, "currency": "USD"}', 
    NOW()
);

COMMIT;

Event Relay Mechanisms

  1. Change Data Capture (CDC): Engines like Debezium tail database transaction logs (e.g., PostgreSQL Write-Ahead Log via pgoutput). CDC reads committed outbox rows asynchronously and streams them to Kafka with sub-10ms latency, bypassing application-level polling overhead.
  2. Polling Publisher: A background worker queries pending records using SELECT ... FOR UPDATE SKIP LOCKED and marks records published. Simple to implement, but creates database query churn and table bloat at scale.

3. Distributed Idempotency and Deduplication

Distributed brokers provide at-least-once delivery guarantees. Network timeouts, consumer group rebalances, and producer retries cause duplicate message deliveries. Consumers must guarantee idempotent execution: processing an event multiple times must yield the same system state as processing it once.

Safe Distributed Locking and Deduplication

Using distributed locks requires strict token validation on release to avoid releasing a lock acquired by another worker after a timeout:

typescript
import { Redis } from "ioredis";
import crypto from "crypto";

const redis = new Redis();

async function processOrderPayment(event: { id: string; orderId: string; amount: number; accountId: string }): Promise<void> {
  const lockKey = `lock:idempotency:${event.id}`;
  const statusKey = `processed:idempotency:${event.id}`;
  const ownerToken = crypto.randomUUID();

  // Acquire distributed lock with 10-second TTL
  const acquired = await redis.set(lockKey, ownerToken, "NX", "PX", 10000);
  if (!acquired) {
    throw new Error("Concurrent execution detected on event. Retry scheduled.");
  }

  try {
    const isProcessed = await redis.get(statusKey);
    if (isProcessed) {
      return; // Skip duplicate message
    }

    // Execute business mutation atomically
    await db.transaction(async (tx) => {
      await tx.execute(
        "UPDATE accounts SET balance = balance - $1 WHERE id = $2", 
        [event.amount, event.accountId]
      );
      await tx.execute(
        "INSERT INTO processed_events (event_id, processed_at) VALUES ($1, NOW())", 
        [event.id]
      );
    });

    // Mark event processed with 7-day TTL
    await redis.set(statusKey, "COMPLETED", "EX", 604800);
  } finally {
    // Release lock only if ownerToken matches (prevents releasing expired locks)
    const releaseScript = `
      if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
      else
        return 0
      end
    `;
    await redis.eval(releaseScript, 1, lockKey, ownerToken);
  }
}

4. Traffic Shaping: Rate Limiting Algorithms

Rate limiters protect downstream services from cascading collapse, enforce multi-tenant quotas, and mitigate volumetric denial-of-service traffic.

Token Bucket Formulation

A bucket with capacity CC refills at rate rr tokens per second. An incoming request consumes kk tokens (k=1k=1). Token balance T(t)T(t) at time tt relative to last update tprevt_{\text{prev}}:

T(t)=min(C,T(tprev)+r(ttprev))T(t) = \min(C, T(t_{\text{prev}}) + r \cdot (t - t_{\text{prev}}))

If T(t)kT(t) \ge k, the request is admitted and tokens decrement: T(t)T(t)kT(t) \leftarrow T(t) - k. If T(t)<kT(t) < k, the edge returns HTTP 429 (Too Many Requests).

Algorithm Trade-Off Matrix

Algorithm Memory Overhead Burst Tolerance Time Complexity Implementation Profile
Token Bucket O(1)O(1) per key High (up to capacity CC) O(1)O(1) Low compute; ideal for API gateways
Leaky Bucket O(1)O(1) per key None (smooths to rr) O(1)O(1) Queue-backed; prevents downstream spikes
Sliding Window Log O(N)O(N) requests Strict sliding boundary O(logN)O(\log N) High memory; exact precision
Sliding Window Counter O(1)O(1) per window Weighted approximation O(1)O(1) Low memory; minimal boundary error

Redis Lua Sliding Window Log

Atomic execution prevents race conditions across concurrent application nodes:

lua
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local clearBefore = now - window

-- Evict timestamps outside the active sliding window
redis.call('ZREMRANGEBYSCORE', key, 0, clearBefore)
local currentRequests = redis.call('ZCARD', key)

if currentRequests < limit then
    redis.call('ZADD', key, now, now)
    redis.call('EXPIRE', key, window)
    return 1
else
    return 0
end

5. Ingress Routing and Consistent Hashing

Load balancers distribute traffic across heterogeneous instances to prevent hot spotting.

Layer 4 vs. Layer 7 Ingress

  • Layer 4 (Transport): Operates at TCP/UDP level (e.g., Linux IPVS, AWS NLB). State-agnostic, low latency, millions of packets per second, no TLS payload inspection.
  • Layer 7 (Application): Operates at HTTP/gRPC level (e.g., Envoy, NGINX). Enables path-based routing, header-based canary deployments, distributed tracing propagation, and gRPC stream load balancing.

Consistent Hashing Ring

Modulus-based routing (N(modM)N \pmod M) forces almost all keys to remap when cluster size MM changes. Consistent hashing maps keys and nodes onto a circular 23212^{32}-1 integer ring:

Node(k)=argminnNhash(n)hash(n)hash(k)\text{Node}(k) = \arg\min_{n \in \mathcal{N}} { \text{hash}(n) \mid \text{hash}(n) \ge \text{hash}(k) }

Adding or removing a node migrates only K/NK/N keys on average. To prevent non-uniform distribution (hot spotting), each physical node is assigned VV virtual nodes on the ring. Load variance scales inversely with virtual node count:

Standard Deviation of Load1V\text{Standard Deviation of Load} \propto \frac{1}{\sqrt{V}}


6. End-to-End Failure Mitigation

A resilient distributed architecture must handle edge failures systematically:

  1. CQRS Replication Lag: Projections update asynchronously. If a client requires immediate read-after-write consistency, return the updated entity state directly in the command response, or pass a sequence token forcing the read query to wait for projection catch-up.
  2. Outbox Table Bloat: High write volume causes PostgreSQL MVCC bloat if rows are continuously deleted. Use range partitioning on created_at (e.g., hourly partitions) and drop expired partitions via DROP TABLE rather than issuing row-level DELETE statements.
  3. Thundering Herds on Upstream Retries: When downstream calls fail, client retries can overwhelm recovering services. Implement truncated exponential backoff with full jitter:

tsleep=random(0,min(M,B2i))t_{\text{sleep}} = \text{random}(0, \min(M, B \cdot 2^i))


Conclusion: Architectural Checkpoints

  • Eliminate dual writes: Combine Transactional Outbox with log-based CDC to decouple business writes from event publication.
  • Enforce idempotency: Pair Redis distributed locks with relational uniqueness constraints to handle duplicate message delivery safely.
  • Shape traffic at ingress: Protect internal systems with Token Bucket or Sliding Window rate limiters before requests consume compute resources.
  • Isolate read and write paths: Use CQRS to scale read and write infrastructure independently, preventing analytical queries from starving transactional workloads.

About the Author

huud

huud

@huud

About →

Systems architect and software engineer building high-performance distributed platforms.