Redis Isn't localStorage: A Backend Caching Primer for Frontend Devs
This article is written for frontend developers who are already familiar with frontend memory cache, localStorage, interface caching, and state management, but have not yet systematically studied Redis. We will use the real code from the current
fullstack-mallbackend project to explain what Redis is, and then cover key, value, TTL, cache hit, cache miss, null-value caching, cache invalidation, degradation, and Cache Aside. The Vue / Axios / TypeScript snippets in the text are only "frontend-side illustrative code" to help understand backend caching concepts; the current repository does not contain real frontend source code.
1. What Problem Does This Article Solve
In the previous 6 articles, we have established a map of the backend project: how Spring Boot starts, how the Controller receives HTTP requests, how Request/Response is defined, how MySQL tables store real data, how MyBatis-Plus connects Java Entities to database tables, and how Spring Security identifies the current user. Next, we will enter a very high-frequency infrastructure in backend performance and stability: Redis.
When many frontend developers hear about Redis, they first think of "caching." This direction is correct, but if you only understand Redis as "something similar to localStorage," you will miss many backend-specific issues: Redis is an independent process, not in the browser; it is accessed over the network, not a regular Java Map; it stores data shared across requests, users, and service instances; it may crash, time out, cache old values, cache non-existent data, and may cause data overwrites due to poorly designed keys.
The current project has already introduced Redis caching in the product detail interface. It does not let business code directly call Redis everywhere but is divided into several layers:
RedisOperatorClientencapsulates low-level Redis string commands;ProductDetailCachePropertiesreceives product detail cache configuration;ProductDetailCacheServiceis responsible for product detail cache key, JSON, TTL, null values, deletion, and degradation;ProductDetailCacheLookupuses a structured object to representHIT,MISS,NULL_HIT,DEGRADED,BYPASS;ProductFacade.getPublishedProductorchestrates the query order of Redis and MySQL.
This article solves these problems:
- What exactly is Redis, and how does it differ from MySQL, Java memory, and frontend caching;
- Why product details are suitable for caching, while order creation and payment callbacks cannot be casually cached;
- What are key, value, and TTL, and why business caches must set an expiration time;
- What are
StringRedisTemplate, Lettuce, and Spring Data Redis respectively; - How the current project encapsulates
GET,SET EX,DELthroughRedisOperatorClient; - What are cache hit
HIT, missMISS, and null-value hitNULL_HIT; - Why we should degrade to querying MySQL when Redis fails, instead of making the interface fail directly;
- Why we should delete the cache after a database update, instead of blindly trusting the old cache;
- How to use Docker,
redis-cli, curl, and logs to verify caching behavior; - The most common pitfalls for beginners learning Redis caching.
After studying this article, you don't need to immediately master all of Redis's data structures, nor do you need to memorize details about Redis clusters, sentinels, or persistence. You need to first establish the most commonly used caching mindset for backend engineers: MySQL is the source of truth, Redis is the performance layer; return quickly on a cache hit, fall back to MySQL on a cache miss; invalidate the old cache after a successful database write; when Redis has problems, core interfaces should try to fail-open, meaning fall back to MySQL queries, rather than becoming unavailable along with the cache.
2. Analogy with Frontend Knowledge: From Browser Cache to Server-Side Shared Cache
In frontend development, you have definitely used something similar to caching. For example, caching product details in component memory:
// Frontend-side illustrative code: component-level memory cache, does not represent a file in the repository
const detailCache = new Map<number, ProductDetail>()
async function getProductDetail(id: number) {
if (detailCache.has(id)) {
return detailCache.get(id)
}
const response = await http.get(`/api/products/${id}`)
detailCache.set(id, response.data.data)
return response.data.data
}
You might also use localStorage for simple caching:
// Frontend-side illustrative code: browser local cache, only affects the current browser
function saveRecentProduct(product: ProductDetail) {
localStorage.setItem(`recent-product:${product.id}`, JSON.stringify(product))
}
These frontend caches solve the problem of "sending fewer requests for the current page or browser." But they have several obvious limitations:
| Frontend Cache | Characteristics | Limitations |
|---|---|---|
| Component Variable / Map | Fast, lost on page refresh | Only valid for the current page instance |
| Pinia / Vuex / Redux | Shared across components | Usually lost after refresh, unless persisted |
| localStorage | Can be persisted to the browser | Belongs only to the current user's current browser, limited security |
| HTTP cache | Browser and CDN can participate | Affected by request headers, resource type, and caching strategy |
Redis is a server-side cache. It is not a cache in the user's browser, but an independent service deployed next to the backend. All requests, all users, and even multiple backend instances can access the same Redis. Therefore, the problems it can solve are different from frontend caching:
| Frontend Concept | Backend Redis Concept | Current Project Example |
|---|---|---|
Map<string, any> |
Redis key-value | mall:product:detail:v1:1 |
| localStorage expiration strategy | TTL | Product detail 10m, null value 30s |
| Checking memory before an Axios request | Cache Aside: check Redis first | ProductFacade.getPublishedProduct |
| Requesting API if not in local cache | Query MySQL after Redis MISS | requirePublishedById |
| User refresh may lose cache | Redis independent process saves | redis service in docker-compose.dev.yml |
| Frontend clears cache | Backend deletes key after writing to DB | productDetailCacheService.evict(productId) |
But note a key difference: if the frontend cache is wrong, usually only one user's page shows old data; if the backend cache is wrong, all users might see old product details, potentially affecting order prices, inventory judgments, and permission checks. Therefore, backend caching must be more restrained: which data can be cached, how long, how to invalidate after a write, and what to do if Redis goes down, all need explicit design.
3. Explanation of Core Backend Concepts
3.1 Redis is an Independent In-Memory Data Service
Redis is not a Java class or a Spring Bean; it is an independently running service process. The current project starts Redis via docker-compose.dev.yml:
redis:
image: redis:7.4-alpine
container_name: fullstack-mall-redis
ports:
- "6379:6379"
volumes:
- fullstack-mall-redis-data:/data
command: ["redis-server", "--appendonly", "yes"]
This configuration tells us: the local Redis runs in a container, the host port is 6379, the data directory is mounted to a Docker volume, and appendonly is enabled. You don't need to delve into Redis persistence now; just know that the Java backend accesses Redis by connecting to localhost:6379 over the network.
3.2 Division of Labor Between Redis and MySQL
MySQL is the real source of data for products, categories, users, orders, payments, etc., in the current project. Redis is the performance layer in front of MySQL. What is stored in the product detail cache is "a JSON copy assembled from MySQL and business logic," not the ultimate truth.
It can be understood like this:
| Dimension | MySQL | Redis |
|---|---|---|
| Primary Responsibility | Persist real business data | Provide high-speed read/write and temporary data |
| Data Model | Tables, rows, columns, indexes, transactions | key-value, expiration time, multiple in-memory structures |
| Typical Access | SQL queries, transactional updates | GET, SET, DEL, EXPIRE |
| Role in Current Project | Real data source for products | Caching layer for product details |
| Impact of Data Loss | Severe, business data loss | Can be rebuilt from MySQL, usually acceptable |
| Consistency Requirement | Strong, must be correct | Can tolerate brief old values, but must have an invalidation strategy |
When transitioning from frontend to backend, remember one sentence: Redis is not a replacement for MySQL. It can make hot reads faster, but it cannot be the sole source of truth for critical data like orders, payments, or inventory. Especially for strongly consistent data like order amounts, inventory deductions, and payment statuses, you cannot casually move judgment logic into the cache just because "Redis is fast."
3.3 key: Address Design for Backend Caches
Redis finds a value by its key. The product detail key in the current project is centrally defined in ProductDetailCacheService:
private static final String KEY_PREFIX = "mall:product:detail:v1:";
String buildKey(Long productId) {
return KEY_PREFIX + productId;
}
When the product ID is 1, the final key is:
mall:product:detail:v1:1
This key is not randomly assembled. Each segment has meaning:
mall: Project name or business system name, to avoid key conflicts with other systems;product: Business module;detail: Type of cached object;v1: Cache structure version; when the field structure changes in the future, you can switch tov2;1: The specific product ID.
Frontend developers can analogize a key to a localStorage key, but backend keys require more standardization. Because Redis is a shared service, haphazardly named keys can easily conflict with other modules and are hard to troubleshoot.
3.4 value: The Current Project Caches JSON Strings
The current project uses StringRedisTemplate, so both keys and values are handled as strings. The value for a normal product detail is the serialized JSON of ProductDetailResponse. This means that on a cache hit, instead of re-querying the product and category tables, the JSON is directly deserialized into ProductDetailResponse and returned.
This is a very common "cache the interface response object" approach. Its advantage is that after a hit, it's very direct, with no need to reassemble fields like category names or status enums. The cost is: if the Response structure changes, the JSON in the old cache might be incompatible, so the key includes the v1 version number, allowing an upgrade to v2 if necessary.
3.5 TTL: Caches Must Have an Expiration Time
TTL stands for Time To Live, indicating how long a key can survive. The current project configures this in application.yml:
mall:
cache:
product-detail:
enabled: ${MALL_PRODUCT_CACHE_ENABLED:true}
value-ttl: ${MALL_PRODUCT_CACHE_VALUE_TTL:10m}
null-ttl: ${MALL_PRODUCT_CACHE_NULL_TTL:30s}
Normal product details are cached for 10 minutes by default, and null-value markers for non-existent products or unavailable categories are cached for 30 seconds by default. Why can't they be cached permanently? Because product titles, categories, and statuses can change. Without a TTL, an old cache could occupy memory long-term and cause users to see old data for a long time.
The comment on RedisOperatorClient.setEx also emphasizes: when writing a string, an expiration time must be set simultaneously to prevent business caches from permanently occupying memory or preserving old data long-term.
3.6 HIT, MISS, NULL_HIT, DEGRADED, BYPASS
The current project does not let lookup directly return ProductDetailResponse or null, but returns ProductDetailCacheLookup. This is done to distinguish different states:
| Status | Meaning | How Facade Handles It |
|---|---|---|
HIT |
Redis hit for a normal product detail | Return the cached response directly |
MISS |
Redis does not have this key, or a corrupted cache was deleted | Fall back to querying MySQL |
NULL_HIT |
Hit a short-TTL null-value marker | Directly restore the 404 business exception |
DEGRADED |
Redis access exception | Degrade to querying MySQL |
BYPASS |
Configuration disables caching | Directly query MySQL |
This is much clearer than simply returning null. Because null could mean "no cache," "Redis is down," or "the cache explicitly records that this product does not exist." Backend code must separate these cases, otherwise it can easily lead to incorrect behavior.
3.7 Null-Value Caching: Preventing Non-Existent IDs from Repeatedly Hitting MySQL
If a product ID does not exist, the first request queries MySQL and returns a 404. If nothing is cached, then an attacker or crawler repeatedly requesting the same non-existent ID will hit MySQL every time. This problem is a manifestation of cache penetration.
The current project uses short-TTL null-value caching for stable 404s. The value is not normal JSON, but something like:
__NULL__:PRODUCT_NOT_FOUND
__NULL__:CATEGORY_NOT_FOUND
When the same ID is requested next time, lookup sees the __NULL__: prefix, restores the corresponding business code, directly throws a 404, and no longer queries MySQL. Why is the null-value TTL only 30 seconds? Because non-existent data might be created in the future, and a category might become available again. A short TTL can reduce penetration without letting the "once non-existent" result linger too long.
3.8 Degradation: If Redis Goes Down, Product Details Remain as Available as Possible
ProductDetailCacheService.lookup catches RuntimeException when reading Redis and returns DEGRADED. ProductFacade.getPublishedProduct handles DEGRADED similarly to MISS: it continues to query MySQL.
This is called fail-open, suitable for scenarios like product details where "caching is just a performance layer." When Redis is unavailable, the interface might be slower, but it shouldn't become directly unavailable. Conversely, if a business strongly depends on Redis distributed locks or rate limiting, whether to fail-open when Redis goes down needs re-evaluation. Strategies differ for different scenarios and cannot be mechanically applied.
3.9 Cache Aside: The Look-Aside Caching Pattern
The current project uses Cache Aside. The application code checks the cache first; if there's a miss, it queries the database, and after the database query, the application code writes the result back to the cache. When writing data, it updates the database first, then deletes the cache.
For frontend developers, this pattern can be analogized to: check the local cache first, call the API if not found; after the API call succeeds, put the result back in the cache; after a user edit succeeds, clear the old cache so it reloads next time. But the backend needs to be more rigorous because the cache is shared by all users and may have concurrent reads and writes.
4. Corresponding Files in This Project
The real files corresponding to this article are as follows:
| File | Role |
|---|---|
backend/service/pom.xml |
Introduces spring-boot-starter-data-redis, which brings Spring Data Redis and the default Lettuce client capabilities |
docker-compose.dev.yml |
Defines the local Redis container, port 6379:6379, with appendonly enabled |
backend/service/src/main/resources/application.yml |
Configures spring.data.redis connection info and mall.cache.product-detail business cache parameters |
backend/service/src/main/java/com/example/fullstackmall/service/cache/RedisOperatorClient.java |
Encapsulates Redis string get, setEx, delete |
backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheProperties.java |
Uses @ConfigurationProperties to bind product detail cache configuration |
backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheStatus.java |
Enum for cache query status: HIT, NULL_HIT, MISS, DEGRADED, BYPASS |
backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheLookup.java |
Uses an object to express a cache query result, avoiding relying solely on null to convey semantics |
backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheService.java |
Responsible for the product detail cache protocol: key, JSON, TTL, null values, corrupted cache deletion, degradation |
backend/service/src/main/java/com/example/fullstackmall/service/product/ProductFacade.java |
Orchestrates Cache Aside in getPublishedProduct; deletes cache after creation and status changes |
backend/service/src/test/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheServiceTest.java |
Unit tests the cache protocol without starting a real Redis |
backend/service/src/test/java/com/example/fullstackmall/service/product/ProductFacadeCacheTest.java |
Unit tests Facade's handling of HIT, MISS, NULL_HIT, DEGRADED |
backend/service/src/test/java/com/example/fullstackmall/service/product/cache/RedisContainerTest.java |
Uses a real Redis container to verify integration behavior |
The design here is very suitable for introductory learning because it doesn't use complex abstractions from the start but breaks the cache into several clear responsibilities. You can first think of RedisOperatorClient as the "backend Redis API client," ProductDetailCacheService as the "product detail cache domain service," and ProductFacade as the "orchestration layer that decides whether to check the cache or the database first."
5. Mermaid Diagrams: Drawing the Redis Caching Link
5.1 Position of Redis, MySQL, and Spring Boot
flowchart LR
Browser[Browser / Frontend Page] -->|HTTP GET /api/products/id| App[Spring Boot Service]
App -->|GET key| Redis[(Redis)]
Redis -->|HIT JSON| App
App -->|MISS / DEGRADED| MySQL[(MySQL)]
MySQL -->|Real Product Data| App
App -->|SET key value TTL| Redis
App -->|ApiResponse JSON| Browser
This diagram illustrates: the browser does not directly access Redis. The frontend only accesses the Spring Boot HTTP interface; Redis is an internal backend dependency. Do not expose the Redis address, Redis key, or Redis password to the browser.
5.2 Product Detail Cache Aside Query Flow
flowchart TD
A[ProductFacade.getPublishedProduct] --> B[ProductDetailCacheService.lookup]
B --> C{Cache Status}
C -->|HIT| D[Directly return ProductDetailResponse]
C -->|NULL_HIT| E[Restore PRODUCT_NOT_FOUND / CATEGORY_NOT_FOUND]
C -->|MISS| F[Query MySQL for product and category]
C -->|DEGRADED| F
C -->|BYPASS| F
F --> G{MySQL Result}
G -->|Found product detail| H[put normal JSON to Redis with valueTtl]
H --> I[Return response]
G -->|Stable 404| J[putNull null-value marker with nullTtl]
J --> K[Throw original business 404]
This diagram is the most important one in this article. You should be able to map it line-by-line to the code in ProductFacade.getPublishedProduct.
5.3 Cache State Machine
stateDiagram-v2
[*] --> BYPASS: enabled=false
[*] --> GET: enabled=true
GET --> DEGRADED: Redis exception
GET --> MISS: key does not exist
GET --> NULL_HIT: value starts with __NULL__: and business code is valid
GET --> HIT: JSON deserialization successful
GET --> MISS: Bad JSON / illegal null marker and delete
MISS --> DB: Fall back to MySQL
DEGRADED --> DB: fail-open
BYPASS --> DB: Bypass cache
HIT --> [*]: Return response
NULL_HIT --> [*]: Return 404
This state machine helps you understand why ProductDetailCacheLookup is better than returning null. Each state has a different business meaning and subsequent processing.
5.4 Deleting Cache After Writing to Database
sequenceDiagram
autonumber
participant Admin as Admin Request
participant Facade as ProductFacade
participant MySQL as MySQL
participant Cache as ProductDetailCacheService
participant Redis as Redis
Admin->>Facade: Modify product status / Create product
Facade->>MySQL: save / updateById
MySQL-->>Facade: Write successful
Facade->>Cache: evict(productId)
Cache->>Redis: DEL mall:product:detail:v1:{id}
Redis-->>Cache: Deletion result
Facade-->>Admin: Return latest product response
Note the order: first write to MySQL successfully, then delete the cache. You cannot delete the cache first and then fail to write to the database, otherwise the cache is gone but the database hasn't changed; nor can you do nothing after a successful database write, otherwise the old cache continues to be read.
6. Reading the Source Code Segment by Segment
6.1 backend/service/pom.xml: Introducing Spring Data Redis
The current project introduces in backend/service/pom.xml:
<!-- Spring Data Redis: Uses Lettuce to connect to Redis and provides StringRedisTemplate. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
For frontend developers, a starter can be understood as "installing a backend dependency package and auto-configuring some capabilities." In Node.js, you might install axios and then create an instance yourself; in Spring Boot, after introducing the starter, Spring Boot will automatically create connection factories and beans like StringRedisTemplate based on spring.data.redis in application.yml.
Lettuce is a Java Redis client responsible for low-level network connections, command sending, and response receiving. You usually don't operate Lettuce directly in business code but use the template classes provided by Spring Data Redis.
6.2 application.yml: Redis Connection Configuration and Business Cache Configuration
Redis connection configuration is in:
spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
timeout: ${REDIS_TIMEOUT:2s}
This indicates the default connection to the local machine on port 6379, with a timeout of 2 seconds. The syntax ${REDIS_HOST:localhost} means: if the environment variable REDIS_HOST exists, use it; otherwise, use the default value localhost. This is somewhat similar to configuring an API address with VITE_API_BASE_URL in a frontend project, except the backend configures internal dependencies.
The business configuration for the product detail cache is in:
mall:
cache:
product-detail:
enabled: ${MALL_PRODUCT_CACHE_ENABLED:true}
value-ttl: ${MALL_PRODUCT_CACHE_VALUE_TTL:10m}
null-ttl: ${MALL_PRODUCT_CACHE_NULL_TTL:30s}
enabled is used for local troubleshooting and testing. When set to false, product details are queried directly from MySQL without accessing Redis. value-ttl is the normal detail cache time, and null-ttl is the null-value marker cache time.
6.3 ProductDetailCacheProperties: Binding YAML to a Java Object
ProductDetailCacheProperties uses:
@Component
@ConfigurationProperties(prefix = "mall.cache.product-detail")
public class ProductDetailCacheProperties {
private boolean enabled = true;
private Duration valueTtl = Duration.ofMinutes(10);
private Duration nullTtl = Duration.ofSeconds(30);
}
This means Spring Boot will bind the configuration under mall.cache.product-detail to this Java object. 10m will be converted to Duration.ofMinutes(10), and 30s will be converted to Duration.ofSeconds(30).
Frontend projects also have configuration objects, for example:
// Frontend-side illustrative code: analogy for a configuration object, does not represent a file in the repository
export const productDetailCacheConfig = {
enabled: true,
valueTtlMs: 10 * 60 * 1000,
nullTtlMs: 30 * 1000,
}
The difference is: backend configurations usually need to support injection for different environments. For example, the Redis address and TTL for local, testing, and production environments might differ, and you cannot hardcode all values in business code.
6.4 RedisOperatorClient: Encapsulating Low-Level Redis String Commands
RedisOperatorClient is short but very important:
@Component
public class RedisOperatorClient {
@Resource
private StringRedisTemplate stringRedisTemplate;
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);
}
}
It centralizes the low-level commands:
| Method | Redis Command Semantics | Role |
|---|---|---|
get |
GET key |
Read the string value |
setEx |
SET key value EX/PX |
Write value and set TTL |
delete |
DEL key |
Delete the cache |
Why not inject StringRedisTemplate directly into ProductFacade? Because the business layer shouldn't have low-level Redis commands scattered everywhere. After central encapsulation, if you need to uniformly add logging, monitoring, serialization strategies, or exception handling in the future, you only need to change one place. This is the same idea as not letting every frontend component directly fetch but encapsulating an API client.
6.5 ProductDetailCacheStatus: Using an Enum to Eliminate Ambiguous States
ProductDetailCacheStatus defines:
public enum ProductDetailCacheStatus {
HIT,
NULL_HIT,
MISS,
DEGRADED,
BYPASS
}
Beginners might ask: why not directly return ProductDetailResponse, and return null if there isn't one? Because null is too ambiguous. A non-existent Redis key is null, a Redis exception might also be wrapped as null by you, and a cache explicitly recording "product does not exist" could also be misinterpreted as null. Once the state is confused, the Facade won't know whether to return a 404, query MySQL, or report a system error.
Using an enum clearly writes out the business semantics and also allows tests to cover each branch.
6.6 ProductDetailCacheLookup: Cache Query Result Object
ProductDetailCacheLookup has 3 fields:
private ProductDetailCacheStatus status;
private ProductDetailResponse response;
private ApiCode nullApiCode;
response is only present on a HIT; nullApiCode is only present on a NULL_HIT. It also provides static factory methods: hit, nullHit, miss, degraded, bypass.
This is a very suitable way of expression for the backend: don't let the caller guess the meaning of null, but use an object to carry the state and necessary data together. In frontend TypeScript, this can be analogized to a discriminated union:
// Frontend-side illustrative code: using a union type to analogize the backend Lookup object
type CacheLookup =
| { status: 'HIT'; response: ProductDetail }
| { status: 'NULL_HIT'; code: 'PRODUCT_NOT_FOUND' | 'CATEGORY_NOT_FOUND' }
| { status: 'MISS' }
| { status: 'DEGRADED' }
| { status: 'BYPASS' }
6.7 ProductDetailCacheService.lookup: The Complete Branch for Reading Cache
lookup is the core method for reading the cache. The first step checks the switch:
if (!properties.isEnabled()) {
return ProductDetailCacheLookup.bypass();
}
If the cache is disabled, it directly returns BYPASS, and the Facade will query MySQL. The second step constructs the key and reads from Redis:
String key = buildKey(productId);
String cachedValue = redisOperatorClient.get(key);
Reading from Redis is wrapped in a try-catch. If Redis throws an exception, it returns DEGRADED. This embodies the principle that "a cache failure should not make an otherwise available product detail interface unavailable."
If cachedValue == null, it means the key does not exist, and it returns MISS. If the value starts with __NULL__:, it tries to parse the business code. If valid, it returns NULL_HIT; if invalid, it deletes the corrupted cache and returns MISS.
Finally, if it's a normal string, it tries to deserialize it into ProductDetailResponse using ObjectMapper.readValue. Success means HIT; failure means the JSON in the cache is corrupted, so it deletes this key and then returns MISS to fall back to MySQL.
Two details here are very worth learning:
- Don't keep a corrupted cache forever, otherwise every request will repeatedly fail to parse until the TTL expires;
- A failure to delete a corrupted cache should not crash the interface directly, so the deletion exception is only logged as a degradation log.
6.8 put and putNull: Backfilling Normal Values and Null Values
After a successful MySQL query, put serializes the ProductDetailResponse into JSON and writes it to Redis with the normal TTL:
String json = objectMapper.writeValueAsString(response);
redisOperatorClient.setEx(buildKey(productId), json, properties.getValueTtl());
A serialization failure or Redis write failure only logs an error and does not affect the current interface response. Because MySQL has already found the real data, a cache write failure should not prevent the user from getting the product details.
When MySQL returns a stable 404, putNull writes a short-TTL null value:
redisOperatorClient.setEx(
buildKey(productId),
NULL_PREFIX + apiCode.name(),
properties.getNullTtl()
);
But it only caches PRODUCT_NOT_FOUND and CATEGORY_NOT_FOUND. Other business conflicts or system exceptions cannot be disguised as "non-existent." For example, insufficient inventory, disallowed status, or database exceptions should not be written as null-value caches.
6.9 evict: Deleting Cache After a Successful Database Write
evict is used to delete the product detail cache:
redisOperatorClient.delete(buildKey(productId));
ProductFacade.createProduct calls evict(product.getId()) after successfully saving the product. The comment mentions the "guess ID in advance" null-value cache: if someone requested an ID that will appear in the future before the product was created, there might already be a short-TTL null-value marker in Redis. Deleting it once after successful creation prevents the new product from being blocked by the null-value cache for a short time.
ProductFacade.changeStatus also calls evict(productId) after a product status change. Because the visibility of public details is related to the product status: from draft to listed, listed to delisted, delisted to listed again, all can change the public interface's return. Deleting the old cache after a successful database write means the next request will rebuild the latest details from MySQL.
A deletion failure does not roll back the database. Why? Because the MySQL write is the primary business result, and a Redis deletion failure is only a cache consistency risk, which the eventual TTL will cover. This reflects the positioning of the cache as a performance layer.
6.10 ProductFacade.getPublishedProduct: The Real Cache Aside Orchestration
The core code structure is:
ProductDetailCacheLookup cacheLookup = productDetailCacheService.lookup(productId);
if (cacheLookup.getStatus() == ProductDetailCacheStatus.HIT) {
return cacheLookup.getResponse();
}
if (cacheLookup.getStatus() == ProductDetailCacheStatus.NULL_HIT) {
ApiCode apiCode = cacheLookup.getNullApiCode();
throw new BusinessException(apiCode, apiCode.defaultMessage(), HttpStatus.NOT_FOUND);
}
// MISS, DEGRADED, and BYPASS all fall back to MySQL
ProductEntity product = productDbService.requirePublishedById(productId);
CategoryEntity category = categoryDbService.requireEnabledCategory(product.getCategoryId());
ProductDetailResponse response = toDetailResponse(product, category);
productDetailCacheService.put(productId, response);
return response;
The division of labor here is very clear: CacheService does not directly query MySQL, it only manages the cache protocol; DbService does not care about Redis, it only manages the database; Facade is responsible for business orchestration, deciding which state returns directly, which state falls back to the source, and which state restores a 404.
This is the value of backend layered design: no single layer does everything, but combined, they form a complete link.
7. Local Run / curl / redis-cli Verification
The following assumes you are in the project root directory and Docker is available.
7.1 Start Redis and MySQL
docker compose -f docker-compose.dev.yml up -d mysql redis
Check container status:
docker compose -f docker-compose.dev.yml ps
Test if Redis is available:
docker exec -it fullstack-mall-redis redis-cli ping
Should return normally:
PONG
7.2 Start the Backend Service
mvn -pl service -am spring-boot:run
If your local Maven repository or Java version was configured according to previous chapters, you can continue using the previous startup method. The default service port is 8080.
7.3 First Request for Product Details: Expect MISS then Fallback to MySQL
First, request a listed product detail, for example:
curl -sS 'http://localhost:8080/api/products/1'
On the first request, if the key is not yet in Redis, ProductDetailCacheService will log MISS, the Facade will fall back to MySQL, and after success, PUT will write to Redis. You can look for these in the service logs:
product_detail_cache event=MISS productId=1
product_detail_cache event=PUT productId=1 ttl=PT10M
The specific log format may be affected by the runtime environment, but the event name is an important clue for troubleshooting the cache link.
7.4 Second Request for Product Details: Expect HIT
Request the same product again:
curl -sS 'http://localhost:8080/api/products/1'
If the cache write was successful, the second time should hit Redis, and the log will show:
product_detail_cache event=HIT productId=1
After a hit, the product and category tables should not be queried again. In the subsequent Chapter 8, we will read the product detail link more completely.
7.5 Use redis-cli to View Keys and TTL
Enter Redis:
docker exec -it fullstack-mall-redis redis-cli
View keys:
keys mall:product:detail:v1:*
View the TTL of a specific key:
ttl mall:product:detail:v1:1
View the value:
get mall:product:detail:v1:1
If you see a JSON string, it means the normal product detail has been cached. Note: In a production environment, it's not recommended to frequently use keys to scan a large keyspace; it's fine for local learning. In a formal environment, scan is generally used.
7.6 Request a Non-Existent Product: Observe Null-Value Caching
Request a non-existent product ID:
curl -i 'http://localhost:8080/api/products/999999'
The first time will query MySQL and return a 404, while also writing a short-TTL null-value marker. You can check:
docker exec -it fullstack-mall-redis redis-cli get mall:product:detail:v1:999999
You might see:
__NULL__:PRODUCT_NOT_FOUND
Then check the TTL:
docker exec -it fullstack-mall-redis redis-cli ttl mall:product:detail:v1:999999
It should be a relatively short time, close to 30 seconds.
7.7 Manually Delete Cache to Simulate Invalidation
docker exec -it fullstack-mall-redis redis-cli del mall:product:detail:v1:1
After deletion, request /api/products/1 again; it should re-MISS and fall back to MySQL. This action corresponds to the backend's productDetailCacheService.evict(productId).
7.8 Disable Cache to Verify BYPASS
You can disable the product detail cache using an environment variable:
MALL_PRODUCT_CACHE_ENABLED=false mvn -pl service -am spring-boot:run
At this point, requesting product details should bypass Redis and directly query MySQL. This configuration is suitable for local troubleshooting: if you suspect the interface is returning old data from the cache, you can first disable the cache to verify if the MySQL link is correct.
7.9 Stop Redis to Verify DEGRADED
For local learning, you can stop Redis:
docker stop fullstack-mall-redis
Then request product details. If MySQL is normal, the interface should still return data as much as possible, but the log will show DEGRADED. After verification, restart Redis:
docker start fullstack-mall-redis
This exercise helps you understand: caching is a performance layer and should not make core read interfaces like product details completely dependent on Redis.
8. Common Mistakes
8.1 Treating Redis as the Source of Truth
The most common mistake is: believing whatever is in Redis, and thinking business data doesn't exist if it's not in Redis. The current project does not do this. Redis MISS, DEGRADED, and BYPASS all fall back to MySQL. Only an explicit null-value marker like NULL_HIT restores a 404, and the null-value TTL is very short.
8.2 Not Setting a TTL for Caches
A cache without a TTL can permanently occupy memory and preserve old data long-term. The current project enforces setting an expiration time on writes via setEx. When you add new caches in the future, you must also ask yourself: what is the maximum lifetime of this key? Can it be rebuilt from the real data source after expiration?
8.3 Key Naming is Too Casual
For example, using just product:1. In the future, orders, back-office systems, and test scripts might all produce similar keys, making troubleshooting difficult. The current project uses mall:product:detail:v1:{id}, clearly writing out the system, module, object, version, and business ID.
8.4 Forgetting to Delete Cache After Updating the Database
If an admin delists a product but the old cache remains, users might continue to see the old product details. The current project calls evict after creating a product and changing its status. In the future, if features like "modify product title," "modify subtitle," or "modify category" are added, you must also consider deleting the corresponding detail cache.
8.5 Deleting Cache Before Writing to the Database
If you delete the cache first and then the database write fails, the cache is gone but the data hasn't changed; if a concurrent request comes in right in the middle, it might also write the old database data back into the cache. The code comments in the current project emphasize "delete the old cache after a successful database write." This is a more robust basic order.
8.6 Letting a Redis Exception Directly Fail the Interface
If product details only use Redis as a performance layer, it's best to degrade to querying MySQL when Redis is briefly unavailable. The current project's lookup, put, putNull, and evict all catch Redis-related runtime exceptions and log them, preventing a cache failure from escalating into a business failure.
8.7 Using null to Express All Cache States
null cannot distinguish between MISS, a Redis exception, a null-value cache, or a disabled cache. The current project uses ProductDetailCacheLookup and ProductDetailCacheStatus to explicitly express state, which is a code readability design very worth learning.
8.8 Null-Value Cache Time is Too Long
A null-value cache can alleviate penetration, but if the time is too long, it can cause "newly created data to still be treated as non-existent." The current project's null-value default is 30 seconds, and it deletes the corresponding key after a successful product creation, reducing this risk.
8.9 Indiscriminate Use of keys in Production
keys mall:product:* is very convenient for local learning, but in a production environment with many keys, it can block Redis. For formal troubleshooting, scan should be prioritized, or keys should be located through business logs, monitoring, and management tools. This article uses keys for intuitive learning.
8.10 Frontend Thinks Clearing localStorage Clears the Backend Cache
Frontend clearing of tokens, localStorage, or the Pinia store only affects the current browser. Redis is a backend shared cache and must be deleted by backend code or operational commands. A user refreshing the page cannot guarantee a change in the Redis cache.
9. Chapter Exercises
Exercise 1: Draw the Product Detail Cache Key
Read ProductDetailCacheService.buildKey and write out the corresponding Redis keys for product IDs 1, 20, and 999999. Then explain the meaning of v1.
Exercise 2: Explain the 5 Cache States in Your Own Words
Without looking at the documentation, write down the meanings of HIT, MISS, NULL_HIT, DEGRADED, and BYPASS, and how ProductFacade handles each.
Exercise 3: Observe Normal Cache TTL
After starting Redis and the backend, request /api/products/1 twice, then use redis-cli ttl mall:product:detail:v1:1 to check the TTL. Observe whether the log for the second request shows HIT.
Exercise 4: Observe Null-Value Caching
Request /api/products/999999, then use redis-cli get mall:product:detail:v1:999999 to view the value. Explain why it is not a JSON product object but a marker starting with __NULL__:.
Exercise 5: Simulate a Redis Failure
After stopping Redis, request product details and observe whether the interface can still return data from MySQL. Then answer: why does this project choose fail-open for product details? If it were an idempotency lock for a payment callback, would fail-open always be suitable?
Exercise 6: Identify Write Operations That Need to Delete the Product Detail Cache
Read ProductFacade.createProduct and ProductFacade.changeStatus to find the evict calls. Then think: if a new feature "admin modifies product subtitle" is added later, should it also delete the product detail cache? Why?
Exercise 7: Write a Piece of Frontend Cache Analogy Code
Write a piece of "frontend-side illustrative code": first check a Map, request the API if not found, write to the Map after a successful request, and set an expiration time. Then compare its similarities and differences with the backend Redis Cache Aside.
10. Adding Another Layer: How to Initially Understand Cache Consistency, Penetration, Breakdown, and Avalanche
Although this article is mainly an introduction to Redis, you will eventually hear three terms in backend interviews or project reviews: cache penetration, cache breakdown, and cache avalanche. Don't be afraid yet; let's use the current project's product detail cache to build a first version of understanding.
Cache penetration refers to: the requested data is neither in the cache nor in the database, so every request bypasses the cache and hits the database. For example, if someone repeatedly requests /api/products/999999 and the project caches nothing, every request will query MySQL and ultimately get a 404. The current project's putNull is handling this problem: for stable 404s like PRODUCT_NOT_FOUND and CATEGORY_NOT_FOUND, it writes a short-TTL null-value marker. The next time the same non-existent ID comes, it directly returns a 404 via NULL_HIT, avoiding repeated database queries.
Cache breakdown refers to: a hot key expires at a certain moment, and a large number of requests come in simultaneously, all finding a MISS, and then all hit the database together. For example, if the homepage is displaying a popular product and many people click on its details at the same time, just as mall:product:detail:v1:1 expires, it could generate instantaneous database pressure. This project currently does not have a mutex lock or singleflight merge for fallback, as the learning project keeps it simple for now; but you should know that real high-concurrency scenarios might require mechanisms like mutex construction, logical expiration, or background refresh for hot keys.
Cache avalanche refers to: a large number of keys expire at the same time, or Redis becomes entirely unavailable, causing a large number of requests to simultaneously fall back to the database. The current project reduces this risk through two basic means: first, when a Redis read is abnormal, it returns DEGRADED, allowing the interface to degrade to MySQL instead of failing directly; second, normal caches and null-value caches are set with different TTLs. In real production, random jitter is also added to TTLs, for example, a 10-minute TTL might fluctuate by tens of seconds, to avoid a large number of keys expiring at the exact same second.
Let's talk about cache consistency. As long as a cache and a database coexist, you must face the question of "who updates, who deletes, and when to delete." The current project adopts the most basic and common strategy: Cache Aside for reads, and for writes, modify MySQL first, then delete Redis on success. It does not pursue perfect consistency between cache and database every millisecond but accepts a very short period of inconsistency, converging through cache deletion, TTL fallback, and the next fallback rebuild. For display-oriented data like product details, this is reasonable; for payment statuses, inventory deductions, and order amounts, you cannot directly copy this and must redesign it in combination with transactions, idempotency, and concurrency control.
Frontend developers can analogize it like this: you cache product details on a page, a backend admin changes the product title, and your page's local cache might still show the old title. The frontend usually solves this through refresh, expiration time, or re-requesting. Backend Redis follows a similar idea, but the scope of impact is larger: it's not a single user's page cache, but a server-side cache shared by all users. Therefore, backend cache design emphasizes key standards, TTL, post-write invalidation, exception degradation, and observable logs.
Finally, an engineering observation: the current project does not rely solely on manual curl to judge if the cache is correct; it also has written tests. ProductDetailCacheServiceTest uses a mocked RedisOperatorClient to verify MISS, HIT, NULL_HIT, corrupted JSON deletion, Redis read failure degradation, different TTLs, and cache disabling branches; ProductFacadeCacheTest verifies that the Facade does not access the database on a cache hit, queries the database and backfills on a MISS, writes a null value on a stable 404, and deletes the cache after a successful status update; RedisContainerTest is closer to a real environment, using a Redis container to verify write, read, null value, and deletion. For frontend developers, this is similar to writing both pure function unit tests and API integration tests: unit tests prove branch logic, and integration tests prove real dependencies can connect. When you modify caching logic in the future, don't just look at whether the interface returns successfully; also confirm that these branch tests still hold.
There is another easily overlooked point: cache logs themselves are also a learning and troubleshooting tool. The current project records events like event=MISS, event=HIT, event=PUT, event=NULL_HIT, event=DEGRADED, event=EVICT, event=CORRUPTED in the cache service. When verifying locally, don't just look at the browser response; also look at the server-side logs. Backend development often requires breaking down "the interface is slow" into finer evidence like "was it a cache hit, did it fall back to the database, did Redis time out, did the write fail." Being able to read logs is a very important step in transitioning from frontend to backend. Problems after a real launch often cannot be solved by guessing but rely on these observable pieces of evidence to narrow down the scope step by step and form a stable backend troubleshooting habit.
11. Next Chapter Preview: The Complete Product Detail Request Link
This article first supplemented the Redis and caching basics. In the next article, we will string together all the previous knowledge and completely read the product detail interface: a browser requests GET /api/products/{id}, enters ProductController, calls ProductFacade.getPublishedProduct, first checks ProductDetailCacheService, returns directly on a cache hit, queries MySQL through ProductDbService and CategoryDbService on a cache miss, assembles ProductDetailResponse, writes it to Redis, and finally returns a unified ApiResponse.
That article will be the first time in this series to truly string together Controller, Facade, MyBatis-Plus, MySQL, Redis, unified response, and business exceptions into a single link. You will see why a backend interface is not as simple as "query a table and return," but is composed of many boundary conditions: the product must be listed, the category must be enabled, the cache might hit, the cache might be corrupted, Redis might be unavailable, a non-existent product needs a null-value cache, and the old cache must be deleted after an update.
If the first 7 articles were about laying the foundation, Chapter 8 is about assembling that foundation into real project capability.
12. Summary of This Article
You need to take away 10 conclusions from this article:
- Redis is an independent server-side in-memory data service, not a browser cache, nor a regular Java Map;
- MySQL is the real data source, Redis is the performance layer; the cache can be lost but must be rebuildable from MySQL;
- The current project accesses Redis through
spring-boot-starter-data-redis, Lettuce, andStringRedisTemplate; RedisOperatorClientencapsulates low-levelGET,SET with TTL,DELto prevent scattering Redis commands in the business layer;- The product detail key uses
mall:product:detail:v1:{id}, containing the system, module, object, version, and business ID; - Normal product details cache the
ProductDetailResponseJSON, with a default TTL of 10 minutes; - Non-existent products or unavailable categories write a short-TTL null-value marker, defaulting to 30 seconds, to mitigate cache penetration;
ProductDetailCacheLookupclearly distinguishesHIT,MISS,NULL_HIT,DEGRADED,BYPASS, which is clearer than returningnull;- Product details use Cache Aside: check Redis first, fall back to MySQL on MISS / DEGRADED / BYPASS, and backfill the cache on success;
- Delete the old cache after a successful database write; when Redis fails, log the degradation and try not to affect the main product detail link.
When you encounter a requirement to "add caching" to an interface in the future, don't just think "set the result to Redis." You must first ask: Who is the real data source? How is the key named? What structure is the value stored in? How long is the TTL? What to do on a miss? Should non-existent data be cached? How to invalidate after a write operation? If Redis goes down, should the interface fail or degrade? Being able to answer these questions clearly means you have truly started to learn backend cache design.