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
(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
| 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 |
- Write-Ahead Log ensures all committed changes are on disk before returning success
- Recovery process replays WAL from last checkpoint to rebuild state
- Uncommitted transactions are rolled back using undo logs
- 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
2.4 Non-Repeatable Read Visualization
2.5 Phantom Read Visualization
2.6 Write Skew Example
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 |
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
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)
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
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 |
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
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
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
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.
- Lock Ordering: Always acquire locks in the same order (e.g., sort by primary key)
- Shorter Transactions: Less time holding locks = less chance of conflict
- Lower Isolation: Read Committed has fewer locks than Serializable
- 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
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;
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
< 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;
- Run EXPLAIN ANALYZE to see actual vs estimated rows
- Look for sequential scans on large tables → add index
- Look for large row estimate errors → run ANALYZE
- Check for expensive joins → rewrite query or add index on join key
- 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
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);
Answer:
(user_id, created_at DESC)— User feed sorted by recency(created_at) WHERE is_deleted = false— Global timeline, partial index(likes_count DESC) INCLUDE (user_id) WHERE is_deleted = false— Trending posts, covering index- 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
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
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
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.
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;
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
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 |
10. Scaling Relational Databases
Relational databases can scale to millions of QPS with proper techniques. Understand the trade-offs.
10.1 Scaling Strategies Overview
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
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
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)
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 |
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
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
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
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? |