Definition
- Step 1: Optimize – Fix queries, add indexes, refresh statistics. Zero infrastructure cost, often the entire fix.
- Step 2: Scale up – More CPU, memory, faster storage. Zero code changes, large headroom.
- Step 3: Read replicas – Offload reads to replicas, freeing the primary for writes.
- Step 4: Cache – Serve hot data from Redis in microseconds, removing load from the database.
- Step 5: CQRS – Separate read and write models, each scaled independently.
- Step 6: Shard – Split data across nodes. Last resort because it breaks joins, transactions, and is effectively a one-way door.
When the National Bank branch gets crowded, you first make the clerks work smarter (fix slow processes and indexes), then give them a faster desk (bigger machine), then add read-only clerks for balance enquiries (replicas), then keep a notepad of common answers (cache), then separate the write counter from the read counter (CQRS). Only when none of that is enough do you split customers across separate branches (sharding), because once you do, answering 'total across all branches' and moving money between branches becomes genuinely hard.
Understanding the Concepts
Why does the order matter?
Each step is dramatically cheaper and less risky than the next, and most systems never need the last step at all. Applying them in order ensures you are not paying the complexity cost of sharding when a simple index fix would have solved the problem.
Scaling steps by cost and complexity:
Step | Infrastructure Cost | Code Change | Reversible? | Risk
-------|--------------------|--------------|--------------|---------
Index | Zero | None | Yes | None
Scale | Hardware cost | None | Yes | Low
Replica | Moderate | Routing | Yes | Low
Cache | Moderate | Cache logic | Yes | Medium
CQRS | Significant | Architecture | Hard | Medium
Shard | Very high | Everything | No | High
Step 1: Optimize queries and indexes
This is free in infrastructure terms and is often the entire fix. A missing index turning a sequential scan into an index seek can cut a query a thousandfold.
-- Before: sequential scan on 10 million rows
EXPLAIN ANALYZE
SELECT * FROM transactions WHERE account_id = 'SAV2001';
-- Seq Scan: 8000ms
-- Fix: add index
CREATE INDEX idx_txn_account ON transactions(account_id);
-- After: index scan
-- Index Scan: 0.5ms (16,000x faster)
package com.bank.maintenance;
import java.sql.Connection;
import java.sql.SQLException;
import java.sql.Statement;
import javax.sql.DataSource;
public class IndexOptimizationService {
private final DataSource dataSource;
public IndexOptimizationService(DataSource dataSource) {
this.dataSource = dataSource;
}
public void addMissingIndex() throws SQLException {
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
stmt.execute("CREATE INDEX CONCURRENTLY idx_txn_acct_date "
+ "ON transactions(account_id, created_at)");
System.out.println("Index created without locking the table");
}
}
}
Step 2: Scale up vertically
More CPU, memory, and faster NVMe storage on one machine. Modern hardware is enormous: a single node can handle tens of thousands of transactions per second. Zero code changes required.
Vertical scaling at National Bank of India: Before: 4 cores, 16 GB RAM, HDD → 2,000 tps, working set does not fit in memory After: 16 cores, 128 GB RAM, NVMe SSD → 20,000 tps, entire working set cached → Zero code changes, zero architecture changes Limit: there is a biggest machine, and cost rises steeply at the top
Step 3: Add read replicas
Most OLTP workloads are read-heavy. Sending reads to replicas frees the primary to handle writes. Replicas replay the primary's WAL, so they are straightforward to set up.
package com.bank.config;
import javax.sql.DataSource;
public class ReadWriteRoutingConfig {
private final DataSource primaryDataSource; // For writes
private final DataSource replicaDataSource; // For reads
public ReadWriteRoutingConfig(DataSource primary, DataSource replica) {
this.primaryDataSource = primary;
this.replicaDataSource = replica;
}
// Write operations go to primary
public DataSource getWriteDataSource() {
return primaryDataSource;
}
// Read operations go to replica
// Caveat: replication lag means reads may be slightly stale
public DataSource getReadDataSource() {
return replicaDataSource;
}
}
Read replica topology: ┌─────────┐ WAL stream ┌──────────┐ │ Primary │────────────────────→│ Replica 1│ (balance checks) │ (writes)│────────────────────→│ Replica 2│ (reports) └─────────┘ └──────────┘
Step 4: Add a cache
A cache like Redis serves hot read-mostly data from memory in microseconds, removing enormous load from the database.
package com.bank.cache;
import redis.clients.jedis.Jedis;
import java.math.BigDecimal;
public class BalanceCacheService {
private final Jedis redis;
public BalanceCacheService(Jedis redis) {
this.redis = redis;
}
public BigDecimal getBalance(String accountId) {
String cached = redis.get("balance:" + accountId);
if (cached != null) {
return new BigDecimal(cached); // Cache hit: microseconds
}
return null; // Cache miss: fall through to database
}
public void cacheBalance(String accountId, BigDecimal balance) {
redis.setex("balance:" + accountId, 60, balance.toPlainString());
}
public void invalidateBalance(String accountId) {
redis.del("balance:" + accountId); // Invalidate on write
}
}
Step 5: Apply CQRS
CQRS splits the write model from one or more read models, each shaped and scaled for its purpose. The read side can be materialized from events and served from a different store.
package com.bank.cqrs;
import java.math.BigDecimal;
// Command side: writes to PostgreSQL (ACID)
public class TransferCommandHandler {
public void handle(String from, String to, BigDecimal amount) {
// Debit and credit in a single ACID transaction
// Publish event: TransferCompleted
}
}
// Query side: reads from a denormalized read store
public class AccountQueryHandler {
public BigDecimal getBalance(String accountId) {
// Read from a denormalized view or separate read database
// Optimized for the specific query pattern
return BigDecimal.ZERO;
}
}
Why is sharding the last resort?
Sharding permanently changes the data model and introduces distributed-systems challenges that do not exist in a single database.
package com.bank.sharding;
public class ShardingChallenges {
// 1. Cross-shard joins are gone
// "Show total balance across all accounts" now requires
// fan-out to every shard and application-side aggregation
// 2. Cross-shard transactions need 2PC or Sagas
// Transfer between accounts on different shards:
// BEGIN on Shard A: debit SAV2001
// BEGIN on Shard B: credit SAV2002
// Both must commit or both roll back → 2PC or Saga
// 3. Choosing a bad shard key creates hot spots
// Shard by city? Mumbai gets 40% of traffic
// Re-sharding moves enormous amounts of data
// 4. Global unique constraints and auto-increment IDs break
// Need distributed ID generation (Snowflake, UUIDv7)
// 5. Schema migrations must be coordinated across all shards
// 6. Operationally: N databases to monitor, backup, and maintain
// 7. It is effectively a one-way door: un-sharding is a major project
}
What is the key nuance about read vs write scaling?
The earlier steps mainly scale reads, while sharding is the main way to scale writes. If the bottleneck is purely read volume, replicas and cache solve it without sharding.
Scaling dimension:
Step | Scales Reads | Scales Writes
Index | Yes | Yes (less I/O)
Scale up | Yes | Yes
Replica | Yes | No (primary only)
Cache | Yes | No
CQRS | Yes | Indirectly
Shard | Yes | YES (main purpose)
Sharding becomes necessary specifically when:
- Write throughput exceeds a single node
- Raw data volume exceeds a single node's storage
- NOT when reads are the bottleneck (replicas + cache solve that)
What should a senior engineer know?
A senior engineer should resist the urge to shard prematurely. Most systems never need it. The right time to shard is when you have genuinely hit the write-throughput or data-size ceiling of a single optimized, replicated, cached node.
Senior decision framework: "The database is slow" → Is it a missing index? Fix it. → Is the machine undersized? Scale up. → Are reads overwhelming the primary? Add replicas. → Is the same data read repeatedly? Add cache. → Do reads and writes have conflicting needs? CQRS. → Is WRITE throughput beyond one node? NOW shard. → Is DATA SIZE beyond one node? NOW shard.
Visual Overview
┌────────────────────────────────────────────────────────────┐ │ Database Scaling Steps (Cheapest First) │ ├────────────────────────────────────────────────────────────┤ │ │ │ 1. Optimize Queries/Indexes [FREE] │ │ └→ Missing index? 16,000x faster │ │ │ │ 2. Scale Up Vertically [$$] │ │ └→ More CPU, RAM, SSD. Zero code change │ │ │ │ 3. Add Read Replicas [$$$] │ │ └→ Offload reads. Primary handles writes only │ │ │ │ 4. Add Cache (Redis) [$$$] │ │ └→ Hot data in microseconds. Less DB load │ │ │ │ 5. CQRS [$$$$] │ │ └→ Separate read/write models. Independent scaling │ │ │ │ 6. Shard [$$$$$] ← LAST RESORT │ │ └→ Breaks joins, transactions, adds ops complexity │ │ └→ ONE-WAY DOOR: un-sharding is a major project │ │ │ │ Most systems never reach step 6. │ └────────────────────────────────────────────────────────────┘
Key Takeaway
The correct order of database scaling steps goes from cheapest and least disruptive to most expensive and most disruptive: optimize queries and indexes, scale up vertically, add read replicas, add a cache, apply CQRS, and only then shard. Each step is dramatically cheaper and safer than the next, and most systems never need the last step.
Optimizing indexes is free in infrastructure terms and is often the entire fix. Scaling up vertically needs zero code changes. Read replicas offload the read-heavy banking workload. A cache serves hot data in microseconds. CQRS lets read and write models scale independently.
Sharding is the last resort for concrete reasons. It permanently splits data across nodes, breaking joins into scatter-gather fan-outs, forcing cross-shard transactions into two-phase commit or Sagas, breaking auto-increment IDs, and requiring coordinated schema migrations across every shard. It is effectively a one-way door: un-sharding is a major project. The key nuance is that the earlier steps mainly scale reads, while sharding scales writes. If the bottleneck is read volume, replicas and cache solve it without sharding.
At the National Bank of India, an engineer wanted to shard the moment the database slowed, but the real cause was a missing index and a read-heavy balance-check load. An index plus read replicas and a Redis cache solved it completely. The team did not shard for years until write throughput genuinely exceeded what a single optimized node could handle.
Remember one sentence: optimize, scale up, replicate, cache, CQRS, then shard: because sharding is a one-way door that breaks joins and transactions, so exhaust every cheaper option first.
---