Chapter 10: Relational Databases Deep Dive

Master ACID properties, isolation levels, MVCC, query optimization, and production patterns for PostgreSQL and MySQL. This chapter covers the internals that distinguish junior from senior engineers in system design interviews.

1. ACID Properties

ACID guarantees make relational databases reliable for transactional workloads. Understanding the implementation details is crucial for interviews.

1.1 Overview

Property Guarantee Implementation Mechanism Performance Impact
Atomicity All-or-nothing execution Undo logs, rollback segments Log writes per transaction
Consistency Valid state transitions only Constraints, triggers, foreign keys Validation overhead
Isolation Concurrent transactions don't interfere MVCC, locks, snapshot isolation Lock contention, version chains
Durability Committed data survives crashes Write-Ahead Log (WAL), fsync Disk I/O per commit

1.2 Transaction Lifecycle

sequenceDiagram participant App participant DB participant WAL as Write-Ahead Log participant Buffer as Buffer Pool participant Disk App->>DB: BEGIN TRANSACTION activate DB DB->>DB: Allocate transaction ID (XID) App->>DB: INSERT INTO users (name) VALUES ('Alice') DB->>Buffer: Write to buffer pool (dirty page) DB->>WAL: Append INSERT log record Note over WAL: Log includes: XID, LSN, operation App->>DB: UPDATE accounts SET balance = 100 WHERE user_id = 1 DB->>Buffer: Update buffer pool (dirty page) DB->>WAL: Append UPDATE log record App->>DB: COMMIT DB->>WAL: Write COMMIT record WAL->>Disk: fsync() - force WAL to disk Note over Disk: Durability achieved DB->>App: COMMIT successful deactivate DB Note over Buffer,Disk: Dirty pages written to disk later
(checkpoint process)

1.3 Atomicity Deep Dive

Undo Logs

When a transaction modifies data, the database writes an undo log entry containing the old value. If the transaction rolls back, the database replays undo logs in reverse order.

-- Transaction T1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1; -- balance was 500
UPDATE accounts SET balance = balance + 100 WHERE id = 2; -- balance was 200
-- System crash here!

-- Undo log entries:
-- [T1, account_id=1, old_balance=500]
-- [T1, account_id=2, old_balance=200]

-- Recovery process:
-- 1. Find uncommitted transaction T1
-- 2. Apply undo logs in reverse: account 2 → 200, account 1 → 500
-- 3. Transaction rolled back

1.4 Durability Deep Dive

graph LR A[Transaction COMMIT] --> B{WAL written?} B -->|No| C[Write to WAL buffer] C --> D[fsync WAL to disk] B -->|Yes| D D --> E{Synchronous commit?} E -->|Yes| F[Return success to client] E -->|No| G[Return immediately] G --> H[Background flush] F --> I[Data pages written later by checkpoint] H --> I style D fill:#e74c3c style F fill:#27ae60
Durability Mode PostgreSQL MySQL InnoDB Performance Safety
Full durability synchronous_commit = on innodb_flush_log_at_trx_commit = 1 ~500 TPS No data loss
Async commit synchronous_commit = off innodb_flush_log_at_trx_commit = 2 ~10,000 TPS Lose last 1s of commits on OS crash
No WAL fsync = off innodb_flush_log_at_trx_commit = 0 ~50,000 TPS Corruption risk
Interview Callout: When asked "How does a database survive a crash?", walk through:
  1. Write-Ahead Log ensures all committed changes are on disk before returning success
  2. Recovery process replays WAL from last checkpoint to rebuild state
  3. Uncommitted transactions are rolled back using undo logs
  4. Trade-off: fsync() adds ~10ms latency per commit, but guarantees durability

1.5 Consistency Enforcement

-- Consistency through constraints
CREATE TABLE accounts (
    id SERIAL PRIMARY KEY,
    balance DECIMAL(10, 2) NOT NULL CHECK (balance >= 0),  -- Domain constraint
    user_id INT NOT NULL REFERENCES users(id),              -- Referential integrity
    account_type VARCHAR(20) CHECK (account_type IN ('checking', 'savings'))
);

-- Consistency through triggers
CREATE TRIGGER maintain_audit_log
AFTER UPDATE ON accounts
FOR EACH ROW
EXECUTE FUNCTION log_balance_change();

-- Application-level consistency via serializable isolation
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT SUM(balance) FROM accounts WHERE user_id = 123;  -- Must equal 1000
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
-- If another transaction modified these rows, this will fail with serialization error
COMMIT;

2. Isolation Levels

Isolation levels define what a transaction can "see" from other concurrent transactions. This is the most frequently asked database topic in interviews.

2.1 Read Phenomena

Phenomenon Description Example
Dirty Read Read uncommitted data from another transaction T1 updates row, T2 reads it, T1 rolls back → T2 saw data that never existed
Non-Repeatable Read Same query returns different results within same transaction T1 reads row (value=10), T2 updates row (value=20) and commits, T1 reads again (value=20)
Phantom Read Same query returns different row sets T1 counts rows (result=5), T2 inserts row and commits, T1 counts again (result=6)
Write Skew Two transactions read overlapping data and make non-conflicting writes that violate constraint Two doctors on-call, constraint requires ≥1 on-call. Both read count=2, both set themselves off-call → 0 on-call
Lost Update Two transactions read-modify-write same row, second overwrites first T1 reads balance=100, T2 reads balance=100, T1 writes 110, T2 writes 120 → T1's +10 lost

2.2 Isolation Level Matrix

Isolation Level Dirty Read Non-Repeatable Read Phantom Read Write Skew Lost Update
Read Uncommitted Possible Possible Possible Possible Possible
Read Committed Not Possible Possible Possible Possible Possible
Repeatable Read Not Possible Not Possible Depends* Depends* Not Possible
Serializable Not Possible Not Possible Not Possible Not Possible Not Possible

*PostgreSQL's Repeatable Read prevents phantoms and write skew; MySQL InnoDB's Repeatable Read allows write skew.

2.3 Dirty Read Visualization

sequenceDiagram participant T1 as Transaction 1 participant DB as Database participant T2 as Transaction 2 T1->>DB: BEGIN T1->>DB: UPDATE accounts SET balance = 500 WHERE id = 1 Note over DB: balance = 500 (uncommitted) T2->>DB: BEGIN (READ UNCOMMITTED) T2->>DB: SELECT balance FROM accounts WHERE id = 1 DB->>T2: 500 ← DIRTY READ! Note over T2: T2 makes decision based on 500 T1->>DB: ROLLBACK Note over DB: balance reverts to 1000 T2->>DB: -- Still thinks balance is 500 T2->>DB: COMMIT Note over T2: Inconsistent state!

2.4 Non-Repeatable Read Visualization

sequenceDiagram participant T1 as Transaction 1 participant DB as Database participant T2 as Transaction 2 T1->>DB: BEGIN (READ COMMITTED) T1->>DB: SELECT balance FROM accounts WHERE id = 1 DB->>T1: 1000 T2->>DB: BEGIN T2->>DB: UPDATE accounts SET balance = 500 WHERE id = 1 T2->>DB: COMMIT Note over DB: balance = 500 (committed) T1->>DB: SELECT balance FROM accounts WHERE id = 1 DB->>T1: 500 ← Different from first read! Note over T1: Same query, different result T1->>DB: COMMIT

2.5 Phantom Read Visualization

sequenceDiagram participant T1 as Transaction 1 participant DB as Database participant T2 as Transaction 2 T1->>DB: BEGIN (REPEATABLE READ - MySQL) T1->>DB: SELECT COUNT(*) FROM orders WHERE status = 'pending' DB->>T1: 5 T2->>DB: BEGIN T2->>DB: INSERT INTO orders (status) VALUES ('pending') T2->>DB: COMMIT Note over DB: New row committed T1->>DB: SELECT COUNT(*) FROM orders WHERE status = 'pending' DB->>T1: 6 ← Phantom row appeared! Note over T1: Row count changed T1->>DB: COMMIT

2.6 Write Skew Example

sequenceDiagram participant T1 as Doctor Alice participant DB as Database participant T2 as Doctor Bob Note over DB: Constraint: At least 1 doctor on-call
Current: Alice=on-call, Bob=on-call T1->>DB: BEGIN (REPEATABLE READ) T2->>DB: BEGIN (REPEATABLE READ) T1->>DB: SELECT COUNT(*) FROM doctors WHERE on_call = true DB->>T1: 2 ← OK to go off-call T2->>DB: SELECT COUNT(*) FROM doctors WHERE on_call = true DB->>T2: 2 ← OK to go off-call T1->>DB: UPDATE doctors SET on_call = false WHERE name = 'Alice' T1->>DB: COMMIT T2->>DB: UPDATE doctors SET on_call = false WHERE name = 'Bob' T2->>DB: COMMIT Note over DB: Constraint violated!
0 doctors on-call
-- Fix with SERIALIZABLE isolation
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT COUNT(*) FROM doctors WHERE on_call = true;  -- Count = 2
UPDATE doctors SET on_call = false WHERE name = 'Alice';
COMMIT;  -- Second transaction will fail with serialization error

2.7 PostgreSQL vs MySQL Isolation Levels

Declared Level PostgreSQL Behavior MySQL InnoDB Behavior
READ UNCOMMITTED Actually runs as READ COMMITTED True READ UNCOMMITTED (rarely used)
READ COMMITTED Default. Each statement sees snapshot at statement start Each statement sees latest committed data
REPEATABLE READ Snapshot at transaction start. Prevents phantoms via predicate locking Default. Snapshot at transaction start. Phantoms prevented via gap locks. Write skew allowed
SERIALIZABLE Uses Serializable Snapshot Isolation (SSI). Detects read-write conflicts Uses locking. Every SELECT becomes SELECT FOR SHARE
Interview Answer Template: "Read Committed is typically sufficient for most applications and is the default in PostgreSQL. It prevents dirty reads while allowing high concurrency. Use Repeatable Read when you need consistent snapshots across multiple queries in a transaction. Use Serializable only when you need to prevent write skew or have complex integrity constraints that can't be enforced with database constraints—but be prepared to handle serialization errors and retry transactions."

3. Multi-Version Concurrency Control (MVCC)

MVCC allows readers and writers to operate without blocking each other by maintaining multiple versions of each row.

3.1 Core Concept

graph TD A[Row Update Request] --> B[Don't modify existing version] B --> C[Create new version with updated data] C --> D[Old version kept for concurrent readers] D --> E{Is old version still needed?} E -->|Yes| F[Keep in version chain] E -->|No| G[Mark for garbage collection] style C fill:#3498db style G fill:#e74c3c

3.2 PostgreSQL MVCC Implementation

Tuple Visibility via Transaction IDs

Every row has hidden columns: xmin (transaction that created it) and xmax (transaction that deleted/updated it).

-- Logical table
CREATE TABLE users (id INT, name TEXT);
INSERT INTO users VALUES (1, 'Alice');

-- Physical storage (simplified)
id | name  | xmin | xmax | ctid
---|-------|------|------|-----
1  | Alice | 1000 | 0    | (0,1)  ← xmin=1000 (created by txn 1000), xmax=0 (not deleted)

-- UPDATE users SET name = 'Alice Smith' WHERE id = 1 (transaction 1001)
-- Creates NEW version, marks old version as deleted
id | name        | xmin | xmax | ctid
---|-------------|------|------|-----
1  | Alice       | 1000 | 1001 | (0,1)  ← xmax=1001 (deleted by txn 1001)
1  | Alice Smith | 1001 | 0    | (0,2)  ← New version

-- Visibility rules for transaction T:
-- Show row if: xmin < T AND (xmax = 0 OR xmax > T)
sequenceDiagram participant T1 as Transaction 1001 (UPDATE) participant Storage participant T2 as Transaction 1000 (SELECT) Note over Storage: Current: (Alice, xmin=1000, xmax=0) T1->>Storage: UPDATE name = 'Alice Smith' Storage->>Storage: Create new tuple (Alice Smith, xmin=1001, xmax=0) Storage->>Storage: Set old tuple xmax = 1001 T2->>Storage: SELECT name Note over Storage: T2's snapshot XID = 1000
Check visibility:
Old tuple: xmin=1000 ≤ 1000, xmax=1001 > 1000 ✓
New tuple: xmin=1001 > 1000 ✗ Storage->>T2: Alice (sees old version) T1->>Storage: COMMIT Note over Storage: Later transaction sees new version

3.3 MySQL InnoDB MVCC Implementation

Undo Logs + Read View

InnoDB maintains a clustered index with the latest version, plus undo logs to reconstruct older versions. Each transaction gets a "read view" of active transactions at start.

-- Clustered index (latest version)
id | name        | DB_TRX_ID | DB_ROLL_PTR
---|-------------|-----------|-------------
1  | Alice Smith | 1001      | ptr → undo log

-- Undo log chain (older versions)
[1001] name = 'Alice Smith' → [1000] name = 'Alice' → NULL

-- Read View for transaction starting at 1000:
-- min_trx_id = 1000, max_trx_id = 1002, active_trx_ids = [1001]
--
-- Visibility check for row with DB_TRX_ID = 1001:
-- 1001 is in active_trx_ids → not visible → follow undo log chain
-- → Find version with DB_TRX_ID = 1000 → visible

3.4 Version Chain Diagram

graph LR A[Latest Version
balance=500
txn=1003] -->|older| B[balance=300
txn=1002] B -->|older| C[balance=100
txn=1001] C -->|older| D[NULL] E[Transaction 1002
snapshot] -.sees.-> B F[Transaction 1003
snapshot] -.sees.-> A G[Transaction 1001
snapshot] -.sees.-> C style A fill:#27ae60 style B fill:#f39c12 style C fill:#95a5a6

3.5 Garbage Collection

Database Mechanism When It Runs What It Does
PostgreSQL VACUUM Autovacuum (default every 1 min) or manual Removes dead tuples with xmax < oldest active transaction. Frees space for reuse. Updates statistics
MySQL InnoDB Purge thread Background thread, continuous Truncates undo log chains for committed transactions no longer needed by any read view
-- PostgreSQL: Manual vacuum
VACUUM users;  -- Reclaim space
VACUUM FULL users;  -- Reclaim space and compact table (locks table)
VACUUM ANALYZE users;  -- Reclaim space + update statistics

-- Check for bloat
SELECT schemaname, tablename,
       pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size,
       n_dead_tup
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC;

3.6 MVCC Performance Implications

Aspect Benefit Cost Mitigation
Concurrency Readers never block writers, writers never block readers Extra storage for versions Aggressive vacuum/purge
Storage Point-in-time recovery possible Table bloat if vacuum lags Tune autovacuum, monitor bloat
Write Amplification Updates don't block reads Updates create new versions (PostgreSQL) or undo log entries (MySQL) Avoid unnecessary updates, use HOT updates (PostgreSQL)
Long Transactions Consistent snapshot across queries Prevents garbage collection, causes bloat Keep transactions short, monitor pg_stat_activity
Common Interview Question: "Why does MVCC eliminate most locking?"
Answer: "When a reader requests a row, MVCC provides a version that was committed before the reader's snapshot time. Concurrent writers create new versions instead of modifying the existing version, so the reader's version remains untouched. This means readers and writers operate on different versions and don't conflict—eliminating the need for read locks. Only writers block writers on the same row."

4. Locking Mechanisms

Despite MVCC, databases still use locks for write-write conflicts and serializable isolation.

4.1 Lock Types

Lock Type Acquired By Conflicts With Use Case
Shared Lock (S) SELECT FOR SHARE Exclusive lock Prevent row from being modified while reading
Exclusive Lock (X) UPDATE, DELETE, SELECT FOR UPDATE Shared lock, Exclusive lock Modify row, prevent concurrent modifications
Gap Lock Range queries under Repeatable Read (MySQL) Inserts into gap Prevent phantom reads
Next-Key Lock Index range scans (MySQL) Inserts and updates in range Combination of record lock + gap lock
Table Lock ALTER TABLE, LOCK TABLE All concurrent access Schema changes, bulk operations

4.2 Lock Compatibility Matrix

Requested \ Held None Shared (S) Exclusive (X)
Shared (S) ✓ ✓ ✗ Wait
Exclusive (X) ✓ ✗ Wait ✗ Wait

4.3 Row-Level vs Table-Level Locking

graph TD A[Locking Granularity] --> B[Table-Level] A --> C[Page-Level] A --> D[Row-Level] B --> B1[Pros: Low overhead] B --> B2[Cons: Low concurrency] C --> C1[Compromise approach] C --> C2[Used by older databases] D --> D1[Pros: High concurrency] D --> D2[Cons: More memory per lock] style D fill:#27ae60 style B fill:#e74c3c

4.4 Gap Locks Example (MySQL)

-- Table: users (id PRIMARY KEY)
-- Current rows: 1, 5, 10

BEGIN;  -- Transaction T1, REPEATABLE READ (default)
SELECT * FROM users WHERE id BETWEEN 3 AND 7 FOR UPDATE;
-- Returns: id=5
-- Gap locks acquired:
--   Gap before 5: (1, 5)
--   Gap after 5: (5, 10)
-- Prevents inserts of id=2,3,4,6,7,8,9

-- Transaction T2 (concurrent)
INSERT INTO users (id, name) VALUES (6, 'Bob');  -- BLOCKED by gap lock!
INSERT INTO users (id, name) VALUES (11, 'Charlie');  -- OK, not in gap

-- T1 commits, T2 proceeds

4.5 Deadlock Detection

graph LR T1[Transaction 1
Holds: Row A
Waits: Row B] -->|waits for| T2[Transaction 2
Holds: Row B
Waits: Row A] T2 -->|waits for| T1 style T1 fill:#e74c3c style T2 fill:#e74c3c
-- Transaction T1
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- Lock row 1
-- ... pause ...
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- Wait for row 2

-- Transaction T2 (concurrent)
BEGIN;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;  -- Lock row 2
UPDATE accounts SET balance = balance + 50 WHERE id = 1;  -- Wait for row 1

-- Deadlock! Database detects cycle and aborts one transaction
-- ERROR: deadlock detected
-- DETAIL: Process 12345 waits for ExclusiveLock on row (1); blocked by process 12346.
--         Process 12346 waits for ExclusiveLock on row (2); blocked by process 12345.

4.6 Wait-For Graph

graph TD T1 -->|waits for| T2 T2 -->|waits for| T3 T3 -->|waits for| T1 style T1 fill:#e74c3c style T2 fill:#e74c3c style T3 fill:#e74c3c

Database periodically builds a wait-for graph. If it detects a cycle, it aborts the youngest transaction.

4.7 Optimistic vs Pessimistic Concurrency

Approach Mechanism When to Use Example
Pessimistic Acquire locks up front High contention, conflicts likely SELECT ... FOR UPDATE
Optimistic Check for conflicts at commit time Low contention, conflicts rare Version column, compare-and-swap
-- Pessimistic: Lock immediately
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;  -- Lock acquired
-- ... business logic ...
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

-- Optimistic: Check version at update time
BEGIN;
SELECT balance, version FROM accounts WHERE id = 1;  -- No lock
-- ... business logic ...
UPDATE accounts SET balance = balance - 100, version = version + 1
WHERE id = 1 AND version = 42;  -- Fails if version changed
-- If 0 rows updated, retry transaction
COMMIT;

4.8 Lock Escalation

SQL Server-specific

When a transaction acquires too many row locks (~5000), SQL Server automatically escalates to a table lock to conserve memory. PostgreSQL and MySQL don't escalate—they may run out of lock memory instead.

Deadlock Prevention Strategies:
  1. Lock Ordering: Always acquire locks in the same order (e.g., sort by primary key)
  2. Shorter Transactions: Less time holding locks = less chance of conflict
  3. Lower Isolation: Read Committed has fewer locks than Serializable
  4. Retry Logic: Catch deadlock errors and retry transaction

5. Query Planning & Optimization

The query optimizer transforms SQL into an execution plan. Understanding this process is critical for performance tuning.

5.1 Query Execution Pipeline

graph LR A[SQL Query] --> B[Parser] B --> C[Query Rewriter
Views, Rules] C --> D[Planner/
Optimizer] D --> E[Executor] E --> F[Results] D --> D1[Generate
Candidate Plans] D1 --> D2[Cost
Estimation] D2 --> D3[Select
Cheapest Plan] style D fill:#3498db

5.2 Query Plan Tree

EXPLAIN (ANALYZE, BUFFERS)
SELECT u.name, o.total
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE o.created_at > '2025-01-01'
ORDER BY o.total DESC
LIMIT 10;
graph TD A[Limit
rows=10] --> B[Sort
key: total DESC
cost=5420..5445] B --> C[Hash Join
condition: u.id = o.user_id
cost=2340..5120] C --> D[Seq Scan on users
rows=10000
cost=0..155] C --> E[Index Scan on orders_created_at_idx
filter: created_at > 2025-01-01
rows=8500
cost=0..1250] style A fill:#27ae60 style C fill:#3498db style E fill:#f39c12

5.3 Join Algorithms

Algorithm How It Works Time Complexity Memory When Used
Nested Loop For each row in outer table, scan inner table O(N * M) O(1) Small outer table, index on inner table
Hash Join Build hash table from smaller table, probe with larger O(N + M) O(smaller table) Large tables, equi-joins, no index
Merge Join Sort both tables, merge sorted streams O(N log N + M log M) O(1) if pre-sorted Both tables sorted on join key

5.4 Join Algorithm Selection

-- Nested Loop (small outer, indexed inner)
SELECT * FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.id = 12345;  -- Single order, use PK index on customers

-- Hash Join (large tables, no index)
SELECT * FROM orders o
JOIN line_items li ON o.id = li.order_id
WHERE o.created_at > '2025-01-01';  -- Many orders, hash join faster

-- Merge Join (both pre-sorted)
SELECT * FROM users u
JOIN profiles p ON u.id = p.user_id
ORDER BY u.id;  -- Both clustered by id, merge join efficient

5.5 Cost Model

PostgreSQL Cost Units

Arbitrary units where seq_page_cost=1.0. Default: random_page_cost=4.0 (spinning disk), cpu_tuple_cost=0.01, cpu_operator_cost=0.0025.

-- Example cost calculation for seq scan
-- Seq Scan on users (cost=0.00..1834.00 rows=100000 width=40)
--
-- Formula: startup_cost + (pages * seq_page_cost) + (rows * cpu_tuple_cost)
-- = 0 + (1000 pages * 1.0) + (100000 rows * 0.01)
-- = 0 + 1000 + 1000 = 2000

-- Index Scan cost includes random I/O penalty
-- Index Scan using users_email_idx (cost=0.29..825.45 rows=100 width=40)
--
-- Higher startup cost (index traversal), but fewer rows processed

5.6 Statistics & Cardinality Estimation

Statistic What It Tracks Impact
n_distinct Estimated number of unique values Affects join selectivity, hash table size
most_common_vals Most frequent values and their frequencies Improves selectivity for specific values
histogram_bounds Value distribution buckets Estimates rows in range queries
correlation Physical vs logical ordering (-1 to 1) Affects cost of index scans
-- View statistics
SELECT tablename, attname, n_distinct, most_common_vals, histogram_bounds
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';

-- Update statistics (run after bulk data changes)
ANALYZE orders;

-- Force statistics update with higher sample size
SET default_statistics_target = 1000;  -- Default 100
ANALYZE orders;

5.7 Index Selection

graph TD A[Query Predicate] --> B{Index Available?} B -->|Yes| C{Selectivity?} B -->|No| D[Sequential Scan] C -->|High
< 5% rows| E[Index Scan] C -->|Medium
5-20% rows| F[Bitmap Index Scan] C -->|Low
> 20% rows| D E --> E1[Random I/O per row] F --> F1[Bitmap of matches
then sequential reads] D --> D1[Read entire table sequentially] style E fill:#27ae60 style F fill:#f39c12 style D fill:#e74c3c
-- High selectivity: Index scan
EXPLAIN SELECT * FROM orders WHERE id = 12345;
-- Index Scan using orders_pkey (cost=0.29..8.31 rows=1)

-- Medium selectivity: Bitmap scan
EXPLAIN SELECT * FROM orders WHERE status = 'pending';  -- 10% of rows
-- Bitmap Heap Scan on orders (cost=120.15..5234.21 rows=10000)
--   -> Bitmap Index Scan on orders_status_idx (cost=0.00..117.65)

-- Low selectivity: Sequential scan
EXPLAIN SELECT * FROM orders WHERE status IN ('completed', 'pending');  -- 80% of rows
-- Seq Scan on orders (cost=0.00..18334.00 rows=80000)

5.8 Query Hints (Use Sparingly)

-- PostgreSQL: No direct hints, but can influence planner
SET enable_seqscan = off;  -- Force index usage (for testing)
SET work_mem = '256MB';    -- Allow larger hash tables

-- MySQL: Index hints
SELECT * FROM orders USE INDEX (orders_status_idx) WHERE status = 'pending';

-- Force join order (both databases)
SELECT /*+ LEADING(o c) */ *  -- MySQL optimizer hint
FROM orders o
JOIN customers c ON o.customer_id = c.id;

-- Better approach: Improve statistics instead of hints
ANALYZE orders;
Explain Plan Interview Question: "How do you optimize a slow query?"
  1. Run EXPLAIN ANALYZE to see actual vs estimated rows
  2. Look for sequential scans on large tables → add index
  3. Look for large row estimate errors → run ANALYZE
  4. Check for expensive joins → rewrite query or add index on join key
  5. Consider query rewrite (subquery to JOIN, EXISTS vs IN)

6. Indexing Strategies

Indexes are the #1 performance lever in relational databases. Knowing when and how to use them is essential.

6.1 B-Tree Index Structure

graph TD A[Root Node
50, 100] --> B[Internal Node
20, 30, 40] A --> C[Internal Node
70, 80, 90] A --> D[Internal Node
120, 140] B --> B1[Leaf
10, 15, 18] B --> B2[Leaf
25, 27, 29] B --> B3[Leaf
35, 38] B --> B4[Leaf
42, 45, 48] C --> C1[Leaf
55, 60, 65] C --> C2[Leaf
72, 75, 78] B1 -.->|next| B2 B2 -.->|next| B3 B3 -.->|next| B4 B4 -.->|next| C1 C1 -.->|next| C2 style A fill:#3498db style B fill:#95a5a6 style C fill:#95a5a6

6.2 Index Types Comparison

Index Type Use Case PostgreSQL MySQL
B-Tree General purpose, equality and range queries Default Default
Hash Equality only, slightly faster than B-Tree Supported In-memory tables only
GiST Geometric data, full-text search, custom types Yes No
GIN JSONB, arrays, full-text (inverted index) Yes No (use JSON indexes)
BRIN Very large tables with natural clustering (time-series) Yes No
Full-Text Text search with stemming, ranking GIN/GiST indexes FULLTEXT indexes

6.3 Composite Index Column Order

Critical Rule

Index on (A, B, C) can satisfy queries on A, (A,B), or (A,B,C), but NOT on B, C, or (B,C). Leftmost prefix rule.

-- Create composite index
CREATE INDEX idx_orders_user_date ON orders (user_id, created_at);

-- Uses index efficiently
SELECT * FROM orders WHERE user_id = 123;                           -- Uses index (leftmost column)
SELECT * FROM orders WHERE user_id = 123 AND created_at > '2025-01-01';  -- Uses full index
SELECT * FROM orders WHERE user_id = 123 ORDER BY created_at;      -- Uses index for filter + sort

-- Does NOT use index
SELECT * FROM orders WHERE created_at > '2025-01-01';  -- Missing leftmost column
SELECT * FROM orders ORDER BY created_at;              -- Can't skip to created_at without user_id

-- Solution: Create separate index
CREATE INDEX idx_orders_date ON orders (created_at);

6.4 Covering Index

-- Query needs: user_id (filter), created_at (filter), total (select)
SELECT total FROM orders WHERE user_id = 123 AND created_at > '2025-01-01';

-- Regular index: Must read heap to get 'total'
CREATE INDEX idx_orders_user_date ON orders (user_id, created_at);
-- Query plan: Index Scan → Heap Fetch for each row

-- Covering index: Includes 'total', no heap access needed
CREATE INDEX idx_orders_user_date_total ON orders (user_id, created_at) INCLUDE (total);
-- Query plan: Index Only Scan (faster, less I/O)

-- MySQL syntax (implicit covering by including in index)
CREATE INDEX idx_orders_user_date_total ON orders (user_id, created_at, total);

6.5 Partial Index

-- Only 5% of orders are 'pending', rest are 'completed'
-- Regular index wastes space on 95% of rows
CREATE INDEX idx_orders_status ON orders (status);

-- Partial index: Only index pending orders
CREATE INDEX idx_orders_pending ON orders (status) WHERE status = 'pending';
-- 20x smaller index, faster writes, same query performance for pending orders

SELECT * FROM orders WHERE status = 'pending';  -- Uses partial index

6.6 Expression Index

-- Query uses lowercase comparison
SELECT * FROM users WHERE LOWER(email) = 'alice@example.com';

-- Regular index on 'email' won't be used (function applied)
CREATE INDEX idx_users_email ON users (email);  -- Not used!

-- Expression index on LOWER(email)
CREATE INDEX idx_users_email_lower ON users (LOWER(email));
-- Query plan: Index Scan using idx_users_email_lower

6.7 Index Maintenance Costs

Operation Impact of Indexes Guideline
SELECT Faster with appropriate indexes Index columns in WHERE, JOIN, ORDER BY
INSERT Slower—must update all indexes Each index adds ~5-10% overhead
UPDATE Slower if indexed columns change Avoid indexing frequently updated columns
DELETE Slower—must update all indexes Consider soft deletes (status flag) if many indexes

6.8 When NOT to Index

  • Low cardinality: Boolean columns, status with 2-3 values (unless using partial index)
  • Small tables: <1000 rows, sequential scan is faster
  • High write rate: Insert-heavy tables with many indexes suffer write amplification
  • Infrequent queries: Index maintenance cost > rare query speedup

6.9 GIN Index for JSONB (PostgreSQL)

-- Store metadata as JSONB
CREATE TABLE events (
    id SERIAL PRIMARY KEY,
    data JSONB
);

-- GIN index for JSONB containment queries
CREATE INDEX idx_events_data ON events USING GIN (data);

-- Fast queries
SELECT * FROM events WHERE data @> '{"user_id": 123}';  -- Uses GIN index
SELECT * FROM events WHERE data ? 'transaction_id';     -- Key existence check
SELECT * FROM events WHERE data @> '{"status": "completed", "amount": 100}';

6.10 Full-Text Search

-- PostgreSQL: tsvector and GIN index
ALTER TABLE articles ADD COLUMN search_vector tsvector;
UPDATE articles SET search_vector = to_tsvector('english', title || ' ' || body);
CREATE INDEX idx_articles_search ON articles USING GIN (search_vector);

SELECT * FROM articles WHERE search_vector @@ to_tsquery('english', 'database & performance');

-- MySQL: FULLTEXT index
CREATE FULLTEXT INDEX idx_articles_fulltext ON articles (title, body);
SELECT * FROM articles WHERE MATCH(title, body) AGAINST ('database performance' IN NATURAL LANGUAGE MODE);
Index Design Interview Question: "Design indexes for a social media posts table with user_id, created_at, likes_count, is_deleted."
Answer:
  1. (user_id, created_at DESC) — User feed sorted by recency
  2. (created_at) WHERE is_deleted = false — Global timeline, partial index
  3. (likes_count DESC) INCLUDE (user_id) WHERE is_deleted = false — Trending posts, covering index
  4. Avoid indexing is_deleted alone (low cardinality)

7. Connection Management

Database connections are expensive resources. Improper management is a top cause of production outages.

7.1 Connection Lifecycle Cost

Phase PostgreSQL MySQL Latency
TCP handshake Required Required 1-5ms
Authentication MD5/SCRAM challenge-response Password hash verification 1-10ms
Process/Thread spawn Fork new backend process Allocate thread from pool PostgreSQL: 5-10ms, MySQL: <1ms
Session initialization Load pg_catalog metadata Initialize thread-local storage 1-5ms
Total 10-30ms per connection vs 0.1ms query

7.2 Connection Pooling Architecture

graph LR A1[App Server 1] --> P[Connection Pooler
PgBouncer / HikariCP] A2[App Server 2] --> P A3[App Server 3] --> P A4[App Server N] --> P P -->|Pool Size: 25| D1[(Database
max_connections: 100)] Note1[Without pooler:
N apps × M conns/app
= 20 apps × 50 conns = 1000 conns] Note2[With pooler:
25-50 conns total] style P fill:#3498db style D1 fill:#27ae60

7.3 Pooling Modes

Mode Behavior Pros Cons Use Case
Session Pooling Client owns connection until disconnect Full feature support (temp tables, prepared statements) Poor connection reuse Long-lived app connections
Transaction Pooling Connection returned after COMMIT/ROLLBACK High reuse, 10x more clients per connection No session-level features, must disable prepared statements Stateless web apps (most common)
Statement Pooling Connection returned after each query Maximum density No multi-statement transactions Read-only analytics, rare

7.4 PgBouncer Configuration

# pgbouncer.ini
[databases]
mydb = host=db.internal port=5432 dbname=production

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

pool_mode = transaction  ← Most common
max_client_conn = 10000   ← Clients can connect
default_pool_size = 25    ← Connections per database
reserve_pool_size = 5     ← Emergency connections
reserve_pool_timeout = 3  ← Wait 3s for reserve pool

server_lifetime = 3600    ← Reconnect after 1h (handles failovers)
server_idle_timeout = 600 ← Close idle connections after 10min

7.5 HikariCP Configuration (Java)

// High-performance JDBC connection pool
HikariConfig config = new HikariConfig();
config.setJdbcUrl("jdbc:postgresql://db.internal:5432/production");
config.setUsername("app_user");
config.setPassword("secret");

config.setMaximumPoolSize(25);        // Total connections in pool
config.setMinimumIdle(5);             // Idle connections kept alive
config.setConnectionTimeout(30000);   // 30s wait for connection
config.setIdleTimeout(600000);        // 10min idle before close
config.setMaxLifetime(1800000);       // 30min max connection lifetime

// Performance tuning
config.addDataSourceProperty("cachePrepStmts", "true");
config.addDataSourceProperty("prepStmtCacheSize", "250");
config.addDataSourceProperty("prepStmtCacheSqlLimit", "2048");

HikariDataSource ds = new HikariDataSource(config);

7.6 Max Connections Formula

Rule of Thumb

max_connections = (core_count * 2) + effective_spindle_count

For SSD: core_count * 2 + 1

Example: 8-core server with SSD → 17 connections

-- PostgreSQL: Check current connections
SELECT count(*), state FROM pg_stat_activity GROUP BY state;

   count |        state
  -------+---------------------
       3 | idle
      12 | active
       1 | idle in transaction  ← Long idle transactions block VACUUM!

-- Kill idle transactions
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
  AND state_change < now() - interval '10 minutes';

7.7 Prepared Statements with Pooling

-- Problem: Prepared statements are per-connection session
-- Transaction pooling switches clients between connections
-- Solution: Disable server-side prepared statements

-- PgBouncer: Forces clients to use simple query protocol
# Set in pgbouncer.ini
ignore_startup_parameters = extra_float_digits

-- Application side (Java/JDBC)
# Disable server-side prepared statements
prepareThreshold=0

-- Or use PgBouncer in session mode if prepared statements are critical
Connection Pooling Interview Answer: "Without pooling, each application connection creates a database connection, leading to thousands of idle connections. This wastes memory (each PostgreSQL backend = ~10MB) and degrades performance. A connection pooler like PgBouncer sits between apps and database, maintaining a small pool (25-50 connections) and multiplexing thousands of client connections over them. In transaction mode, connections are returned to the pool after each commit, achieving 100:1 or higher client-to-connection ratios."

8. Vacuum & Maintenance (PostgreSQL)

MVCC creates dead tuples that must be cleaned up. Vacuum is critical for performance and preventing transaction ID wraparound.

8.1 Why Vacuum is Needed

sequenceDiagram participant App participant Table participant Vacuum App->>Table: INSERT (id=1, name=Alice) Note over Table: Tuple 1: (id=1, name=Alice, xmin=1000, xmax=0) App->>Table: UPDATE id=1 SET name='Alice Smith' Note over Table: Tuple 1: xmax=1001 (dead after txn 1001)
Tuple 2: (id=1, name=Alice Smith, xmin=1001, xmax=0) App->>Table: DELETE id=1 Note over Table: Tuple 2: xmax=1002 (dead after txn 1002) Note over Table: 2 dead tuples wasting space! Vacuum->>Table: VACUUM Table->>Table: Remove tuples where xmax < oldest active txn Note over Table: Space reclaimed, statistics updated

8.2 Vacuum vs Vacuum Full

Command How It Works Locking Disk I/O When to Use
VACUUM Marks dead tuples as reusable, updates free space map No exclusive lock, queries run concurrently Low Regular maintenance (autovacuum)
VACUUM FULL Rewrites entire table, compacts to remove all dead space Exclusive lock, blocks all queries Very high (rewrites table + indexes) Severe bloat recovery (rare)

8.3 Autovacuum Configuration

-- postgresql.conf
autovacuum = on  # Default
autovacuum_max_workers = 3  # Parallel vacuum workers
autovacuum_naptime = 60s    # Check interval

# Trigger vacuum when:
# dead_tuples > autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor * total_tuples
autovacuum_vacuum_threshold = 50       # Minimum dead tuples
autovacuum_vacuum_scale_factor = 0.2   # 20% of table size

# Example: 1M row table
# Trigger when dead_tuples > 50 + 0.2 * 1,000,000 = 200,050 dead tuples

# For high-write tables, make vacuuming more aggressive
ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.05);  # Vacuum at 5% dead

8.4 Monitoring Vacuum

-- Check last vacuum time and dead tuples
SELECT schemaname, relname,
       n_dead_tup,
       n_live_tup,
       round(100.0 * n_dead_tup / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
       last_vacuum,
       last_autovacuum
FROM pg_stat_user_tables
WHERE n_dead_tup > 1000
ORDER BY n_dead_tup DESC;

-- Check table bloat
SELECT schemaname, tablename,
       pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size,
       round(100.0 * n_dead_tup / NULLIF(n_live_tup, 0), 2) AS bloat_pct
FROM pg_stat_user_tables
WHERE n_dead_tup > n_live_tup * 0.1  -- >10% dead
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC;

8.5 Transaction ID Wraparound

Critical PostgreSQL Concept

PostgreSQL uses 32-bit transaction IDs. After 2 billion transactions, IDs wrap around. Without vacuum, old rows would become "in the future" and disappear. Vacuum freezes old tuples to prevent this.

graph LR A[XID: 0] -->|2B transactions| B[XID: 2,147,483,647] B -->|wraparound!| C[XID: 0 again] C -->|Problem| D[Old rows appear
newer than new rows] E[Solution: VACUUM FREEZE] --> F[Marks old tuples as
frozen, visible to all XIDs] style D fill:#e74c3c style F fill:#27ae60
-- Check age of oldest unfrozen transaction
SELECT datname, age(datfrozenxid),
       current_setting('autovacuum_freeze_max_age')::int AS freeze_max_age
FROM pg_database
ORDER BY age(datfrozenxid) DESC;

-- Warning if age > 1.5 billion (critical at 2 billion)
-- Database will force emergency vacuum and refuse writes if it reaches 2B

8.6 ANALYZE for Statistics

-- VACUUM reclaims space, ANALYZE updates statistics for query planner
VACUUM ANALYZE orders;  -- Both operations

-- When to ANALYZE:
-- - After bulk INSERT/UPDATE/DELETE
-- - Query plans seem wrong (wrong join algorithm, bad cardinality estimates)
-- - After changing data distribution significantly

-- Auto-analyze triggers (separate from autovacuum)
-- Runs when: changed_tuples > analyze_threshold + analyze_scale_factor * total_tuples
autovacuum_analyze_threshold = 50
autovacuum_analyze_scale_factor = 0.1  # 10% of table

8.7 REINDEX

-- Indexes become bloated too (especially on frequently updated columns)
-- REINDEX rebuilds index from scratch

-- Check index bloat
SELECT schemaname, tablename, indexname,
       pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC;

-- Rebuild single index (locks table)
REINDEX INDEX CONCURRENTLY idx_orders_user_id;  -- CONCURRENTLY avoids blocking

-- Rebuild all indexes on table
REINDEX TABLE CONCURRENTLY orders;

-- Rebuild entire database (requires downtime)
REINDEX DATABASE production;
Vacuum Interview Question: "Why does a PostgreSQL table keep growing even after deleting rows?"
Answer: "Due to MVCC, DELETE doesn't immediately remove rows—it marks them dead. Space is only reclaimed by VACUUM, which runs periodically via autovacuum. However, VACUUM doesn't shrink the table file; it marks space as reusable for future INSERTs. If you need to actually shrink the file, you need VACUUM FULL, but that requires an exclusive lock and rewrites the entire table. The better approach is to tune autovacuum to run more frequently on high-churn tables."

9. PostgreSQL vs MySQL

Both are production-grade, but architectural differences matter for system design decisions.

9.1 Architecture Comparison

Aspect PostgreSQL MySQL (InnoDB) Implications
Concurrency Model Process per connection Thread per connection PostgreSQL: Higher per-connection memory (~10MB), better isolation. MySQL: Lower overhead (~256KB)
MVCC Storage New tuple versions in heap Latest version in clustered index + undo logs PostgreSQL: Faster writes, needs VACUUM. MySQL: Slower writes, automatic purge
Default Isolation Read Committed Repeatable Read MySQL provides snapshot isolation by default, PostgreSQL optimizes for concurrency
Replication Streaming (WAL shipping), logical replication Binlog replication (statement/row/mixed) PostgreSQL: Physical = identical replica. MySQL: Flexible but can drift
JSON Support JSONB (binary), GIN indexes, rich operators JSON (text), limited indexing via virtual columns PostgreSQL is first-class document DB
Storage Engines Single pluggable storage, extensions common InnoDB (transactional), MyISAM (legacy), others MySQL: Per-table storage choice. PostgreSQL: System-wide extensions

9.2 Replication Architecture

graph TD subgraph PostgreSQL PG_Primary[Primary] -->|WAL stream| PG_Replica1[Replica 1
Read-only] PG_Primary -->|WAL stream| PG_Replica2[Replica 2
Read-only] end subgraph MySQL MY_Primary[Primary] -->|Binlog events| MY_Replica1[Replica 1
Can write locally] MY_Primary -->|Binlog events| MY_Replica2[Replica 2
Can write locally] end style PG_Primary fill:#336791 style MY_Primary fill:#00758F

9.3 Index Differences

Feature PostgreSQL MySQL
Primary Key Storage Heap table + B-Tree index Clustered index (data stored in PK B-Tree)
Secondary Indexes Point to heap tuple Store primary key value (not row pointer)
Covering Indexes INCLUDE clause (PostgreSQL 11+) Add extra columns to index definition
Expression Indexes Full support: CREATE INDEX ON t (LOWER(col)) Via generated columns + index
Partial Indexes Full support: WHERE clause Not supported
Multi-Column Index Order Per-column sort order: (a ASC, b DESC) All columns same order

9.4 Performance Characteristics

Workload PostgreSQL Sweet Spot MySQL Sweet Spot
Read-Heavy Excellent with materialized views, parallel queries Excellent with clustered indexes (primary key lookups)
Write-Heavy Good, but needs VACUUM tuning Good, but secondary index updates expensive (store PK)
Complex Queries Superior optimizer, CTEs, window functions, parallel execution Improving, but simpler optimizer
JSON/Document Native JSONB, GIN indexes, operators Functional, but less mature
Full-Text Search Built-in tsvector, ranking, multi-language stemming FULLTEXT indexes, simpler ranking
Geo-Spatial PostGIS extension (industry standard) Basic spatial types, less mature

9.5 Feature Comparison

Feature PostgreSQL MySQL
Window Functions Since 8.4 (2009) Since 8.0 (2018)
CTEs (WITH) Recursive, materialized control Recursive, no materialization control
Lateral Joins Yes Yes (since 8.0.14)
CHECK Constraints Fully enforced Enforced (since 8.0.16)
Deferrable Constraints Yes (defer to end of transaction) No
Stored Procedures Functions in PL/pgSQL, Python, etc. No OUT params in procedures Procedures with OUT params
Triggers Row-level, statement-level, INSTEAD OF Row-level only
Extensions Rich ecosystem (PostGIS, pgvector, TimescaleDB) Limited plugin system

9.6 Migration Gotchas

-- PostgreSQL → MySQL
-- 1. Auto-increment syntax
PostgreSQL: id SERIAL PRIMARY KEY
MySQL:      id INT AUTO_INCREMENT PRIMARY KEY

-- 2. String comparison
PostgreSQL: Case-sensitive by default
MySQL:      Case-insensitive by default (depends on collation)

-- 3. Boolean type
PostgreSQL: BOOLEAN (true/false)
MySQL:      TINYINT(1) (0/1)

-- 4. LIMIT/OFFSET
PostgreSQL: LIMIT 10 OFFSET 20
MySQL:      LIMIT 20, 10  (offset, count) — confusing!

-- 5. Sequences
PostgreSQL: CREATE SEQUENCE, nextval('seq')
MySQL:      AUTO_INCREMENT only, or emulate with separate table

-- 6. RETURNING clause
PostgreSQL: INSERT ... RETURNING id
MySQL:      LAST_INSERT_ID() function

9.7 When to Choose Which

Choose PostgreSQL When Choose MySQL When
Complex queries, joins, window functions Simple queries, primary key lookups
JSONB/document workloads Strict relational schema
Geospatial data (PostGIS) High replication fan-out (many replicas)
Need advanced features (CTEs, partial indexes, custom types) Team has deep MySQL expertise
Data integrity is critical (deferrable constraints) Using managed service optimized for MySQL (Aurora)
Analytical queries alongside transactional Primary key-based access patterns dominate
Interview Answer: "Both are production-grade. PostgreSQL excels at complex queries, JSON, and has a richer feature set—making it great for applications that evolve in complexity. MySQL's clustered primary key makes it very fast for key-value access patterns and it has lower per-connection overhead. In practice, choose based on team expertise and specific features you need (e.g., PostGIS for geo, or Aurora for managed MySQL at scale). For greenfield projects with uncertain requirements, PostgreSQL is often the safer bet due to its extensibility."

10. Scaling Relational Databases

Relational databases can scale to millions of QPS with proper techniques. Understand the trade-offs.

10.1 Scaling Strategies Overview

graph TD A[Relational DB Scaling] --> B[Vertical Scaling] A --> C[Read Scaling] A --> D[Write Scaling] A --> E[Data Scaling] B --> B1[Bigger machine
More RAM, CPU, IOPS] C --> C1[Read Replicas] C --> C2[Caching Layer] C --> C3[Connection Pooling] D --> D1[Optimize Indexes] D --> D2[Batch Writes] D --> D3[Async Commits] E --> E1[Partitioning
Single DB] E --> E2[Sharding
Multiple DBs] style B1 fill:#3498db style C1 fill:#27ae60 style E2 fill:#e74c3c

10.2 Read Replicas

graph LR A[Application] -->|Writes| P[(Primary
Read-Write)] P -->|Replication| R1[(Replica 1
Read-Only)] P -->|Replication| R2[(Replica 2
Read-Only)] P -->|Replication| R3[(Replica 3
Read-Only)] A -->|Reads| R1 A -->|Reads| R2 A -->|Reads| R3 style P fill:#e74c3c style R1 fill:#27ae60 style R2 fill:#27ae60 style R3 fill:#27ae60
-- Application pattern: Route reads to replicas
class DatabaseRouter:
    def get_connection(self, operation):
        if operation == 'write':
            return primary_db_connection()
        else:
            return random.choice([replica1, replica2, replica3])

# Django example
class ReplicaRouter:
    def db_for_read(self, model, **hints):
        return 'replica'
    def db_for_write(self, model, **hints):
        return 'primary'

10.3 Replication Lag Handling

Pattern How It Works Trade-off
Read Your Writes After write, read from primary or wait for replica to catch up Defeats purpose of replicas for that user's session
Monotonic Reads Stick user to same replica (session affinity) Uneven load distribution
Read from Primary Critical reads always hit primary Primary becomes bottleneck
Eventual Consistency Accept stale reads, design UI to handle it Best for scale, worse UX
-- Read your writes pattern
def update_profile(user_id, name):
    primary_db.execute("UPDATE users SET name = %s WHERE id = %s", (name, user_id))
    # Critical: Read from primary after write
    return primary_db.query("SELECT * FROM users WHERE id = %s", (user_id,))

def list_all_users():
    # Eventually consistent, can read from replica
    return replica_db.query("SELECT * FROM users")

10.4 Caching Layer

graph LR A[Application] --> B{Cache
Redis/Memcached} B -->|Hit| C[Return from Cache] B -->|Miss| D[(Database)] D --> E[Query DB] E --> F[Store in Cache] F --> C style B fill:#f39c12 style C fill:#27ae60
-- Cache-aside pattern (most common)
def get_user(user_id):
    cache_key = f"user:{user_id}"
    user = cache.get(cache_key)
    if user is None:
        user = db.query("SELECT * FROM users WHERE id = %s", (user_id,))
        cache.set(cache_key, user, ttl=3600)  # 1 hour TTL
    return user

-- Invalidation strategies
def update_user(user_id, name):
    db.execute("UPDATE users SET name = %s WHERE id = %s", (name, user_id))
    cache.delete(f"user:{user_id}")  # Invalidate on write

10.5 Partitioning (Within Single Database)

Strategy How It Works Use Case
Range Partitioning Split by value range (e.g., date) Time-series data, historical data archival
List Partitioning Split by discrete values (e.g., country) Multi-tenant with known partitions
Hash Partitioning Hash partition key, distribute evenly Evenly distributed load, no natural key
-- PostgreSQL: Range partitioning by date
CREATE TABLE orders (
    id BIGSERIAL,
    user_id INT,
    created_at TIMESTAMP,
    total DECIMAL
) PARTITION BY RANGE (created_at);

CREATE TABLE orders_2025_01 PARTITION OF orders
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

CREATE TABLE orders_2025_02 PARTITION OF orders
    FOR VALUES FROM ('2025-02-01') TO ('2025-03-01');

-- Query optimizer automatically selects partition
SELECT * FROM orders WHERE created_at BETWEEN '2025-01-15' AND '2025-01-20';
-- Scans only orders_2025_01 partition

-- Benefits:
-- 1. Drop old partitions instantly (vs slow DELETE)
-- 2. Index only recent partitions (smaller indexes)
-- 3. Parallel scans across partitions

10.6 Sharding (Application-Level)

graph TD A[Application
Shard Router] --> B{Shard Key:
user_id % 4} B -->|user_id % 4 = 0| S0[(Shard 0
users 0, 4, 8...)] B -->|user_id % 4 = 1| S1[(Shard 1
users 1, 5, 9...)] B -->|user_id % 4 = 2| S2[(Shard 2
users 2, 6, 10...)] B -->|user_id % 4 = 3| S3[(Shard 3
users 3, 7, 11...)] style A fill:#3498db
-- Sharding function
def get_shard(user_id, num_shards=4):
    return user_id % num_shards

def get_user_orders(user_id):
    shard_id = get_shard(user_id)
    db = shard_connections[shard_id]
    return db.query("SELECT * FROM orders WHERE user_id = %s", (user_id,))

-- Works great for single-shard queries
-- Problem: Cross-shard queries are hard/slow
def get_all_orders_by_status(status):
    results = []
    for shard_db in shard_connections:
        results.extend(shard_db.query("SELECT * FROM orders WHERE status = %s", (status,)))
    return results  # Must merge and sort in application!

10.7 Sharding Challenges

Challenge Problem Solution
Cross-Shard Joins Can't JOIN across databases Denormalize, application-level joins, or avoid
Distributed Transactions Can't use ACID across shards 2PC (slow), Saga pattern, avoid cross-shard writes
Resharding Changing shard count requires data migration Consistent hashing, virtual shards (16 virtual → 4 physical)
Hot Shards Popular users cause uneven load Better shard key, dedicated shards for hot users
Schema Changes Must migrate all shards Automated tooling, rolling migrations

10.8 Materialized Views

-- Expensive query: Top selling products
SELECT p.id, p.name, SUM(li.quantity) AS total_sold
FROM products p
JOIN line_items li ON p.id = li.product_id
JOIN orders o ON li.order_id = o.id
WHERE o.created_at > now() - interval '30 days'
GROUP BY p.id, p.name
ORDER BY total_sold DESC
LIMIT 100;

-- Create materialized view (pre-computed result)
CREATE MATERIALIZED VIEW top_products_30d AS
SELECT p.id, p.name, SUM(li.quantity) AS total_sold
FROM products p
JOIN line_items li ON p.id = li.product_id
JOIN orders o ON li.order_id = o.id
WHERE o.created_at > now() - interval '30 days'
GROUP BY p.id, p.name
ORDER BY total_sold DESC
LIMIT 100;

CREATE INDEX ON top_products_30d (total_sold DESC);

-- Query is now instant
SELECT * FROM top_products_30d;

-- Refresh periodically (can be concurrent in PostgreSQL)
REFRESH MATERIALIZED VIEW CONCURRENTLY top_products_30d;

10.9 Scaling Limits

Technique Read QPS Limit Write QPS Limit Complexity
Single Instance ~10K ~5K Low
+ Read Replicas (5) ~50K ~5K Low
+ Caching Layer ~500K ~5K Medium
+ Partitioning ~500K ~20K Medium
+ Sharding (10 shards) ~5M ~50K High
Scaling Interview Answer: "Start with a single instance and optimize queries and indexes. Add read replicas when read traffic dominates—this gets you 5-10x read capacity with minimal complexity. Introduce caching for frequently accessed data (user profiles, product details). If writes become the bottleneck, consider partitioning within a single database to parallelize I/O. Only shard when you've exhausted these options—sharding adds significant complexity (cross-shard queries, distributed transactions, resharding). In practice, most applications never need sharding; vertical scaling and replicas handle millions of requests per day."

11. When NOT to Use Relational Databases

Relational databases are not a silver bullet. Recognize when other systems are better.

11.1 Alternative Database Types

Database Type Strengths Example Systems Use Cases
Document Store Flexible schema, nested documents, fast writes MongoDB, CouchDB Content management, catalogs, user profiles
Key-Value Store Extreme simplicity, horizontal scaling, low latency Redis, DynamoDB, Riak Session store, caching, rate limiting
Wide-Column Store Massive write throughput, petabyte scale Cassandra, HBase, BigTable Time-series, IoT, event logging
Graph Database Relationship traversal, pattern matching Neo4j, Amazon Neptune Social networks, fraud detection, recommendations
Time-Series DB Optimized for timestamps, compression, downsampling InfluxDB, TimescaleDB, Prometheus Metrics, monitoring, IoT sensor data
Search Engine Full-text search, relevance ranking, faceting Elasticsearch, Solr, Meilisearch Product search, log analysis, autocomplete

11.2 Decision Tree

graph TD A[Choose Database Type] --> B{Data Structure?} B -->|Tabular, relations| C{Consistency?} C -->|ACID critical| D[Relational DB] C -->|Eventual OK| E[NoSQL Wide-Column] B -->|Documents, flexible| F{Query Complexity?} F -->|Simple key lookups| G[Document Store] F -->|Complex queries, aggregations| D B -->|Key-value pairs| H{Scale?} H -->|Small/Medium| I[Redis] H -->|Massive| J[DynamoDB] B -->|Graphs, relationships| K[Graph DB] B -->|Time-series| L{Write Rate?} L -->|Low/Medium| M[TimescaleDB
Relational] L -->|Very High| N[Cassandra
InfluxDB] B -->|Full-text search| O[Elasticsearch] style D fill:#336791 style G fill:#27ae60 style K fill:#e74c3c

11.3 When Relational Databases Struggle

Scenario Why Relational Struggles Better Alternative
Deeply nested JSON Either store as JSON (loses query ability) or EAV anti-pattern (explosion of joins) MongoDB, PostgreSQL JSONB (hybrid)
Graph traversals Recursive CTEs are slow, multi-hop joins expensive Neo4j (optimized for relationship queries)
Massive write throughput Single primary bottleneck, index maintenance overhead Cassandra (distributed writes), Kafka (log)
Horizontal scaling Sharding is complex, distributed transactions hard DynamoDB, Cassandra (built for horizontal scale)
Variable schema ALTER TABLE locks, NULL columns waste space MongoDB (schemaless), JSONB (semi-structured)
Time-series at scale Range queries slow without partitioning, no built-in downsampling TimescaleDB, InfluxDB (compression, automatic rollups)

11.4 Example: Social Network Friend Graph

-- Relational DB: Friends as edges in a table
CREATE TABLE friendships (
    user_id INT,
    friend_id INT,
    PRIMARY KEY (user_id, friend_id)
);

-- Query: Friends of friends (2 hops)
SELECT DISTINCT f2.friend_id
FROM friendships f1
JOIN friendships f2 ON f1.friend_id = f2.user_id
WHERE f1.user_id = 123;

-- Problems:
-- 1. Expensive join for each hop
-- 2. 3+ hops become very slow
-- 3. Hard to find shortest path, centrality, communities

-- Graph DB (Neo4j): Natural representation
CREATE (alice:User {id: 123, name: 'Alice'})
CREATE (bob:User {id: 456, name: 'Bob'})
CREATE (alice)-[:FRIENDS_WITH]->(bob)

-- Query: Friends of friends (2 hops)
MATCH (alice:User {id: 123})-[:FRIENDS_WITH*2]-(fof)
RETURN DISTINCT fof;

-- Benefits:
-- - Constant time per hop (follows pointers)
-- - Variable-length paths efficient
-- - Built-in algorithms (shortest path, PageRank)

11.5 Example: Product Catalog with Varying Attributes

-- Relational DB: EAV anti-pattern (Entity-Attribute-Value)
CREATE TABLE products (id INT, name TEXT);
CREATE TABLE product_attributes (
    product_id INT,
    attribute_name TEXT,
    attribute_value TEXT
);

-- Query: Products with RAM=16GB AND Storage=512GB
SELECT p.* FROM products p
WHERE EXISTS (
    SELECT 1 FROM product_attributes pa1
    WHERE pa1.product_id = p.id AND pa1.attribute_name = 'RAM' AND pa1.attribute_value = '16GB'
) AND EXISTS (
    SELECT 1 FROM product_attributes pa2
    WHERE pa2.product_id = p.id AND pa2.attribute_name = 'Storage' AND pa2.attribute_value = '512GB'
);
-- Very slow! Multiple EXISTS subqueries

-- Document DB (MongoDB): Nested attributes
{
  "_id": 123,
  "name": "MacBook Pro",
  "attributes": {
    "RAM": "16GB",
    "Storage": "512GB",
    "Color": "Space Gray"
  }
}

// Query: Simple dot notation
db.products.find({
  "attributes.RAM": "16GB",
  "attributes.Storage": "512GB"
});

-- PostgreSQL JSONB: Best of both worlds
CREATE TABLE products (
    id SERIAL PRIMARY KEY,
    name TEXT,
    attributes JSONB
);

CREATE INDEX ON products USING GIN (attributes);

SELECT * FROM products
WHERE attributes @> '{"RAM": "16GB", "Storage": "512GB"}';

11.6 Polyglot Persistence

graph LR A[Application] --> B[(PostgreSQL
User Accounts
Orders)] A --> C[(Redis
Sessions
Cache)] A --> D[(Elasticsearch
Product Search)] A --> E[(S3
Images
Backups)] style B fill:#336791 style C fill:#d62728 style D fill:#2ca02c style E fill:#ff7f0e

Use the right tool for each use case within the same application:

  • PostgreSQL: Core transactional data (users, orders, payments)
  • Redis: Session store, cache, rate limiting, real-time leaderboards
  • Elasticsearch: Product search, log analysis, autocomplete
  • S3: File storage, backups, static assets
  • Cassandra: Activity feed, time-series metrics
Interview Answer: "Relational databases are excellent when you need ACID guarantees, complex queries with joins, and a well-defined schema. Avoid them when you need massive horizontal scale (>10K writes/sec), flexible schemas (e.g., product catalogs with arbitrary attributes), or graph traversals (e.g., social networks). In these cases, use NoSQL systems designed for those workloads. However, don't default to NoSQL—relational databases can scale further than most applications need, and prematurely choosing NoSQL adds operational complexity. Consider PostgreSQL with JSONB as a middle ground for semi-structured data."

Summary

Key Takeaways

  • ACID: Atomicity via undo logs, Durability via WAL, Isolation via MVCC/locks, Consistency via constraints
  • Isolation Levels: Read Committed = default and sufficient for most. Serializable prevents write skew but hurts performance
  • MVCC: Readers don't block writers. PostgreSQL stores versions in heap, MySQL uses undo logs. Both need garbage collection
  • Locking: Use SELECT FOR UPDATE for pessimistic locking, version columns for optimistic. Deadlocks detected via wait-for graph
  • Query Optimization: EXPLAIN ANALYZE is your best friend. Add indexes for WHERE/JOIN/ORDER BY, update statistics with ANALYZE
  • Indexing: B-Tree default. Composite index order matters. Covering indexes avoid heap lookups. Partial indexes save space
  • Connections: Use pooling (PgBouncer, HikariCP). Transaction pooling for 100:1 multiplexing. Keep pool size = 2×cores
  • Vacuum: PostgreSQL needs it to reclaim dead tuples and prevent XID wraparound. Tune autovacuum for high-write tables
  • PostgreSQL vs MySQL: PostgreSQL = advanced features, better optimizer. MySQL = lower overhead, clustered PK. Both scale to millions of QPS
  • Scaling: Start with replicas and caching. Partition within single DB before sharding. Sharding is last resort due to complexity
  • When Not to Use: Graph queries → Neo4j. Massive writes → Cassandra. Flexible schema → MongoDB. Always consider PostgreSQL JSONB first

Interview Checklist

Topic Can You Explain...
ACID How WAL provides durability? Trade-off between durability and performance?
Isolation Difference between Repeatable Read and Serializable? When to use each?
MVCC How readers don't block writers? Why PostgreSQL needs VACUUM?
Locking Gap locks in MySQL? Deadlock detection? Optimistic vs pessimistic concurrency?
Query Optimization How to read an EXPLAIN plan? When does optimizer choose index vs seq scan?
Indexing Covering index? Partial index? Composite index column order?
Scaling Read replicas vs sharding? When to shard? Cross-shard query challenges?
System Design How to scale Instagram? When to use NoSQL vs relational?