A product detail endpoint is getting slower. Several requests repeatedly load the same product information from MySQL, so adding Redis looks like a straightforward improvement. Cache the result, avoid another database query, and return the page faster.
Then a merchant changes a product description and still sees the old text. A popular product expires from the cache and suddenly generates hundreds of database queries. During a Redis outage, the application falls back to MySQL so aggressively that the database becomes unavailable too.
These problems belong to the same design. A cache changes where reads go, how long old information remains visible, and how much work reaches the database when something fails.
This article uses a Java product service as a concrete example. Product descriptions can tolerate some staleness, while checkout prices, inventory reservations, and authorization decisions need their own authoritative paths. The scenarios and numbers below explain design choices; they are not production benchmark results.
Define what each reader needs to see
Before selecting an expiration time, decide what the application promises its readers. “Product data can be eventually consistent” is too broad when the same product appears in a merchant editor, a public detail page, and an order submission.
| Reader or operation | Required behavior | Implication |
|---|---|---|
| Customer browsing a description | Briefly outdated content may be acceptable | Cache with an explicit freshness policy |
| Merchant checking a saved edit | See at least the version just committed | Support read-your-writes |
| Customer requesting a checkout quote | Receive a price governed by current pricing rules | Use the pricing service’s quote contract |
| Order submission | Reserve stock and establish the agreed transaction | Enforce authoritative business conditions |
The merchant editor illustrates read-your-writes. After saving version 18, the merchant should not immediately read version 17. This is a narrower requirement than making every reader observe version 18 instantly.
A practical approach is for the update endpoint to return the committed product and its version. The editor can display that response and include the minimum acceptable version in subsequent reads. A cache entry below that version cannot satisfy the request, so the service must use a source capable of returning the required state. An asynchronously replicated database reader may need the same consideration.
Displaying the update response alone is insufficient: it improves the immediate screen, but the next refresh could still retrieve an older cache entry.
Public browsing has a different contract. A slightly old description may be acceptable, provided the business defines what happens when refreshing repeatedly fails. That allowance should not silently extend to withdrawn products, restricted content, or other fields whose stale state has different consequences.
Cache Aside does not itself guarantee consistency between the database and the cache. These reader-specific promises must be implemented by the application.
Reproduce a stale read before changing the policy
A cache policy is easier to review when the failure can be repeated with one key. The following is a local test shape for product:1042; the product versions and timings are illustrative, so record the values from your own service when you run it.
| Step | Action | Record |
|---|---|---|
| 1 | Seed Redis with product version 17 and a short TTL. | Cache version and expiry time |
| 2 | Update MySQL to version 18, then commit the transaction. | Database commit timestamp |
| 3 | Delete the key, but pause one reader after its database lookup and before its refill. | Delete result and paused reader |
| 4 | Let a second reader request the product, then release the paused reader. | Versions returned and written |
| 5 | Repeat with Redis unavailable and with the key already expired. | Stale reads, misses, source queries, and p95 latency |
SET product:1042 version=17 EX 300
-- commit version 18 in the database
DEL product:1042
-- pause the first refill, read from a second instance, then resume
The expected result is not “zero stale reads” by assumption. It is a bounded, observable outcome: the service either rejects a refill below the invalidation generation, serves a documented stale value, or falls back to the authoritative source. Record which path occurred and how long repair took.
Cache according to ownership and update frequency
A product page looks like one object, but its data may belong to several services. The name and description change infrequently. Promotional prices follow campaign rules. Inventory changes with reservations, releases, and replenishment.
Putting everything into one cached Product object creates competing requirements. Frequent inventory changes can invalidate an expensive description query. A long expiration time that improves description hit rates can keep a promotion visible after its pricing conditions have changed.
A reasonable starting point is to cache stable product information separately from pricing and inventory summaries. The page service assembles the response, while checkout obtains an authoritative quote and the inventory service performs a conditional reservation.
This separation introduces its own tradeoff: the assembled page can contain components read at different times. If promotional copy must match a specific offer, both should refer to the same campaign revision or come from one coherent quote response. Splitting objects is useful only when the resulting combinations remain meaningful.
Cache keys also need to reflect the inputs that determine the response. A description in one language should not overwrite another language’s entry. A customer-specific price should not be placed in a globally shared product key. On the other hand, including every possible request parameter creates excessive key cardinality and reduces reuse. Cache the stable shared representation, then apply request-specific behavior deliberately.
The transaction path completes this separation. A customer may browse cached information, obtain a time-limited quote, and submit an order that validates the quote and reserves inventory. The order then records the agreed price, discounts, and relevant product snapshot. Later changes to the product cache must not rewrite the commercial facts of that order.
Before introducing any cache, check whether the database query is unnecessarily expensive. Missing indexes and repeated queries within a single request still deserve correction. Redis is most useful when it avoids worthwhile repeated work, rather than concealing an inefficient access path.
Treat misses, missing products, and Redis failures differently
In a Cache Aside read, the service checks Redis, loads the database on a miss, and caches the result. The implementation becomes unreliable when it treats every unsuccessful cache read as equivalent.
| Cache outcome | Meaning | Next action |
|---|---|---|
| Product entry found | A cached representation is available | Apply freshness and version requirements |
NOT_FOUND entry found | A recent lookup found no product | Return not found within the negative-cache policy |
| Key absent | No usable entry exists | Load within the database budget |
| Redis timeout or connection error | Cache availability is uncertain | Apply failure admission and fallback rules |
Negative caching helps when the same nonexistent product ID is repeatedly requested. Use an explicit representation, such as an envelope with FOUND or NOT_FOUND, rather than storing an empty string that another code path tries to deserialize as a product.
The envelope can distinguish format changes from business changes:
{
"schemaVersion": 1,
"status": "FOUND",
"productVersion": 18,
"validatedAt": "2026-09-09T08:00:00Z",
"product": {
"id": 1042,
"name": "Example product"
}
}
schemaVersion describes serialization. productVersion identifies business state. validatedAt is useful only if the application defines how it was established; it should not automatically mean “the moment this JSON was written to Redis.”
A negative entry usually needs a shorter lifetime than ordinary product data. Product creation should invalidate the corresponding negative entry. Random requests for millions of different IDs can still overwhelm the source, so input validation and admission limits remain necessary.
Popular existing products produce another problem. When one hot key expires, many requests can discover the miss before the first database lookup finishes. Combining concurrent loads for the same key—often called singleflight—reduces this duplication.
A local implementation combines work within one JVM. With twenty service instances, it can still permit twenty simultaneous loads. Cross-instance coordination may reduce that further, but adds a dependency on the coordination service. If a Redis rebuild lock is used, its owner should recheck the cache after acquisition and release only its own lease. Waiting callers need bounded waiting and retries; failure to acquire the lock is not evidence that the product does not exist.
For descriptive content, another option is stale-while-revalidate: return an acceptable old entry while one bounded refresh runs. This needs both a refresh threshold and a maximum stale-serving policy. Otherwise, repeated refresh failures can turn a temporary fallback into indefinitely outdated content.
Commit the change, then make invalidation recoverable
For product updates, a common sequence is to commit MySQL and then delete the affected cache entry. The next read loads the new state.
Deleting avoids one direct-update race: two writers can commit in one order but reach Redis in the opposite order, allowing an older value to overwrite a newer one. However, database commit and cache deletion remain separate operations.
Spring adds an important timing detail. Deleting Redis at the end of an @Transactional method body normally happens before the proxy completes the database transaction. An AFTER_COMMIT transaction listener can move invalidation after a successful commit.
That listener is not a durable queue. The process can stop after commit but before deletion. Writing a retry record only after observing deletion failure leaves the same crash window.
Where reliable invalidation matters, write the product change and an invalidation event in the same local database transaction. A relay later processes the event, retries failures, and records progress. The outbox belongs with the authoritative record in the same transaction-capable database boundary. CDC is another possible delivery path, with its own operational responsibilities.
Repeated deletion is generally harmless to cached business content, though it can increase misses. An event marked as delivered also does not prove every reader now sees fresh data: there may be local copies, delayed source replicas, or an in-flight request about to refill the cache.
That last case deserves a separate timeline. A reader loads version 17 and pauses before caching it. Another request commits version 18 and deletes the cache key. When the original reader resumes, it fills the empty cache with version 17.

Figure 1. Successful database commit and cache deletion do not prevent a delayed reader from restoring an older value.
A delayed second deletion may reduce this window for some workloads, but a fixed delay cannot cover every possible pause. Likewise, adding a version field does not automatically solve the race. If the key is absent, a write rule that compares only with the currently cached version has nothing against which to reject version 17.
A stricter design can maintain an independent version floor or invalidation generation and atomically check it during refill. But the design must also explain how that marker is updated, retained, and recovered after loss. The marker is part of the protocol, not a small optional field.
For ordinary browsing, reliable invalidation plus expiration may be a sufficient tradeoff. For merchant confirmation or transaction decisions, a version-aware authoritative read can be easier to justify than making every public cache read participate in a more complicated consistency protocol.
Expiration needs similarly careful interpretation. A TTL limits an entry’s residence after insertion. It does not necessarily limit staleness measured from a business update. A slow request can insert an old value and start a fresh TTL.
Nor is the product’s last modification time, by itself, a freshness deadline: an unchanged description may legitimately be months old. A strict freshness requirement needs a meaningful validation point, a suitable source, and a rule for rejecting delayed results. Merely recording the cache insertion time cannot establish that guarantee.
Budget for the requests that the cache normally hides
Suppose a detail endpoint receives 10,000 requests per second and has a 99% cache hit rate. Under the simplifying assumption that each miss produces one database query, ordinary source traffic is:
10,000 × (1 − 0.99) = 100 database queries/second
If Redis becomes unavailable and every request falls back directly, the potential demand becomes 10,000 queries per second—one hundred times the normal level.
These are illustrative numbers, but the relationship matters. A high cache hit rate can hide a large dependency on the cache remaining available.
Assume the database has a tested budget of 500 queries per second for this endpoint after reserving capacity for other work. The application cannot preserve full service by forwarding all 10,000 requests. It must constrain admission and decide what the remaining requests receive.

Figure 2. Cache failure does not grant unlimited access to the database. Fallback behavior and recovery work must respect the source budget.
An acceptable old description may be returned from a bounded local fallback. Optional page components may be omitted. Other requests should receive a clear temporary-unavailability response. A timeout or overload must not be translated into “product not found.”
Both rate and concurrency matter. A rate limit controls how much work starts, while a concurrency limit constrains outstanding work when queries slow down. Each limit must account for the entire service fleet. Fifty permitted source queries per instance can become one thousand concurrent queries across twenty instances, and autoscaling can increase that number during the incident.
The fallback budget should therefore consider the maximum active fleet or use a suitable shared admission mechanism. A shared mechanism hosted only in the failing Redis deployment would not provide a dependable outage control. Local conservative limits, bounded queues, and carefully allocated service budgets can provide useful protection without adding that circular dependency.
Redis calls need short, deliberate timeout and retry policies. Once failures are persistent, a circuit breaker can reduce time spent attempting a dependency that is unlikely to respond. It does not increase database capacity; source admission limits still apply.
Recovery is part of the same event. Redis responding again does not mean hot entries have been restored. A nearly empty cache can generate a second wave of source requests. Keep admission limits active, warm known hot data gradually, and make refresh jobs share the relevant database budget. Removing every restriction immediately after a successful health check defeats the protection.
Failure matrix for writes, invalidation, and refill
| Failure point | Visible symptom | Recovery rule to test |
|---|---|---|
| Database commit succeeds, cache delete fails | Readers may continue to see the old entry | Retry through an outbox or repair worker; expose backlog age |
| Delete succeeds, database read fails on refill | Cache miss becomes a slow or unavailable request | Keep the database as source of truth and bound caller retries |
| TTL expires during a database incident | Many callers miss at once | Use singleflight, admission limits, and a documented stale policy |
| Two writers update the same key | An older response can refill after a newer commit | Compare versions or invalidation generations before writing |
Each row should have an owner, a metric, and a retry boundary. A log line saying “cache refreshed” is not enough: include the product version, invalidation event ID, source used, and final cache version so an incident can be reconstructed.
Add complexity only when its benefit is observable
Local caching, sharding, and background refresh can all help, but each changes the failure and consistency model.
A local cache avoids a network round trip, yet every JVM holds a separate copy. Invalidating Redis does not invalidate those copies. Short lifetimes, invalidation notifications, and recovery after lost notifications must be considered together.
Sharding spreads different keys across nodes. It does not divide one hot key’s traffic automatically. Adding replicas or persistence also does not remove the need to rebuild a cache safely after failure. For this product service, rebuild cost and source protection are at least as important as the deployment topology.
Measure the system in terms of both workload and user-visible behavior. Hit rate should be accompanied by source-query volume, query cost, Redis timeouts, eviction activity, load duration, invalidation backlog, and the age of data served through fallback paths. A higher hit rate is not an improvement if merchants wait longer to see edits or if the remaining misses are precisely the queries that exhaust the database.
The most useful verification exercises follow the business journey:
| Exercise | What to verify |
|---|---|
| Save a product and immediately refresh the editor | The editor does not return a version below the committed one |
| Create a product with an existing negative entry | The product becomes visible through the defined invalidation path |
| Pause a reader between source lookup and cache refill | The stale-refill behavior matches the documented consistency policy |
| Expire one heavily requested product | Source loading and caller waiting remain bounded |
| Interrupt Redis, then restore it with cold entries | Neither fallback nor warming exhausts the database budget |
| Change a promotion during checkout | Order acceptance follows the quote contract, not a stale page object |
These exercises provide more useful evidence than an unexplained performance multiplier. They reveal which assumptions the service relies on and whether its fallback behavior is sustainable.
For a product detail service, I would begin with a small shared cache, explicit negative entries, recoverable invalidation, and a fleet-aware source budget. The merchant editor would carry a minimum acceptable version, while checkout would use authoritative pricing and reservation contracts.
That leaves each part of the system with a clear responsibility. The cache reduces repeated work. The database and domain services enforce business facts. Failure controls determine how much traffic the source can safely receive. Together, those decisions make the performance improvement something the application can continue to rely on when requests overlap, data changes, or Redis stops responding.
For a durable update-and-invalidation event, see Distributed Transactions in Java: Reliable Orders with Outbox, TCC, and Saga. For Redis pressure and recovery signals, compare Monitoring Redis Pressure and the Online Classroom Experience.
