Database Architecture for System Design: The Complete Guide
A Senior+ Guide — From Relational Fundamentals to Distributed Database Systems at Scale
1. Introduction & Database Selection Philosophy
The database layer is the most consequential architectural decision in any system. It determines your write throughput ceiling, your query latency floor, your scaling trajectory, and your operational complexity for years to come. A wrong database choice made early in a startup's life can become a multi-million-dollar migration project at scale. A wise choice can carry a product from zero to billions of transactions with minimal friction. This guide provides the deep technical knowledge needed to make these decisions with confidence.
At its core, database architecture is about managing the fundamental tension between consistency, availability, partition tolerance, and performance. Every database system makes specific tradeoffs along these axes, and understanding these tradeoffs is what separates senior engineers from architects. A PostgreSQL single-node deployment offers strong consistency and ACID guarantees but cannot horizontally scale writes beyond a single machine. Cassandra offers linear write scalability across hundreds of nodes but sacrifices strong consistency for eventual consistency. Redis provides sub-millisecond latency but limits you to datasets that fit in memory. There is no universal answer — only well-informed tradeoffs matched to specific requirements.
The database selection philosophy starts with understanding your workload. Is it read-heavy (95% reads, 5% writes like a content management system), write-heavy (60% writes, 40% reads like an IoT telemetry ingestion pipeline), or mixed (balanced reads and writes like an e-commerce order system)? Is your data relational with complex joins and transactions, or is it hierarchical and document-like? Do you need strong consistency for financial operations, or is eventual consistency acceptable for social media feeds? Do you need sub-millisecond latency for real-time bidding, or is 50ms acceptable for a dashboard query? Each question narrows the design space and points toward specific database technologies.
Real-world systems at scale rarely use a single database. Netflix uses MySQL for user data, Cassandra for viewing history, Elasticsearch for search, Redis for caching, and S3 for content metadata. Uber migrated from PostgreSQL to Schemaless (their custom document store on top of MySQL) and later to Docstore for specific workloads. Instagram runs one of the largest PostgreSQL deployments in the world alongside Redis for feed caching and Cassandra for analytics. The trend toward polyglot persistence — using multiple specialized databases — is driven by the recognition that no single database excels at everything. The challenge shifts from "which database?" to "how do I keep multiple databases consistent and operational?"
This guide covers the full spectrum of database architecture decisions: from the foundational concepts of SQL versus NoSQL and ACID versus BASE, through the distributed systems mechanics of sharding, replication, and consensus, to the practical concerns of indexing, connection pooling, caching, migration, and disaster recovery. Every concept is illustrated with production-ready C# code, SQL schemas, Mermaid architecture diagrams, and real-world examples from companies operating at internet scale.
Database Landscape Overview
| Category | Examples | Best For | Tradeoff |
|---|---|---|---|
| Relational (RDBMS) | PostgreSQL, MySQL, SQL Server | Structured data, complex queries, transactions | Vertical scaling limit, schema rigidity |
| Document Store | MongoDB, CouchDB, DynamoDB | Semi-structured data, rapid iteration | Limited joins, eventual consistency |
| Key-Value | Redis, Memcached, etcd | Caching, sessions, real-time leaderboards | No complex queries, memory-bound |
| Wide Column | Cassandra, HBase, ScyllaDB | Time-series, write-heavy workloads, IoT | Operational complexity, limited ad-hoc queries |
| Graph | Neo4j, Amazon Neptune, JanusGraph | Relationship-heavy queries, social networks | Poor at aggregate queries, horizontal scaling challenges |
| Search Engine | Elasticsearch, Apache Solr | Full-text search, log analysis, faceted search | Not a primary data store, indexing overhead |
| Time-Series | InfluxDB, TimescaleDB, QuestDB | Metrics, IoT sensors, financial tick data | Optimized for append-heavy, limited updates |
| NewSQL | CockroachDB, YugabyteDB, TiDB | Distributed SQL with horizontal scaling | Newer ecosystem, operational immaturity |
Decision Framework
When evaluating databases for a new system, senior engineers should work through this decision framework in order. First, define the data model: is it relational, document, key-value, or graph? Second, identify the dominant query pattern: point lookups, range scans, full-text search, graph traversal, or aggregation? Third, establish consistency requirements: strong consistency, eventual consistency, or read-after-write? Fourth, estimate scale: data volume, read QPS, write QPS, and growth trajectory? Fifth, assess operational maturity: does your team have experience running this database in production? The last point is often underrated — a database you know deeply is almost always better than a theoretically superior database your team has never operated.
C#
public class DatabaseSelectionCriteria
{
public DataModelKind DataModel { get; set; }
public QueryPattern DominantQuery { get; set; }
public ConsistencyLevel ConsistencyRequirement { get; set; }
public long EstimatedDataGB { get; set; }
public int ReadQPS { get; set; }
public int WriteQPS { get; set; }
public double DailyGrowthRatePercent { get; set; }
public TeamExperienceLevel TeamExperience { get; set; }
public string Evaluate()
{
if (DataModel == DataModelKind.Relational &&
ConsistencyRequirement == ConsistencyLevel.Strong &&
WriteQPS < 50000)
return "PostgreSQL (primary candidate)";
if (DataModel == DataModelKind.Document &&
WriteQPS > 100000 &&
ConsistencyRequirement != ConsistencyLevel.Strong)
return "Cassandra or DynamoDB";
if (DominantQuery == QueryPattern.FullTextSearch)
return "Elasticsearch (as secondary store)";
if (DominantQuery == QueryPattern.GraphTraversal)
return "Neo4j or Neptune";
if (EstimatedDataGB > 1000 &&
DominantQuery == QueryPattern.TimeRangeScan)
return "TimescaleDB or InfluxDB";
if (WriteQPS > 200000 && DataModel == DataModelKind.Relational)
return "CockroachDB or YugabyteDB (distributed SQL)";
return "PostgreSQL (safe default)";
}
}
2. SQL vs NoSQL: When to Use What
The SQL versus NoSQL debate is one of the most misunderstood topics in system design. It is not a binary choice between "good" and "bad" databases — it is a spectrum of tradeoffs across data modeling flexibility, query capability, consistency guarantees, and scaling patterns. Understanding when each paradigm excels is essential for making informed architectural decisions.
The Relational Model: Why SQL Endures
Relational databases have dominated for over four decades because the relational model, when combined with SQL, provides a declarative query language that separates what you want from how to get it. You describe the desired result set — the database's query optimizer figures out the execution plan. This abstraction is extraordinarily powerful: you can rewrite a query's execution strategy by adding an index, without changing a single line of application code. The relational model enforces data integrity through schemas, foreign keys, and constraints, catching data corruption at the database level rather than relying on application-level validation.
The real power of SQL becomes apparent with complex analytical queries. Consider a query that joins ten tables, filters on three conditions, groups by two columns, and computes aggregations — in SQL, this is a single declarative statement. In NoSQL, this might require multiple round-trips to the database, application-level joins, and significant code complexity. PostgreSQL's query planner can optimize this join across billions of rows using statistics, cost models, and adaptive algorithms that far outperform hand-tuned application code for most workloads.
SQL
-- Complex analytical query that NoSQL cannot express efficiently
SELECT
c.customer_region,
p.product_category,
COUNT(DISTINCT o.order_id) AS total_orders,
SUM(oi.quantity * oi.unit_price) AS total_revenue,
AVG(EXTRACT(EPOCH FROM (o.delivered_at - o.created_at)) / 3600)
AS avg_delivery_hours,
PERCENTILE_CONT(0.95) WITHIN GROUP (
ORDER BY EXTRACT(EPOCH FROM (o.delivered_at - o.created_at))
) AS p95_delivery_hours
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
JOIN products p ON oi.product_id = p.product_id
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.created_at BETWEEN '2025-01-01' AND '2025-12-31'
AND o.status IN ('delivered', 'completed')
AND p.is_active = TRUE
GROUP BY c.customer_region, p.product_category
HAVING COUNT(DISTINCT o.order_id) > 100
ORDER BY total_revenue DESC;
The NoSQL Movement: Why It Exists
NoSQL emerged in the mid-2000s when companies like Google (Bigtable), Amazon (Dynamo), and Facebook (Cassandra) hit the practical limits of relational databases at internet scale. The core problems were: (1) Horizontal write scaling — relational databases scale writes vertically (bigger machine) but horizontal sharding is complex and often manual. (2) Schema flexibility — rapid product iteration requires adding fields without table migrations that lock millions of rows. (3) Latency predictability — relational databases can have tail latency spikes from query optimization, vacuum operations, or lock contention, while key-value stores offer more predictable sub-millisecond responses. (4) Geographic distribution — distributing data across regions while maintaining strong consistency is fundamentally hard in relational systems.
Document databases like MongoDB model data as nested documents (JSON/BSON) that map naturally to object-oriented code. This eliminates the impedance mismatch between application objects and database rows — no ORM needed. A user profile with nested addresses, preferences, and recent orders is a single document that loads in a single read, rather than five separate tables joined together. The tradeoff is that cross-document joins are not supported (or are poorly supported), and data normalization must be handled at the application level.
C#
// Document model: User profile with nested data (single read)
public class UserProfileDocument
{
public string UserId { get; set; }
public string Email { get; set; }
public PersonalInfo Profile { get; set; }
public List<Address> Addresses { get; set; }
public Dictionary<string, string> Preferences { get; set; }
public List<RecentOrder> RecentOrders { get; set; }
public DateTime LastUpdated { get; set; }
}
// Relational model: Same data across 4 tables, requires joins
// SELECT u.*, a.*, p.*, o.*
// FROM users u
// LEFT JOIN addresses a ON u.user_id = a.user_id
// LEFT JOIN preferences p ON u.user_id = p.user_id
// LEFT JOIN (
// SELECT *, ROW_NUMBER() OVER (PARTITION BY user_id
// ORDER BY created_at DESC) as rn
// FROM orders
// ) o ON u.user_id = o.user_id AND o.rn <= 5
// WHERE u.user_id = @userId;
Decision Matrix
| Factor | Choose SQL | Choose NoSQL |
|---|---|---|
| Data relationships | Complex, multi-table joins needed | Denormalized, hierarchical data |
| Schema stability | Well-defined, rarely changes | Rapidly evolving, schema-free |
| Transactions | Multi-row, multi-table ACID transactions | Single-document or eventual consistency |
| Query patterns | Ad-hoc queries, analytics, reporting | Known access patterns, key-based lookups |
| Write scale | Under 50K writes/sec per node | Hundreds of thousands writes/sec across cluster |
| Consistency | Strong consistency required | Eventual consistency acceptable |
| Operational maturity | Team has deep SQL experience | Team has distributed systems expertise |
3. ACID vs BASE Consistency Models
The consistency model of a database determines what guarantees you can rely on when reading and writing data. Understanding ACID and BASE is not just academic — it directly affects whether your application produces correct results under concurrent access and failure conditions. Choosing the wrong consistency model for your workload can lead to data loss, duplicate processing, or silent corruption that goes undetected for months.
ACID: The Gold Standard for Correctness
ACID stands for Atomicity, Consistency, Isolation, and Durability. Atomicity guarantees that a transaction either completes entirely or not at all — there is no partial state. If a bank transfer debits Account A but the system crashes before crediting Account B, atomicity ensures the debit is rolled back. Consistency guarantees that a transaction transforms the database from one valid state to another, respecting all constraints (foreign keys, unique indexes, check constraints). Isolation guarantees that concurrent transactions do not interfere with each other — the outcome is as if transactions executed sequentially, even though they run in parallel. Durability guarantees that once a transaction commits, it survives subsequent crashes — the data is persisted to non-volatile storage.
PostgreSQL implements full ACID with multi-version concurrency control (MVCC). When a transaction reads data, it sees a consistent snapshot as of the transaction's start time, regardless of concurrent modifications. When a transaction writes data, the changes are invisible to other transactions until commit. This snapshot isolation level prevents dirty reads, non-repeatable reads, and (with Serializable Snapshot Isolation) even phantom reads. The performance cost is that PostgreSQL must maintain multiple versions of each row and periodically vacuum dead tuples — but the correctness guarantees are worth it for most applications.
C#
// ACID transaction: Transfer funds between accounts
// Either both operations succeed, or neither does
public async Task<bool> TransferFundsAsync(
Guid fromAccountId, Guid toAccountId, decimal amount)
{
await using var connection = new NpgsqlConnection(_connectionString);
await connection.OpenAsync();
// Transaction ensures atomicity — both UPDATEs succeed or both fail
await using var transaction = await connection.BeginTransactionAsync(
IsolationLevel.Serializable);
try
{
// Debit: use FOR UPDATE to lock the row and prevent concurrent modification
var debitResult = await connection.ExecuteAsync(
@"UPDATE accounts
SET balance = balance - @Amount, updated_at = NOW()
WHERE account_id = @FromId AND balance >= @Amount",
new { FromId = fromAccountId, Amount = amount },
transaction);
if (debitResult == 0)
throw new InsufficientFundsException(fromAccountId);
// Credit
await connection.ExecuteAsync(
@"UPDATE accounts
SET balance = balance + @Amount, updated_at = NOW()
WHERE account_id = @ToId",
new { ToId = toAccountId, Amount = amount },
transaction);
// Record the transfer
await connection.ExecuteAsync(
@"INSERT INTO transfers (transfer_id, from_account, to_account, amount, created_at)
VALUES (@Id, @From, @To, @Amount, NOW())",
new
{
Id = Guid.NewGuid(),
From = fromAccountId,
To = toAccountId,
Amount = amount
},
transaction);
await transaction.CommitAsync();
return true;
}
catch
{
await transaction.RollbackAsync();
throw;
}
}
BASE: Embracing Availability
BASE stands for Basically Available, Soft state, Eventually consistent. It is the pragmatic response to the CAP theorem — when you need to serve reads and writes during network partitions, you must sacrifice strong consistency. "Basically available" means the system guarantees a response (even if stale), rather than timing out or refusing service. "Soft state" means the system's state may change over time without additional input, as replicas converge. "Eventually consistent" means that if no new updates are made, all replicas will eventually reach the same state — but there is no bounded time guarantee.
Cassandra exemplifies the BASE model. When you write data, it is written to a configurable number of replicas (typically 3). The write is acknowledged as soon as the required number of replicas acknowledge (QUORUM write with RF=3 requires 2 acknowledgments). If a replica is temporarily unavailable, the write succeeds on available replicas and the data is "hinted" — stored temporarily for delivery when the replica recovers. Reads can specify a consistency level: ONE (fastest, may return stale data), QUORUM (balances consistency and latency), or ALL (strongest, but fails if any replica is down).
SQL
-- BASE example: Eventual consistency in a user activity feed
-- Write happens immediately, but reads may see stale data briefly
-- until all replicas converge
-- Cassandra CQL (similar pattern in DynamoDB, Cosmos DB)
CREATE TABLE user_activity_feed (
user_id UUID,
activity_id TIMEUUID,
activity_type TEXT,
metadata MAP<TEXT, TEXT>
PRIMARY KEY (user_id, activity_id)
) WITH CLUSTERING ORDER BY (activity_id DESC)
AND default_time_to_live = 7776000; -- 90-day TTL
-- Write with QUORUM consistency (2 of 3 replicas)
INSERT INTO user_activity_feed (user_id, activity_id, activity_type, metadata)
VALUES (uuid(), now(), 'page_view', {'url': '/products/123'})
USING CONSISTENCY QUORUM;
-- Read with ONE consistency (fast, but may be stale)
SELECT * FROM user_activity_feed
WHERE user_id = ?
USING CONSISTENCY ONE
LIMIT 20;
Consistency Spectrum
| Level | Guarantee | Latency | Use Case |
|---|---|---|---|
| Strong / Linearizable | Read sees latest write | Higher | Financial transactions, leader election |
| Snapshot Isolation | Read sees consistent snapshot | Moderate | OLTP workloads, reporting |
| Read-Your-Writes | Client always sees own writes | Moderate | User profiles, settings |
| Monotonic Reads | Never go backward in time | Moderate | Social media feeds |
| Eventual Consistency | Converges eventually (unbounded) | Lowest | Activity feeds, analytics |
5. Replication: Leader-Follower & Multi-Leader
Replication is the process of copying data from one database node to others. It serves three purposes: increasing read throughput (serve reads from multiple replicas), improving durability (survive the loss of a single machine), and reducing latency (serve reads from geographically close replicas). The two primary replication patterns are leader-follower (single-leader) and multi-leader, each with distinct tradeoffs around consistency, conflict resolution, and operational complexity.
Leader-Follower Replication
In leader-follower replication, all writes go to the leader node. The leader writes changes to its local storage and then streams the changes to follower replicas. Followers apply the changes asynchronously (eventual consistency) or synchronously (strong consistency at the cost of write latency). Clients read from any replica but always write through the leader. This model is simple, well-understood, and supported natively by PostgreSQL, MySQL, and SQL Server.
PostgreSQL's streaming replication works at the WAL (Write-Ahead Log) level. The leader continuously streams WAL records to followers. Each follower replays the WAL records, maintaining an up-to-date copy of the data. The replication lag (the time between a write on the leader and its visibility on a follower) is typically under 100ms in the same datacenter. Synchronous replication (synchronous_commit = remote_apply) ensures zero data loss but increases write latency by the round-trip time to the closest synchronous replica.
SQL
-- PostgreSQL replication configuration
-- Leader (primary) configuration
-- postgresql.conf
-- wal_level = replica
-- max_wal_senders = 10
-- synchronous_standby_names = 'replica1' -- synchronous follower
-- Follower (standby) setup
-- pg_basebackup -h primary-host -D /var/lib/postgresql/data -Fp -Xs -P
-- Connection configuration on follower
-- standby.signal
-- primary_conninfo = 'host=primary-host port=5432 user=replicator password=xxx'
-- Monitor replication lag on the leader
SELECT
client_addr,
state,
sent_lsn,
write_lsn,
flush_lsn,
replay_lsn,
pg_wal_lsn_diff(sent_lsn, replay_lsn) AS replication_lag_bytes,
EXTRACT(EPOCH FROM (now() - backend_start)) AS connected_seconds
FROM pg_stat_replication;
Handling Replication Lag
Asynchronous replication introduces a window where the follower's data is stale. Applications that read from followers may see inconsistent results: a user creates an account (written to leader), then immediately tries to log in (read from follower that hasn't received the write yet). This is the "read-your-writes" consistency problem. Solutions include: (1) routing reads that need fresh data to the leader, (2) tracking the leader's write position and waiting for the follower to catch up before serving the read, or (3) using synchronous replication for critical writes only.
C#
public class ConsistentReadStrategy
{
private readonly NpgsqlConnection _leaderConnection;
private readonly NpgsqlConnection _followerConnection;
public async Task<User> GetUserConsistentlyAsync(Guid userId, Guid? lastWriteLsn)
{
if (lastWriteLsn.HasValue)
{
// Wait for the follower to catch up to the last write
var caughtUp = await WaitForReplicationLagAsync(
_followerConnection, lastWriteLsn.Value,
timeout: TimeSpan.FromSeconds(2));
if (caughtUp)
{
return await QueryFollowerAsync<User>(
_followerConnection, userId);
}
}
// Fallback to leader if follower is too far behind
return await QueryLeaderAsync<User>(
_leaderConnection, userId);
}
private async Task<bool> WaitForReplicationLagAsync(
NpgsqlConnection follower, Npgsql.NpgsqlLSN targetLsn,
TimeSpan timeout)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
while (sw.Elapsed < timeout)
{
var currentLsn = await follower.QuerySingleAsync<string>(
"SELECT pg_last_wal_replay_lsn()");
if (ParseLsn(currentLsn) >= targetLsn.Value)
return true;
await Task.Delay(50);
}
return false;
}
}
Multi-Leader Replication
Multi-leader replication allows writes to multiple nodes simultaneously. Each node can accept writes and replicate to all other leaders. This is useful for multi-datacenter deployments where each datacenter has a local leader for low-latency writes. The fundamental challenge is conflict resolution: if two leaders modify the same row concurrently, which version wins? Common strategies include: last-write-wins (LWW), which uses timestamps to pick the most recent write; merge functions, which combine both versions (e.g., union two sets of tags); and application-level resolution, which presents conflicts to business logic.
C#
// Multi-leader conflict resolution: Last-Write-Wins with vector clocks
public class ConflictResolver
{
public T Resolve<T>(T leaderVersion, T followerVersion)
where T : IHasTimestamp
{
// LWW: compare timestamps, prefer the later write
if (leaderVersion.LastModified > followerVersion.LastModified)
return leaderVersion;
if (followerVersion.LastModified > leaderVersion.LastModified)
return followerVersion;
// Tie-breaking: use node ID for deterministic resolution
return leaderVersion.NodeId > followerVersion.NodeId
? leaderVersion
: followerVersion;
}
// Custom merge for lists (union of both versions)
public List<string> MergeTagLists(
List<string> leaderTags, List<string> followerTags)
{
return leaderTags.Union(followerTags).Distinct().ToList();
}
}
Replication Comparison
| Pattern | Write Throughput | Read Latency | Consistency | Complexity |
|---|---|---|---|---|
| Single Leader + Async Followers | Limited to leader | Low (read from replica) | Eventual | Low |
| Single Leader + Sync Follower | Limited to leader + RTT | Low (read from replica) | Strong (zero data loss) | Low |
| Multi-Leader | Multiplied by leaders | Low (local reads) | Eventual (conflict risk) | High |
| Leaderless (Dynamo-style) | Highest (all nodes accept writes) | Configurable (ONE/QUORUM/ALL) | Configurable | Highest |
6. Consensus Algorithms (Raft, Paxos)
Consensus algorithms solve the fundamental problem of getting multiple distributed nodes to agree on a single value. This is the building block for leader election, distributed locking, replicated state machines, and transaction commit protocols. Without consensus, a distributed database cannot determine which node is the leader, which version of a row is authoritative, or whether a distributed transaction should commit or abort. Understanding Raft and Paxos is essential for anyone working with distributed databases or building systems on top of consensus-backed storage like etcd or ZooKeeper.
Raft: Understandability Through Simplicity
Raft was designed as an alternative to Paxos with explicit goals of understandability and ease of implementation. It decomposes consensus into three sub-problems: leader election, log replication, and safety. A Raft cluster has exactly one leader at any time. The leader receives all client writes, appends them to its log, and replicates log entries to followers. Once a majority of nodes acknowledge a log entry, it is considered committed and applied to the state machine. If the leader fails, followers that haven't received heartbeats timeout and initiate a new election.
The leader election process works as follows. All nodes start in the follower state. A follower increments its election timer when it doesn't receive a heartbeat from the leader. When the timer expires, the follower transitions to the candidate state, increments its term number, votes for itself, and requests votes from peers. A candidate wins the election if it receives votes from a majority (N/2 + 1) of nodes. The winner becomes the leader and immediately begins sending heartbeats to prevent new elections. If no candidate wins (split vote), a random timeout causes a new election round.
Raft Log Replication
When a client writes to the leader, the leader appends the write to its log as a new entry with the current term number. The leader then sends AppendEntries RPCs to all followers. Each follower validates that the leader's term is at least as recent as its own and that the log entry immediately preceding the new entry matches its own log. If both conditions pass, the follower appends the entry. Once a majority of nodes (including the leader) have the entry, the leader commits it and applies it to the state machine. The leader then notifies followers of the commit in the next heartbeat. If a follower's log is out of date, the leader retries with earlier log entries until the logs converge — this is the log catch-up mechanism.
C#
public class RaftNode
{
private NodeState _state = NodeState.Follower;
private int _currentTerm = 0;
private Guid _votedFor = Guid.Empty;
private List<LogEntry> _log = new();
private int _commitIndex = 0;
public async Task<RequestVoteResponse> HandleRequestVoteAsync(
RequestVoteRequest request)
{
// Reject if candidate's term is older than ours
if (request.Term < _currentTerm)
return new RequestVoteResponse { Term = _currentTerm, VoteGranted = false };
// Update term and convert to follower if candidate has higher term
if (request.Term > _currentTerm)
{
_currentTerm = request.Term;
_state = NodeState.Follower;
_votedFor = Guid.Empty;
}
// Grant vote if we haven't voted for anyone else in this term
// and candidate's log is at least as up-to-date as ours
var canVote = _votedFor == Guid.Empty || _votedFor == request.CandidateId;
var logUpToDate = request.LastLogIndex >= _commitIndex;
if (canVote && logUpToDate)
{
_votedFor = request.CandidateId;
return new RequestVoteResponse
{
Term = _currentTerm,
VoteGranted = true
};
}
return new RequestVoteResponse { Term = _currentTerm, VoteGranted = false };
}
public async Task<AppendEntriesResponse> HandleAppendEntriesAsync(
AppendEntriesRequest request)
{
if (request.Term < _currentTerm)
return new AppendEntriesResponse { Term = _currentTerm, Success = false };
_currentTerm = request.Term;
_state = NodeState.Follower;
// Verify log consistency
if (request.PrevLogIndex > 0 &&
_log.Count <= request.PrevLogIndex - 1)
return new AppendEntriesResponse { Term = _currentTerm, Success = false };
// Append new entries
foreach (var entry in request.Entries)
{
var index = entry.Index - 1;
if (index < _log.Count && _log[index].Term != entry.Term)
_log.RemoveRange(index, _log.Count - index);
if (index >= _log.Count)
_log.Add(entry);
}
_commitIndex = Math.Max(_commitIndex, request.LeaderCommit);
return new AppendEntriesResponse { Term = _currentTerm, Success = true };
}
}
Paxos: The Theoretical Foundation
Paxos predates Raft and is the theoretical foundation for most consensus protocols. The original paper, "The Part-Time Parliament" by Leslie Lamport, describes a protocol where nodes take on roles of proposers, acceptors, and learners. A proposer selects a value and sends prepare/accept requests to acceptors. Acceptors promise to accept a value once a majority agrees. Paxos is more flexible than Raft but significantly harder to understand and implement correctly. Google's Chubby lock service and Spanner database use variants of Paxos for internal consensus.
Practical Applications in Databases
| System | Algorithm | Use Case |
|---|---|---|
| etcd | Raft | Kubernetes metadata store, distributed configuration |
| CockroachDB | Raft (per range) | Replicated ranges, distributed transactions |
| YugabyteDB | Raft (per tablet) | Tablet replication, metadata management |
| ZooKeeper | ZAB (ZooKeeper Atomic Broadcast) | Leader election, distributed locking |
| Google Spanner | Paxos (per shard) + 2PC | Multi-shard transactions, global consistency |
| PostgreSQL (Patroni) | Raft (via etcd/ZooKeeper) | Automatic failover for HA clusters |
7. Indexing & Query Optimization
Indexing is the single most impactful performance optimization in relational databases. A well-designed index can transform a query that scans billions of rows into one that reads a handful of pages. A missing index can turn a sub-millisecond query into a multi-minute table scan. Understanding B-tree indexes, partial indexes, covering indexes, and the PostgreSQL query planner is essential for any engineer working with databases at scale.
B-Tree Index Anatomy
A B-tree index organizes data into a balanced tree structure where each node contains multiple keys and pointers. The root node points to intermediate nodes, which point to leaf nodes that contain the actual indexed values and pointers to table rows. PostgreSQL's default page size is 8KB, and each B-tree page typically holds 200-400 keys. For a table with 1 billion rows and an index with 4 bytes per key, the B-tree height is typically 3-4 levels — meaning any lookup requires 3-4 page reads (32KB-64KB of I/O) to find the row, versus scanning the entire table (potentially terabytes).
SQL
-- B-tree index: The workhorse of relational databases
CREATE INDEX idx_orders_customer_date
ON orders (customer_id, order_date DESC);
-- This index supports:
-- WHERE customer_id = X (point lookup)
-- WHERE customer_id = X AND order_date > Y (range scan)
-- WHERE customer_id = X ORDER BY order_date (sorted result)
-- Composite index order matters!
-- A WHERE clause on order_date alone CANNOT use this index
-- because customer_id is the leading column
-- Covering index (INCLUDE): avoids table lookup entirely
CREATE INDEX idx_orders_covering
ON orders (customer_id, order_date DESC)
INCLUDE (total_amount, status);
-- This index satisfies the query without touching the table:
-- SELECT order_date, total_amount, status
-- FROM orders WHERE customer_id = X ORDER BY order_date DESC;
-- Partial index: index only a subset of rows
CREATE INDEX idx_orders_pending
ON orders (created_at)
WHERE status = 'pending';
-- Much smaller than a full index, much faster for pending-order queries
-- Expression index: index computed values
CREATE INDEX idx_users_email_lower
ON users (LOWER(email));
-- Enables efficient case-insensitive lookups:
-- WHERE LOWER(email) = 'user@example.com'
Index Design Patterns
| Pattern | When to Use | Tradeoff |
|---|---|---|
| Single-column B-tree | Frequent equality or range on one column | Simple, low overhead |
| Composite B-tree | Multi-column WHERE clauses | Column order matters for query patterns |
| Covering (INCLUDE) | Frequently queried columns in SELECT | Larger index, zero table lookups |
| Partial (WHERE) | Queries filtering on specific values | Small index, only useful for matching queries |
| GIN | Full-text search, JSONB, arrays | Write overhead, larger size |
| GiST | Geospatial, range types, fuzzy search | Lossy, requires recheck |
| BRIN | Naturally ordered data (timestamps, IDs) | Very small, block-range summarization |
Query Plan Analysis
The PostgreSQL query planner uses table statistics (maintained by ANALYZE) to estimate the cost of each execution plan. Understanding EXPLAIN (ANALYZE, BUFFERS) output is critical for diagnosing slow queries. Key metrics to watch for: Seq Scan (full table scan — often a missing index), Nested Loop with high row estimates (examine join conditions), Sort with high memory usage (consider adding an ORDER BY index), and Bitmap Heap Scan with recheck conditions (partial index may help).
SQL
-- Analyze query performance
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT c.customer_name, SUM(oi.quantity * oi.unit_price) AS total_spent
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
JOIN order_items oi ON o.order_id = oi.order_id
WHERE o.created_at >= '2025-01-01'
AND o.status = 'completed'
GROUP BY c.customer_id, c.customer_name
HAVING SUM(oi.quantity * oi.unit_price) > 1000
ORDER BY total_spent DESC
LIMIT 20;
-- Expected output (good plan):
-- Limit (actual rows=20, loops=1) (cost=...)
-- Sort (actual rows=20, loops=1)
-- HashAggregate (actual rows=1500, loops=1)
-- Nested Loop (actual rows=50000, loops=1)
-- Index Scan using idx_orders_date_status on orders o
-- (actual rows=50000, loops=1)
-- Filter: (status = 'completed')
-- Index Scan using idx_order_items_order on order_items oi
-- (actual rows=2, loops=50000)
-- Index Scan using idx_customers_pk on customers c
-- (actual rows=1, loops=50000)
-- Bad plan indicators:
-- Seq Scan on large tables (missing index)
-- Hash Join with high memory (increase work_mem)
-- Sort with external merge (increase work_mem)
C#
public class QueryOptimizer
{
private readonly NpgsqlConnection _connection;
public async Task<QueryPlan> AnalyzeQueryAsync(string query)
{
var explainQuery = $"EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) {query}";
var result = await _connection.QuerySingleAsync<JsonElement>(
explainQuery);
var plan = result[0].GetProperty("Plan");
var analysis = new QueryPlan
{
TotalCost = plan.GetProperty("Total Cost").GetDouble(),
ActualRows = plan.GetProperty("Actual Rows").GetLong(),
PlanningTime = result[0].GetProperty("Planning Time").GetDouble(),
ExecutionTime = result[0].GetProperty("Execution Time").GetDouble(),
SharedBuffersHit = plan.GetProperty("Shared Buffers Hit").GetInt64(),
SharedBuffersRead = plan.GetProperty("Shared Buffers Read").GetInt64()
};
// Check for sequential scans on large tables
analysis.Warnings = DetectSeqScans(plan);
return analysis;
}
private List<string> DetectSeqScans(JsonElement plan)
{
var warnings = new List<string>();
if (plan.GetProperty("Node Type").GetString() == "Seq Scan")
{
var rows = plan.GetProperty("Actual Rows").GetInt64();
if (rows > 10000)
warnings.Add(
$"Sequential scan on large table ({rows:N0} rows). " +
"Consider adding an index.");
}
foreach (var subPlan in plan.EnumerateProperty("Plans"))
warnings.AddRange(DetectSeqScans(subPlan.Value));
return warnings;
}
}
8. Connection Pooling & Resource Management
Database connections are expensive resources. Each PostgreSQL connection spawns a backend process that consumes 5-10MB of memory. A server with 200 connections uses 1-2GB just for connection overhead. More critically, PostgreSQL's process-per-connection model means that at high connection counts, context switching and lock contention degrade performance. Connection pooling solves this by maintaining a smaller pool of database connections and multiplexing application requests across them.
Why Connection Pooling Matters
PostgreSQL's performance degrades significantly beyond 100-200 concurrent connections for most workloads. This is because each connection requires a dedicated backend process, each query acquires locks that contend with other connections, and the shared buffers must accommodate working sets from all active connections. Connection pooling allows 10,000 application connections to share 50-100 database connections, maintaining performance while supporting high concurrency. PgBouncer is the industry standard for PostgreSQL connection pooling, offering three pooling modes: transaction (release connection after each transaction), session (release after session end), and statement (release after each statement, which breaks multi-statement transactions).
C#
// .NET connection pooling configuration
public class DatabaseConfiguration
{
// Connection string with pooling parameters
// Max Pool Size should match your PostgreSQL max_connections
// divided by the number of application instances
private const string BaseConnectionString =
"Host=primary.db.internal;" +
"Port=5432;" +
"Database=production;" +
"Username=app_user;" +
"Password={{secret:db-password}};" +
"Maximum Pool Size=50;" + // Per-app-instance connections
"Minimum Pool Size=5;" + // Keep 5 warm connections
"Connection Idle Lifetime=300;" + // Close idle connections after 5 min
"Connection Pruning Interval=30;" + // Check every 30s
"Timeout=30;" + // Connection timeout
"Command Timeout=60;" + // Query timeout
"Pooling=true;" +
"Include Error Detail=true;";
public string GetConnectionString() => BaseConnectionString;
}
// Connection pool monitoring
public class ConnectionPoolMonitor
{
private readonly NpgsqlConnection _monitorConn;
public async Task<PoolStats> GetPoolStatsAsync()
{
// Monitor active connections
var stats = await _monitorConn.QuerySingleAsync<PoolStats>(@"
SELECT
(SELECT count(*) FROM pg_stat_activity
WHERE datname = current_database()
AND state = 'active') AS active_connections,
(SELECT count(*) FROM pg_stat_activity
WHERE datname = current_database()
AND state = 'idle') AS idle_connections,
(SELECT count(*) FROM pg_stat_activity
WHERE datname = current_database()) AS total_connections,
(SELECT count(*) FROM pg_locks
WHERE NOT granted) AS waiting_for_lock,
(SELECT max(EXTRACT(EPOCH FROM (now() - query_start)))
FROM pg_stat_activity
WHERE state = 'active'
AND datname = current_database())
AS longest_running_query_seconds");
return stats;
}
}
// PgBouncer configuration (pgbouncer.ini)
// [databases]
// production = host=primary.db.internal port=5432 dbname=production
//
// [pgbouncer]
// pool_mode = transaction
// max_client_conn = 10000
// default_pool_size = 100
// min_pool_size = 10
// reserve_pool_size = 20
// reserve_pool_timeout = 3
// server_idle_timeout = 600
// client_idle_timeout = 0
Connection Pool Tuning
| Parameter | Recommended Value | Rationale |
|---|---|---|
| Max Pool Size (per instance) | max_connections / num_instances | Distribute connections evenly |
| Min Pool Size | 5-10 | Keep warm connections for low latency |
| Connection Idle Lifetime | 300 seconds | Release unused connections to the pool |
| Command Timeout | 30-60 seconds | Prevent hung queries from holding connections |
| PgBouncer pool_mode | transaction | Best throughput, compatible with most ORMs |
| PgBouncer max_client_conn | 10,000+ | Accommodate all application instances |
9. Database Partitioning & Range/Hash Strategies
Partitioning divides a single large table into smaller, more manageable pieces while presenting a unified query interface to the application. Unlike sharding (which distributes data across multiple servers), partitioning typically operates within a single database instance. PostgreSQL natively supports range, hash, and list partitioning. The primary benefits are: query performance (partition pruning eliminates scanning irrelevant partitions), maintenance operations (VACUUM, REINDEX, and backup operate on individual partitions), and data lifecycle management (drop old partitions instantly instead of deleting billions of rows).
Range Partitioning
Range partitioning assigns rows to partitions based on contiguous ranges of the partition key. It is ideal for time-series data where queries typically filter by time ranges. When you query WHERE created_at BETWEEN '2025-01-01' AND '2025-03-31', PostgreSQL's partition pruning automatically scans only the January, February, and March partitions, skipping all other months entirely. For a table with 10 years of data (120 monthly partitions), this reduces I/O by 97% for a quarterly query.
SQL
-- Range partitioning for a time-series events table
CREATE TABLE events (
event_id BIGSERIAL,
tenant_id UUID NOT NULL,
event_type VARCHAR(50) NOT NULL,
payload JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) PARTITION BY RANGE (created_at);
-- Create partitions with automatic partition management
CREATE TABLE events_2025_q1 PARTITION OF events
FOR VALUES FROM ('2025-01-01') TO ('2025-04-01');
CREATE TABLE events_2025_q2 PARTITION OF events
FOR VALUES FROM ('2025-04-01') TO ('2025-07-01');
CREATE TABLE events_2025_q3 PARTITION OF events
FOR VALUES FROM ('2025-07-01') TO ('2025-10-01');
CREATE TABLE events_2025_q4 PARTITION OF events
FOR VALUES FROM ('2025-10-01') TO ('2026-01-01');
CREATE TABLE events_default PARTITION OF events DEFAULT;
-- Index each partition independently
CREATE INDEX idx_events_tenant_time ON events (tenant_id, created_at DESC);
-- Partition pruning: only scans relevant partitions
EXPLAIN SELECT * FROM events
WHERE tenant_id = 'abc-123'
AND created_at BETWEEN '2025-03-01' AND '2025-05-01';
-- Only scans events_2025_q1 and events_2025_q2
Hash Partitioning
Hash partitioning distributes rows across partitions using a hash function on the partition key. This ensures even distribution regardless of key distribution, making it ideal for high-cardinality keys like UUIDs or user IDs. Hash partitioning does not benefit from range queries but evenly distributes write load and allows parallel query execution across partitions.
SQL
-- Hash partitioning for even distribution
CREATE TABLE sessions (
session_id UUID NOT NULL,
user_id UUID NOT NULL,
data JSONB,
expires_at TIMESTAMPTZ,
PRIMARY KEY (session_id)
) PARTITION BY HASH (session_id);
-- Create 8 hash partitions
CREATE TABLE sessions_p0 PARTITION OF sessions
FOR VALUES WITH (MODULUS 8, REMAINDER 0);
CREATE TABLE sessions_p1 PARTITION OF sessions
FOR VALUES WITH (MODULUS 8, REMAINDER 1);
-- ... through sessions_p7
-- Point lookups hit a single partition
SELECT * FROM sessions WHERE session_id = 'abc-def-ghi';
-- Only scans one of 8 partitions (12.5% of total data)
Partition Strategy Decision
| Strategy | Best Key Type | Query Pattern | Distribution | Pruning Benefit |
|---|---|---|---|---|
| Range | Timestamp, sequential ID | Range scans on time | Depends on data pattern | Excellent for time queries |
| Hash | UUID, high-cardinality | Point lookups | Uniform | Point lookups hit 1 partition |
| List | Region, status, category | Known value sets | Depends on value distribution | Good for categorical queries |
| Multi-level | Range + Hash combined | Time + ID queries | Uniform within range | Both time and ID pruning |
10. Polyglot Persistence
Polyglot persistence is the practice of using multiple database technologies within a single system, each chosen for its strengths in handling a specific workload. Rather than forcing one database to serve all purposes — transactional writes, full-text search, real-time analytics, caching, and graph traversal — polyglot persistence selects the optimal tool for each job. The challenge is maintaining data consistency across multiple stores and managing the operational complexity of running diverse database technologies.
Architectural Pattern
A typical polyglot architecture for an e-commerce platform might use PostgreSQL for order and inventory management (ACID transactions), Elasticsearch for product search (full-text and faceted search), Redis for session management and shopping cart (sub-millisecond reads), Cassandra for clickstream analytics (write-heavy time-series), Neo4j for product recommendations (graph traversal), and S3 for product images (blob storage). Each database handles what it does best, and the application coordinates data flow between them.
Orders, Inventory"] end subgraph Search["Search"] ES["Elasticsearch
Product Search"] end subgraph Cache["Cache Layer"] Redis["Redis
Sessions, Cart"] end subgraph Analytics["Analytics"] Cassandra["Cassandra
Clickstream"] end subgraph Graph["Recommendations"] Neo4j["Neo4j
Product Graph"] end API --> PG API --> ES API --> Redis API --> Cassandra API --> Neo4j
Data Synchronization Patterns
Keeping multiple databases synchronized requires careful design. The most common patterns are: (1) Dual-write — the application writes to the primary database and then to secondary stores. This is simple but risks inconsistency if the second write fails. (2) Change Data Capture (CDC) — the primary database streams its change log (PostgreSQL logical replication, Debezium) to secondary stores. This is reliable and decouples the primary from secondary stores. (3) Event sourcing — all state changes are recorded as events in a message bus (Kafka), and each database consumes the relevant events. This provides a single source of truth and naturally supports multiple read models.
C#
// CDC-based synchronization using Debezium + Kafka
public class ProductEventConsumer
{
private readonly IElasticsearchClient _searchClient;
private readonly IGraphDatabase _graphDb;
public async Task HandleProductEventAsync(ProductChangeEvent evt)
{
switch (evt.EventType)
{
case "created":
case "updated":
// Sync to Elasticsearch for search
await _searchClient.IndexProductAsync(evt.Product);
// Sync to Neo4j for recommendation graph
await _graphDb.UpsertProductNodeAsync(evt.Product);
// Redis cache invalidation
await InvalidateProductCacheAsync(evt.Product.Id);
break;
case "deleted":
await _searchClient.DeleteProductAsync(evt.ProductId);
await _graphDb.DeleteProductNodeAsync(evt.ProductId);
await InvalidateProductCacheAsync(evt.ProductId);
break;
}
}
private async Task InvalidateProductCacheAsync(Guid productId)
{
var cacheKeys = new[]
{
$"product:{productId}",
$"product:{productId}:variants",
$"product:{productId}:reviews"
};
foreach (var key in cacheKeys)
await _redis.KeyDeleteAsync(key);
}
}
Polyglot Tradeoffs
| Benefit | Cost | Mitigation |
|---|---|---|
| Best tool for each job | Multiple databases to operate | Managed services (RDS, ElastiCache, etc.) |
| Independent scaling | Data synchronization complexity | CDC with Debezium or Kafka Connect |
| Technology diversity | Team must learn multiple systems | Cross-training, internal documentation |
| Failure isolation | More failure modes to monitor | Centralized observability, consistent health checks |
11. NewSQL: CockroachDB, Yugabyte, TiDB
NewSQL databases attempt to combine the best properties of traditional SQL databases (ACID transactions, SQL interface, strong consistency) with the horizontal scalability of NoSQL systems. They provide distributed SQL across multiple nodes with automatic sharding, replication, and fault tolerance — without requiring the application to handle distribution logic. The three major NewSQL players are CockroachDB, YugabyteDB, and TiDB, each with distinct architectures and tradeoffs.
CockroachDB: Serializable Distributed SQL
CockroachDB is a distributed SQL database built on top of a key-value store (RocksDB/Pebble) with Raft consensus per range. It provides serializable isolation (the strongest level) across distributed transactions using a variant of the Parallel Commits protocol. Data is automatically sharded into ranges (~512MB each), each replicated 3 times across different nodes using Raft. When you run a single-node query, it executes on the leaseholder for the relevant ranges. Cross-range transactions use a two-phase commit protocol with automatic deadlock detection.
SQL
-- CockroachDB: Distributed SQL that "just works"
-- Same PostgreSQL-compatible syntax, runs across 100+ nodes
-- Create a distributed table (automatically sharded by primary key)
CREATE TABLE user_orders (
user_id UUID NOT NULL,
order_id UUID NOT NULL DEFAULT gen_random_uuid(),
amount DECIMAL(12, 2),
created_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (user_id, order_id)
) LOCALITY REGIONAL BY ROW;
-- Distributed transaction across multiple ranges
-- CockroachDB handles the 2PC, Raft consensus, and conflict resolution
BEGIN;
UPDATE accounts SET balance = balance - 100
WHERE user_id = 'abc-123' AND account_type = 'checking';
UPDATE accounts SET balance = balance + 100
WHERE user_id = 'abc-123' AND account_type = 'savings';
INSERT INTO transfers (from_account, to_account, amount)
VALUES ('checking', 'savings', 100);
COMMIT;
-- Geographic distribution with follower reads
-- Read from the nearest replica (even stale data is fine for analytics)
SET CLUSTER SETTING kv.closed_timestamp.target_duration = '5s';
SELECT * FROM user_orders
FOR SYSTEM TIME AS OF '-10s'
WHERE user_id = 'abc-123';
Comparison
| Feature | CockroachDB | YugabyteDB | TiDB |
|---|---|---|---|
| Storage Engine | Pebble (Go) | RocksDB / DocDB | RocksDB / TiKV |
| Consensus | Raft per range | Raft per tablet | Raft per region |
| SQL Compatibility | PostgreSQL wire protocol | PostgreSQL wire protocol | MySQL wire protocol |
| Isolation Level | Serializable (default) | Serializable (default) | Snapshot (default) |
| Geo-Distribution | Native (multi-region tables) | Tablespaces with region affinity | Placement rules |
| Best For | Global applications, strong consistency | PostgreSQL migrations, hybrid cloud | MySQL migrations, HTAP workloads |
| Maturity | Production since 2017 | Production since 2019 | Production since 2017 |
12. Time-Series & Specialized Databases
Time-series data — metrics, events, IoT sensor readings, financial tick data — has unique access patterns that general-purpose databases handle poorly. Time-series workloads are overwhelmingly append-heavy (99%+ writes), queries almost always filter by time range, data naturally ages and should be expired, and compression ratios can exceed 95% with domain-specific algorithms. Specialized time-series databases exploit these properties for orders-of-magnitude better performance and storage efficiency compared to PostgreSQL or MongoDB.
TimescaleDB: Time-Series on PostgreSQL
TimescaleDB is a PostgreSQL extension that adds automatic time-based partitioning (hypertables), continuous aggregates (materialized views that auto-refresh), and columnar compression. It is ideal for teams that want time-series capabilities without abandoning PostgreSQL. A hypertable automatically partitions your data into chunks (~7 days by default), each backed by a PostgreSQL partition with its own indexes. Continuous aggregates provide pre-computed rollups (hourly, daily) that update incrementally as new data arrives.
SQL
-- TimescaleDB: Time-series optimized PostgreSQL
CREATE EXTENSION IF NOT EXISTS timescaledb;
-- Create a hypertable (automatic time-based partitioning)
CREATE TABLE sensor_readings (
time TIMESTAMPTZ NOT NULL,
sensor_id INTEGER NOT NULL,
temperature DOUBLE PRECISION,
humidity DOUBLE PRECISION,
battery SMALLINT
);
SELECT create_hypertable('sensor_readings', 'time',
chunk_time_interval => INTERVAL '1 day');
-- Compression policy: compress chunks older than 7 days
ALTER TABLE sensor_readings SET (
timescaledb.compress,
timescaledb.compress_segmentby = 'sensor_id',
timescaledb.compress_orderby = 'time DESC'
);
SELECT add_compression_policy('sensor_readings', INTERVAL '7 days');
-- Continuous aggregate: auto-refreshing hourly rollups
CREATE MATERIALIZED VIEW sensor_hourly
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 hour', time) AS bucket,
sensor_id,
AVG(temperature) AS avg_temp,
MAX(temperature) AS max_temp,
MIN(temperature) AS min_temp,
AVG(humidity) AS avg_humidity,
COUNT(*) AS reading_count
FROM sensor_readings
GROUP BY bucket, sensor_id
WITH NO DATA;
-- Auto-refresh policy
SELECT add_continuous_aggregate_policy('sensor_hourly',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 hour',
schedule_interval => INTERVAL '1 hour');
-- Retention policy: drop raw data after 90 days
SELECT add_retention_policy('sensor_readings', INTERVAL '90 days');
-- Query: fast time-range scan with partition pruning
SELECT * FROM sensor_readings
WHERE sensor_id = 42
AND time >= NOW() - INTERVAL '1 hour';
Specialized Database Comparison
| Database | Type | Best For | Tradeoff |
|---|---|---|---|
| TimescaleDB | Time-series (PostgreSQL) | Metrics, IoT, existing PG teams | Extension, not standalone |
| InfluxDB | Time-series (purpose-built) | DevOps monitoring, high-cardinality | Custom query language (Flux/InfluxQL) |
| QuestDB | Time-series (columnar) | Financial data, ultra-fast analytics | Newer, smaller ecosystem |
| Elasticsearch | Search + analytics | Full-text search, log analytics | Not a primary data store, expensive at scale |
| Apache Pinot | Real-time OLAP | User-facing analytics, dashboards | Complex setup, Kafka dependency |
| ClickHouse | Columnar OLAP | Analytics, log analysis, reporting | Limited updates, eventual consistency |
13. Caching Layers (Redis, Memcached)
Caching is the most impactful performance optimization in most web applications. A properly designed cache reduces database load by 90%+ and cuts p99 latency from hundreds of milliseconds to single-digit milliseconds. Redis and Memcached are the two dominant in-memory caching solutions, with Redis offering richer data structures and persistence, and Memcached providing simpler, more memory-efficient caching for pure key-value workloads.
Cache-Aside Pattern
The cache-aside pattern is the most common caching strategy. The application first checks the cache; on a cache miss, it reads from the database, populates the cache, and returns the result. On a write, the application updates the database and invalidates (deletes) the cached entry. This pattern ensures that stale data is never served (the cache is always populated fresh from the database) and handles cold starts gracefully. The key design decisions are: TTL (time-to-live) for cache entries, invalidation strategy (delete vs. update), and stampede prevention (what happens when 1,000 requests hit a cache miss simultaneously).
C#
public class CacheAsideRepository<T>
{
private readonly IRedisCluster _redis;
private readonly IDatabase _database;
private readonly TimeSpan _defaultTtl = TimeSpan.FromMinutes(5);
public async Task<T> GetAsync(string key, Func<Task<T>> dbQuery)
{
// 1. Try cache first
var cached = await _redis.StringGetAsync($"cache:{key}");
if (cached.HasValue)
return JsonSerializer.Deserialize<T>(cached);
// 2. Cache miss — query database
var result = await dbQuery();
// 3. Populate cache (with jittered TTL to prevent stampede)
var ttl = _defaultTtl.Add(
TimeSpan.FromSeconds(Random.Shared.Next(0, 30)));
await _redis.StringSetAsync(
$"cache:{key}",
JsonSerializer.Serialize(result),
ttl);
return result;
}
public async Task InvalidateAsync(string key)
{
// Delete on write (not update) to prevent stale cache
await _redis.KeyDeleteAsync($"cache:{key}");
}
// Stampede prevention: distributed lock for cache rebuild
public async Task<T> GetWithLockAsync<T>(
string key, Func<Task<T>> dbQuery)
{
var cached = await _redis.StringGetAsync($"cache:{key}");
if (cached.HasValue)
return JsonSerializer.Deserialize<T>(cached);
var lockKey = $"lock:cache:{key}";
var acquired = await _redis.LockTakeAsync(
lockKey, "rebuilder", TimeSpan.FromSeconds(10));
if (!acquired)
{
// Another process is rebuilding — wait and retry
await Task.Delay(100);
return await GetWithLockAsync(key, dbQuery);
}
try
{
// Double-check after acquiring lock
cached = await _redis.StringGetAsync($"cache:{key}");
if (cached.HasValue)
return JsonSerializer.Deserialize<T>(cached);
var result = await dbQuery();
await _redis.StringSetAsync(
$"cache:{key}",
JsonSerializer.Serialize(result),
_defaultTtl);
return result;
}
finally
{
await _redis.LockReleaseAsync(lockKey, "rebuilder");
}
}
}
Redis vs Memcached
| Feature | Redis | Memcached |
|---|---|---|
| Data Structures | Strings, hashes, lists, sets, sorted sets, streams | Strings only |
| Persistence | RDB snapshots + AOF log | No persistence |
| Clustering | Redis Cluster (hash slots) | Client-side sharding |
| Memory Efficiency | Higher overhead per key | Optimized slab allocator |
| Pub/Sub | Built-in | No |
| Lua Scripting | Built-in (atomic execution) | No |
| Best For | Complex caching, sessions, rate limiting, leaderboards | Simple key-value caching at scale |
Cache Invalidation Strategies
| Strategy | How It Works | Pros | Cons |
|---|---|---|---|
| Time-Based (TTL) | Cache expires after N seconds | Simple, guaranteed freshness | Stale until TTL expires |
| Write-Through | Write updates cache + DB simultaneously | Always consistent | Write latency includes cache + DB |
| Write-Behind | Write updates cache, async flush to DB | Fast writes | Data loss risk on crash |
| Event-Driven Invalidation | DB change events trigger cache deletion | Immediate consistency | Requires event infrastructure |
14. Data Migration & Schema Evolution
Schema changes are inevitable in any long-lived application. Adding a column, changing a data type, creating an index on a billion-row table, or migrating to an entirely new database — these operations must be performed without downtime. The techniques for zero-downtime schema evolution include online schema changes, expand-contract migrations, and blue-green database migrations. Getting these wrong causes data loss, extended outages, or silent data corruption.
Online Schema Changes with pt-online-schema-change
MySQL's InnoDB engine locks the entire table during ALTER TABLE, which can take hours for billion-row tables. Percona's pt-online-schema-change (and the similar gh-ost from GitHub) performs schema changes without locking by: (1) creating a new table with the desired schema, (2) copying data from the old table to the new table in small batches, (3) applying ongoing changes (via triggers or binlog) to keep the new table in sync, (4) atomically swapping the old and new table names. PostgreSQL handles many ALTER TABLE operations more gracefully (adding a column with a default is instant in PG 11+), but operations like adding an index on a large table still benefit from CONCURRENTLY.
SQL
-- PostgreSQL zero-downtime migrations
-- 1. Adding a column (instant in PG 11+ if no table rewrite needed)
ALTER TABLE users ADD COLUMN phone_verified BOOLEAN DEFAULT FALSE;
-- This is instant — PostgreSQL stores the default without rewriting rows
-- 2. Creating an index without blocking writes
CREATE INDEX CONCURRENTLY idx_users_phone_verified
ON users (phone_verified)
WHERE phone_verified = TRUE;
-- CONCURRENTLY allows reads and writes during index creation
-- Takes longer but zero downtime
-- 3. Renaming a column (expand phase — add new, dual-write)
ALTER TABLE users ADD COLUMN email_address VARCHAR(255);
UPDATE users SET email_address = email WHERE email_address IS NULL;
-- (Application now writes to both email and email_address)
-- 4. Drop old column (contract phase — after all code updated)
ALTER TABLE users DROP COLUMN email;
-- 5. Changing data type (requires table rewrite — do during low traffic)
ALTER TABLE orders ALTER COLUMN total_amount TYPE NUMERIC(14, 2);
-- Monitor progress:
SELECT * FROM pg_stat_progress_create_index;
Expand-Contract Migration Pattern
The expand-contract pattern eliminates downtime for breaking schema changes. In the expand phase, add the new column or table while keeping the old one. Deploy application code that writes to both the old and new schema (dual-write). In the migrate phase, backfill existing data from the old schema to the new one. In the contract phase, after confirming the new schema is working correctly, remove the old column or table. This three-phase approach ensures that at every point, both the old and new application versions can operate correctly against the current database schema.
C#
// Expand-contract: Dual-write during migration
public class UserRepository
{
private readonly NpgsqlConnection _db;
public async Task<User> GetUserAsync(Guid userId)
{
// Read from new column (fall back to old if null)
var user = await _db.QuerySingleAsync<User>(@"
SELECT id,
COALESCE(email_address, email) AS email,
name,
created_at
FROM users WHERE id = @Id",
new { Id = userId });
return user;
}
public async Task UpdateEmailAsync(Guid userId, string newEmail)
{
// Dual-write: update both old and new columns
await _db.ExecuteAsync(@"
UPDATE users
SET email = @Email,
email_address = @Email,
updated_at = NOW()
WHERE id = @Id",
new { Id = userId, Email = newEmail });
}
}
Migration Safety Checklist
| Phase | Action | Risk | Mitigation |
|---|---|---|---|
| Before | Backup the database | Data loss during migration | Full backup + WAL archiving |
| Before | Test migration on staging with production data volume | Unexpected duration or failures | Use pg_dump/restore for staging clone |
| During | Monitor replication lag and lock wait times | Locks blocking application queries | Set lock_timeout, statement_timeout |
| During | Use CONCURRENTLY for index creation | Table locks blocking writes | Always use CONCURRENTLY in production |
| After | Verify row counts and data integrity | Missing or corrupted data | CHECKSUM or hash comparison |
| After | Rollback plan tested | Failed migration, no way to revert | Keep old columns until migration confirmed |
15. Backup, Recovery & Disaster Recovery
Backup and disaster recovery (DR) are the last line of defense against data loss. A database without tested backups is one power failure away from catastrophe. The two critical metrics are RPO (Recovery Point Objective) — how much data can you afford to lose? — and RTO (Recovery Time Objective) — how quickly must you recover? For most production systems, the target is RPO of near-zero (via continuous WAL archiving) and RTO under 30 minutes (via automated failover and point-in-time recovery).
PostgreSQL Backup Strategy
PostgreSQL's backup strategy combines base backups (full snapshots of the database) with WAL (Write-Ahead Log) archiving for point-in-time recovery. A base backup captures the entire database at a point in time. WAL archiving captures every change made after the base backup. To recover to any point in time, you restore the base backup and replay WAL records up to the desired timestamp. This combination provides RPO of zero — you can recover to any transaction in your WAL retention window.
SQL
-- PostgreSQL backup configuration
-- Enable WAL archiving (postgresql.conf)
-- archive_mode = on
-- archive_command = 'cp %p /mnt/wal_archive/%f'
-- wal_level = replica
-- max_wal_senders = 5
-- Continuous backup using pg_basebackup
-- pg_basebackup -h primary.db.internal -D /mnt/basebackups/current \
-- -Fp -Xs -P -R --checkpoint=fast
-- Point-in-time recovery setup
-- postgresql.conf:
-- restore_command = 'cp /mnt/wal_archive/%f %p'
-- recovery_target_time = '2025-07-13 14:30:00'
-- recovery_target_action = 'promote'
-- Monitor backup status
SELECT
archived_count,
failed_count,
last_archived_time,
last_failed_time,
EXTRACT(EPOCH FROM (now() - last_archived_time)) AS seconds_since_last_archive
FROM pg_stat_archiver;
-- Check WAL retention
SELECT
slot_name,
restart_lsn,
confirmed_flush_lsn,
pg_size_pretty(pg_wal_lsn_diff(confirmed_flush_lsn, restart_lsn)) AS retained_wal
FROM pg_replication_slots;
Disaster Recovery Architecture
A production DR strategy includes: local replication (synchronous or asynchronous to a standby in the same datacenter for fast failover), cross-region replication (asynchronous to a standby in a different geographic region for disaster recovery), automated failover (Patroni, repmgr, or cloud-managed failover), and tested recovery procedures (regular DR drills). The DR standby should be continuously validated by running read queries against it to ensure it is in a recoverable state.
Recovery Time Estimates
| Failure Scenario | Recovery Method | Estimated RTO | Data Loss (RPO) |
|---|---|---|---|
| Primary node crash | Automatic failover to sync standby | < 30 seconds | Zero (sync replication) |
| Data corruption | Point-in-time recovery to pre-corruption | 5-30 minutes | Depends on when corruption occurred |
| Region failure | Failover to DR region standby | 5-15 minutes | Seconds (async replication lag) |
| Accidental DELETE all data | PITR to timestamp before deletion | 10-60 minutes | None (recover to exact second) |
| Full region disaster | Restore from S3 backup + WAL replay | 30-120 minutes | Depends on WAL backup frequency |
16. Cost Estimation
Database infrastructure is often the largest line item in a cloud computing budget. Understanding the cost drivers and optimization levers is essential for architects who must balance performance with budget constraints. The primary cost components are compute (instance size and count), storage (SSD/HDD volume and IOPS), I/O (read/write operations beyond baseline), network (cross-AZ and cross-region data transfer), and managed service fees.
Cost Breakdown: PostgreSQL on AWS
| Component | Specification | Monthly Cost | Notes |
|---|---|---|---|
| Primary instance (r6g.2xlarge) | 8 vCPU, 64GB RAM, gp3 | $876 | Production OLTP workload |
| Standby instance (r6g.2xlarge) | 8 vCPU, 64GB RAM, gp3 | $876 | Synchronous standby |
| Read replica (r6g.xlarge) | 4 vCPU, 32GB RAM, gp3 | $438 | Analytical queries, reporting |
| Storage (gp3, 1TB, 12K IOPS) | 1TB gp3 with provisioned IOPS | $220 | High IOPS for OLTP |
| Backup storage (S3) | 500GB WAL + base backups | $12 | 90-day retention |
| Cross-AZ data transfer | ~500GB/month | $11 | Replication traffic |
| RDS Multi-AZ surcharge | Primary + Standby | ~$0 | Included in instance costs |
| Total | ~$2,433 |
Full Stack Cost: Production System
| Component | Monthly Cost | % of Total |
|---|---|---|
| PostgreSQL cluster (primary + standby + replica) | $2,433 | 30% |
| Redis cluster (3-node, r6g.large) | $660 | 8% |
| Elasticsearch cluster (3-node, m6g.large) | $900 | 11% |
| Cassandra cluster (3-node, r6i.large) | $960 | 12% |
| PgBouncer instances (2x t3.medium) | $60 | 1% |
| Application servers (6x c6g.xlarge) | $1,800 | 22% |
| Data transfer (cross-AZ + internet) | $400 | 5% |
| Monitoring (Prometheus, Grafana, PagerDuty) | $500 | 6% |
| Backup & DR | $350 | 4% |
| Total | ~$8,063 | 100% |
Cost Optimization Strategies
C#
// Cost-aware database configuration
public class DatabaseCostOptimizer
{
// Strategy 1: Right-size based on actual utilization
public InstanceRecommendation AnalyzeUtilization(MetricsData metrics)
{
var avgCpuPercent = metrics.CpuPercentiles.P50;
var peakCpuPercent = metrics.CpuPercentiles.P99;
var avgMemoryPercent = metrics.MemoryPercentiles.P50;
if (avgCpuPercent < 20 && peakCpuPercent < 50)
return new InstanceRecommendation
{
Current = "r6g.2xlarge (8 vCPU, 64GB)",
Recommended = "r6g.xlarge (4 vCPU, 32GB)",
EstimatedSavings = 438, // 50% reduction
Risk = RiskLevel.Low
};
if (avgCpuPercent > 70 || peakCpuPercent > 90)
return new InstanceRecommendation
{
Current = "r6g.2xlarge (8 vCPU, 64GB)",
Recommended = "r6g.4xlarge (16 vCPU, 128GB)",
EstimatedSavings = -876, // Cost increase
Risk = RiskLevel.Low,
Reason = "CPU saturation detected"
};
return InstanceRecommendation.NoChange;
}
// Strategy 2: Reserved Instance savings
public decimal CalculateReservedSavings(
string instanceType, int count, int months)
{
// On-demand: r6g.2xlarge = $0.504/hr
// 1-year reserved: $0.318/hr (37% savings)
// 3-year reserved: $0.202/hr (60% savings)
var onDemandMonthly = 0.504m * 730 * count;
var reservedYearly = 0.318m * 730 * 12 * count;
var reservedMonthly = reservedYearly / 12;
return (onDemandMonthly - reservedMonthly) * months;
}
}
17. Interview Q&A Deep Dive
Q1: When would you choose Cassandra over PostgreSQL?
Answer: Choose Cassandra when your write throughput exceeds what a single PostgreSQL node can handle (roughly 10K-50K writes/sec for a well-tuned instance), when you need multi-datacenter write availability, and when eventual consistency is acceptable. Cassandra's log-structured storage engine handles write-heavy workloads with predictable latency because writes are sequential (append to commit log + memtable flush), while PostgreSQL's B-tree updates require random I/O. A concrete example: IoT telemetry ingestion where 100K sensors each send a reading every second. That is 100K writes/sec, each being a small time-series record. Cassandra handles this across 10-20 nodes with linear scaling, while PostgreSQL would need significant sharding and still struggle with the write amplification from B-tree maintenance. However, if you need ad-hoc analytical queries, joins, or strong consistency, PostgreSQL is the better choice.
Q2: Explain the CAP theorem with real-world database examples.
Answer: The CAP theorem states that during a network partition, a distributed system must choose between consistency and availability. In practice: PostgreSQL single-node with synchronous replication prioritizes CP (consistency + partition tolerance) — if the replica is unreachable, writes fail. Cassandra with CL=ONE prioritizes AP (availability + partition tolerance) — every node accepts writes regardless of connectivity, but reads may return stale data. DynamoDB with default settings prioritizes AP with tunable consistency. MongoDB with majority read concern and write concern majority provides CP behavior. The critical insight is that most databases are not purely CP or AP — they offer tunable consistency levels that let you make the choice per-query. Cassandra's consistency levels (ONE, QUORUM, ALL) let you trade latency for consistency on each operation. PostgreSQL's synchronous_commit setting lets you trade write latency for durability guarantees.
Q3: How do you design a database schema for a multi-tenant SaaS?
Answer: There are three main approaches, each with distinct tradeoffs. (1) Shared database, shared schema — all tenants' data lives in the same tables, differentiated by a tenant_id column. This is the simplest to operate and most resource-efficient, but provides the weakest isolation — a bug in row-level security can leak data across tenants. (2) Shared database, separate schemas — each tenant gets its own PostgreSQL schema within the same database instance. This provides logical isolation without the operational overhead of separate databases. (3) Separate databases — each tenant gets a dedicated database instance. This provides the strongest isolation and allows per-tenant backup/restore, but costs more and complicates cross-tenant analytics. For most SaaS products, option 1 with row-level security (RLS) is the right starting point. PostgreSQL's RLS policies enforce tenant_id filtering at the database level, preventing application bugs from causing cross-tenant data access.
SQL
-- Row-Level Security for multi-tenant isolation
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON orders
USING (tenant_id = current_setting('app.current_tenant')::uuid);
-- Application sets the tenant context on each connection
SET app.current_tenant = 'abc-123-def-456';
-- Now all queries automatically filter by tenant
SELECT * FROM orders; -- Only returns orders for tenant abc-123-def-456
-- Even if application code forgets WHERE tenant_id = ..., RLS enforces it
Q4: How do you handle database migrations with zero downtime?
Answer: The expand-contract pattern is the gold standard. In the expand phase, add the new column or table while keeping the old one. Deploy application code that writes to both schemas (dual-write). In the migrate phase, backfill existing data asynchronously using batch updates with a WHERE clause to resume if interrupted. In the contract phase, after confirming the new schema is working and all reads use the new column, remove the old column. For index creation, always use CREATE INDEX CONCURRENTLY which allows reads and writes during the build. For adding a column with a default in PostgreSQL 11+, ALTER TABLE ADD COLUMN with a DEFAULT is instant — no table rewrite needed. For data type changes that require a rewrite, schedule the migration during a low-traffic window and monitor pg_stat_progress to track completion.
Q5: Compare read replicas, connection pooling, and caching — when to use each?
Answer: These three techniques solve different problems. Read replicas distribute read load across multiple database copies, useful when your read-to-write ratio is high (10:1 or more) and you're hitting CPU or I/O limits on the primary. Connection pooling multiplexes application connections onto fewer database connections, solving the problem of PostgreSQL's process-per-connection model degrading above 100-200 connections. Caching stores frequently accessed data in memory (Redis), bypassing the database entirely for hot reads. The optimal architecture uses all three: PgBouncer for connection pooling (ensuring the database isn't overwhelmed by connection count), 1-2 read replicas for distributing analytical and reporting queries, and Redis for caching hot data like user sessions and product details. Layer them in order: application checks Redis first (sub-millisecond), then the read replica (1-5ms), then the primary (for writes and read-your-writes consistency).
Q6: How do you prevent and detect silent data corruption?
Answer: Silent data corruption is insidious because the database reports success while storing incorrect data. Prevention: (1) Use checksums — PostgreSQL 12+ has data checksums enabled at initdb time, detecting bit-rot on disk. (2) Use foreign key constraints and check constraints to catch referential integrity violations at the database level. (3) Use database replication with a third witness node — corruption on one node is detectable when compared against replicas. Detection: (1) Run periodic consistency checks using pg_checksums or pg_cheksums on backups. (2) Implement application-level hash verification for critical data — compute a hash when writing, verify when reading. (3) Monitor for anomalies like unexpected row count changes, sudden value distribution shifts, or constraint violations in application logs. (4) Use point-in-time recovery to restore to a known-good state if corruption is detected.
C#
// Application-level data integrity verification
public class IntegrityChecker
{
private readonly NpgsqlConnection _db;
public async Task<IntegrityReport> VerifyOrderIntegrityAsync()
{
var report = new IntegrityReport();
// Check 1: Every order must have at least one item
var orphanOrders = await _db.QueryAsync<Guid>(@"
SELECT o.order_id FROM orders o
LEFT JOIN order_items oi ON o.order_id = oi.order_id
WHERE oi.order_id IS NULL");
report.OrphanedOrders = orphanOrders.ToList();
// Check 2: Order total must match sum of items
var mismatchedTotals = await _db.QueryAsync(@"
SELECT o.order_id, o.total_amount AS stated_total,
SUM(oi.quantity * oi.unit_price) AS computed_total
FROM orders o
JOIN order_items oi ON o.order_id = oi.order_id
GROUP BY o.order_id, o.total_amount
HAVING ABS(o.total_amount - SUM(oi.quantity * oi.unit_price)) > 0.01");
report.MismatchedTotals = mismatchedTotals.ToList();
// Check 3: Referential integrity across key tables
var brokenRefs = await _db.QueryAsync(@"
SELECT 'order_customer' AS violation_type, o.order_id
FROM orders o
LEFT JOIN customers c ON o.customer_id = c.customer_id
WHERE c.customer_id IS NULL");
report.BrokenReferences = brokenRefs.ToList();
return report;
}
}
Q7: Design a database architecture for a system that must handle 1 million writes per second.
Answer: At 1M writes/sec, no single database node can handle the load. The architecture requires multiple layers of distribution. Start with a Kafka ingestion layer that buffers writes and provides backpressure. The Kafka topic is partitioned by a shard key (e.g., user_id or device_id), with 100-1000 partitions to parallelize consumption. Consumer groups read from Kafka and write to the storage layer. The storage layer depends on the query pattern: for time-series metrics, use a cluster of TimescaleDB or InfluxDB nodes, each handling a hash partition of the key space. For a general-purpose key-value store, use Cassandra with a 3-node vNode ring per region, writing with CL=QUORUM for durability. For relational data that needs ACID transactions, use a CockroachDB or YugabyteDB cluster with 20+ nodes, sharding by primary key. The key architectural insight is that the Kafka layer decouples ingestion from storage — the storage layer can be slow, undergo maintenance, or fail, and the Kafka buffer absorbs the write backlog until recovery.
Key Numbers to Remember
| Metric | Value |
|---|---|
| PostgreSQL max practical connections | 100-200 (use PgBouncer beyond this) |
| PostgreSQL single-node write throughput | 10K-50K writes/sec (workload dependent) |
| Redis latency | < 1ms for simple key-value operations |
| B-tree index depth for 1B rows | 3-4 levels (~32-64KB I/O) |
| PostgreSQL page size | 8KB |
| PostgreSQL tuple header overhead | ~23 bytes per row |
| Raft leader election timeout | 150-300ms (etcd default) |
| Cassandra write path | Commit log + Memtable (sequential I/O) |
| TimescaleDB compression ratio | 10:1 to 100:1 for metric data |
| gp3 baseline IOPS | 3,000 (provision up to 16,000) |
Pre-Interview Checklist
- Understand ACID vs BASE and when each applies
- Know sharding strategies: hash, range, consistent hashing, and their tradeoffs
- Explain Raft consensus: leader election, log replication, safety
- Design composite indexes and understand leading-column rule
- Compare read replicas, connection pooling, and caching use cases
- Describe the expand-contract migration pattern for zero-downtime deployments
- Explain PostgreSQL WAL archiving and point-in-time recovery
- Know when to choose NewSQL (CockroachDB) over traditional PostgreSQL
- Discuss polyglot persistence: CDC, event sourcing, dual-write patterns
- Estimate infrastructure costs and identify optimization levers
- Understand connection pooling modes (PgBouncer: transaction vs session)
- Explain CAP theorem with concrete database examples