Redis Cache Aside for Product Details: A Frontend Dev's Guide to Backend Caching
GitHub Repository: https://github.com/2530622506/fullstack-mall
04|(Frontend to Fullstack) Why Is Frontend State Not Enough? From Page Data to MySQL Persistence
This article is aimed at frontend engineers and explains Redis Cache Aside based on the real backend code of the current
fullstack-mall. The Vue/TypeScript snippets in the text are only "frontend-side illustrative code"; the repository itself has no independent frontend source code. The key files in this article areProductFacade,ProductDetailCacheService,RedisOperatorClient,ProductDetailCacheLookup,ProductDetailCacheProperties,application.yml, and related tests.
1. What Problem Does This Solve
We have already read through the main e-commerce backend chain, from products, SKUs, shopping carts, orders, to payments. Now we return to a core read-heavy, write-light interface: the public product detail.
GET /api/products/{id}
Without caching, every product detail request would go through: Controller → Facade → DbService → Mapper → MySQL. This is fine in a learning project, but in a real e-commerce system, the product detail page is opened repeatedly by many users, and the same product usually doesn't change frequently in a short period. Hitting MySQL for every request is like a frontend page re-requesting the API every time a component renders, instead of reusing existing server state, which wastes resources.
In phase 9, this project added Redis Cache Aside, or "look-aside caching," for product details. It doesn't turn Redis into a database, nor does it make all business logic depend on Redis. Instead, when reading a product detail, it checks the cache first: if it's a hit, it returns directly; if it's a miss, it queries MySQL, then writes the result back to Redis. When writing a product, it doesn't directly update the cache but deletes the old cache after a successful MySQL operation, allowing the next read to rebuild the new cache.
This article aims to solve 8 problems:
- How frontend developers can understand Redis and Cache Aside;
- Why Redis is only a performance layer, not the source of truth for product data;
- How the cache key, value, and TTL for product details are designed in this project;
- What
HIT,MISS,NULL_HIT,DEGRADED, andBYPASSmean respectively; - Why cache a 404 null value;
- Why product details should still be available if Redis goes down;
- Why the cache must be deleted after creating a product, changing its status, or modifying its subtitle in the admin panel;
- How to verify caching behavior using curl, logs, Redis CLI, and tests.
If you are a frontend developer, you can think of this article as "moving the caching experience from SWR/React Query/Pinia to the server side," but the server side has a few constraints that the frontend doesn't often encounter: the cache is shared across users, cache content can be read by many backend instances, cache invalidation must coordinate with database write ordering, and cache exceptions must not escalate into core interface failures.
2. Analogy Using Frontend Knowledge
When building a list page or detail page on the frontend, you often write logic like this: first read from the in-memory cache; if not found, request the API; after a successful request, put the result in the cache; if the user modifies the data, invalidate the old cache.
Frontend-side illustrative code:
// Frontend-side illustrative code: This is a caching model to aid understanding, not the real frontend source code of this repository.
const productCache = new Map<number, ProductDetail>()
async function getProductDetail(productId: number) {
const cached = productCache.get(productId)
if (cached) {
return cached
}
const res = await request.get(`/api/products/${productId}`)
productCache.set(productId, res.data.data)
return res.data.data
}
async function updateSubtitle(productId: number, subtitle: string) {
await request.patch(`/api/admin/products/${productId}/subtitle`, { subtitle })
productCache.delete(productId)
}
Backend Redis Cache Aside is very similar to this idea:
| Frontend Concept | Backend Counterpart | File in This Project |
|---|---|---|
| memory cache / React Query cache | String cache in Redis | RedisOperatorClient |
| query key | Redis key | mall:product:detail:v1:{productId} |
| queryFn | Fallback to MySQL | ProductDbService.requirePublishedById |
| stale time / cache time | TTL | valueTtl, nullTtl |
| invalidateQueries | Delete cache | ProductDetailCacheService.evict |
| error boundary / fallback | Degrade to MySQL on Redis exception | DEGRADED |
| not found cache | Null value cache | __NULL__:PRODUCT_NOT_FOUND |
But backend caching is more dangerous than frontend caching. Frontend cache only affects the current browser; a mistake can likely be recovered by refreshing the page. Redis cache affects all users and all backend instances. If the old cache remains after a product is delisted, anonymous users might continue to see a product that should not be public. If a Redis failure is thrown directly to the Controller, a product detail that MySQL could have returned would become a 500 error. Therefore, this project does not allow the Controller or Facade to call Redis casually but confines the caching details within ProductDetailCacheService.
flowchart LR
FE[Frontend Page Local Cache] --> FE_RISK[Affects only current browser]
BE[Backend Redis Cache] --> BE_RISK[Affects all users and all instances]
BE --> RULE1[Must have TTL]
BE --> RULE2[Must invalidate after write]
BE --> RULE3[Must degrade on exception]
BE --> RULE4[Bad values must self-heal]
Frontend developers need to establish an important awareness: Redis is not a "faster database," but a "discardable, rebuildable, expirable performance layer." The moment you treat Redis as a database, you start putting important state only in Redis, eventually encountering problems like data loss on restart, cache-database inconsistency, and concurrent write overwrites. The correct mindset for product detail caching is: use Redis if it's there, query MySQL if it's not; delete or bypass Redis if it's wrong; MySQL is the source of truth.
3. Explanation of Core Backend Concepts
3.1 Redis Is an Independent Service, Not a Java Map
RedisOperatorClient uses StringRedisTemplate under the hood. This means Redis is not inside the JVM process but is an independent network service. Every get, setEx, and delete call in Java code requires a network connection to Redis. It is faster than MySQL, but problems like connection failures, timeouts, serialization errors, and service restarts can still occur.
This project encapsulates only 3 basic actions:
public String get(String key) {
return stringRedisTemplate.opsForValue().get(key);
}
public void setEx(String key, String value, Duration ttl) {
stringRedisTemplate.opsForValue().set(key, value, ttl);
}
public Boolean delete(String key) {
return stringRedisTemplate.delete(key);
}
The EX in setEx can be understood as "set the value while setting an expiration time." Without a TTL, the cache could permanently store old product details, which is dangerous for visibility and product information updates.
3.2 The Read Flow of Cache Aside
The read flow of Cache Aside is:
sequenceDiagram
participant FE as Frontend or curl
participant C as ProductController
participant F as ProductFacade
participant Cache as ProductDetailCacheService
participant Redis as Redis
participant DB as MySQL
FE->>C: GET /api/products/{id}
C->>F: getPublishedProduct(id)
F->>Cache: lookup(id)
Cache->>Redis: GET mall:product:detail:v1:{id}
alt HIT
Redis-->>Cache: JSON product detail
Cache-->>F: HIT and ProductDetailResponse
else MISS
Redis-->>Cache: null
Cache-->>F: MISS
F->>DB: Query public product and enabled category
DB-->>F: Product detail
F->>Cache: put(id, response)
Cache->>Redis: SET key JSON EX valueTtl
end
F-->>C: ProductDetailResponse
C-->>FE: ApiResponse
It's called look-aside caching because the business code still knows about the existence of MySQL. Redis does not actively synchronize with MySQL, and MySQL does not automatically update Redis. Whether to read the cache, when to fall back to the source, when to write the cache, and when to delete the cache are all decided by the business layer.
3.3 The Write Flow of Cache Aside
The write flow is not "update MySQL and then conveniently update Redis," but "update MySQL and then delete Redis." In this project, after product creation, status change, and subtitle modification, productDetailCacheService.evict(productId) is called.
flowchart TD
A[Admin writes product] --> B[Write MySQL first]
B --> C{Is MySQL successful?}
C -->|No| D[Return error directly]
C -->|Yes| E[Delete product detail cache]
E --> F[Next read request MISS]
F --> G[Fallback to MySQL to build new cache]
Why not update the cache directly? Because the product detail response might be assembled from multiple tables, not just the mall_product table. If the product detail later includes categories, SKUs, images, and seller info, you would need to reassemble the complete JSON for every write, which is prone to missing fields. Deleting the cache is simpler: let the next read request rebuild it based on the database.
3.4 Null Value Caching Solves Cache Penetration
If someone continuously requests non-existent product IDs, like /api/products/99999999, Redis will always be a MISS, and MySQL will have to query each time, always finding nothing. This is called cache penetration. One solution is to cache a short-TTL "null value marker." This project uses the __NULL__: prefix to store a stable 404:
mall:product:detail:v1:99999999 = __NULL__:PRODUCT_NOT_FOUND
The next time the same non-existent product is requested, ProductDetailCacheService.lookup will return NULL_HIT, and ProductFacade will directly restore the PRODUCT_NOT_FOUND business error without hitting MySQL again. The null value cache TTL is shorter than that of normal products, defaulting to 30 seconds in this project, to avoid blocking for too long if "the ID is actually created later."
flowchart TD
A[Request non-existent product] --> B[Redis MISS]
B --> C[MySQL query finds nothing]
C --> D[Write short-TTL null value cache]
D --> E[Subsequent same requests NULL_HIT]
E --> F[Directly return PRODUCT_NOT_FOUND]
F --> G[Auto-expire after 30 seconds]
3.5 Degradation and Self-Healing
Caching is a performance layer, so a Redis read failure must not cause the product detail interface to fail. This project converts Redis exceptions into DEGRADED, allowing ProductFacade to continue querying MySQL. This is fail-open. It's similar to frontend degradation: if the local cache is corrupted, re-request the API instead of letting the page crash.
Bad JSON is also a problem. If Redis stores a string that cannot be deserialized into ProductDetailResponse, every read will fail. ProductDetailCacheService handles this by deleting the bad value and treating it as a MISS, letting the request fall back to MySQL. This action is called self-healing. The point of self-healing is not to "cover up the error" but to prevent a bad cache from continuously affecting subsequent requests.
flowchart LR
A[Redis normal] --> B[HIT or MISS]
C[Redis connection failure] --> D[DEGRADED]
D --> E[Fallback to MySQL]
F[Bad JSON in Redis] --> G[Delete bad cache]
G --> H[Fallback after MISS]
4. Corresponding Files in This Project
| File | What You Should Focus On |
|---|---|
backend/service/src/main/java/com/example/fullstackmall/service/product/ProductFacade.java |
How getPublishedProduct orchestrates cache and database, and how evict is called after write operations |
backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheService.java |
Key, JSON, normal TTL, null TTL, bad JSON, self-healing, degradation |
backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheLookup.java |
Using a structured object to express HIT, MISS, NULL_HIT, DEGRADED, BYPASS |
backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheStatus.java |
Cache query status enum |
backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheProperties.java |
@ConfigurationProperties binding for cache switch and TTL |
backend/service/src/main/java/com/example/fullstackmall/service/cache/RedisOperatorClient.java |
Thin wrapper over StringRedisTemplate |
backend/service/src/main/resources/application.yml |
Redis connection and mall.cache.product-detail configuration |
ProductDetailCacheServiceTest |
Does not depend on real Redis; uses mocks to verify caching details |
ProductFacadeCacheTest |
Verifies Facade orchestration: hit doesn't query DB, MISS backfills, DEGRADED falls back |
RedisContainerTest |
After explicit enabling, uses a real Redis container to verify TTL, GET, DEL |
The overall relationship is as follows:
flowchart TD
Controller[ProductController] --> Facade[ProductFacade]
Facade --> Lookup[ProductDetailCacheLookup]
Facade --> Cache[ProductDetailCacheService]
Cache --> RedisClient[RedisOperatorClient]
RedisClient --> Template[StringRedisTemplate]
Template --> Redis[(Redis)]
Facade --> ProductDb[ProductDbService]
Facade --> CategoryDb[CategoryDbService]
ProductDb --> MySQL[(MySQL mall_product)]
CategoryDb --> MySQL
Config[application.yml] --> Props[ProductDetailCacheProperties]
Props --> Cache
5. Reading the Source Code Section by Section
5.1 ProductFacade.getPublishedProduct Is the Cache Orchestration Entry Point
The key code is in ProductFacade.getPublishedProduct. It first calls:
ProductDetailCacheLookup cacheLookup = productDetailCacheService.lookup(productId);
Then it processes based on the status branch:
HIT: Directly returns theProductDetailResponsefrom the cache;NULL_HIT: Restores the business code from the null value cache into aBusinessException;MISS,DEGRADED,BYPASS: All continue to query MySQL;- After a successful MySQL query, calls
productDetailCacheService.put(productId, response)to backfill the cache; - When MySQL returns a stable 404, calls
putNullto write a short-TTL null value.
What's most worth learning for frontend developers in this code is the "explicit cache state." Don't use a single null to represent all outcomes. Is null a missing key, a Redis error, a disabled cache, or a cached null value? If they are all mixed together, the caller can only guess. This project uses ProductDetailCacheLookup to clearly name each state, making the business branches readable.
5.2 ProductDetailCacheService.lookup Is Responsible for Reading the Cache
The first step of lookup is to check the cache switch:
if (!properties.isEnabled()) {
return ProductDetailCacheLookup.bypass();
}
This is very useful for local troubleshooting. If you suspect Redis is interfering with results, you can start the service with MALL_PRODUCT_CACHE_ENABLED=false to make product details query MySQL directly.
The second step is to read from Redis:
try {
cachedValue = redisOperatorClient.get(key);
} catch (RuntimeException exception) {
return ProductDetailCacheLookup.degraded();
}
This catch is not out of laziness but explicitly executes fail-open. The source of truth for product details is MySQL; Redis is just an acceleration layer. If Redis is temporarily unavailable, a slightly slower interface is acceptable; a direct 500 is not.
The third step is to distinguish between MISS, NULL_HIT, normal JSON, and bad JSON. Bad JSON triggers evictBrokenValue, which deletes it and returns a MISS. This prevents subsequent requests from repeatedly hitting the same bad value.
5.3 put and putNull Have Different TTLs
Normal product details use valueTtl, defaulting to 10 minutes; null value caches use nullTtl, defaulting to 30 seconds. Why the difference?
Normal product details are cached longer to reduce database pressure from hot products. Null value caches are short-lived to prevent penetration while avoiding the long-term storage of a "temporarily non-existent" conclusion. The frontend has a similar experience: successful API data can be cached longer, but error states should generally not be cached for long periods.
flowchart LR
A[Normal Product Detail] --> B[valueTtl defaults to 10 minutes]
C[Non-existent Product Null Value] --> D[nullTtl defaults to 30 seconds]
B --> E[Reduce hot reads on DB]
D --> F[Mitigate penetration without overly blocking new data]
5.4 Why evict Swallows Redis Deletion Exceptions
The comment on evict is important: after a successful database write, delete the old cache; if the deletion fails, do not roll back the database, and rely on TTL as the ultimate fallback. In other words, when writing a product, MySQL has already succeeded. If the Redis deletion fails, you cannot roll back the database modification because of a cache failure. Otherwise, the user would see "modification failed," but what the business truly needs is a successful database update.
This is also a difference between backend and frontend. If a frontend local cache modification fails, refreshing the page usually fixes it. On the backend, if a Redis deletion failure is escalated to a business failure, it creates a larger availability problem. This project chooses "log the error + TTL fallback." The cost is that old cache might be read for a short time; the benefit is that core writes are not dragged down by the caching system.
5.5 Post-Write Invalidation Points in ProductFacade
This project has 3 types of product write operations that delete the product detail cache:
- After creating a product,
evict(product.getId())is called to clean up null value caches that might have been generated by "guessing IDs in advance"; - After a status change,
evict(productId)is called, because listing/delisting affects the visibility of public details; - After modifying the subtitle,
evict(productId)is called, because thesubtitlein the public detail response has changed.
flowchart TD
A[Product creation successful] --> E[evict product detail cache]
B[Product status change successful] --> E
C[Product subtitle modification successful] --> E
E --> R[Next read falls back to MySQL]
R --> P[Backfill new cache]
Pay special attention to status changes. After a product changes from ON_SALE to OFF_SALE, if the cache is not deleted, anonymous users might continue to see the delisted product through the public detail. This is not a performance issue but a business visibility error.
6. Local Execution and curl Verification
6.1 Start MySQL and Redis
# Start local dependencies in the project root directory
docker compose -f docker-compose.dev.yml up -d mysql redis
Then start the backend:
cd backend
mvn -Dmaven.repo.local=$PWD/.m2-repository -pl service -am spring-boot:run
The default Redis configuration is in application.yml: host=localhost, port=6379, timeout=2s. The product detail cache configuration is under mall.cache.product-detail: enabled=true, value-ttl=10m, null-ttl=30s.
6.2 Observe Caching with Consecutive Product Detail Requests
curl -sS http://localhost:8080/api/products/1
curl -sS http://localhost:8080/api/products/1
docker exec fullstack-mall-redis redis-cli GET mall:product:detail:v1:1
docker exec fullstack-mall-redis redis-cli TTL mall:product:detail:v1:1
The first request is likely a MISS and falls back to MySQL; the second request should hit Redis. You can observe the server logs for product_detail_cache event=MISS, event=PUT, event=HIT.
6.3 Verify Null Value Caching
curl -i http://localhost:8080/api/products/999999
docker exec fullstack-mall-redis redis-cli GET mall:product:detail:v1:999999
docker exec fullstack-mall-redis redis-cli TTL mall:product:detail:v1:999999
If it returns __NULL__:PRODUCT_NOT_FOUND, the null value cache is working. The next time the same ID is requested, the backend does not need to query MySQL but restores the 404 directly from the null value cache.
6.4 Verify Post-Write Invalidation
After logging in as an admin, modify the subtitle:
ADMIN_TOKEN='Paste admin accessToken'
curl -sS -X PATCH http://localhost:8080/api/admin/products/1/subtitle \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"subtitle":"Cache Invalidation Verification Subtitle"}'
docker exec fullstack-mall-redis redis-cli GET mall:product:detail:v1:1
If the deletion is successful, GET should return empty. The next access to GET /api/products/1 will re-query MySQL and write the new subtitle into the cache.
6.5 Disable Cache for Troubleshooting
MALL_PRODUCT_CACHE_ENABLED=false \
SERVER_PORT=18080 \
java -jar service/target/fullstack-mall-service-0.0.1-SNAPSHOT.jar
After disabling, ProductDetailCacheService.lookup returns BYPASS, and product details query the database directly. This switch is very useful when troubleshooting "why is the page seeing old data": if you see the new value after disabling the cache, the problem is with the old Redis value or invalidation; if you still see the old value after disabling the cache, the problem is with the MySQL write or the response assembly.
7. Common Mistakes
7.1 Treating Redis as the Source of Truth
Don't let "what's in Redis" determine the real status of a product. Whether a product is listed, whether a category is enabled, and what the subtitle is, all rely on MySQL. Redis is just a cached JSON copy of the ProductDetailResponse.
7.2 Deleting the Cache Before Writing to the Database
If you delete the cache first and then the database write fails, the next read will fall back to the old data and re-cache the old value. The correct order is to delete the cache after a successful MySQL operation.
7.3 Only Updating the Cache, Not Deleting It
Directly updating the cache seems real-time, but it's easy to miss composite fields and can be inconsistent with database transaction boundaries. This project chooses post-write invalidation, letting the next read rebuild.
7.4 Null Value Cache TTL Too Long
Null value caching is for preventing penetration, not for permanently saving "non-existence." If the TTL is too long, users might still see a 404 for a product that was later created or re-listed. This project defaults to 30 seconds, a moderate compromise in a learning project.
7.5 Directly Throwing Redis Exceptions
The public product detail is a core read interface. If Redis goes down, it should fall back to MySQL more slowly, not directly return a 500. Only scenarios where Redis is used as a source of truth require a separately designed strong-dependency failure strategy.
7.6 Not Handling Bad JSON
A cached value can become corrupted due to manual debugging, version upgrades, or serialization changes. If the bad value is not deleted, every request will repeatedly fail. Self-healing by deleting the bad value allows the system to automatically return to the healthy path of MISS → fallback → backfill.
7.7 Cache Key Without a Version Number
The key prefix in this project is mall:product:detail:v1:. The v1 might seem superfluous, but when the ProductDetailResponse field structure changes significantly in the future, you can switch to v2, and the old cache will naturally not be misread by the new code.
7.8 Thinking All Interfaces Are Suitable for Caching
Product details are suitable for caching because they are read-heavy, write-light, and allow short-term eventual consistency. Strongly consistent write paths like inventory deduction, order payment, idempotency keys, and payment callbacks cannot simply rely on caching. Frontend developers need to distinguish between "display-type read data" and "transactional write data."
8. Chapter Exercises
- Explain in your own words the difference between
HIT,MISS,NULL_HIT,DEGRADED, andBYPASS. - Find
ProductDetailCacheService.buildKeyand explain why the key needsv1. - Consecutively request
GET /api/products/1twice and observe ifMISS,PUT, andHITappear in the logs. - Request a non-existent product and check the null value cache and its TTL in Redis.
- After modifying a product subtitle, check if the Redis key has been deleted.
- If you manually write a piece of bad JSON into Redis, explain how this project will self-heal.
- Explain why a successful MySQL modification should not be rolled back if the Redis deletion fails.
- Draw a link diagram for "directly querying MySQL after disabling the cache."
9. Next Chapter Preview: Complete Development Practice for Admin Modifying Product Subtitle
The next chapter will treat "admin modifying product subtitle" as a real requirement, walking through the entire process from database fields, DTOs, Facade contracts, DbService, Controller, permissions, cache invalidation, manual curl verification, to common debugging points. It will feel more like the first small task you receive after joining a backend team: it looks like just adding a field, but it actually traverses SQL, Entity, Request, Response, Service, Controller, Security, caching, and testing. Mastering this process is more important than memorizing Spring annotations.
10. Chapter Summary
These are the conclusions you need to take away from this chapter: Redis is an independent service, not a Java Map; product detail caching uses Cache Aside; on read, check Redis first, and query MySQL on MISS or DEGRADED; on write, modify MySQL first, then delete the cache; normal values and null values should use different TTLs; null value caching is used to mitigate cache penetration; Redis exceptions should fail-open; bad JSON should be deleted for self-healing; cache keys should have a business prefix and version; public product detail caching is a performance optimization and cannot replace the database as the source of truth.
If you can clearly explain "why delete the cache after a write instead of updating it" and can verify the MISS → PUT → HIT → EVICT → MISS chain using curl, you have mastered the core backend thinking behind this project's Redis caching.
Appendix A: Explaining Cache Aside to the Point of Designing It Yourself
We've finished reading the product detail cache in the current project, but to truly write a cache independently, you need to think through several design questions. First, what granularity to cache? This project caches the public product detail response, not the ProductEntity alone. The benefit is that it doesn't need reassembly when returning to the frontend, making the hit path the shortest; the cost is that this cache depends on all data affecting the detail response. Once the product, category, or future images/SKU summaries change, invalidation must be considered. Frontend developers can analogize: do you cache the API response, or many small atomic states? Caching the API response is simple but has a coarser invalidation scope; caching atomic states is flexible but has a higher assembly cost.
Second, is the cache key stable? mall:product:detail:v1:{productId} contains at least 4 layers of meaning: mall represents the business system, product represents the module, detail represents the scenario, v1 represents the structure version, and finally the product id. Don't just write product:{id}, because as the project grows, keys will conflict and batch troubleshooting will be difficult. The version number is especially important. Suppose after chapter 20, you change the product detail response to include an image list. The old Redis will still contain the old JSON. If the key has no version, you'll have to write complex compatibility logic; with v1, you can directly switch the new code to v2, and the old cache will naturally expire.
Third, should the cache value be compressed or split into fields? The current project uses Jackson to serialize ProductDetailResponse into a JSON string, which is suitable for learning and troubleshooting because redis-cli GET can read it directly. Real high-traffic projects might consider compression, binary serialization, Hash structures, or local second-level caches, but these optimizations should be done after you can prove a bottleneck. When frontend developers first transition to the backend, don't pursue complex caching architectures right away. Getting the key, TTL, invalidation, degradation, and observability right first is more important than introducing more middleware.
Fourth, which errors can be cached? This project only caches stable 404s: PRODUCT_NOT_FOUND and CATEGORY_NOT_FOUND. Don't cache all exceptions as null values. For example, temporary database failures, Redis timeouts, permission errors, parameter errors, and inventory conflicts should not become "this product does not exist." When caching an error, you must ask yourself: is this error stably true for the same key in a short period? If not, don't cache it. The frontend is similar: a 500 from an API should not be written into a long-term cache, otherwise the user won't see the recovered data even after refreshing.
Fifth, how long should the TTL be? There is no standard answer; it depends on the acceptable staleness for the business. Product titles, subtitles, and descriptions allow for a few minutes of eventual consistency, so a 10-minute TTL is acceptable. Product listing/delisting affects visibility, so write operations must actively evict and not just wait for the TTL. If inventory and price are used for order settlement, you cannot just trust the display value in the cache; the order creation must re-query the database and the current SKU status. This project already emphasized in the order chapter: the order price cannot trust the frontend parameters, and similarly cannot trust the display price in the product detail cache.
flowchart TD
A[Design a cache] --> B{What is the cache granularity?}
B --> C[API Response]
B --> D[Domain object or field]
A --> E{Does the key have module and version?}
E --> F[Facilitates troubleshooting and upgrades]
A --> G{Which errors can be cached?}
G --> H[Only cache short-term stable 404s]
A --> I{How to invalidate on write operations?}
I --> J[Delete cache after MySQL success]
A --> K{How to degrade on exceptions?}
K --> L[Redis failure falls back to MySQL]
Appendix B: The Real Meaning of the 5 Cache States
There are 5 states in ProductDetailCacheStatus, not for the sake of complexity, but so the business layer doesn't have to guess.
HIT means normal JSON exists in Redis and can be deserialized into ProductDetailResponse. In this case, MySQL is not queried, offering the best performance. But you must also accept that it might not be the latest value, as caching inherently allows short-term staleness.
MISS means Redis does not have this key, or had a bad value that has been deleted. MISS is not an error; it just means a fallback is needed this time. In many normally operating caching systems, a large number of keys will MISS on first access and then be backfilled.
NULL_HIT means a short-TTL null value marker was hit. It is not a simple null but carries a business code, such as PRODUCT_NOT_FOUND. This allows the Facade to restore the correct error response instead of arbitrarily returning a 404 message.
DEGRADED means a Redis operation threw an exception, such as a connection failure, timeout, or command error. It differs from MISS: MISS is Redis normally telling you there is no data; DEGRADED is Redis itself being unavailable. Both subsequently fall back to MySQL, but their logging and operational significance differ. Too many MISSes might mean the cache just started or the TTL is too short; too many DEGRADEDs indicates a problem with the Redis service or network.
BYPASS means the configuration has disabled the cache. It is typically used for local troubleshooting and testing and does not represent a Redis failure. After disabling the cache, all product details query MySQL directly. You must be able to distinguish BYPASS from DEGRADED through logs, otherwise you might misdiagnose "I disabled the cache myself" as "Redis is broken" during troubleshooting.
These 5 states also embody backend observability thinking: business results, system status, and configuration behavior must be clearly separated. Frontend developers can also learn from this when writing pages: don't use a single loading=false && data=null to represent all failures; it's better to distinguish between unrequested, requesting, success, business empty, network error, and permission error.
Appendix C: Why Cache Consistency Can Only Be "Reasonable," Not "Magic"
Many beginners ask: is there a caching solution that is always a hit, always up-to-date, unaffected by Redis downtime, and has no extra cost for writes? The answer is no. A caching system is essentially a trade-off between performance, consistency, complexity, and availability.
This project chooses a trade-off suitable for product details: the read path prioritizes performance, the write path prioritizes database correctness, cache exceptions prioritize interface availability, and consistency is achieved through active invalidation and a TTL fallback. It is not a strongly consistent cache. If the admin just modified a subtitle and the Redis deletion fails, the user might read the old subtitle until the TTL ends; but the system will not cause the product modification to fail because the Redis deletion failed. This trade-off is acceptable for product details but not necessarily for payment status.
Frontend developers can understand it this way: if you set staleTime=10min with React Query, you are implicitly allowing old data to be reused for 10 minutes; if invalidateQueries fails after a mutation, the page might be stale for a while. But for a payment success status, you wouldn't rely solely on the frontend cache to judge; you would re-request the server. Backend Redis is the same: display data can be cached, but transactional data must go back to the database and transactions.
flowchart LR
A[Cache Trade-offs] --> B[Performance]
A --> C[Consistency]
A --> D[Availability]
A --> E[Complexity]
B --> F[More hits, faster]
C --> G[Newer is harder]
D --> H[Failures must be degradable]
E --> I[More complex solutions are harder to maintain]
Appendix D: What Interviews and Code Reviews Will Ask
If you put this project on your resume and say you implemented Redis product detail caching, an interviewer will likely ask: How is the cache key designed? Why is there a version? Why are the TTLs for normal and null values different? How to prevent cache penetration? What happens if Redis goes down? Why delete the cache instead of updating it on writes? What happens if the cache deletion fails? How to handle bad JSON in the cache? How to prove a cache hit didn't query the database? How to cover these behaviors with tests?
You can answer using this project: the key uses mall:product:detail:v1:{id}; normal value defaults to 10 minutes, null value defaults to 30 seconds; stable 404s write a __NULL__: null marker; Redis read/write exceptions do not affect the main product detail flow, read failures return DEGRADED and fall back to MySQL, write and deletion failures are logged; bad JSON is deleted and treated as a MISS; evict is called after product creation, status change, and subtitle modification; ProductFacadeCacheTest verifies orchestration, ProductDetailCacheServiceTest verifies details, and RedisContainerTest verifies real Redis TTL.
During code reviews, you can also inversely check others' cache code: Did they throw Redis exceptions directly to the Controller? Did they cache non-stable errors? Did they forget post-write invalidation? Did they put strongly consistent data like inventory only in Redis? Is there observable logging? Are there tests for bad JSON and Redis failures? These questions reflect backend capability more than "can you write redisTemplate.opsForValue().get."
Appendix E: Applying Product Detail Caching to a Real Troubleshooting Session
Suppose you encounter this problem locally: the admin has changed the product subtitle from "Old Subtitle" to "New Subtitle," the admin API returns success, and you can see mall_product.subtitle has changed in the database, but when an anonymous user accesses GET /api/products/{id}, the page still shows the old subtitle. A frontend developer's first reaction is usually to check the page state, browser cache, and whether the API was actually re-requested; these all need to be checked, but backend troubleshooting must go further because this project's product detail has a Redis caching layer in front.
First, confirm whether the frontend actually requested the public detail interface, not the admin interface. Seeing a new value in the admin panel doesn't guarantee the public detail is new, because the public detail goes through ProductFacade.getPublishedProduct and ProductDetailCacheService.lookup. Second, take the traceId from the response and look in the logs to see if a cache hit log appeared for this request. If it was a HIT, the return value came from Redis, not a fresh MySQL query. Third, use redis-cli GET mall:product:detail:v1:{productId} to see if the cache contains the old JSON. If it's the old value, then check if the write interface called productDetailCacheService.evict(productId).
This troubleshooting process is excellent training for transitioning from frontend to backend: the frontend only sees "the API returned old data," but the backend must be able to break down the old data into multiple sources: it could be the browser cache, a CDN, Redis, an uncommitted database transaction, a read/write split, or a wrong field used during DTO assembly. This project currently has no CDN or read/write splitting, so the focus is on the Controller path, Facade cache orchestration, Redis key, MySQL fields, and cache invalidation points. When you enter more complex projects later, you'll also start by eliminating simple links before expanding the scope.
If you want to turn this into a stable exercise, follow this sequence: first, access the public detail to create a cache; then call the admin subtitle modification API; then immediately access the public detail again; finally, observe whether the new subtitle is returned. The correct result should be the new value, because updateSubtitle deletes the cache after a successful database update. If you temporarily comment out evict, you can reproduce the "stale cache" problem. Note that this is just a learning experiment; don't commit such destructive changes to the codebase.
Appendix F: Cache Hit Rate Is Not the Only Goal
Many tutorials emphasize hit rate when talking about Redis, but for business systems, hit rate is just one metric, not the only goal. The product detail interface has at least 4 goals: first, anonymous access must be fast enough; second, non-existent products should not repeatedly hit the database; third, the interface should still be able to read MySQL when Redis fails; fourth, after an admin update, the public detail should not return old values for a long time. A high hit rate only proves Redis is being used, not that the business is correct.
The frontend can analogize this to lazy loading images: loading images quickly is good, but if the wrong product image is shown, the experience is meaningless no matter how fast it is. Caching is the same. Hitting old data, hitting data with wrong permissions, or hitting an expired price is all "fast but wrong." Therefore, this project caches public product details, not user private data; and write operations like creation, listing/delisting, and subtitle modification all delete the corresponding product cache. Public details allow short-term eventual consistency, but after a successful admin write, the next read should fall back to the source to generate a new cache as soon as possible.
When designing cache metrics in practice, you can divide them into 3 categories. Performance metrics include average latency, P95 latency, Redis hit rate, and MySQL query count. Correctness metrics include the duration of old values after a write, the number of false positives from null value caching, and the number of bad JSON self-healings. Stability metrics include the number of Redis exceptions, degradation count, and the number of 5xx errors on the interface. Frontend developers should also build this multi-metric awareness when looking at monitoring: looking at just one number often hides the real problem.
Appendix G: Why This Project Didn't Introduce Complex Caching Solutions from the Start
You may have heard of concepts like cache breakdown, cache avalanche, cache warm-up, distributed locks, local second-level caches, and message queue-based cache deletion. They all genuinely exist, but they are not suitable as the starting point for this series' first Redis hands-on. The easiest trap to fall into when learning the backend is "noun-driven development": wanting to use a concept as soon as you hear about it, making the code increasingly complex without understanding what problem each layer of complexity solves.
This project currently chooses the minimal closed loop: Cache Aside, TTL, null value caching, post-write invalidation, Redis exception degradation, and bad JSON self-healing. This combination already covers the core read caching problems in a learning e-commerce system. Only when you can prove through stress testing or logs that a product is hit by a large number of concurrent requests to the database at the moment of cache invalidation should you discuss mutex locks or logical expiration; only when you can prove that a large number of keys expiring simultaneously causes database jitter should you discuss TTL randomization; only when the admin panel frequently updates products in batches and requires millisecond-level consistency should you discuss message queues or binlog subscription.
Frontend developers can analogize this to component state management. When only two components on a page share state, props and events are enough; when dozens of pages share state, need persistence, and need debugging tools, then introduce Pinia or Redux. Backend middleware is the same: don't push all problems onto Redis just because it's common. Write correct database queries and clear business boundaries first, then use caching to optimize hot reads. This is a more stable growth path.
Appendix H: Reference Answers for This Chapter's Self-Test Questions
If an interviewer asks "Can product details still be accessed if Redis goes down?", the reference answer from this project is: Yes, but performance will fall back to MySQL. Because ProductDetailCacheService.lookup catches Redis read exceptions and returns DEGRADED, and ProductFacade.getPublishedProduct continues to use ProductDbService and CategoryDbService to fall back to the source upon receiving DEGRADED. A cache write failure also won't cause the interface to fail, because the source of truth for product details is MySQL, and the cache is only a performance layer.
If asked "Why cache a non-existent product?", the reference answer is: To prevent cache penetration. A large number of requests for non-existent product IDs, if each one results in a MISS and then a MySQL query, would create meaningless pressure on the database. This project uses putNull to save a short-TTL null value and records the corresponding business code. The next time a NULL_HIT occurs, the business exception is restored directly. But the null value TTL must be shorter than the normal value TTL to avoid a product being considered non-existent for a long time after it is later created or its status changes.
If asked "Should the JSON in Redis be updated after modifying a product subtitle?", the reference answer is: This project chooses to delete the cache rather than update it. Deletion is simpler and avoids inconsistencies in assembling the cache value from multiple write entry points. The next public detail request will fall back to MySQL, reassemble the ProductDetailResponse, and write the new cache. For the learning phase and most business backends, this strategy is easier to verify and maintain.
Appendix I: The 6 Most Easily Missed Questions During Cache Code Review
When reviewing cache code, don't just look at "whether Redis is used." First, check if the key has a clear namespace and version; second, check if the TTL distinguishes between normal and null values; third, check if Redis exceptions will drag down the main flow; fourth, check if deserialization failures can delete the bad cache for self-healing; fifth, check if all write entry points invalidate the same key; sixth, check if the business layer still treats MySQL as the standard. If any one of these is missing, the cache can turn from a performance optimization into a source of failure.
This project's product detail cache basically covers these 6 points, making it suitable as a template for your future caching features. You can copy this line of thinking first, rather than copying the specific code. Because different projects may have different serialization methods, Redis clients, and logging frameworks, but the design questions are unchanging: what to do if it can't be read, what to do if the write fails, what to do with old data, what to do if it doesn't exist, and how to verify it.
Appendix J: Understanding Server-Side Cache Responsibility from Browser Caching
Browser caching usually only affects the current user or current device; when problems occur, clearing the cache or force-refreshing can temporarily bypass them. Server-side Redis caching affects all users accessing the same key, so the responsibility is heavier. If a product detail key stores an old value, all anonymous users might see the old value; if a null value cache has an excessively long TTL, all users might believe the product does not exist for a period of time. This is why backend caching must have unified key rules, unified invalidation points, and observable logs.
When frontend developers write page caches, they often clean up state when a component unmounts or a route switches; when writing backend caches, they must also find the corresponding cleanup action for every write operation. Creation, update, listing/delisting, deletion, batch import, and backend data fixes can all change the public detail. The current project first covers the core write entry points. If images, price displays, or category name modifications are added later, the invalidation scope must continue to expand. Caching is not finished once written; it is a layer of contract that is continuously maintained as the business evolves.
Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.
Like
Keep it up