system-design55 min read

Database Architecture for System Design: The Complete Guide — A Senior+ Guide | Ayodhyya

Database Architecture for System Design: The Complete Guide

A Senior+ Guide — From Relational Fundamentals to Distributed Database Systems at Scale

Senior+ System Design Guide 10,000+ Words 17 Deep-Dive Sections C# · Mermaid · SQL · Real-World Case Studies

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.

Key Insight: The most common mistake in database architecture is choosing a database technology before understanding the workload. Start with the data model, query patterns, consistency requirements, and scaling needs — then let those requirements drive the technology selection. A PostgreSQL database with proper indexing and partitioning can handle workloads that many teams instinctively reach for NoSQL to solve.

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

CategoryExamplesBest ForTradeoff
Relational (RDBMS)PostgreSQL, MySQL, SQL ServerStructured data, complex queries, transactionsVertical scaling limit, schema rigidity
Document StoreMongoDB, CouchDB, DynamoDBSemi-structured data, rapid iterationLimited joins, eventual consistency
Key-ValueRedis, Memcached, etcdCaching, sessions, real-time leaderboardsNo complex queries, memory-bound
Wide ColumnCassandra, HBase, ScyllaDBTime-series, write-heavy workloads, IoTOperational complexity, limited ad-hoc queries
GraphNeo4j, Amazon Neptune, JanusGraphRelationship-heavy queries, social networksPoor at aggregate queries, horizontal scaling challenges
Search EngineElasticsearch, Apache SolrFull-text search, log analysis, faceted searchNot a primary data store, indexing overhead
Time-SeriesInfluxDB, TimescaleDB, QuestDBMetrics, IoT sensors, financial tick dataOptimized for append-heavy, limited updates
NewSQLCockroachDB, YugabyteDB, TiDBDistributed SQL with horizontal scalingNewer 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

FactorChoose SQLChoose NoSQL
Data relationshipsComplex, multi-table joins neededDenormalized, hierarchical data
Schema stabilityWell-defined, rarely changesRapidly evolving, schema-free
TransactionsMulti-row, multi-table ACID transactionsSingle-document or eventual consistency
Query patternsAd-hoc queries, analytics, reportingKnown access patterns, key-based lookups
Write scaleUnder 50K writes/sec per nodeHundreds of thousands writes/sec across cluster
ConsistencyStrong consistency requiredEventual consistency acceptable
Operational maturityTeam has deep SQL experienceTeam has distributed systems expertise
Common Anti-Pattern: Choosing MongoDB because "schemas are hard" often leads to data integrity problems at scale. Schema-less does not mean schema-free — it means the schema is enforced in application code rather than the database. If your application code has bugs, your data becomes corrupted. PostgreSQL's JSONB column type gives you the flexibility of document storage with the safety of relational constraints — a best-of-both-worlds approach that many teams overlook.

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

LevelGuaranteeLatencyUse Case
Strong / LinearizableRead sees latest writeHigherFinancial transactions, leader election
Snapshot IsolationRead sees consistent snapshotModerateOLTP workloads, reporting
Read-Your-WritesClient always sees own writesModerateUser profiles, settings
Monotonic ReadsNever go backward in timeModerateSocial media feeds
Eventual ConsistencyConverges eventually (unbounded)LowestActivity feeds, analytics
Key Insight: The CAP theorem is not "pick two of three" — it is "during a network partition, you must choose between consistency and availability." In normal operation (no partition), a system can provide both. The real design question is: "What happens during the 0.01% of the time when the network is partitioned?" For financial systems, sacrifice availability (reject writes). For social media, sacrifice consistency (serve stale reads).

4. Database Sharding Strategies

Sharding is the horizontal partitioning of data across multiple database instances. When a single PostgreSQL node cannot handle your write throughput (typically 10K-50K writes/sec for a well-tuned instance) or your data exceeds a single machine's storage capacity (typically 2-10TB for good performance), sharding distributes the load across multiple machines. Each shard holds a subset of the data and handles a subset of the queries. The fundamental challenge is choosing a shard key that distributes data evenly and maps queries to the correct shard without cross-shard joins.

Hash-Based Sharding

Hash-based sharding applies a hash function to the shard key and distributes data based on the hash value. This guarantees even distribution when the key has high cardinality (many unique values). The downside is that range queries span all shards — "give me all orders from January to March" must query every shard because adjacent keys in the hash space do not correspond to adjacent keys in the original domain. Hash sharding is ideal for workloads dominated by point lookups: user profiles by user_id, sessions by session_id, or products by product_id.

C#
public class HashShardRouter
{
    private readonly int _numShards;
    private readonly string[] _connectionStrings;

    public HashShardRouter(int numShards, string[] connectionStrings)
    {
        _numShards = numShards;
        _connectionStrings = connectionStrings;
    }

    public string GetShardConnectionString(Guid shardKey)
    {
        var hash = shardKey.GetHashCode();
        var shardIndex = Math.Abs(hash) % _numShards;
        return _connectionStrings[shardIndex];
    }

    public async Task<T> QueryShardAsync<T>(
        Guid shardKey,
        Func<NpgsqlConnection, Task<T>> query)
    {
        var connectionString = GetShardConnectionString(shardKey);
        await using var connection = new NpgsqlConnection(connectionString);
        await connection.OpenAsync();
        return await query(connection);
    }

    // Fan-out query: execute on all shards and merge results
    public async Task<List<T>> QueryAllShardsAsync<T>(
        Func<NpgsqlConnection, Task<List<T>>> query)
    {
        var tasks = _connectionStrings.Select(async cs =>
        {
            await using var connection = new NpgsqlConnection(cs);
            await connection.OpenAsync();
            return await query(connection);
        });

        var results = await Task.WhenAll(tasks);
        return results.SelectMany(r => r).ToList();
    }
}

Range-Based Sharding

Range-based sharding assigns contiguous ranges of the shard key to each shard. All orders with IDs 1-1M go to Shard 0, 1M-2M to Shard 1, and so on. Range queries are efficient — "all orders from January" maps to a specific shard if the shard key includes the timestamp. The downside is hotspot risk: if you shard by timestamp, the shard receiving the current time period receives all write traffic (the "hot shard" problem). Range sharding works best when the access pattern aligns with the range boundaries and writes are distributed across time ranges.

SQL
-- Range-based sharding in PostgreSQL using native partitioning
CREATE TABLE orders (
    order_id    BIGSERIAL,
    customer_id UUID NOT NULL,
    order_date  DATE NOT NULL,
    total_amount DECIMAL(12, 2),
    status      VARCHAR(20),
    PRIMARY KEY (order_id, order_date)
) PARTITION BY RANGE (order_date);

-- Create monthly partitions
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');
CREATE TABLE orders_2025_03 PARTITION OF orders
    FOR VALUES FROM ('2025-03-01') TO ('2025-04-01');
-- ... continue for each month

-- Query automatically routes to the correct partition
-- PostgreSQL prunes partitions that don't match the WHERE clause
SELECT * FROM orders
WHERE order_date BETWEEN '2025-01-15' AND '2025-02-15';
-- Only scans orders_2025_01 and orders_2025_02 partitions

Consistent Hashing

Consistent hashing solves the resharding problem. When you add or remove a shard, consistent hashing minimizes the number of keys that need to move. In simple hash sharding (numShards = N), adding one shard moves approximately 1/N of all data. With consistent hashing using virtual nodes, adding a shard only moves 1/N of the data with minimal redistribution. This is critical for systems that need to grow incrementally without downtime.

graph LR subgraph "Consistent Hash Ring" A["Node A (shard 1)"] --> B["Node B (shard 2)"] B --> C["Node C (shard 3)"] C --> D["Node D (shard 4)"] D --> A end K1["Key: user-123"] -->|hash| A K2["Key: user-456"] -->|hash| B K3["Key: order-789"] -->|hash| C K4["Key: session-abc"] -->|hash| A

Sharding Comparison

StrategyDistributionRange QueriesResharding CostHotspots
Hash-BasedUniform (high cardinality)Spans all shardsHigh (full redistribution)Minimal
Range-BasedDepends on key distributionSingle shard (if aligned)Low (add new range shard)Risk at range boundaries
Consistent HashingUniform (with virtual nodes)Spans all shardsMinimal (only 1/N keys move)Minimal
Directory-BasedAny (lookup table)Depends on mappingModerate (update directory)Depends on mapping
Sharding Pitfall: Cross-shard joins are the most expensive operation in a sharded database. They require fetching data from multiple shards and joining in the application layer, with performance that degrades linearly with the number of shards. Design your schema so that related data lives on the same shard. In a multi-tenant SaaS, shard by tenant_id so all of a tenant's data is co-located. In an e-commerce system, shard by customer_id so orders, payments, and addresses are together.

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

PatternWrite ThroughputRead LatencyConsistencyComplexity
Single Leader + Async FollowersLimited to leaderLow (read from replica)EventualLow
Single Leader + Sync FollowerLimited to leader + RTTLow (read from replica)Strong (zero data loss)Low
Multi-LeaderMultiplied by leadersLow (local reads)Eventual (conflict risk)High
Leaderless (Dynamo-style)Highest (all nodes accept writes)Configurable (ONE/QUORUM/ALL)ConfigurableHighest

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.

stateDiagram-v2 [*] --> Follower Follower --> Candidate: Election timeout Candidate --> Leader: Receives majority votes Candidate --> Follower: Discovers higher term Leader --> Follower: Discovers higher term Candidate --> Candidate: Split vote (timeout)

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

SystemAlgorithmUse Case
etcdRaftKubernetes metadata store, distributed configuration
CockroachDBRaft (per range)Replicated ranges, distributed transactions
YugabyteDBRaft (per tablet)Tablet replication, metadata management
ZooKeeperZAB (ZooKeeper Atomic Broadcast)Leader election, distributed locking
Google SpannerPaxos (per shard) + 2PCMulti-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

PatternWhen to UseTradeoff
Single-column B-treeFrequent equality or range on one columnSimple, low overhead
Composite B-treeMulti-column WHERE clausesColumn order matters for query patterns
Covering (INCLUDE)Frequently queried columns in SELECTLarger index, zero table lookups
Partial (WHERE)Queries filtering on specific valuesSmall index, only useful for matching queries
GINFull-text search, JSONB, arraysWrite overhead, larger size
GiSTGeospatial, range types, fuzzy searchLossy, requires recheck
BRINNaturally 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;
    }
}
Index Maintenance Cost: Every index must be updated on INSERT, UPDATE, and DELETE. A table with 5 indexes incurs 5x the write amplification compared to a heap-only table. Monitor index usage with pg_stat_user_indexes — drop unused indexes to improve write performance. The pg_stat_user_indexes view shows index scans, rows read, and size, allowing data-driven index decisions.

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

ParameterRecommended ValueRationale
Max Pool Size (per instance)max_connections / num_instancesDistribute connections evenly
Min Pool Size5-10Keep warm connections for low latency
Connection Idle Lifetime300 secondsRelease unused connections to the pool
Command Timeout30-60 secondsPrevent hung queries from holding connections
PgBouncer pool_modetransactionBest throughput, compatible with most ORMs
PgBouncer max_client_conn10,000+Accommodate all application instances
PgBouncer Transaction Mode Caveat: Transaction mode releases the database connection after each transaction, which means prepared statements, session variables (SET), and LISTEN/NOTIFY will not work correctly. If your application uses these features, use session mode or switch to PgCat (which supports prepared statement tracking) or pgBouncer 1.21+ which has limited prepared statement support in transaction mode.

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

StrategyBest Key TypeQuery PatternDistributionPruning Benefit
RangeTimestamp, sequential IDRange scans on timeDepends on data patternExcellent for time queries
HashUUID, high-cardinalityPoint lookupsUniformPoint lookups hit 1 partition
ListRegion, status, categoryKnown value setsDepends on value distributionGood for categorical queries
Multi-levelRange + Hash combinedTime + ID queriesUniform within rangeBoth time and ID pruning
Partition Maintenance: Automate partition creation and deletion using pg_partman or a custom cron job. Create future partitions 1-3 months ahead to avoid write failures. Drop old partitions using DETACH PARTITION (not DELETE) for instant removal with zero vacuum overhead. Monitor partition sizes with pg_partition_tree to detect uneven distribution.

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.

graph TB subgraph Application["Application Layer"] API["API Gateway"] end subgraph Primary["Primary Store"] PG["PostgreSQL
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

BenefitCostMitigation
Best tool for each jobMultiple databases to operateManaged services (RDS, ElastiCache, etc.)
Independent scalingData synchronization complexityCDC with Debezium or Kafka Connect
Technology diversityTeam must learn multiple systemsCross-training, internal documentation
Failure isolationMore failure modes to monitorCentralized 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

FeatureCockroachDBYugabyteDBTiDB
Storage EnginePebble (Go)RocksDB / DocDBRocksDB / TiKV
ConsensusRaft per rangeRaft per tabletRaft per region
SQL CompatibilityPostgreSQL wire protocolPostgreSQL wire protocolMySQL wire protocol
Isolation LevelSerializable (default)Serializable (default)Snapshot (default)
Geo-DistributionNative (multi-region tables)Tablespaces with region affinityPlacement rules
Best ForGlobal applications, strong consistencyPostgreSQL migrations, hybrid cloudMySQL migrations, HTAP workloads
MaturityProduction since 2017Production since 2019Production since 2017
When to Choose NewSQL: NewSQL is ideal when you need: horizontal write scaling beyond a single PostgreSQL node, multi-region deployments with low-latency local reads, automatic failover without manual intervention, and the safety of ACID transactions. It is NOT ideal when: you need sub-millisecond latency (network hops between nodes add latency), your workload is mostly reads (a PostgreSQL read replica cluster is simpler and cheaper), or your team lacks distributed systems operational experience.

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

DatabaseTypeBest ForTradeoff
TimescaleDBTime-series (PostgreSQL)Metrics, IoT, existing PG teamsExtension, not standalone
InfluxDBTime-series (purpose-built)DevOps monitoring, high-cardinalityCustom query language (Flux/InfluxQL)
QuestDBTime-series (columnar)Financial data, ultra-fast analyticsNewer, smaller ecosystem
ElasticsearchSearch + analyticsFull-text search, log analyticsNot a primary data store, expensive at scale
Apache PinotReal-time OLAPUser-facing analytics, dashboardsComplex setup, Kafka dependency
ClickHouseColumnar OLAPAnalytics, log analysis, reportingLimited updates, eventual consistency
When to Use Specialized Databases: If your data has a natural time dimension, queries always include a time range, the dataset exceeds 100GB, and you need sub-second analytical queries, a specialized time-series database will outperform PostgreSQL by 10-100x on storage cost and query performance. The compression alone (10:1 to 100:1 for metric data) can save thousands of dollars per month in storage.

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

FeatureRedisMemcached
Data StructuresStrings, hashes, lists, sets, sorted sets, streamsStrings only
PersistenceRDB snapshots + AOF logNo persistence
ClusteringRedis Cluster (hash slots)Client-side sharding
Memory EfficiencyHigher overhead per keyOptimized slab allocator
Pub/SubBuilt-inNo
Lua ScriptingBuilt-in (atomic execution)No
Best ForComplex caching, sessions, rate limiting, leaderboardsSimple key-value caching at scale

Cache Invalidation Strategies

StrategyHow It WorksProsCons
Time-Based (TTL)Cache expires after N secondsSimple, guaranteed freshnessStale until TTL expires
Write-ThroughWrite updates cache + DB simultaneouslyAlways consistentWrite latency includes cache + DB
Write-BehindWrite updates cache, async flush to DBFast writesData loss risk on crash
Event-Driven InvalidationDB change events trigger cache deletionImmediate consistencyRequires event infrastructure
Cache Stampede: When a popular cache entry expires, hundreds of simultaneous requests all detect the miss and hit the database simultaneously. This spike can overwhelm the database. Solutions: (1) Use a distributed lock to serialize cache rebuilds — only one process rebuilds while others wait. (2) Use probabilistic early expiration — a random subset of requests proactively refresh the cache before TTL expires. (3) Use stale-while-revalidate — serve the stale cached value while refreshing asynchronously in the background.

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

PhaseActionRiskMitigation
BeforeBackup the databaseData loss during migrationFull backup + WAL archiving
BeforeTest migration on staging with production data volumeUnexpected duration or failuresUse pg_dump/restore for staging clone
DuringMonitor replication lag and lock wait timesLocks blocking application queriesSet lock_timeout, statement_timeout
DuringUse CONCURRENTLY for index creationTable locks blocking writesAlways use CONCURRENTLY in production
AfterVerify row counts and data integrityMissing or corrupted dataCHECKSUM or hash comparison
AfterRollback plan testedFailed migration, no way to revertKeep old columns until migration confirmed
Never Run Destructive Migrations Without a Backup: DROP COLUMN, TRUNCATE, and data type conversions that truncate data are irreversible. Always create a backup (or at minimum, a table copy) before running destructive operations. Test the rollback procedure before you need it — a migration rollback under pressure is how mistakes compound.

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.

graph LR subgraph Region1["Primary Region (us-east-1)"] PG_Primary["PostgreSQL Primary"] PG_Standby1["PostgreSQL Standby 1"] end subgraph Region2["DR Region (us-west-2)"] PG_Standby2["PostgreSQL Standby 2"] end subgraph Storage["Shared Storage"] S3["S3 WAL Archive"] end PG_Primary -->|Sync Replication| PG_Standby1 PG_Primary -->|Async Replication| PG_Standby2 PG_Primary -->|WAL Streaming| S3 S3 -->|WAL Restore| PG_Standby2

Recovery Time Estimates

Failure ScenarioRecovery MethodEstimated RTOData Loss (RPO)
Primary node crashAutomatic failover to sync standby< 30 secondsZero (sync replication)
Data corruptionPoint-in-time recovery to pre-corruption5-30 minutesDepends on when corruption occurred
Region failureFailover to DR region standby5-15 minutesSeconds (async replication lag)
Accidental DELETE all dataPITR to timestamp before deletion10-60 minutesNone (recover to exact second)
Full region disasterRestore from S3 backup + WAL replay30-120 minutesDepends on WAL backup frequency
Test Your Backups: An untested backup is not a backup. Perform monthly recovery drills: restore a production backup to a separate environment, verify data integrity, measure recovery time, and document any issues. Many organizations discover their backups are corrupted or incomplete only during an actual disaster — by which time it is too late.

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

ComponentSpecificationMonthly CostNotes
Primary instance (r6g.2xlarge)8 vCPU, 64GB RAM, gp3$876Production OLTP workload
Standby instance (r6g.2xlarge)8 vCPU, 64GB RAM, gp3$876Synchronous standby
Read replica (r6g.xlarge)4 vCPU, 32GB RAM, gp3$438Analytical queries, reporting
Storage (gp3, 1TB, 12K IOPS)1TB gp3 with provisioned IOPS$220High IOPS for OLTP
Backup storage (S3)500GB WAL + base backups$1290-day retention
Cross-AZ data transfer~500GB/month$11Replication traffic
RDS Multi-AZ surchargePrimary + Standby~$0Included in instance costs
Total~$2,433

Full Stack Cost: Production System

ComponentMonthly Cost% of Total
PostgreSQL cluster (primary + standby + replica)$2,43330%
Redis cluster (3-node, r6g.large)$6608%
Elasticsearch cluster (3-node, m6g.large)$90011%
Cassandra cluster (3-node, r6i.large)$96012%
PgBouncer instances (2x t3.medium)$601%
Application servers (6x c6g.xlarge)$1,80022%
Data transfer (cross-AZ + internet)$4005%
Monitoring (Prometheus, Grafana, PagerDuty)$5006%
Backup & DR$3504%
Total~$8,063100%

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;
    }
}
Top 3 Cost Optimization Tips: (1) Reserved Instances/Savings Plans — commit to 1-year usage for 30-40% discount on database instances. (2) Right-sizing — use CloudWatch/pg_stat to identify over-provisioned instances and downgrade. Most production databases run at 15-30% average CPU, meaning they're 2-3x over-provisioned. (3) Storage tiering — move old data to cheaper storage (S3, gp2, cold HDD) and use partitioning to keep hot data on fast SSDs.

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

MetricValue
PostgreSQL max practical connections100-200 (use PgBouncer beyond this)
PostgreSQL single-node write throughput10K-50K writes/sec (workload dependent)
Redis latency< 1ms for simple key-value operations
B-tree index depth for 1B rows3-4 levels (~32-64KB I/O)
PostgreSQL page size8KB
PostgreSQL tuple header overhead~23 bytes per row
Raft leader election timeout150-300ms (etcd default)
Cassandra write pathCommit log + Memtable (sequential I/O)
TimescaleDB compression ratio10:1 to 100:1 for metric data
gp3 baseline IOPS3,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

Database Architecture for System Design — Senior+ Guide | Ayodhyya