Database Scaling Patterns in Practice
The database is almost always the first bottleneck you'll hit. Unlike stateless app servers that scale with a click, databases hold state — and state is hard to distribute. This lesson covers the practical patterns used in production, ordered from simplest to most complex.
Recap: Database Scaling Through the Scale Cube
- X-Axis (Read Replicas): Clone the database — one primary handles writes, replicas handle reads
- Y-Axis (Functional Partitioning): Separate databases per service — orders DB, users DB, products DB
- Z-Axis (Sharding): Split one table's data across multiple databases by key (user_id, region)
Connection Pooling: The First Thing You Need
Why Every Production DB Needs a Connection Pooler
Database connections are expensive: each one consumes ~10MB of memory in PostgreSQL. A server with 16GB RAM can handle maybe 500 connections — but a busy app server might try to open thousands.
- PgBouncer (PostgreSQL): Sits between app and DB, maintains a pool of reusable connections. One PgBouncer can multiplex 10,000 app connections into 100 real DB connections.
- ProxySQL (MySQL): Connection pooler + query router. Can also do read/write splitting.
- Application-level pooling: Libraries like HikariCP (Java), SQLAlchemy pool (Python) — good but don't help across multiple app instances.
Rule of thumb: Max DB connections = (CPU cores × 2) + effective_spindle_count. For a 4-core server, that's ~10 connections doing useful work. More connections = more context switching = slower.
Read/Write Splitting
Most applications are 80-95% reads. By routing reads to replicas and writes to the primary, you can scale read capacity almost linearly.
Denormalization: Trading Storage for Speed
Pre-Computing Joins
Normalized data (3NF) is great for writes and consistency, but reads often require expensive joins across many tables. Denormalization stores pre-joined data to eliminate joins at read time.
- Example: Instead of joining
orders+users+productson every page view, store aorder_summarytable with user_name and product_name already included. - Trade-off: Storage increases, writes become more complex (must update multiple places), but reads go from 50ms → 2ms.
- When to use: Read-heavy workloads where the same join is done thousands of times per second.
Materialized Views
Database-Level Caching of Expensive Queries
A materialized view is a saved query result that the database stores as a table. Unlike regular views, it doesn't re-execute the query each time.
- PostgreSQL:
CREATE MATERIALIZED VIEW monthly_stats AS SELECT ... ; REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_stats; - Refresh strategies: On schedule (every 5 min), on trigger (after writes), or manually
- Advantage over app-level cache: The DB manages it, it's queryable with SQL, and indexes can be added
- Limitation: Data can be stale between refreshes
CQRS Preview: Separate Read and Write Models
Command Query Responsibility Segregation
The ultimate evolution of read/write splitting — use completely different data models for reads vs writes:
- Write model: Normalized, optimized for consistency and transactions (e.g., PostgreSQL)
- Read model: Denormalized, optimized for queries (e.g., Elasticsearch, Redis, or a pre-computed view)
- Sync mechanism: Events flow from write side to read side (event sourcing, CDC, or application-level)
CQRS is powerful but complex. Use it when your read and write patterns are fundamentally different — not as a default.
🏢 Real-World: How Slack Scaled MySQL
Slack's journey through database scaling patterns:
- Connection pooling: Introduced ProxySQL to manage thousands of connections from their Go services to MySQL. Reduced connection count from 10K to 200 per database.
- Read replicas: Added read replicas for channel history queries (which dominate traffic). Tolerable staleness — you might not see the very latest message for 100ms.
- Sharding by workspace: Each Slack workspace's data lives on one shard. New workspaces go to the least-loaded shard. Large workspaces (enterprise customers) get dedicated shards.
- Vitess: Eventually adopted Vitess (YouTube's MySQL sharding middleware) to manage shard routing, schema migrations, and rebalancing automatically.
Key insight: They applied these in order of increasing complexity, only moving to the next when the previous wasn't enough.
🏢 Real-World: How Pinterest Uses Denormalization
Pinterest's home feed must render fast — but the data is deeply relational (pins → boards → users → interests → followers).
- Problem: Generating a personalized feed required joins across 5+ tables with billions of rows. Too slow for real-time.
- Solution: Pre-compute each user's feed into a denormalized
user_feedtable. When someone pins something, fan out the update to all followers' feed tables. - Trade-off: Massive write amplification (one pin → thousands of feed writes), but reads are O(1) — just scan user's pre-built feed.
- Result: Home feed renders in <100ms regardless of how many people you follow.
Interactive: Build a Database Scaling Strategy
Toggle scaling techniques to see how they affect your database's throughput and latency: