Caching Strategies: Redis Patterns and Pitfalls in Web Applications
Caching is the most effective way to improve web application performance — and the fastest way to introduce bugs. This article covers caching patterns, expiration strategies, cache penetration/cache avalanche/cache stampede, and Redis memory management — for backend developers adding or optimizing a caching layer.
The Bottom Line: Caching Is a Performance Power Tool — and a Bug Incubator
Caching is the single most effective way to improve web application performance. A well-designed cache layer can reduce response times from hundreds of milliseconds to single-digit milliseconds. But caching is also the fastest way to introduce bugs — cache penetration, cache stampede, and cache-DB inconsistency can each take hours to debug.
This article covers four layers: caching patterns, expiration strategies, common problems, and Redis in practice.
1. Caching Patterns
1.1 Cache-Aside (Most Common)
Read: App → Read cache → Hit → Return
→ Miss → Read DB → Write cache → Return
Write: App → Update DB → Delete cache
async function getUser(id: number): Promise<User> {
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
if (!user) return null;
await redis.set(`user:${id}`, JSON.stringify(user), 'EX', 3600);
return user;
}
async function updateUser(id: number, data: Partial<User>): Promise<void> {
await db.query('UPDATE users SET name = $1 WHERE id = $2', [data.name, id]);
await redis.del(`user:${id}`);
}
Why delete instead of update? Deletion is idempotent — deleting multiple times has the same effect. Updating the cache has concurrency issues: two simultaneous requests may cause the later DB write to be overwritten by the earlier cache write.
1.2 Read-Through
The application only talks to the cache; the cache loads data from the database. Suitable for read-heavy workloads, but more complex to implement.
2. Expiration Strategies
2.1 TTL Recommendations
| Data Type | Recommended TTL | Note |
|---|---|---|
| User info | 30-60 min | Infrequent changes |
| Articles | 1-6 hours | Low update frequency |
| Config | 24 hours | Rarely changes |
| Counters | 5-15 min | Needs timely updates |
| Temp data | 1-5 min | Short-lived |
2.2 Prevent Cache Avalanche: Add Random Jitter
// ❌ All keys expire at the same time
await redis.set(`article:${id}`, data, 'EX', 3600);
// ✅ Add random jitter to prevent simultaneous expiration
const ttl = 3600 + Math.floor(Math.random() * 600); // 3600-4200 seconds
await redis.set(`article:${id}`, data, 'EX', ttl);
3. Common Problems
3.1 Cache Penetration
Querying a non-existent key — every request bypasses the cache and hits the database.
Solution: Cache null values (short TTL), or use a Bloom filter.
async function getArticle(id: number) {
const cached = await redis.get(`article:${id}`);
if (cached !== null) {
if (cached === 'NULL') return null;
return JSON.parse(cached);
}
const article = await db.query('SELECT * FROM articles WHERE id = $1', [id]);
await redis.set(`article:${id}`, JSON.stringify(article) || 'NULL', 'EX', article ? 3600 : 60);
return article;
}
3.2 Cache Stampede (Cache Breakdown)
A hot key expires, and thousands of concurrent requests hit the database.
Solution: Mutex lock, or never-expire hot keys (async background refresh).
async function getHotData(id: number) {
const cached = await redis.get(`hot:${id}`);
if (cached) return JSON.parse(cached);
const lock = await redis.set(`lock:hot:${id}`, '1', 'NX', 'EX', 5);
if (!lock) {
await sleep(50);
return getHotData(id);
}
const data = await db.query('SELECT * FROM hot_data WHERE id = $1', [id]);
await redis.set(`hot:${id}`, JSON.stringify(data), 'EX', 3600);
await redis.del(`lock:hot:${id}`);
return data;
}
3.3 Cache Avalanche
A large number of keys expire simultaneously, causing a database load spike.
Solution: Add random jitter to TTLs, or use multi-level caching (local cache + Redis).
4. Redis Memory Management
# Limit maximum memory (recommended: 60-70% of server memory)
maxmemory 4gb
# Eviction policy: allkeys-lru (least recently used)
maxmemory-policy allkeys-lru
# Monitor memory usage
redis-cli INFO memory | grep used_memory_human
Summary
| Problem | Symptom | Solution |
|---|---|---|
| Cache penetration | Non-existent keys bypass cache | Cache nulls / Bloom filter |
| Cache stampede | Hot key expires, concurrent requests | Mutex lock / never-expire |
| Cache avalanche | Mass key expiration | Random TTL jitter / multi-level cache |
| Inconsistency | Cache and DB out of sync | Cache-Aside: update DB, delete cache |
Caching is not a silver bullet — but not caching is a missed opportunity. A well-designed cache layer can improve your application performance by an order of magnitude — but only if you understand the pitfalls.
Need Redis caching design or backend performance optimization? Contact us — tell us about your data access patterns and scale, feasibility within 24 hours.
Related reading
- Docker Compose in Practice — deploying Redis with Docker Compose
- Ops Automation Script Patterns — Redis monitoring and ops automation
FAQ
What is the difference between cache penetration, cache stampede, and cache avalanche?
Cache penetration: querying a non-existent key that is neither in cache nor database — every request hits the database. Attackers exploit this with大量 requests for non-existent keys. Solution: cache null values (short TTL) or use a Bloom filter. Cache stampede (cache breakdown): a hot key expires, and thousands of concurrent requests hit the database simultaneously. Solution: mutex lock or never-expire hot keys (async background refresh). Cache avalanche: a large number of keys expire simultaneously, causing a database load spike. Solution: add random jitter to TTLs.
How do you ensure cache and database consistency?
Perfect strong consistency between cache and database is not achievable without distributed transactions (too costly). Recommended: Cache-Aside pattern — on read, check cache first, miss → read DB → write cache; on write, update DB first, then delete cache. Why delete instead of update? Deletion is idempotent — deleting multiple times has the same effect. Updating the cache has concurrency issues: two simultaneous writes may cause the later DB write to be overwritten by the earlier cache write. For higher consistency requirements, use delayed double-deletion: delete cache, update DB, wait a few ms, delete cache again.
What happens when Redis runs out of memory?
Configure maxmemory to limit Redis maximum memory and set an eviction policy. Recommended: allkeys-lru (least recently used) or allkeys-lfu (least frequently used). Without maxmemory, Redis grows until it exhausts server memory and gets killed by OOM Killer. Also monitor memory usage with INFO memory and alert when usage exceeds 80%.
When should you NOT use caching?
Three cases: ① Extremely high consistency requirements (financial transactions, balances) — the latency improvement is not worth the inconsistency risk; ② Cold data that is rarely accessed — low cache hit rate, better not to cache; ③ Very small datasets with fast queries (e.g., a few hundred rows) — querying the database directly may be faster than going through a cache layer.
This article comes from AI Enable Harness front-line delivery practice. Need a similar system or optimization service?
Subscribe to Updates
Get notified when new articles are published. No spam, occasional updates only.
Subscribe →