Storage Engines & Recovery
How Databases Store Your Data
Understanding database internals helps you design better schemas and debug performance issues.
The Storage Hierarchy
Databases carefully manage the journey from SQL to disk:
SQL Query
↓
Buffer Pool (RAM) - "Hot" data cached here
↓
Storage Engine - Manages pages and files
↓
File System - Database files
↓
Disk - Actual persistent storage
Pages: The Building Blocks
Databases don’t read individual rows from disk—they read pages (typically 8KB or 16KB):
+------------------Page 1 (8KB)-------------------+
| Header (checksum, LSN, free space pointer) |
|--------------------------------------------------||
| Row 1: {id: 1, name: "Alice", email: "..."} |
| Row 2: {id: 2, name: "Bob", email: "..."} |
| Row 3: {id: 3, name: "Charlie", email: "..."} |
| ... (more rows) ... |
| Free Space |
+--------------------------------------------------+
Why Pages Matter:
- Disk I/O is slow; reading 8KB isn’t much slower than reading 100 bytes
- Related data stored together (spatial locality)
- Enables efficient caching in memory
Buffer Pool: Your Database’s Cache
The buffer pool is why databases can serve queries from memory:
class BufferPool:
def __init__(self, size_mb):
self.pages = {} # page_id -> page_data
self.lru = OrderedDict() # for eviction
self.max_pages = size_mb * 1024 // 8 # 8KB pages
def get_page(self, page_id):
if page_id in self.pages:
# Cache hit! Move to end (most recently used)
self.lru.move_to_end(page_id)
return self.pages[page_id]
else:
# Cache miss - read from disk
page = read_page_from_disk(page_id)
self.add_to_cache(page_id, page)
return page
Tuning Buffer Pool:
-- PostgreSQL: Check cache hit ratio
SELECT
sum(blks_hit)/(sum(blks_hit)+sum(blks_read)) as cache_hit_ratio
FROM pg_stat_database;
-- Want > 0.95 (95% from cache)
-- Increase if too low
ALTER SYSTEM SET shared_buffers = '4GB';
A frame-and-page-table view of the same buffer pool, alongside the clock eviction algorithm and work-memory areas, appears in Indexing & Query Execution.
B+ Trees: The Workhorse Index Structure
B+ trees power most database indexes. Think of them as a multi-level phone book:
[M]
/ \
[D,G,J] [P,S,V]
/ | \ / | \
[A-C][D-F][G-I][M-O][P-R][S-U][V-Z]
↓ ↓ ↓ ↓ ↓ ↓ ↓
(actual data rows in leaf nodes)
Why B+ Trees Work Well:
- Shallow: Even with millions of rows, only 3-4 levels deep
- Cache-friendly: Each node fits in a page
- Range queries: Leaf nodes linked for scanning
- Predictable: Always balanced, consistent performance
Following a Search:
# Finding "John" in a B+ tree index on names
1. Root: "John" < "M", go left
2. Level 2: "John" > "G", go to middle child
3. Leaf: Scan "G-I" page, find "John" -> row location
4. Fetch actual row from heap file
Insert Example:
def insert(tree, key, value):
leaf = find_leaf(tree.root, key)
if leaf.has_space():
leaf.insert(key, value)
else:
# Split leaf into two
new_leaf = leaf.split()
middle_key = new_leaf.keys[0]
# Propagate split up the tree
insert_into_parent(leaf.parent, middle_key, new_leaf)
LSM Trees: Built for Big Data Writes
While B+ trees update in place, LSM trees use a different strategy perfect for write-heavy workloads:
The Big Idea: Buffer writes in memory, flush to disk in batches
Writes go to:
MemTable (in RAM)
↓ (when full)
SSTable Level 0 (on disk)
↓ (compaction)
SSTable Level 1 (larger, sorted)
↓ (compaction)
SSTable Level 2 (even larger)
Write Path Example:
class LSMTree:
def write(self, key, value):
# 1. Log for crash recovery
self.wal.append(f"SET {key} = {value}")
# 2. Add to in-memory table
self.memtable[key] = value
# 3. Flush when full
if self.memtable.size() > THRESHOLD:
self.flush_to_disk()
def flush_to_disk(self):
# Sort and write to new SSTable file
sorted_data = sorted(self.memtable.items())
sstable = create_sstable(sorted_data)
self.sstables[0].append(sstable)
self.memtable.clear()
Why Cassandra/RocksDB Use LSM:
- Sequential writes are 100x faster than random writes
- Great for time-series data (always appending)
- Compaction happens in background
The Trade-off:
- Writes: Super fast (just append)
- Reads: Slower (might check multiple files)
- Solution: Bloom filters to skip files that definitely don’t have the key
Write-Ahead Logging: Surviving Crashes
How do databases maintain ACID’s durability when power fails mid-transaction? Write-Ahead Logging (WAL).
The Rule: Log changes before applying them
class WriteAheadLog:
def __init__(self):
self.log_file = open("database.wal", "ab") # Append, binary
self.lsn = 0 # Log Sequence Number
def log_update(self, tx_id, table, row_id, old_value, new_value):
entry = {
"lsn": self.lsn,
"tx_id": tx_id,
"type": "UPDATE",
"table": table,
"row_id": row_id,
"old_value": old_value, # For undo
"new_value": new_value # For redo
}
self.log_file.write(serialize(entry))
self.log_file.flush() # Force to disk
self.lsn += 1
# Only now safe to update actual data
return self.lsn
Recovery After Crash:
def recover():
# Phase 1: Analysis - What was happening?
committed_txns = set()
active_txns = set()
for entry in read_log_from_checkpoint():
if entry.type == "BEGIN":
active_txns.add(entry.tx_id)
elif entry.type == "COMMIT":
active_txns.remove(entry.tx_id)
committed_txns.add(entry.tx_id)
# Phase 2: Redo - Replay committed transactions
for entry in read_log_from_checkpoint():
if entry.tx_id in committed_txns:
apply_change(entry)
# Phase 3: Undo - Rollback incomplete transactions
for entry in reversed(read_log_from_checkpoint()):
if entry.tx_id in active_txns:
undo_change(entry)
Why This Works:
- Log is sequential (fast writes)
- Log records are small
- Can reconstruct any state from log
- Checkpoints limit recovery time
Code Reference: For working implementations, see
storage_engines.py
Backup and Recovery
Backup Strategies
Full Backup:
# PostgreSQL
pg_dump dbname > backup.sql
# MySQL
mysqldump --all-databases > backup.sql
Incremental Backup:
- Only changes since last backup
- Requires less storage
- Faster backup, slower restore
Point-in-Time Recovery:
- Transaction logs
- Restore to specific moment
High Availability
Replication:
- Master-slave
- Master-master
- Multi-master
Clustering:
- Active-passive
- Active-active
- Shared storage
Troubleshooting Common Database Issues
Debugging Slow Queries
Step 1: Identify the Culprit
-- PostgreSQL: Enable slow query logging
ALTER SYSTEM SET log_min_duration_statement = 1000; -- Log queries > 1 second
SELECT pg_reload_conf();
-- MySQL: Check slow query log
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
-- Find currently running queries
SELECT pid, now() - query_start as duration, query
FROM pg_stat_activity
WHERE state = 'active'
ORDER BY duration DESC;
Step 2: Analyze the Query Plan
-- Look for these red flags in EXPLAIN output:
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
-- Bad signs:
-- - "Seq Scan" on large tables (missing index?)
-- - "Nested Loop" with high row counts (consider hash join)
-- - High "Buffers: shared hit" (data not in cache)
-- - "Sort" with "Disk: ..." (increase work_mem)
Step 3: Common Fixes
-- Missing index on JOIN columns
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
-- Statistics out of date
ANALYZE orders; -- PostgreSQL
ANALYZE TABLE orders; -- MySQL
-- Query needs rewriting
-- Bad: Correlated subquery
SELECT * FROM orders o
WHERE total > (SELECT AVG(total) FROM orders WHERE customer_id = o.customer_id);
-- Good: Window function
WITH customer_avgs AS (
SELECT customer_id, AVG(total) OVER (PARTITION BY customer_id) as avg_total
FROM orders
)
SELECT o.* FROM orders o
JOIN customer_avgs ca ON o.customer_id = ca.customer_id
WHERE o.total > ca.avg_total;
Connection Pool Issues
Symptoms: “Too many connections”, intermittent timeouts
Diagnosis:
-- Check current connections
SELECT count(*) FROM pg_stat_activity;
-- See what connections are doing
SELECT state, count(*)
FROM pg_stat_activity
GROUP BY state;
-- Find idle connections
SELECT pid, usename, application_name, state_change
FROM pg_stat_activity
WHERE state = 'idle'
AND state_change < NOW() - INTERVAL '10 minutes';
Solutions:
# Configure connection pooling properly
pool = psycopg2.pool.ThreadedConnectionPool(
minconn=5, # Keep some connections ready
maxconn=20, # Limit maximum connections
host="localhost",
database="mydb"
)
# Use context managers to ensure cleanup
from contextlib import contextmanager
@contextmanager
def get_db_connection():
conn = pool.getconn()
try:
yield conn
conn.commit()
except:
conn.rollback()
raise
finally:
pool.putconn(conn)
Disk Space Issues
Prevention:
-- Monitor table sizes
SELECT
schemaname,
tablename,
pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) as size
FROM pg_tables
ORDER BY pg_total_relation_size(schemaname||'.'||tablename) DESC
LIMIT 20;
-- Set up automatic cleanup
-- PostgreSQL: Configure autovacuum
ALTER TABLE large_table SET (autovacuum_vacuum_scale_factor = 0.01);
-- Archive old data
CREATE TABLE orders_archive (LIKE orders INCLUDING ALL);
INSERT INTO orders_archive SELECT * FROM orders WHERE created_at < '2023-01-01';
DELETE FROM orders WHERE created_at < '2023-01-01';
Performance Tuning: Making It Fast
Performance tuning is part science, part art. Here’s a practical approach:
Step 1: Measure First
Find Slow Queries:
-- PostgreSQL: Find slowest queries
SELECT
mean_exec_time,
calls,
total_exec_time,
query
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
Check Cache Performance:
-- Cache hit ratio (want > 95%)
SELECT
sum(heap_blks_hit) /
(sum(heap_blks_hit) + sum(heap_blks_read)) as cache_hit_ratio
FROM pg_statio_user_tables;
Step 2: Tune Configuration
Memory Settings (PostgreSQL example):
# Buffer pool - start with 25% of RAM
shared_buffers = 4GB
# Total memory for queries
work_mem = 50MB # Per operation!
# Maintenance operations
maintenance_work_mem = 1GB
# Effective cache - tell planner about OS cache
effective_cache_size = 12GB # ~75% of RAM
Step 3: Optimize Schema
Partitioning for Large Tables:
-- Partition orders by month
CREATE TABLE orders (
order_id BIGINT,
order_date DATE,
customer_id INT,
total DECIMAL(10,2)
) PARTITION BY RANGE (order_date);
-- Create monthly partitions
CREATE TABLE orders_2024_01 PARTITION OF orders
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
-- Queries on date ranges now scan only relevant partitions!
Materialized Views for Complex Queries:
-- Expensive dashboard query
CREATE MATERIALIZED VIEW customer_stats AS
SELECT
c.customer_id,
c.name,
COUNT(DISTINCT o.order_id) as order_count,
SUM(o.total) as lifetime_value,
MAX(o.order_date) as last_order_date
FROM customers c
LEFT JOIN orders o ON c.customer_id = o.customer_id
GROUP BY c.customer_id, c.name;
-- Refresh periodically
CREATE INDEX idx_customer_stats_value ON customer_stats(lifetime_value);
-- Now dashboard query is instant!
Step 4: Monitor and Iterate
Key Metrics to Watch:
- Response time: 95th percentile latency
- Throughput: Queries per second
- Resource usage: CPU, memory, disk I/O
- Lock waits: Blocked queries
- Connection pool: Active vs idle connections
Next Steps
- Previous: Transactions & Concurrency
- Next: Distributed Databases & NoSQL — scaling storage beyond a single machine.
- Up: Database Design hub
- See also: Indexing & Query Execution for how the planner reads these pages.