Caching with Redis
Why Caching Exists and When to Use It
Evaluating cache hits rates, read-heavy workloads, and write-through/cache-aside patterns.
Introduction
In modern enterprise web applications, performance bottlenecks are rarely caused by business logic execution. Instead, they are dominated by slow Input/Output (IO) operations, particularly database lookups and external API network calls. Caching is the process of storing copies of data in a temporary, high-speed storage layer (typically RAM) to serve future requests exponentially faster.
By placing an in-memory cache between your Spring Boot application and your primary database, you can bypass disk-bound queries for frequently requested, slow-changing resources.
Why It Matters
Databases have a finite number of connection pools and suffer from high disk latency. When traffic spikes, executing redundant, heavy read queries repeatedly starves resources, leading to connection timeouts and application downtime. Caching reduces database load, minimizes response times from hundreds of milliseconds to microseconds, and dramatically lowers infrastructure costs.
Real-World Analogy
Imagine visiting a library to read a popular dictionary. If the dictionary is stored in the archives in the basement, the librarian must walk downstairs, retrieve it, let you read it, and return it to the basement shelf every single time (database lookup). To save time, the librarian keeps a copy of the dictionary directly on their counter desk (cache). When anyone asks for it, it is handed over instantly (cache hit).
How It Works
A cache intercepts application requests using specific patterns:
- Cache-Aside (Lazy Loading): The application queries the cache first. If the data is found (Cache Hit), it is returned. If not (Cache Miss), the application queries the database, writes the retrieved data to the cache, and then returns it to the client. This is the most common pattern for general workloads.
- Write-Through: The application writes data to the cache and the database simultaneously. This ensures the cache is never stale, but introduces write latency.
- Write-Behind (Write-Back): The application writes directly to the cache, which acknowledges the write immediately. An asynchronous worker background process later batches these writes to the database.
An effective caching strategy is measured by the **Cache Hit Rate**:
Hit Rate = Cache Hits / (Cache Hits + Cache Misses)
Internal Architecture
Caching layers operate at different levels. Application-level local caching (e.g. Caffeine, ConcurrentHashMap) runs within the application JVM heap memory. Distributed caching (e.g. Redis) runs on an external dedicated memory cluster.
When a cache request is initiated, the caching client serializes the Java objects into a common format (like JSON or binary) and transmits it to the cache server over TCP. The cache server stores the serialized keys and values in volatile memory (RAM), mapping them using hash rings or lookup tables for O(1) retrieval speeds.
Visual Explanation
Practical Example
Here is how to implement the standard Cache-Aside pattern manually using a database repository and an in-memory cache representation:
Common Mistakes
- Caching Dynamic Data: Caching rapidly changing fields (like financial stock prices or real-time counters) leads to stale data and overhead from constant cache invalidations.
- No Memory Allocation Limits: Setting infinite cache lifespans without eviction policies, causing JVM Out-Of-Memory (OOM) errors or distributed memory exhaustion.
- Ignored Cache Stampede: Underestimating concurrency. If a hot cache key expires, thousands of threads might concurrently query the database for the same key, overloading the database.
Quick Quiz
Q1: Which caching pattern queries the cache first, reads from the database on a miss, and writes back the fetched value to the cache?
A) Write-Through
B) Cache-Aside (Lazy Loading)
C) Write-Behind
D) Refresh-Ahead
Answer: B — Under the Cache-Aside pattern, the application is responsible for orchestrating cache checks, querying the database on misses, and writing the data back to the cache.
Q2: What is the primary operational risk of in-memory caching without configuring an eviction policy or maximum memory limits?
A) Database deadlock exceptions
B) Out-Of-Memory (OOM) crashes on the server
C) Serialization timeout errors
D) Subresource integrity check failures
Answer: B — Without bounds or eviction policies, the cache will grow indefinitely as new entries are added, eventually exhausting heap memory or system RAM and causing application crashes.
Scenario-Based Challenge
Production Scenario:
During a flash sale, the cache key representing the product catalog homepage expires. Within 500 milliseconds, 10,000 requests hit the product page. Since the cache has expired, all 10,000 threads attempt to query the database simultaneously, causing a database connection pool starvation and crashing the application. What is this phenomenon, and how do you protect your Spring Boot application against it?
View Solution
This phenomenon is a Cache Stampede (or thundering herd).
To protect the application:
1. Mutex Lock (Single Flight): Ensure only the first thread that detects the miss is allowed to query the database. Other concurrent threads are blocked or wait until the first thread completes and updates the cache. In Spring Boot caching, you achieve this by setting the sync = true parameter on the @Cacheable annotation.
2. Cache Pre-warming: Use background worker cronjobs to recalculate and refresh cache entries before they expire.
3. Jitter / Random TTLs: Avoid expiring all hot cache keys at the exact same time by adding a small random time offset to key TTL configurations.
Debugging Exercise
Broken Configuration:
The developer complains that despite querying products, the cache is never updated, and every call hits the database. Below is the service code:
Reason: Saving mutable objects by reference directly to in-memory maps allows external modifications to contaminate the cache. Additionally, the manual lookup logic lacks thread synchronization, which leads to race conditions and multiple threads reading/writing the database simultaneously under load.
The Fix: Implement immutable deep-copies for cached items, or use Spring Cache abstractions to automate thread-safe synchronization:
Interview Questions
1. Conceptual: Explain the trade-offs between In-Memory Local Caching (like Caffeine) and Distributed Caching (like Redis).
Local caching stores data within the application process heap memory. It has near-zero latency because it requires no network hops, but it is limited by JVM memory space and does not synchronize state across multiple application replicas in a cloud cluster. Distributed caching stores data in an external system like Redis. It introduces network latency (typically 1-5ms), but offers a shared centralized cache, ensuring consistency across application nodes, surviving app restarts, and holding terabytes of structured data.
2. Technical: What is a Cache Stampede, and how does the sync = true parameter in Spring's @Cacheable mitigate this issue?
A cache stampede occurs when high concurrency causes a cache miss, resulting in many threads querying the database for the same key simultaneously. Setting sync = true instructs the underlying Spring Cache provider to use a mutex lock. The proxy coordinates threads so that only one thread executes the database load method, while other threads block and wait for the cache to be populated, resolving database starvation.
Production Considerations
In production setups, always isolate caching layers using secondary networks to limit bandwidth bottlenecks. Set hard memory maximum allocations and eviction strategies (e.g. Least Recently Used). Never cache security-sensitive data like plain-text user passwords or authorization tokens unless encrypted.