Redis can reduce repeated database work in a Spring Boot application, but a cache is a second representation of data. The design must specify who may read it, how stale it may become and what happens when it is unavailable.
This guide develops a cache-aside design for public product summaries. It focuses on correctness and operational behavior before throughput. Code fragments are deliberately partial examples: integrate them with the versions, security model and failure policy of the actual application, then verify them in an integration environment.
Choose data that can tolerate caching
A good initial candidate is read frequently, expensive enough to justify an extra service, and allowed to be slightly stale. Public catalogue descriptions may fit. Payment authorization, current account permissions or a final stock reservation may not. The deciding factor is the business requirement, not whether an annotation can be added to the method.
Write a freshness contract. For example, a hypothetical public product page might tolerate a short delay before showing a changed description, while the purchase operation must validate current price and stock against the authoritative system. Serving a cached preview and making an authoritative transaction decision are different responsibilities.
Measure the existing path first: query count, database duration, connection waits and response size. If the query is cheap and Redis is distant, caching may add complexity without helping latency. Check whether a query improvement, request consolidation or static publication would remove the repeated work more directly.
Define the cache-aside read path
In cache-aside, the application checks the cache, loads missing data from the authoritative store and populates the cache. Spring's cache abstraction provides annotations such as @Cacheable and @CacheEvict. The Spring Framework documentation also describes proxy behavior: ordinary self-invocation does not pass through the proxy and therefore does not trigger caching in the default proxy mode.
// Illustration: public, non-personalized product data only.
@Cacheable(cacheNames = "publicProductsV1", key = "#p0", unless = "#result == null")
public PublicProductView findPublicProduct(String productId) {
return repository.findPublicView(productId).orElse(null);
}
Use a dedicated Spring-managed read service invoked through the application context. The example does not include dependencies, cache activation, serialization or a failure handler; those remain required parts of the application setup. Test that a repeated call avoids the repository and that a call through the actual HTTP path receives the same behavior.
Prefer explicit cache names and versioned value formats. A deployment that changes a serialized object can otherwise leave new code reading old entries. A versioned namespace makes migration behavior easier to reason about, although old entries still need expiry or cleanup so the migration does not retain unnecessary data indefinitely.
Design keys around the data boundary
The cache key must include every input that changes the returned representation. A product identifier alone is insufficient when the response also depends on language, currency or tenant. For personalized data, authorization boundaries are part of the design. Sharing one cached response across users can expose information even when the database query itself is secure.
Use a canonical key encoding with unambiguous field boundaries. For example, encode a structured tuple or escape separators consistently rather than concatenating arbitrary user strings. Validate key inputs and bound their lengths. If a key is derived from an authenticated tenant, obtain that tenant from trusted application context rather than accepting a caller's unverified claim.
| Input | Include when | Failure if omitted |
|---|---|---|
| Tenant | Data differs between organizations | Cross-tenant data reuse |
| Locale | Content is translated | Wrong language returned |
| Representation version | Value format changes | Incompatible deserialization |
| Permission context | Results depend on access rights | Unauthorized representation reused |
Set expiry deliberately
Spring Data Redis supports configuring cache TTL and serialization through RedisCacheConfiguration. Its defaults should be reviewed explicitly; do not assume that every cache entry automatically receives the expiry your application needs. See the Spring Data Redis cache documentation for the current options.
// A starting configuration, not a universal freshness policy.
RedisCacheConfiguration configuration =
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(60))
.disableCachingNullValues();
A 60-second TTL means an entry expires relative to its cache write under this simple policy; it does not prove every response is at most 60 seconds behind a committed database change. Concurrent loads and writes can reintroduce old values. Document that race when deciding whether simple expiry is sufficient.
If many hot entries expire together, database demand can spike. Consider bounded TTL variation, request coalescing or controlled refresh where appropriate. Verify the concurrency scope of the mechanism: synchronization inside one application instance does not necessarily coordinate several instances. Load tests should include a cold cache and synchronized misses.
Handle writes and invalidation races
A common policy commits a database update and then evicts the affected cache entry. However, method return is not automatically proof that the surrounding transaction has committed. Arrange invalidation after a successful commit, using a transaction-aware design appropriate to the application's consistency requirements.
Now consider a reader that misses the cache, loads an old value, and pauses. A writer commits the new value and evicts the key. The reader then populates the cache with its old value. This stale repopulation race survives an apparently correct “write then evict” sequence. More demanding workloads may need version checks, coordinated updates or a design that avoids caching the mutable decision entirely.
Do not use Redis keyspace notifications as a durable business-event system. The Redis documentation explains their Pub/Sub delivery model and the limitations of expiration event timing. A disconnected subscriber can miss notifications. Use a durable mechanism when every invalidation-related business event must be processed.
Plan for Redis being slow or unavailable
Decide whether the service fails open to the database, serves an acceptable stale representation or returns an explicit failure. Spring annotations do not, by themselves, define that product policy. A fallback to the database may preserve correctness while overwhelming the database if all cache traffic arrives at once.
Set bounded connection and operation timeouts, limit fallback concurrency and observe database saturation. Avoid an unrestricted retry loop around cache operations. If the cache is already under pressure, retrying every request can increase the load that prevents recovery. Make the failure path visible in metrics and traces rather than treating it as an invisible implementation detail.
Verify behavior before claiming a performance win
Test cold reads, warm reads, expiry, concurrent misses, writes during reads, deployment format changes and Redis outages. Include authorization cases when data is tenant-specific. A cache test that only checks whether a key exists misses the failures that matter most to users.
Track response latency distributions, database work per successful request, hit ratio, eviction rate, cache errors and stale-data incidents. Compare equivalent workloads with and without the cache. Report the consistency tradeoff with any performance result, and connect the extra infrastructure to cost per useful operation.
The safest cache is one with a narrow purpose and a clear failure policy. Start with data whose freshness requirements are understood, preserve authorization boundaries, and treat invalidation and recovery as part of the feature. Faster reads are valuable only when the returned data remains appropriate for the decision being made.
