Understanding Redis Cache Types and Common Disasters
Introduction
Redis is a very common in-memory database. I encounter it regularly at work but rarely study it thoroughly from the ground up, so I wrote this post to reproduce some scenarios and gain a deeper understanding.
Why You Need Caching
Caching stores frequently accessed data in a place that can be retrieved quickly — in this case: Redis. Compared to typical databases, Redis uses memory as the storage medium, so it naturally has an “better read advantage” compared to other common databases like Postgres or Mongo.
The drawback is that memory is not as suitable as disk for long-term persistence, but whether the goal is to increase speed, reduce latency, lower load, or cut costs… caching is still a great option.
| Type | Typical access time |
|---|---|
| CPU L1 Cache | 1 ns |
| CPU L2 Cache | 4 ns |
| CPU L3 Cache | 40 ns |
| Main Memory | 100 ns |
| SSD | 100,000 ns |
| HDD | 10,000,000 ns |
Cache Aside Pattern - Database-Centric Caching
A simple and common client-side caching pattern is “Cache Aside”, which means:
- Read: check Redis first
- Hit: return cache
- Miss: query the database, then write the result into Redis
- Write: update the database and simultaneously delete the corresponding Redis cache so the next read repopulates it
| Features | Description |
|---|---|
| ✅ Simple to implement | Clear logic, application layer has complete control over cache behavior |
| ✅ Excellent fault tolerance | When Redis crashes, the system can still revert to directly querying the database |
| ✅ On-demand loading | Only data actually queried is cached, saving memory |
| ⚠️ Guaranteed first miss | High latency for the first request after a cold start or cache clearing |
| ⚠️ Brief inconsistency | A very brief dirty read exists between writing to the DB and deleting from the cache |
async function main() { // --- Read --- // First read: Cache Miss, load from DB // Result: {"id":1,"name":"Alice","email":"alice@example.com"} const user = await getUser(1);
// Second read: Cache Hit, directly returned from Redis. // Result: {"id":1,"name":"Alice","email":"alice@example.com"} const userCached = await getUser(1);
// --- Write --- // When updating data, the cache will be deleted. await updateUser(1, { id: 1, name: "Alice Wu", email: "alice@example.com" });
// Read again: The cache is invalid; reload from DB. const userUpdated = await getUser(1); // Result: {"id":1,"name":"Alice Wu","email":"alice@example.com"}}Read
async function getUser(userId: number): Promise<string | null> { const cacheKey = `user:${userId}`;
// 1. Check Redis first const cached = await redis.get(cacheKey); if (cached !== null) { return cached; }
// 2. Cache Miss: Check DB const data = await queryDB(cacheKey); if (data === null) return null;
// 3. Refill cache and set TTL to avoid permanent memory occupation. const TTL = 60; // 60 secs await redis.set(cacheKey, data, "EX", TTL); return data;}Write
async function updateUser(userId: number, newData: object): Promise<void> { const cacheKey = `user:${userId}`; const value = JSON.stringify(newData);
await updateDB(cacheKey, value);
await redis.del(cacheKey);}Why delete the cache instead of updating it?
Deletion is idempotent, so you don’t need to worry about concurrent updates.
Why must update the database before deleting the cache?
If you do it the other way around — delete the cache first and then update the database — the following can happen under concurrency:
- Request A writes and deletes the cache
- Request B reads and finds a cache miss
- B reads old data from the DB
- B writes the old data back into the cache
- A updates the DB with new data
- Result: the cache still contains old data
When can data still be inconsistent?
- Parallel read/writes: A reads old cache and then B deletes the old cache
- Cache deletion fails
Possible fixs: retry mechanisms, asynchronous cache deletion, double delete
Read Through Pattern - Cache-First with DB Backing
Read Through is similar to Cache Aside in the read flow; the biggest difference is that the responsibility for filling the cache shifts from the application layer to the cache layer itself. The application always talks to the cache, which decides when to query the database.
- Read: only query the cache
- Hit: return cache
- Miss: cache layer automatically queries DB, fills the cache, then returns the result
- Write: update the database; the cache naturally expires via TTL (or synchronize using Write Through/Write Behind)
| Features | Description |
| :--- | :--- | | ✅ Simple Application Logic | Applications only need to call the cache interface; no need to write their own logic for handling cache misses. | ✅ On-Demand Loading | Similar to Cache Aside, only data actually queried is cached, saving space. | ⚠️ Guaranteed First Miss | After a cold start or TTL expiration, the first request still needs to wait for the cache layer to query the database (DB). | ⚠️ High Implementation Coupling | The cache layer needs to integrate the database query code, increasing the complexity of the infrastructure layer. | ⚠️ Poor Fault Tolerance | If the cache service fails, applications usually cannot skip the cache and query the DB themselves; additional degradation mechanisms must be designed.
async function main() { // --- Read --- // First read: Cache Miss // The cache layer automatically queries and populates the database; the application layer is unaware of this. // Result: {"id":1,"name":"Alice","email":"alice@example.com"} const user = await cache.get("user:1");
// Second read: Cache Hit, directly retrieved from the cache. // Result: {"id":1,"name":"Alice","email":"alice@example.com"} const userCached = await cache.get("user:1");
// --- Read --- // Direct database update; cache awaits TTL natural expiration. await db.update("users", { id: 1, name: "Alice Wu", email: "alice@example.com" });
// Reading again after TTL expires: Cache Miss // The cache layer re-queries the database and populates the cache. // Result: {"id":1,"name":"Alice Wu","email":"alice@example.com"} const userUpdated = await cache.get("user:1");}Write Through
When updating data, the cache synchronously writes to the database as well and only returns success when both succeed. Highest consistency, but higher write latency.
Write Behind
When updating data, the cache returns success immediately after updating itself and then asynchronously batches updates to the database. Very high performance and suitable for high write concurrency, but if the cache crashes before flushing to the DB, data loss can occur.
Refresh-Ahead
The system proactively refreshes data before the cache expires so that applications almost always hit valid cache and avoid latency caused by misses.
- Read: only query the cache
- Hit and data is fresh: return directly
- Hit but near expiration: return existing cache and refresh asynchronously in the background
- Miss: query DB and fill cache synchronously (same behavior as Read Through)
- Write: update database; cache is kept in sync by background refresh mechanisms
const TTL = 60; // Total cache lifetime (seconds)const REFRESH_THRESHOLD = 0.75; // A refresh is triggered once the survival time exceeds 75%.
async function getUser(id: number) { const cached = await redis.get(`user:${id}`);
if (cached) { const { data, cachedAt } = JSON.parse(cached); const age = (Date.now() - cachedAt) / 1000;
// The cache is still active, but the window has already refreshed → the background is not updating synchronously, so this request will not wait. if (age > TTL * REFRESH_THRESHOLD) { refreshInBackground(id); }
// Regardless of whether a refresh is triggered, all data will be sent back to the existing cache. return data; }
// Cache Miss:Synchronously query the database and populate the cache return await loadFromDB(id);}
async function refreshInBackground(id: number) { // Set a lock to prevent multiple requests from triggering a race condition simultaneously. const lockKey = `lock:user:${id}`; const acquired = await redis.set(lockKey, "1", { NX: true, EX: 10 }); if (!acquired) return;
try { await loadFromDB(id); // Query the database and write to the cache } finally { await redis.del(lockKey); }}
async function loadFromDB(id: number) { const user = await db.findUser(id); await redis.set( `user:${id}`, JSON.stringify({ data: user, cachedAt: Date.now() }), { EX: TTL } ); return user;}Cache Disasters
Cache Penetration - Many Requests for Non-Existent Data
Suppose a user repeatedly requests an ID that doesn’t exist in the database. The requests will “penetrate” the cache and hit the database, consuming a lot of resources.
Solutions
- Cache null values: even if the DB returns nothing, store this key in Redis with a null or sentinel value and a short TTL, so subsequent requests are blocked by Redis.
- Bloom filter: put a Bloom filter layer before hitting Redis. It can very efficiently tell you “this ID definitely does not exist” or “might exist”. If it definitely doesn’t exist, reject the request early.
Cache Breakdown - A Hot Key Suddenly Expires
A single extremely hot cache entry expires, and all incoming requests hit the database at once, overwhelming it.
- Mutex Lock (Redis SETNX): when cache is missing, don’t let all requests query the DB. Let the first request that acquires the lock read the DB and write back the cache, while others wait and retry reading the cache.
- Logical Expiration: same as Refresh-Ahead — don’t rely on Redis TTL; instead write an expiration time inside the value. When the cache is “logically” expired, return the old data and update in the background.
Cache Avalanche - Many Caches Expire Simultaneously
Many caches expire at the same time and all requests hit the database, overwhelming it.
- TTL with random jitter: don’t set the same fixed TTL (e.g., all 3600 seconds). Add a random jitter (e.g., 3600 + Math.random() * 300 seconds) so expirations are staggered.
- High availability: deploy Redis Sentinel or Redis Cluster to ensure failover.
- Rate limiting & degradation: if the DB is under heavy load, reject some requests at the application layer or disable non-critical features to protect the database.
Conclusion
- Caching strategies:
- Cache Aside: DB-centric
- Cache Through: Cache-centric
- Write Through: favor consistency
- Write Behind: favor speed
- Refresh-Ahead: trade space for time
- Cache disasters:
- Penetration: lots of requests for non-cached items
- Breakdown: a hot key suddenly expires
- Avalanche: many caches expire at once