Skip to main content

Scaling DB Read & Write

Scale DB Read Write

Scale DB Read Write


Why This Matters at Staff Level

Most systems fail not because of bad code, but because the database becomes the bottleneck. A staff engineer needs to reason about which bottleneck they're solving (reads vs. writes vs. both), when each technique applies, and what trade-offs are introduced. Throwing read replicas at a write-heavy system accomplishes nothing. Blindly sharding before exhausting vertical scaling creates operational debt for years.

The framework: Diagnose → Choose the right lever → Understand the cost.


Diagnosing the Bottleneck First

Before proposing any solution, characterize the workload:

SignalLikely Problem
High SELECT latency, low INSERT loadRead bottleneck
High INSERT/UPDATE latency, replication lagWrite bottleneck
Both slow, disk I/O saturatedStorage/throughput bottleneck
Queries fast individually, slow under concurrencyLock contention
Queries always slowMissing indexes or schema design issue

Tools to reach for: EXPLAIN ANALYZE, slow query log, pg_stat_activity, CloudWatch/Datadog DB metrics, replication lag dashboards.

At the staff level, you're expected to name the metric you'd look at, not just say "add a cache."


Scaling Reads

1. Read Replicas

Offload SELECT queries to one or more read-only replicas that stream changes from the primary via replication.

┌─────────────┐
Writes ──────► │ Primary │
└──────┬──────┘
│ replication (async/sync)
┌───────────┼───────────┐
▼ ▼ ▼
Replica 1 Replica 2 Replica 3
▲ ▲ ▲
Reads ──────┴───────────┴───────────┘

When to use: Read-heavy workload (>80% reads), reporting queries, analytics, search.

Trade-offs:

  • Replication lag — async replicas may serve stale data. Acceptable for feeds, not for "read your own write" scenarios.
  • Read-after-write consistency — route writes + the immediately following reads to the primary, or use synchronous replication (higher write latency).
  • Operational cost — each replica doubles storage and increases failover complexity.

Mitigation for lag: Route session reads to primary for N seconds post-write, or use a version token / logical clock check.


2. Caching Layer

Serve reads from an in-memory cache (Redis, Memcached) in front of the DB.

Client → Cache hit? → Return cached value

└─ Cache miss → Query DB → Populate cache → Return

Patterns:

PatternDescriptionBest For
Cache-asideApp checks cache, falls back to DBGeneral purpose, flexible TTL
Read-throughCache fetches from DB on miss automaticallySimpler app code
Write-throughWrite to cache + DB synchronouslyStrong consistency, lower cache miss rate
Write-behindWrite to cache, async flush to DBHigh write throughput, risk of data loss

When to use: Repeated reads of the same data (hot keys), expensive aggregations, session data.

Trade-offs:

  • Cache invalidation is famously hard. Stale reads happen if invalidation is delayed or missed.
  • Cache stampede — many requests hit DB simultaneously on cold start or expiry. Use probabilistic early expiry or request coalescing.
  • Memory cost — cache only what's hot; eviction policies (LRU, LFU) matter at scale.

3. Denormalization & Materialized Views

Pre-compute expensive joins and aggregations, store the result.

-- Instead of joining 5 tables on every read:
CREATE MATERIALIZED VIEW user_feed_summary AS
SELECT u.id, u.name, COUNT(p.id) AS post_count, MAX(p.created_at) AS last_post
FROM users u LEFT JOIN posts p ON p.user_id = u.id
GROUP BY u.id, u.name;

When to use: Dashboards, leaderboards, analytics with complex aggregations that don't need real-time accuracy.

Trade-offs: Data is eventually consistent with the source. Refresh costs write I/O.


4. Indexes

Before any infrastructure change, verify indexes cover the query pattern.

-- Composite index for common filter + sort
CREATE INDEX idx_posts_user_created ON posts(user_id, created_at DESC);

-- Partial index for sparse conditions
CREATE INDEX idx_active_users ON users(email) WHERE active = true;

Staff-level nuance: Indexes speed reads but slow writes (each write updates all covering indexes). Index bloat on high-write tables is real — monitor pg_stat_user_indexes for unused indexes.


Scaling Writes

1. Vertical Scaling (Scale Up)

Increase CPU, RAM, and IOPS on the primary. Simple, no code change.

When it's the right answer: You haven't hit hardware limits yet. Many "scaling problems" at mid-size companies are solved by going from db.t3.medium to db.r6g.4xlarge. Exhaust this before sharding.

Ceiling: AWS RDS max is ~128 vCPU / 1TB RAM. Beyond that, you need a distributed strategy.


2. Write Batching & Buffering

Coalesce many small writes into fewer large writes.

User actions ──► In-memory buffer (Redis / app-level queue)

batch every 100ms


Single bulk INSERT

Techniques:

  • Bulk INSERTINSERT INTO ... VALUES (...), (...), (...) — orders of magnitude faster than N individual inserts.
  • Async writes — acknowledge the user immediately, persist in background (acceptable for non-critical data like analytics events, view counts).
  • Message queue buffer — Kafka/SQS absorbs write spikes, DB consumes at sustainable rate.

Trade-offs: Async writes risk data loss on crash. Not suitable for financial transactions.


3. Write-Optimized Storage Engines

Switch storage engines or DB types for write-heavy patterns:

TechnologyWrite OptimizationUse Case
Cassandra / ScyllaDBLSM tree — writes go to memtable, flushed to SSTablesTime-series, append-heavy, high write throughput
ClickHouseColumn-oriented, batch inserts, merge-treeAnalytics, event logs
PostgreSQL + BRIN indexBlock Range indexes for monotonically increasing dataTimestamped event tables
TimescaleDBHypertables, automatic partitioning by timeMetrics, IoT

4. Partitioning (Within One DB)

Split a large table into smaller physical partitions without changing the application query logic.

-- Range partition by month
CREATE TABLE events (
id BIGSERIAL,
user_id BIGINT,
created_at TIMESTAMPTZ
) PARTITION BY RANGE (created_at);

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

Benefits: Partition pruning speeds queries, old partitions can be dropped instantly (vs. DELETE on millions of rows), maintenance operations (VACUUM, ANALYZE) run per-partition.

When to use: Time-series data, large tables with natural range keys (date, region, tenant).


5. Sharding (Horizontal Scaling)

Distribute data across multiple independent DB instances, each owning a subset of the keyspace.

Shard key: user_id % 4

user_id 0,4,8... → Shard 0 (primary + replicas)
user_id 1,5,9... → Shard 1 (primary + replicas)
user_id 2,6,10... → Shard 2 (primary + replicas)
user_id 3,7,11... → Shard 3 (primary + replicas)

Shard key selection — the most critical decision:

Shard KeyRisk
user_id (hash)Hot shard if a few users have massive data
created_atHot shard — all current writes go to latest shard
tenant_idGood for multi-tenant SaaS; uneven if tenants differ in size
Consistent hashEven distribution, easier rebalancing when adding shards

Cross-shard queries: JOINs across shards require scatter-gather (fan out to all shards, merge results in app). Avoid them by designing queries to stay within one shard — this forces you to choose the shard key based on your most common query pattern.

Resharding: Adding shards after the fact is painful. Plan capacity with 2–4x headroom.

When to use: After exhausting vertical scaling, read replicas, and caching. Sharding introduces significant operational complexity — treat it as a last resort for write scaling.


6. CQRS (Command Query Responsibility Segregation)

Maintain separate models optimized for reads and writes.

Write path: Command → Write Model (normalized, ACID) → Event
Read path: Event → Async projection → Read Model (denormalized, optimized)

Client queries read model

When to use: Systems where read and write patterns are fundamentally different and at odds (e.g., an order management system where writes need strict ACID, reads need aggregated summaries across joins).

Trade-offs: Eventual consistency between write and read models. Additional infrastructure to maintain projections.


Decision Framework


Common Interview Follow-ups

"How do you handle read-after-write consistency with replicas?" Route writes and the user's subsequent reads to the primary for a short window (sticky primary reads), or use synchronous replication for critical paths while keeping async for everything else.

"How do you avoid a hot shard?" Use consistent hashing with virtual nodes so load distributes evenly. Add a salt/secondary key if a small number of entities are disproportionately large.

"What's the difference between partitioning and sharding?" Partitioning splits a table within a single DB instance — the DB engine manages it, queries are transparent. Sharding splits data across multiple DB instances — the application (or a proxy like Vitess/Citus) routes queries. Partitioning is a schema optimization; sharding is a distributed systems problem.

"When would you NOT add a cache?" Write-heavy data that changes on every read (real-time counters, live leaderboards). The invalidation overhead exceeds the cache benefit. Use atomic DB counters or a purpose-built store (Redis sorted sets) instead.

"How do you monitor if your scaling strategy is working?" Track: DB CPU %, I/O wait %, replication lag, slow query rate, p99 query latency, connection pool saturation, cache hit rate. Alert thresholds, not just averages.