Definition
- Cache-Aside (Lazy Loading) – the application checks the cache first; on a miss it loads from the database, writes to the cache with a TTL, and returns. The app owns the logic.
- Read-Through – the cache owns loading: the app only asks the cache, and the cache fetches from the database on a miss through a configured loader.
- Write-Through – every write goes through the cache, which synchronously writes to both cache and database before returning.
- Write-Behind (Write-Back) – the write updates the cache immediately and the database is updated asynchronously, giving fast writes but risking data loss on cache failure.
- Caffeine – in-process JVM cache with nanosecond lookups, scope limited to one instance.
- Redis – distributed cache shared across all instances, adds a network hop but ensures consistency.
At National Bank of India, the authoritative ledger is in the vault (the database) and the teller keeps a notepad of recently looked-up balances (the cache). Cache-Aside is the teller's own habit: "check my notepad; not there? I'll run to the vault." Read-Through is having an assistant who quietly fetches from the vault when the notepad is empty. Write-Through is updating both the notepad and the vault at the same time. Write-Behind is scribbling the change on the notepad instantly and walking it to the vault later. When ten tellers each have a private notepad, they disagree, so the bank installs one shared whiteboard (Redis) instead.
Understanding the Concepts
What is Cache-Aside?
Cache-Aside is the most common pattern and the default for most services. The application controls everything: on a read, check the cache; on a miss, load from the database, write to the cache with a TTL, and return.
package com.bank.cache;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
public class CacheAsideExample {
private final Map<String, BigDecimal> cache = new HashMap<>();
private final Map<String, BigDecimal> database = Map.of(
"SAV2001", new BigDecimal("75000.00"),
"SAV3002", new BigDecimal("120000.00"));
public BigDecimal getBalance(String accountId) {
BigDecimal cached = cache.get(accountId);
if (cached != null) {
System.out.println("Cache HIT for " + accountId);
return cached;
}
System.out.println("Cache MISS for " + accountId + ", loading from DB");
BigDecimal fromDb = database.get(accountId);
if (fromDb != null) {
cache.put(accountId, fromDb); // populate cache
}
return fromDb;
}
public static void main(String[] args) {
CacheAsideExample svc = new CacheAsideExample();
svc.getBalance("SAV2001"); // miss
svc.getBalance("SAV2001"); // hit
svc.getBalance("SAV3002"); // miss
}
}
Cache MISS for SAV2001, loading from DB
Cache HIT for SAV2001
Cache MISS for SAV3002, loading from DB
What is Read-Through?
Read-Through moves the load-on-miss responsibility into the cache itself. The application only asks the cache, and the cache invokes a configured loader on a miss.
package com.bank.cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import java.math.BigDecimal;
import java.time.Duration;
public class ReadThroughExample {
// Caffeine LoadingCache is a Read-Through cache
private final LoadingCache<String, BigDecimal> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.build(accountId -> loadFromDatabase(accountId)); // loader
private BigDecimal loadFromDatabase(String accountId) {
System.out.println("Loader: fetching " + accountId + " from DB");
return new BigDecimal("75000.00"); // simulated
}
public BigDecimal getBalance(String accountId) {
return cache.get(accountId); // miss triggers loader transparently
}
public static void main(String[] args) {
ReadThroughExample svc = new ReadThroughExample();
System.out.println("Balance: " + svc.getBalance("SAV2001"));
System.out.println("Balance: " + svc.getBalance("SAV2001")); // hit, no loader call
}
}
Loader: fetching SAV2001 from DB
Balance: 75000.00
Balance: 75000.00
What are Write-Through and Write-Behind?
Write-Through writes to both the cache and the database synchronously. Write-Behind writes to the cache and flushes to the database asynchronously.
package com.bank.cache;
import java.math.BigDecimal;
import java.util.concurrent.ConcurrentHashMap;
public class WritePatternsExample {
private final ConcurrentHashMap<String, BigDecimal> cache = new ConcurrentHashMap<>();
// Write-Through: sync write to both
public void writeThrough(String accountId, BigDecimal balance) {
cache.put(accountId, balance); // cache update
System.out.println("DB write (sync): " + accountId + " = " + balance);
// Both updated before returning. Consistent but slower.
}
// Write-Behind: cache now, DB later
public void writeBehind(String accountId, BigDecimal balance) {
cache.put(accountId, balance); // cache update (instant)
System.out.println("Queued for DB flush: " + accountId + " = " + balance);
// Background thread flushes to DB later. Fast but risks data loss.
}
public static void main(String[] args) {
WritePatternsExample svc = new WritePatternsExample();
svc.writeThrough("SAV2001", new BigDecimal("80000.00"));
svc.writeBehind("SAV3002", new BigDecimal("95000.00"));
}
}
DB write (sync): SAV2001 = 80000.00
Queued for DB flush: SAV3002 = 95000.00
How do you compare the caching patterns?
| Pattern | Who Loads? | Write Path | Consistency | Durability Risk | Best For |
|---|---|---|---|---|---|
| Cache-Aside | App code | App invalidates cache | Eventual (TTL) | None (DB is truth) | Read-heavy, most services |
| Read-Through | Cache (loader) | Separate | Eventual (TTL) | None | Centralized loading logic |
| Write-Through | Cache | Sync to both | Strong | None | Reads must see latest write |
| Write-Behind | Cache | Async to DB | Eventual | Yes (cache failure) | High-write, counters, metrics |
When do you evolve from Caffeine to Redis?
Caffeine is in-process: a lookup is a method call with no network hop, nanosecond speed. But each instance has its own private cache. When you run multiple instances, they drift: a write on one instance does not invalidate the stale copy on another.
package com.bank.cache;
public class CaffeineVsRedis {
public static void main(String[] args) {
System.out.println("=== Single Instance ===");
System.out.println("Caffeine: nanosecond lookups, perfect for one instance");
System.out.println();
System.out.println("=== Multiple Instances ===");
System.out.println("Instance-1 Caffeine: SAV2001 = 75000 (stale!)");
System.out.println("Instance-2 Caffeine: SAV2001 = 80000 (updated)");
System.out.println("Problem: instance-1 never heard about the update");
System.out.println();
System.out.println("=== Fix: Shared Redis ===");
System.out.println("Redis: SAV2001 = 80000 (shared, all instances see it)");
System.out.println("Cost: network hop per access (~0.5ms vs ~50ns)");
}
}
=== Single Instance ===
Caffeine: nanosecond lookups, perfect for one instance
=== Multiple Instances ===
Instance-1 Caffeine: SAV2001 = 75000 (stale!)
Instance-2 Caffeine: SAV2001 = 80000 (updated)
Problem: instance-1 never heard about the update
=== Fix: Shared Redis ===
Redis: SAV2001 = 80000 (shared, all instances see it)
Cost: network hop per access (~0.5ms vs ~50ns)
What is multi-level caching (L1 Caffeine + L2 Redis)?
The best of both worlds: a small, short-lived Caffeine L1 in each instance sits in front of a shared Redis L2. The hottest keys are served at nanosecond speed from L1 while L2 keeps instances roughly consistent.
package com.bank.cache;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.Map;
public class MultiLevelCache {
private final Map<String, BigDecimal> l1 = new HashMap<>(); // Caffeine (per-instance)
private final Map<String, BigDecimal> l2 = new HashMap<>(); // Redis (shared)
private final Map<String, BigDecimal> db = Map.of("SAV2001", new BigDecimal("75000.00"));
public BigDecimal get(String accountId) {
BigDecimal val = l1.get(accountId);
if (val != null) { System.out.println("L1 HIT"); return val; }
val = l2.get(accountId);
if (val != null) { System.out.println("L2 HIT"); l1.put(accountId, val); return val; }
System.out.println("DB LOAD");
val = db.get(accountId);
l2.put(accountId, val);
l1.put(accountId, val);
return val;
}
public static void main(String[] args) {
MultiLevelCache cache = new MultiLevelCache();
cache.get("SAV2001"); // DB LOAD
cache.get("SAV2001"); // L1 HIT
}
}
DB LOAD
L1 HIT
What are common mistakes?
The most dangerous mistake is caching the authoritative balance used to authorize a debit. A cache with a TTL is eventually consistent by definition. You cache a displayed balance but you always read the authoritative balance from the database when authorizing a debit.
package com.bank.cache;
import java.math.BigDecimal;
public class CachingMistakes {
// WRONG: using cached balance for debit authorization
static void wrongDebit(BigDecimal cachedBalance, BigDecimal amount) {
if (cachedBalance.compareTo(amount) >= 0) {
System.out.println("WRONG: approved debit based on stale cached balance");
// Cache might be 2 seconds old, actual balance could be lower
}
}
// CORRECT: read authoritative balance from DB for debit
static void correctDebit(BigDecimal dbBalance, BigDecimal amount) {
if (dbBalance.compareTo(amount) >= 0) {
System.out.println("CORRECT: approved debit based on DB (source of truth)");
}
}
}
What are common interview follow-up questions?
- How do you handle cache invalidation on writes in Cache-Aside?
- When is Write-Behind acceptable for financial data?
- How do you prevent a cache stampede when a hot key expires?
- What is the consistency trade-off of the L1 TTL in multi-level caching?
- How does Redis publish/subscribe help with cross-instance invalidation?
Visual Overview
Cache-Aside Flow
────────────────
Client → App
│
├─ cache.get(key)
│ HIT? → return cached value
│ MISS? ──► db.load(key)
│ │
│ ▼
│ cache.put(key, val, TTL)
│ │
└──────────────┘
return val
Multi-Level Cache (L1 + L2)
───────────────────────────
Client → App
│
├─ L1 (Caffeine, in-process, ~50ns)
│ HIT? → return
│ MISS?
├─ L2 (Redis, distributed, ~0.5ms)
│ HIT? → populate L1 → return
│ MISS?
├─ Database (source of truth, ~5ms)
│ → populate L2 → populate L1 → return
│
Hottest keys served from L1 (nanoseconds).
L2 keeps instances consistent.
DB sees only a trickle of misses.
Key Takeaway
The main caching patterns differ in who talks to the database and when. Cache-Aside is the most common: the application checks the cache, loads from the database on a miss, and populates the cache. Read-Through moves loading into the cache via a configured loader. Write-Through writes synchronously to both cache and database for consistency. Write-Behind writes the cache first and flushes to the database asynchronously for speed, but risks data loss.
The evolution from Caffeine to Redis is driven by moving from one application instance to many. Caffeine is blazing fast but per-instance, so multiple instances drift apart after writes. Redis is shared across all instances but adds a network hop. Multi-level caching gives the best of both: a short-lived Caffeine L1 serves the hottest keys at nanosecond speed while Redis L2 keeps instances consistent.
The senior judgment is that a cache with a TTL is eventually consistent by definition. You cache a displayed balance for fast reads, but you always read the authoritative balance from the database when authorizing a debit, because stale data in a financial decision is far worse than a cache miss.
Remember one sentence: Cache-Aside for most services, evolve from Caffeine to Redis when multiple instances must agree, and never cache the authoritative balance used to approve a debit.
---