Designing Databases for High Read Throughput
Fast recall
Quick revision
Core answer
Whether you can reason systematically about read scaling and identify the right tool for each layer of the stack rather than jumping straight to database hardware upgrades.
Upgrading the database server as the first response to read performance issues, rather than introducing caching or read replicas which are cheaper and more effective.
Describe the cache-aside pattern with TTL and cache invalidation strategy, and discuss how read replicas introduce replication lag that affects read-your-writes consistency.
Interview-ready guide
Interview answer
High read throughput is a layered problem — the solution is identifying which layer can absorb the most load before a request reaches the database, then applying the right technique at each layer.
Layer 1 — Cache before the database
- The cheapest read is one that never touches the database — a response cached in Redis with a 60-second TTL serves thousands of requests from memory, which means the database sees one query per minute instead of thousands
- Cache-aside is the most common pattern: check Redis first, return if found, otherwise query the database, populate the cache, and return; the challenge is invalidation when the underlying data changes
Layer 2 — Read replicas
- Add PostgreSQL or MySQL read replicas to distribute SELECT queries — writes go to the primary, reads route to replicas via a load balancer or the application's data source configuration
- Replicas introduce replication lag — a user who just wrote a record may not see it on the first read from a replica; solve this with read-your-writes: route reads to the primary for a short window after a write
Layer 3 — Query and schema optimization
- Before adding hardware, check the execution plan — a missing index on a frequently filtered column can cause a full table scan on millions of rows; the right index eliminates the problem without any infrastructure change
- Denormalizing pre-joins frequently read tables trades storage and write complexity for read speed on hot paths
Layer 4 — Connection pooling
- Connection pooling (HikariCP, PgBouncer) prevents the overhead of creating a new database connection per request — at scale, connection establishment is a bottleneck before the database engine becomes one
Always profile before adding layers. A missing index often provides more improvement than adding a cache tier or a new replica.
Designing Databases for High Read Throughput
Direct Answer
Design for high read throughput by layering caching (application-level, distributed cache, HTTP cache), adding read replicas to distribute load, denormalizing hot query paths to avoid expensive joins, using materialized views or precomputed aggregates, and ensuring proper indexing. The goal is to reduce work per read and distribute reads across more hardware.
Code Example
A read-heavy system (100k+ reads/sec) is primarily a caching and fan-out problem, not a query-tuning problem. The database is the system of record; most reads should never touch it.
@Service
public class ProductService {
private final ProductRepository repo; // Postgres (primary + read replicas)
private final RedisTemplate<String, Product> redis;
private final Cache<Long, Product> local = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(30)) // short TTL → freshness + locality
.build();
// ---- Multi-layer cache-aside read path ----
public Product findById(Long id) {
// L1: JVM cache (hot product → nanoseconds, no network)
Product cached = local.getIfPresent(id);
if (cached != null) return cached;
// L2: Redis (shared across pods → sub-millisecond)
cached = redis.opsForValue().get("product:" + id);
if (cached != null) { local.put(id, cached); return cached; }
// L3: read-replica DB (milliseconds)
Product fresh = repo.findById(id).orElseThrow();
redis.opsForValue().set("product:" + id, fresh, Duration.ofMinutes(5));
local.put(id, fresh);
return fresh;
}
// ---- Write path invalidates both caches ----
@Transactional
public Product update(Long id, ProductDto dto) {
Product saved = repo.save(dto.toEntity(id)); // writes to PRIMARY only
redis.delete("product:" + id); // invalidate L2
local.invalidate(id); // invalidate L1 on this pod
eventPublisher.publish(new ProductChanged(id)); // other pods evict via pub/sub
return saved;
}
}
# Read-replica routing — writes go to primary, reads to replica
spring:
datasource:
primary: { jdbc-url: jdbc:postgresql://primary:5432/app }
replica: { jdbc-url: jdbc:postgresql://replica:5432/app, read-only: true }
- Layer caches L1 (local, 10ms TTL, 10k entries) + L2 (Redis, 5-min TTL, shared) + L3 (read replica). Each layer absorbs 80-99% of traffic from the next — the primary sees only writes and cold reads.
- Read replicas scale read throughput linearly; expect a few hundred ms of replication lag, so route real-time reads (user just posted a comment) to the primary.
- CDN edge caching (Cloudflare, Fastly) is the ultimate L0 for public
GETs — response in 10 ms from anywhere on Earth. - Denormalize for the hottest reads: pre-compute feed timelines, product recommendations, leaderboards — do not rebuild them on each request.
- Watch cache stampede: when a hot key expires, thousands of pods hit the DB simultaneously. Use single-flight locks, probabilistic early refresh, or stale-while-revalidate.
Common Mistakes & Pitfalls
Upgrading the database server as the first response to read performance issues, rather than introducing caching or read replicas which are cheaper and more effective.
Check your understanding
Answer these without looking back at the article.
- How do you handle cache invalidation when underlying data changes?
- What consistency problems arise with read replicas and how do you mitigate them?
- When would a materialized view be preferable to a Redis cache?
Related interview prep categories
2Looking for a structured roadmap?
Every module on this page is also part of the full Java backend interview track.