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).
- 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
| Method | How It Works | Pros | Cons |
|---|---|---|---|
| Synchronous | Master waits for replica to confirm before committing | Zero data loss, replicas always current | Slow writes (network latency), replica failure blocks writes |
| Asynchronous | Master commits immediately, replicas catch up later | Fast writes, replica failures don't block writes | Replication lag (replicas behind), potential data loss on failure |
| Semi-Synchronous | Wait for at least one replica, others async | Balance of speed and safety | Still some lag on async replicas |
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
# 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
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!)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 replicaPattern 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 UXdef 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 delayMulti-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!
Conflict Resolution Strategies
| Strategy | How It Works | Example |
|---|---|---|
| Last Write Wins (LWW) | Timestamp-based, latest write kept | alice@europe.com written at 10:05:03, alice@usa.com at 10:05:01 → Keep europe.com |
| Version Vectors | Track causality, detect true conflicts | Riak uses version vectors to determine if writes are concurrent (Cassandra uses LWW instead) |
| Application-Level Merge | Store both values, app decides | CouchDB returns conflicts, application merges them |
| CRDTs | Conflict-free data types with merge semantics | Counter CRDT: merge by summing increments from all nodes |
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
- Detection: Monitor detects master is unreachable (health checks fail for 10-30 seconds)
- Consensus: Remaining nodes agree master is down (avoid false positives from network partitions)
- Election: Choose most up-to-date replica as new master (least replication lag)
- Promotion: Convert replica to master (remove read-only mode, accept writes)
- Reconfiguration: Point other replicas to new master (update replication source)
- 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: 256MBMonitoring 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)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!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
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 replicasConnection 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 connectionsProduction-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
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 exitsHigh 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)