Replication & High Availability

Keeping databases online when hardware fails and traffic spikes

From Single Point of Failure to Fault Tolerance

A single database server is a single point of failure. When it crashes, your application goes offline. Replication creates copies of your data across multiple servers, enabling high availability (survive failures) and horizontal scaling (distribute load). This lesson bridges lesson 9 (ACID properties) and lesson 14 (scaling strategies).

Real-World Impact:
  • GitHub (2018): Automated failover promoted a behind replica during a network partition, causing 24hr outage to reconcile data inconsistencies
  • Netflix: Uses Cassandra multi-master replication across 3 AWS regions, survives entire region failures
  • Discord: Read replicas handle 99% of queries, master only handles writes

Replication Fundamentals

Replication copies data from one database server (source) to one or more servers (replicas). Changes on the source are propagated to replicas, keeping them in sync.

Why Replicate?
High Availability

If primary fails, promote replica to primary.

Uptime Goal: 99.99%
= 52 minutes downtime/year

Single server: 99.5%
= 43 hours downtime/year

With replication + failover:
Achieve 99.99% or higher
Read Scaling

Distribute read queries across replicas.

Read-heavy app: 90% reads
1 master + 3 replicas

Before: 10,000 QPS on master
After: 2,500 QPS on master
       7,500 QPS on replicas

4x read capacity!
Geographic Distribution

Place replicas near users for low latency.

Master: US East
Replica: EU West
Replica: Asia Pacific

EU users query EU replica:
50ms instead of 200ms

Latency reduced 4x
Replication Methods
MethodHow It WorksProsCons
SynchronousMaster waits for replica to confirm before committingZero data loss, replicas always currentSlow writes (network latency), replica failure blocks writes
AsynchronousMaster commits immediately, replicas catch up laterFast writes, replica failures don't block writesReplication lag (replicas behind), potential data loss on failure
Semi-SynchronousWait for at least one replica, others asyncBalance of speed and safetyStill some lag on async replicas
Trade-off: Synchronous replication guarantees consistency but hurts write performance. Asynchronous is fast but replicas may be seconds/minutes behind. Most production systems use asynchronous replication and accept eventual consistency.

Master-Slave Replication

Also called primary-replica or leader-follower. One master accepts writes, multiple replicas accept reads. This is the most common replication topology.

Architecture
                         ┌─────────────┐
                         │   Master    │
                         │  (Writes)   │
                         └──────┬──────┘
                                │
                    ┌───────────┼───────────┐
                    │           │           │
            ┌───────▼──┐  ┌────▼─────┐  ┌───▼──────┐
            │ Replica 1│  │ Replica 2│  │ Replica 3│
            │ (Reads)  │  │ (Reads)  │  │ (Reads)  │
            └──────────┘  └──────────┘  └──────────┘

Write path:  Application → Master → Replicas (async)
Read path:   Application → Any Replica (load balanced)
PostgreSQL Streaming Replication Setup
# On MASTER server (primary)

# 1. Configure postgresql.conf
wal_level = replica               # Write-Ahead Log includes replication data
max_wal_senders = 3               # Max 3 replicas can connect
wal_keep_size = 1GB               # Keep 1GB of WAL for slow replicas

# 2. Allow replica connections in pg_hba.conf
# TYPE  DATABASE  USER        ADDRESS         METHOD
host    replication  replicator  192.168.1.0/24  md5

# 3. Create replication user
CREATE USER replicator WITH REPLICATION PASSWORD 'secure_password';

# 4. Restart PostgreSQL
sudo systemctl restart postgresql
Result: Master is configured to stream WAL (Write-Ahead Log) to replicas. Replicas will replay these logs to stay in sync.
# On REPLICA server (standby)

# 1. Create base backup from master
pg_basebackup -h master.example.com -D /var/lib/postgresql/data               -U replicator -P -v -R -X stream -C -S replica1

# Flags:
#   -R: Create standby.signal (marks as replica)
#   -X stream: Stream WAL during backup
#   -C -S replica1: Create replication slot 'replica1'

# 2. Verify standby.signal exists
ls /var/lib/postgresql/data/standby.signal  # Should exist

# 3. Configure primary connection in postgresql.auto.conf
primary_conninfo = 'host=master.example.com port=5432 user=replicator password=secure_password'
primary_slot_name = 'replica1'

# 4. Start PostgreSQL (automatically becomes replica)
sudo systemctl start postgresql
Result: Replica is live-streaming changes from master. Any write to master is automatically replicated (usually within 100ms-1s depending on network).
Verifying Replication Status
# On MASTER: Check connected replicas
SELECT client_addr, state, sync_state, replay_lag
FROM pg_stat_replication;

# Output:
#  client_addr  |   state   | sync_state | replay_lag
# --------------+-----------+------------+------------
#  192.168.1.10 | streaming | async      | 00:00:00.05
#  192.168.1.11 | streaming | async      | 00:00:00.12

# On REPLICA: Check replication lag
SELECT
    now() - pg_last_xact_replay_timestamp() AS replication_lag;

# Output:
#  replication_lag
# -----------------
#  00:00:00.087654  -- 87ms behind master (excellent!)
Result: Replication lag under 100ms is excellent. Over 1 second indicates network issues or replica falling behind (slow disk, high load).

Read Replicas & Load Distribution

With master-slave replication in place, direct read queries to replicas to offload the master. This requires application-level routing logic.

Pattern 1: Manual Routing in Application
import psycopg2
import random

# Database connections
MASTER = "postgresql://user:pass@master.example.com:5432/mydb"
REPLICAS = [
    "postgresql://user:pass@replica1.example.com:5432/mydb",
    "postgresql://user:pass@replica2.example.com:5432/mydb",
    "postgresql://user:pass@replica3.example.com:5432/mydb",
]

def get_write_conn():
    """Get connection to master for writes."""
    return psycopg2.connect(MASTER)

def get_read_conn():
    """Get connection to random replica for reads."""
    replica_url = random.choice(REPLICAS)  # Random selection
    return psycopg2.connect(replica_url)
def create_user(username, email):
    """Write operation → Master"""
    conn = get_write_conn()
    cur = conn.cursor()
    cur.execute(
        "INSERT INTO users (username, email) VALUES (%s, %s)",
        (username, email)
    )
    conn.commit()
    conn.close()
    # Result: Written to master, async replicated to replicas

def get_users():
    """Read operation → Replica"""
    conn = get_read_conn()
    cur = conn.cursor()
    cur.execute("SELECT username, email FROM users")
    results = cur.fetchall()
    conn.close()
    return results
    # Result: Read from replica, master not touched

# Usage
create_user('alice', 'alice@example.com')  # Writes to master
users = get_users()  # Reads from random replica
Result: Writes go to master, reads are distributed across replicas. Master handles 10% of queries (writes), replicas handle 90% (reads). 10x effective capacity!
Pattern 2: Read-After-Write Consistency
import time

def create_post_and_show(user_id, content):
    """Problem: Read replica may not have the post yet!"""

    # Write to master
    conn_master = get_write_conn()
    cur = conn_master.cursor()
    cur.execute(
        "INSERT INTO posts (user_id, content) VALUES (%s, %s) RETURNING id",
        (user_id, content)
    )
    post_id = cur.fetchone()[0]
    conn_master.commit()
    conn_master.close()

    # Immediate read from replica - MIGHT FAIL!
    conn_replica = get_read_conn()
    cur_replica = conn_replica.cursor()
    cur_replica.execute("SELECT * FROM posts WHERE id = %s", (post_id,))
    post = cur_replica.fetchone()  # Could be None due to replication lag!

    return post  # User doesn't see their own post! Bad UX
Problem: Replication lag means user might not see data they just wrote. Classic issue with async replication.
def create_post_and_show_fixed(user_id, content):
    """Solution: Read from master after write (read-your-writes consistency)"""

    # Write to master
    conn_master = get_write_conn()
    cur = conn_master.cursor()
    cur.execute(
        "INSERT INTO posts (user_id, content) VALUES (%s, %s) RETURNING id",
        (user_id, content)
    )
    post_id = cur.fetchone()[0]
    conn_master.commit()

    # Read from SAME master connection (not replica)
    cur.execute("SELECT * FROM posts WHERE id = %s", (post_id,))
    post = cur.fetchone()
    conn_master.close()

    return post  # Guaranteed to exist!

# Result: User always sees their own writes, other users tolerate slight delay
Result: Route reads to master for the user who just wrote, use replicas for everyone else. Balances consistency and performance.

Multi-Master Replication

All nodes accept writes and propagate to others. Enables active-active deployment and geographic distribution. But introduces conflict resolution complexity.

Master-Slave
Master (writes)
   ↓  ↓  ↓
Replica Replica Replica
(reads) (reads) (reads)

Pros:

  • Simple, no write conflicts
  • Clear consistency model
  • Easy to reason about

Cons:

  • Master is bottleneck for writes
  • Failover creates downtime
Multi-Master
Master 1 ←→ Master 2
    ↕          ↕
Master 3 ←→ Master 4
(all accept reads + writes)

Pros:

  • No single point of failure
  • Geo-distributed writes
  • Zero-downtime failover

Cons:

  • Write conflicts must be resolved
  • Complex to operate
Write Conflicts in Multi-Master
# Scenario: Two users update same record on different masters simultaneously

# Master 1 (US East)                    # Master 2 (EU West)
UPDATE users                            UPDATE users
SET email = 'alice@usa.com'             SET email = 'alice@europe.com'
WHERE id = 1;                           WHERE id = 1;

# Both commit successfully (on their local master)
# Replication propagates changes to all masters

# Result: CONFLICT! Which email is correct?
# Master 1 sees: alice@usa.com → alice@europe.com (overwritten by M2)
# Master 2 sees: alice@europe.com → alice@usa.com (overwritten by M1)

# Without conflict resolution, databases diverge!
Problem: Concurrent writes to same record on different masters create conflicts. Database must choose a winner or merge changes.
Conflict Resolution Strategies
StrategyHow It WorksExample
Last Write Wins (LWW)Timestamp-based, latest write keptalice@europe.com written at 10:05:03, alice@usa.com at 10:05:01 → Keep europe.com
Version VectorsTrack causality, detect true conflictsRiak uses version vectors to determine if writes are concurrent (Cassandra uses LWW instead)
Application-Level MergeStore both values, app decidesCouchDB returns conflicts, application merges them
CRDTsConflict-free data types with merge semanticsCounter CRDT: merge by summing increments from all nodes
Trade-off: Multi-master is powerful but complex. Only use if you need geo-distributed writes or zero-downtime failover. For most applications, master-slave with automatic failover is sufficient.

Automatic Failover Strategies

When the master fails, a replica must be promoted to master. Manual failover takes minutes; automatic failover takes seconds. But automation risks split-brain scenarios.

Failover Steps
  1. Detection: Monitor detects master is unreachable (health checks fail for 10-30 seconds)
  2. Consensus: Remaining nodes agree master is down (avoid false positives from network partitions)
  3. Election: Choose most up-to-date replica as new master (least replication lag)
  4. Promotion: Convert replica to master (remove read-only mode, accept writes)
  5. Reconfiguration: Point other replicas to new master (update replication source)
  6. DNS Update: Update connection strings to point applications to new master
Tool: Patroni for PostgreSQL
# Patroni: HA solution using etcd/Consul for leader election

# patroni.yml configuration
scope: postgres-cluster
name: node1

restapi:
  listen: 0.0.0.0:8008
  connect_address: 192.168.1.10:8008

etcd:
  hosts: etcd1:2379,etcd2:2379,etcd3:2379  # Distributed consensus

bootstrap:
  dcs:
    ttl: 30                    # Leader lease time
    loop_wait: 10              # Check interval
    retry_timeout: 10          # Retry before giving up
    maximum_lag_on_failover: 1048576  # Max 1MB lag for promotion

postgresql:
  listen: 0.0.0.0:5432
  data_dir: /var/lib/postgresql/data
  parameters:
    max_connections: 100
    shared_buffers: 256MB
Result: Patroni uses etcd (distributed key-value store) for leader election. If master fails, replicas vote on new leader within 10-30 seconds. Automatic, no human intervention.
Monitoring Failover Events
import requests
import time

def monitor_cluster_health():
    """Monitor Patroni cluster via REST API."""
    patroni_api = "http://localhost:8008"

    while True:
        try:
            response = requests.get(f"{patroni_api}/cluster")
            cluster_info = response.json()

            for member in cluster_info['members']:
                print(f"Node: {member['name']}")
                print(f"  Role: {member['role']}")  # leader or replica
                print(f"  State: {member['state']}")  # running or stopped
                print(f"  Lag: {member.get('lag', 0)} bytes")

            # Alert if no leader
            leaders = [m for m in cluster_info['members'] if m['role'] == 'leader']
            if len(leaders) == 0:
                print("⚠️  ALERT: No leader! Failover in progress...")
            elif len(leaders) > 1:
                print("🚨 CRITICAL: Split-brain detected! Multiple leaders!")

        except Exception as e:
            print(f"Error monitoring cluster: {e}")

        time.sleep(5)
Result: REST API provides real-time cluster state. Alert on: no leader (failover in progress), multiple leaders (split-brain), high replication lag.

The Split-Brain Problem

Network partition splits cluster into isolated groups. Each group thinks the other is dead and elects its own leader. Result: multiple masters, causing data divergence and corruption.

How Split-Brain Happens
Initial State:
  Master (US-East) → Replica 1 (US-East)
                   → Replica 2 (EU-West)

Network Partition:
  US-East network isolated from EU-West

  US-East group:           EU-West group:
  ✓ Master (online)        ✗ Can't reach Master (timeout)
  ✓ Replica 1 (online)     ✓ Replica 2 (online)

  US-East: "Everything fine, keep Master"
  EU-West: "Master is dead, promote Replica 2!"

Split-Brain State (BAD!):
  Master (US-East)  ← Accepts writes from US users
  Master (EU-West)  ← Accepts writes from EU users

  Both think they're the primary!
  Data diverges irreversibly!
Impact: Applications write to both masters. When partition heals, databases have conflicting data. Manual resolution required, can take hours/days.
Prevention: Quorum-Based Elections
# Prevent split-brain with quorum (majority voting)

Cluster with 3 nodes:
  Master, Replica 1, Replica 2

Quorum = (3 / 2) + 1 = 2 nodes minimum

Network Partition:
  Group A: Master + Replica 1 (2 nodes) ✓ Has quorum, keeps leadership
  Group B: Replica 2 (1 node)           ✗ No quorum, goes read-only

Result:
  - Group A continues operating (has majority)
  - Group B rejects writes (minority, can't elect leader)
  - No split-brain!

When partition heals:
  - Replica 2 rejoins, replicates from Master
  - Cluster returns to normal automatically
Result: Quorum ensures only one group can elect a leader. Minority partition becomes read-only, prevents divergence. Requires odd number of nodes (3, 5, 7).
Fencing: Force Old Master Offline
# STONITH: Shoot The Other Node In The Head
# When promoting new master, forcibly shut down old master

# Option 1: Power fencing (IPMI/iLO)
def fence_node_power(node_ip):
    """Power off node via IPMI interface."""
    subprocess.run([
        "ipmitool", "-H", node_ip, "-U", "admin", "-P", "password",
        "power", "off"
    ])
    # Result: Old master powered off, can't accept writes

# Option 2: Network fencing (firewall rules)
def fence_node_network(node_ip):
    """Block node's database port at network level."""
    subprocess.run([
        "iptables", "-A", "INPUT", "-s", node_ip, "-p", "tcp",
        "--dport", "5432", "-j", "DROP"
    ])
    # Result: Old master can't communicate with clients or replicas
Result: Fencing guarantees old master can't interfere. Aggressive but necessary for data safety. Production clusters use automated fencing during failover.

Connection Pooling

Database connections are expensive (TCP handshake, authentication, memory allocation). Connection pools reuse connections across requests, reducing latency and database load.

Problem: Creating Connections Per Request
Without Pooling
def handle_request():
    conn = psycopg2.connect(DSN)
    cur = conn.cursor()
    cur.execute("SELECT ...")
    results = cur.fetchall()
    conn.close()  # Close immediately
    return results

# Each request:
# 1. Open TCP connection: 10ms
# 2. Authenticate: 5ms
# 3. Execute query: 2ms
# 4. Close connection: 5ms
# Total: 22ms (90% overhead!)

# 1000 req/s = 1000 connections/s
# Database exhausted!
With Pooling
from psycopg2 import pool

conn_pool = pool.SimpleConnectionPool(
    minconn=5, maxconn=20, dsn=DSN
)

def handle_request():
    conn = conn_pool.getconn()
    cur = conn.cursor()
    cur.execute("SELECT ...")
    results = cur.fetchall()
    conn_pool.putconn(conn)  # Return to pool
    return results

# Each request:
# 1. Get from pool: 0.1ms
# 2. Execute query: 2ms
# 3. Return to pool: 0.1ms
# Total: 2.2ms (10x faster!)

# Reuses 5-20 connections
Production-Grade Pooling with PgBouncer
# PgBouncer: External connection pooler (sits between app and database)

# pgbouncer.ini configuration
[databases]
mydb = host=localhost port=5432 dbname=mydb

[pgbouncer]
listen_addr = *
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt

pool_mode = transaction        # Connection returned after each transaction
max_client_conn = 1000         # Max connections from applications
default_pool_size = 25         # Actual connections to database
reserve_pool_size = 5          # Extra connections for spikes

# Result:
# 1000 app connections → 25-30 database connections
# Database sees consistent, low connection count
# Apps get fast connection acquisition
Result: PgBouncer multiplexes 1000 application connections onto 25-30 database connections. Database load drops 40x while handling same traffic.
Application-Level Pooling
from psycopg2 import pool
from contextlib import contextmanager

# Create global pool at application startup
connection_pool = pool.ThreadedConnectionPool(
    minconn=5,
    maxconn=50,
    host='localhost',
    database='mydb',
    user='postgres',
    password='password'
)

@contextmanager
def get_db_connection():
    """Context manager for safe connection handling."""
    conn = connection_pool.getconn()
    try:
        yield conn
        conn.commit()  # Commit transaction
    except Exception:
        conn.rollback()  # Rollback on error
        raise
    finally:
        connection_pool.putconn(conn)  # Always return to pool

# Usage
def get_user(user_id):
    with get_db_connection() as conn:
        cur = conn.cursor()
        cur.execute("SELECT * FROM users WHERE id = %s", (user_id,))
        return cur.fetchone()
    # Connection automatically returned to pool when context exits
Result: Context manager ensures connections are always returned to pool, even if exceptions occur. Prevents connection leaks.

High Availability Best Practices

Do This
  • Use odd number of nodes (3, 5, 7) for quorum-based systems
  • Monitor replication lag continuously (alert if >5 seconds)
  • Test failover regularly (monthly chaos engineering)
  • Use connection pooling (PgBouncer, HikariCP) to reduce load
  • Implement read-your-writes consistency for user-facing queries
  • Enable automated fencing to prevent split-brain
  • Distribute replicas geographically for disaster recovery
  • Set up alerts for: replication lag, failed nodes, split-brain
Avoid This
  • Don't use 2-node clusters (can't achieve quorum, both go read-only)
  • Don't ignore replication lag (causes stale reads and failover issues)
  • Don't rely on DNS for failover alone (TTL causes delays)
  • Don't skip connection pooling (database overwhelmed by connections)
  • Don't assume replicas are real-time (async = eventual consistency)
  • Don't use multi-master unless truly needed (complexity not worth it)
  • Don't test failover only in staging (production behaves differently)
  • Don't create new connections per request (use pooling!)

Replication Architecture Decision Tree

1. What is your read/write ratio?
  • 90%+ reads → Use master-slave with read replicas
  • Balanced or write-heavy → Continue to question 2
2. Do you need geo-distributed writes?
  • YES → Use multi-master replication (accept complexity)
  • NO → Continue to question 3
3. What is your uptime requirement?
  • 99.9% (8.7hr downtime/year)Manual failover acceptable
  • 99.99% (52min downtime/year)Automated failover (Patroni, AWS RDS Multi-AZ)
  • 99.999% (5min downtime/year)Multi-region active-passive with instant failover
4. Can you tolerate eventual consistency for reads?
  • YES → Use asynchronous replication (fast writes)
  • NO → Use synchronous replication (slow writes, zero data loss)
Common Pattern: For most web applications, the optimal architecture is: 1 master + 2-3 replicas with asynchronous replication and automated failover (Patroni).This achieves 99.95%+ uptime with manageable complexity.