Definition
- Vertical scaling – a bigger machine with more CPU, memory, and faster disks; cheap but has a ceiling.
- Read replicas – offload read traffic from the primary; powerful for read-heavy workloads like balance checks.
- Caching – Redis or Memcached in front of hot read-mostly data; dramatically reduces database load.
- CQRS – Command Query Responsibility Segregation; separate write model from optimized read models.
- Sharding – splitting data horizontally across many database nodes by a shard key.
- Shard key – the column used to determine which shard holds a row; must spread load evenly and keep related data together.
As the National Bank of India grows from one branch to the whole country, you cannot keep one ledger clerk serving everyone. You give the busy clerk a faster desk (vertical scale), add copy-reading clerks for people who only want to read balances (replicas), keep a notepad of popular answers (cache), and eventually split customers across many branches by account number (sharding). The hard parts are making sure every copy agrees, deciding how to split customers fairly, and answering questions that span all branches.
Understanding the Concepts
What is the layered approach to database scaling?
A scalable database design is not one trick; it is a sequence of layers applied in order of cost and complexity. You start simple and add complexity only when measurements prove you need it.
Scaling Layers (in order): Layer 1: Schema + Indexes ──► Fix slow queries first Layer 2: Vertical Scaling ──► Bigger machine Layer 3: Read Replicas ──► Offload reads Layer 4: Caching (Redis) ──► Hot data in memory Layer 5: CQRS ──► Separate read/write models Layer 6: Sharding ──► Split data across nodes
Why start with schema and indexes?
No amount of hardware rescues a design that does full table scans. The first step is always query optimization and proper indexing.
-- Before scaling: fix the slow query
-- Without index: full table scan on 100M rows
SELECT * FROM transactions
WHERE account_id = 'SAV2001'
AND txn_date >= '2024-01-01';
-- Seq Scan: 45 seconds
-- Add a composite index
CREATE INDEX idx_txn_account_date
ON transactions (account_id, txn_date);
-- With index: milliseconds
-- Index Scan: 3 ms
How do read replicas help?
Read replicas offload read traffic from the primary. Most banking workloads are read-heavy: balance checks, statements, and reports vastly outnumber writes.
package com.bank.config;
import javax.sql.DataSource;
public class ScalableDataSourceConfig {
private final DataSource primaryDs; // writes
private final DataSource replicaDs; // reads
public ScalableDataSourceConfig(DataSource primary, DataSource replica) {
this.primaryDs = primary;
this.replicaDs = replica;
}
public DataSource forWrite() { return primaryDs; }
public DataSource forRead() { return replicaDs; }
}
How does caching reduce database load?
A cache like Redis holds hot, read-mostly data in memory. For a balance that is checked hundreds of times per second but changes only on transactions, caching is very effective.
package com.bank.service;
import java.math.BigDecimal;
import java.sql.*;
import java.util.logging.Logger;
public class CachedBalanceService {
private static final Logger LOG = Logger.getLogger(
CachedBalanceService.class.getName());
private final Connection dbConnection;
// Simulated Redis cache
private final java.util.Map<String, BigDecimal> cache =
new java.util.concurrent.ConcurrentHashMap<>();
public CachedBalanceService(Connection dbConnection) {
this.dbConnection = dbConnection;
}
public BigDecimal getBalance(String accountId) throws SQLException {
// Check cache first
BigDecimal cached = cache.get(accountId);
if (cached != null) {
LOG.fine("Cache hit for " + accountId);
return cached;
}
// Cache miss: read from database
String sql = "SELECT balance FROM accounts WHERE account_id = ?";
try (PreparedStatement ps = dbConnection.prepareStatement(sql)) {
ps.setString(1, accountId);
try (ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
BigDecimal balance = rs.getBigDecimal("balance");
cache.put(accountId, balance); // cache-aside
return balance;
}
}
}
throw new SQLException("Account not found: " + accountId);
}
public void invalidateOnWrite(String accountId) {
cache.remove(accountId); // invalidate on write
}
}
How does sharding work?
Sharding splits data horizontally across many database nodes by a shard key. Each shard holds a subset of the data.
Sharding by account_id (hash-based): account_id hash shard ─────────── ──── ───── SAV2001 0x3A Shard 1 SAV2002 0x7F Shard 2 SAV2003 0x12 Shard 1 CUR3001 0xB4 Shard 3 Shard 1: SAV2001, SAV2003 Shard 2: SAV2002 Shard 3: CUR3001
-- Single-shard query (fast): account_id is the shard key
SELECT balance FROM accounts WHERE account_id = 'SAV2001';
-- Routed to Shard 1 only
-- Cross-shard query (slow): no shard key in WHERE
SELECT SUM(balance) FROM accounts WHERE status = 'ACTIVE';
-- Must fan out to ALL shards and merge results
What makes a good shard key?
A good shard key spreads data and load evenly and keeps related rows together. A bad key creates hot spots.
Good shard key: account_id
- Spreads customers evenly across shards
- One customer's data lives on one shard
- Common query (balance by account) hits one shard
Bad shard key: created_at (timestamp)
- All new inserts go to one shard (latest timestamp)
- Hot spot: one shard handles all writes
- Other shards are idle
How do you handle cross-shard queries?
A query like "sum deposits across all customers" must fan out to every shard, each computes its partial result, and a coordinator merges them.
package com.bank.service;
import java.math.BigDecimal;
import java.sql.*;
import java.util.List;
import java.util.logging.Logger;
public class CrossShardQueryService {
private static final Logger LOG = Logger.getLogger(
CrossShardQueryService.class.getName());
private final List<Connection> shardConnections;
public CrossShardQueryService(List<Connection> shardConnections) {
this.shardConnections = shardConnections;
}
public BigDecimal totalDepositsAcrossShards() throws SQLException {
BigDecimal total = BigDecimal.ZERO;
String sql = "SELECT SUM(balance) AS shard_total FROM accounts "
+ "WHERE status = 'ACTIVE'";
for (Connection conn : shardConnections) {
try (PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
BigDecimal shardTotal = rs.getBigDecimal("shard_total");
if (shardTotal != null) {
total = total.add(shardTotal);
}
}
}
}
LOG.info("Total deposits across " + shardConnections.size()
+ " shards: " + total);
return total;
}
}
How do you handle distributed transactions across shards?
Global transactions across shards need the saga pattern with compensating actions rather than expensive two-phase commit. Unique IDs need a scheme like UUIDv7 or Snowflake IDs.
package com.bank.model;
import java.util.UUID;
import java.time.Instant;
public class DistributedIdGenerator {
/**
* Generate a time-ordered UUID (UUIDv7-like)
* that is globally unique across shards.
*/
public static String generateId() {
long timestamp = Instant.now().toEpochMilli();
String uuid = UUID.randomUUID().toString();
// Prefix with timestamp for ordering
return String.format("%013d-%s", timestamp, uuid.substring(0, 8));
}
}
What should a senior engineer know?
A senior should add each layer only when evidence demands it, because every layer adds operational burden. Sharding is the last resort, not the first optimization.
Senior Scaling Decision Tree: Is the query slow? ──► Fix schema / add index Is the CPU maxed? ──► Vertical scale (bigger machine) Are reads saturating? ──► Add read replicas Is the DB hit rate too high? ──► Add Redis cache Is the read model different? ──► CQRS (separate read store) Is the write volume too high?──► Shard (last resort) Each layer adds complexity. Measure before you act.
Visual Overview
┌──────────────────────────────────────────────────────┐ │ Database Scaling Layers │ ├──────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────────────────────┐ │ │ │ Layer 1: Schema + Indexes │ ◄─ Always first│ │ └───────────────┬─────────────────┘ │ │ │ │ │ ┌───────────────▼─────────────────┐ │ │ │ Layer 2: Vertical Scale │ ◄─ Cheap │ │ └───────────────┬─────────────────┘ │ │ │ │ │ ┌───────────────▼─────────────────┐ │ │ │ Layer 3: Read Replicas │ ◄─ Read scale │ │ └───────────────┬─────────────────┘ │ │ │ │ │ ┌───────────────▼─────────────────┐ │ │ │ Layer 4: Cache (Redis) │ ◄─ Hot data │ │ └───────────────┬─────────────────┘ │ │ │ │ │ ┌───────────────▼─────────────────┐ │ │ │ Layer 5: CQRS │ ◄─ Read/write │ │ │ (separate read/write models) │ separation │ │ └───────────────┬─────────────────┘ │ │ │ │ │ ┌───────────────▼─────────────────┐ │ │ │ Layer 6: Sharding │ ◄─ Last resort │ │ │ (horizontal data partitioning) │ │ │ └─────────────────────────────────┘ │ └──────────────────────────────────────────────────────┘
Key Takeaway
A scalable database design is not one trick; it is a sequence of layers applied in order of cost and complexity. The foundation is a sound schema and good indexes, because no hardware rescues full table scans. Then vertical scaling buys time cheaply. Read replicas offload the read-heavy traffic that dominates banking workloads. A Redis cache cuts database load for hot read-mostly data. CQRS separates write and read models for independent scaling. Sharding is the last layer because it introduces the hardest problems.
The three hard challenges with sharding are consistency (replicas and caches create multiple copies that can disagree), shard-key choice (a good key like account_id spreads load evenly, while a bad key like timestamp creates hot spots), and cross-shard queries (a bank-wide total must fan out to every shard and merge, which is slow). Distributed transactions use sagas with compensating actions, and unique IDs need UUIDv7 or Snowflake schemes.
At the National Bank of India, as the customer base grew toward hundreds of millions, Pankaj sharded accounts by account_id using consistent hashing so each customer lived on one shard and the common balance query touched a single node. The rare bank-wide total fanned out across shards, and that reporting was moved into an analytics warehouse fed by CDC.
The guiding principle is to add each layer only when evidence demands it, because every layer adds operational burden. Measure before you act.
Remember one sentence: scale in layers: tune, replicate, cache, then shard, and plan up front for consistency, shard-key choice, and cross-shard queries.
---