Info

Anchored in notes from Data Models and Query Languages and pragmatic trade-offs teams make when shipping features fast.

Senior engineers are often asked: “Should we go relational, document, or graph?” The right answer optimizes for iteration speed today while leaving options open for tomorrow. Here’s a decision framework.

1) Start from access patterns, not ideology

  • Enumerate the top 10 read/write paths and their SLAs. Prototype queries first.
  • If you’re rendering whole aggregates (profile, order, report) → document/locality is your friend.
  • If you’re joining across many-to-many, enforcing invariants, and ad-hoc querying → relational wins.
  • If relationships are the product (recommendations, network paths) → graph may be the simplest.

2) Locality vs normalization

  • Document models maximize storage locality; fewer roundtrips and indexes for aggregate reads.
  • Normalized relational schemas keep writes simple, constraints enforceable, and queries composable.
  • Hybridize intentionally: denormalize read models (materialized views) fed from normalized writes.

3) Evolution and schema

  • “Schemaless” is actually schema-on-read. You still need contracts; they just move.
  • In fast-changing domains, prefer document models for rapid iteration; codify validation at the edges.
  • When the domain stabilizes, migrate critical paths to relational for constraints and rich querying.

4) Performance levers before you switch stores

  • Indexes (B-Tree/GIN) tuned to queries before changing models.
  • Materialized views for heavy aggregations; schedule refreshes aligned to business needs.
  • Caching layers for hot reads; invalidate on writes using domain keys.

5) Scaling patterns and when to use them

  • Vertical scale until it hurts. Simpler ops, fewer moving parts.
  • Replication for read scaling; mind consistency choices (sync → latency; async → staleness).
  • Sharding only when necessary; it’s the last resort due to operational and query complexity.

6) Migration playbook

  • Keep write path stable; introduce parallel read models first.
  • Dual-write with strong observability; verify parity before cutover.
  • Backfills as idempotent jobs; throttle and checkpoint.

7) A staff engineer’s rubric

  • Does the chosen model minimize cross-aggregate transactions for the hot paths?
  • Can we enforce the key invariants natively (constraints, unique indexes)? If not, what guardrails replace them?
  • Is there a clear evolution path (validation, backfills, dual reads) for the next 6–12 months?
  • Are we reaching for sharding too early? Can indexing/denormalization buy us time?

TL;DR

Model around access patterns and product velocity. Use document for locality and speed of change; relational for constraints and complex queries; graph when relationships are first-class. Delay sharding; invest in indexes, materialized views, and caches first.


Technical appendix

A. B-Tree vs LSM-Tree

  • B-Tree: point/range reads O(log n), in-place updates, good read latency; write amplification low, random writes.
  • LSM-Tree: sequential appends to immutable segments + compaction; excellent write throughput, higher read amp; good fit for write-heavy and SSDs.

Compaction strategies:

  • Leveled (RocksDB default): lower write amp, more read amp; predictable space.
  • Tiered: fewer compactions, higher space amp.

Bloom filters reduce disk lookups; false positive rate (\approx (1 - e^{-kn/m})^k) where m = bits, n = items, k = hashes.

B. Index choices and examples (PostgreSQL)

-- JSONB containment and GIN
create index idx_user_profile_gin on users using gin (profile jsonb_path_ops);
 
-- Composite B-Tree to cover query and order
create index idx_orders_customer_created on orders (customer_id, created_at desc);
 
-- Partial index for hot subset
create index idx_orders_open on orders (status) where status = 'open';

C. Materialized views and refresh

create materialized view mv_daily_sales as
select date_trunc('day', created_at) as day,
       sum(amount) as gross
from payments
group by 1;
 
-- Concurrent refresh keeps view available; pair with incremental staging table if needed
refresh materialized view concurrently mv_daily_sales;

For near-real-time aggregates, prefer a streaming read model (Debezium → Kafka → consumer → table) over frequent full refreshes.

D. Replication and consistency knobs

  • Synchronous replication (synchronous_commit = on) trades latency for durability across replicas.
  • Async replication yields lower latency with potential staleness on failover.
  • Reads: choose follower reads for staleness-tolerant paths; leader reads where monotonicity is required.

E. Sharding notes

  • Consistent hashing over (N) virtual nodes smooths rebalancing; prefer power-of-two shard counts.
  • Co-locate hot aggregates; avoid fan-out cross-shard transactions.
class ConsistentHash:
    def __init__(self, nodes, vnodes=256):
        self.ring = []
        for n in nodes:
            for v in range(vnodes):
                self.ring.append((hash(f"{n}-{v}") & 0xffffffff, n))
        self.ring.sort()
    def pick(self, key):
        h = hash(key) & 0xffffffff
        i = bisect.bisect_left(self.ring, (h,))
        return self.ring[i % len(self.ring)][1]