本篇写给已经熟悉前端 memory cache、localStorage、接口缓存、状态管理,但还没有系统学过 Redis 的前端同学。我们会基于当前
fullstack-mall后端工程真实代码,从 Redis 是什么讲起,再讲 key、value、TTL、缓存命中、缓存未命中、空值缓存、缓存失效、降级、Cache Aside。文中的 Vue / Axios / TypeScript 片段都只是"前端侧示意代码",用于帮助理解后端缓存思想,当前仓库没有真实前端源码。
1. 这篇解决什么问题
前 6 篇我们已经建立了后端工程地图:Spring Boot 如何启动,Controller 如何接 HTTP 请求,Request / Response 如何定义,MySQL 表如何保存真实数据,MyBatis-Plus 如何把 Java Entity 和数据库表连接起来,Spring Security 如何识别当前用户。接下来要进入后端性能和稳定性里非常高频的基础设施:Redis。
很多前端同学听到 Redis 时会先想到"缓存"。这个方向没错,但如果只把 Redis 理解成"类似 localStorage 的东西",就会漏掉很多后端特有问题:Redis 是一个独立进程,不在浏览器里;它通过网络访问,不是一个普通 Java Map;它存的是跨请求、跨用户、跨服务实例共享的数据;它可能宕机、超时、缓存旧值、缓存不存在数据,还可能因为 key 设计不合理导致数据互相覆盖。
当前项目已经在商品详情接口中引入了 Redis 缓存。它没有让业务代码到处直接调用 Redis,而是分成几层:
RedisOperatorClient封装底层 Redis 字符串命令;ProductDetailCacheProperties接收商品详情缓存配置;ProductDetailCacheService负责商品详情缓存 key、JSON、TTL、空值、删除和降级;ProductDetailCacheLookup用结构化对象表达HIT、MISS、NULL_HIT、DEGRADED、BYPASS;ProductFacade.getPublishedProduct编排 Redis 和 MySQL 的查询顺序。
本篇解决这些问题:
- Redis 到底是什么,和 MySQL、Java 内存、前端缓存有什么区别;
- 为什么商品详情适合做缓存,而订单创建、支付回调不能随便缓存;
- 什么是 key、value、TTL,为什么业务缓存必须设置过期时间;
StringRedisTemplate、Lettuce、Spring Data Redis 分别是什么;- 当前项目如何通过
RedisOperatorClient封装GET、SET EX、DEL; - 什么是缓存命中
HIT、未命中MISS、空值命中NULL_HIT; - Redis 故障时为什么要降级查 MySQL,而不是让接口直接失败;
- 数据库更新后为什么要删除缓存,而不是盲目相信旧缓存;
- 如何用 Docker、
redis-cli、curl 和日志验证缓存行为; - 初学 Redis 缓存最容易踩哪些坑。
学完本篇,你不需要立刻掌握 Redis 的所有数据结构,也不需要背 Redis 集群、哨兵、持久化细节。你要先建立一个后端工程师最常用的缓存心智:MySQL 是真实数据来源,Redis 是性能层;缓存命中时快速返回,缓存未命中时回源 MySQL;写数据库成功后让旧缓存失效;Redis 出问题时,核心接口尽量 fail-open,也就是退回 MySQL 查询,而不是跟着缓存一起不可用。
2. 用前端知识类比:从浏览器缓存到服务端共享缓存
前端开发里,你一定用过类似缓存的东西。比如组件内存里缓存商品详情:
ts
// 前端侧示意代码:组件级内存缓存,不代表仓库里存在这个文件
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
}
你也可能用 localStorage 做简单缓存:
ts
// 前端侧示意代码:浏览器本地缓存,只影响当前浏览器
function saveRecentProduct(product: ProductDetail) {
localStorage.setItem(`recent-product:${product.id}`, JSON.stringify(product))
}
这些前端缓存解决的是"当前页面或当前浏览器少发一点请求"。但它有几个明显限制:
| 前端缓存 | 特点 | 局限 |
|---|---|---|
| 组件变量 / Map | 速度快,页面刷新即丢失 | 只对当前页面实例有效 |
| Pinia / Vuex / Redux | 跨组件共享 | 刷新后通常丢失,除非持久化 |
| localStorage | 可持久化到浏览器 | 只属于当前用户当前浏览器,安全性有限 |
| HTTP cache | 浏览器和 CDN 可参与 | 受请求头、资源类型、缓存策略影响 |
Redis 是服务端缓存。它不是用户浏览器里的缓存,而是部署在后端旁边的一个独立服务。所有请求、所有用户、甚至多个后端实例都可以访问同一个 Redis。因此它能解决的问题和前端缓存不一样:
| 前端概念 | 后端 Redis 概念 | 当前项目例子 |
|---|---|---|
Map<string, any> |
Redis key-value | mall:product:detail:v1:1 |
| localStorage 过期策略 | TTL | 商品详情 10m,空值 30s |
| Axios 请求前先查内存 | Cache Aside 先查 Redis | ProductFacade.getPublishedProduct |
| 本地缓存没有就请求接口 | Redis MISS 后查 MySQL | requirePublishedById |
| 用户刷新页面缓存可能丢失 | Redis 独立进程保存 | docker-compose.dev.yml 中的 redis 服务 |
| 前端清理缓存 | 后端写库后删除 key | productDetailCacheService.evict(productId) |
但要注意一个关键区别:前端缓存错了,通常只是某个用户页面展示旧一点;后端缓存错了,可能所有用户都看到旧商品详情,甚至可能影响订单价格、库存判断、权限判断。所以后端缓存必须更克制:哪些数据能缓存,缓存多久,写库后怎么失效,Redis 挂了怎么办,都要明确设计。
3. 后端核心概念讲解
3.1 Redis 是独立的内存数据服务
Redis 不是 Java 类,也不是 Spring Bean,它是一个独立运行的服务进程。当前项目通过 docker-compose.dev.yml 启动 Redis:
yaml
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"]
这段配置告诉我们:本地 Redis 运行在容器里,宿主机端口是 6379,数据目录挂载到 Docker volume,并开启 appendonly。你现在不需要深究 Redis 持久化,只要先知道:Java 后端访问 Redis,要通过网络连到 localhost:6379。
3.2 Redis 和 MySQL 的分工
MySQL 是当前项目商品、分类、用户、订单、支付等数据的真实来源。Redis 是 MySQL 前面的性能层。商品详情缓存里存的是"从 MySQL 和业务组装出来的一份 JSON 副本",不是最终真相。
可以这样理解:
| 维度 | MySQL | Redis |
|---|---|---|
| 主要职责 | 持久化真实业务数据 | 提供高速读写和临时数据 |
| 数据模型 | 表、行、列、索引、事务 | key-value、过期时间、多种内存结构 |
| 典型访问 | SQL 查询、事务更新 | GET、SET、DEL、EXPIRE |
| 当前项目角色 | 商品真实数据源 | 商品详情缓存层 |
| 数据丢失影响 | 严重,业务数据丢失 | 可从 MySQL 重建,通常可接受 |
| 一致性要求 | 强,必须正确 | 可短暂旧值,但要有失效策略 |
前端转后端时要记住一句话:Redis 不是用来替代 MySQL 的。它可以让热点读取更快,但不能成为订单、支付、库存这类关键数据的唯一来源。尤其是订单金额、库存扣减、支付状态这类强一致数据,不能因为"Redis 快"就随便把判断逻辑搬到缓存里。
3.3 key:后端缓存的地址设计
Redis 通过 key 找 value。当前项目商品详情 key 在 ProductDetailCacheService 里集中定义:
java
private static final String KEY_PREFIX = "mall:product:detail:v1:";
String buildKey(Long productId) {
return KEY_PREFIX + productId;
}
商品 ID 为 1 时,最终 key 是:
text
mall:product:detail:v1:1
这个 key 不是随便拼的。每一段都有意义:
mall:项目名或业务系统名,避免和其他系统 key 冲突;product:业务模块;detail:缓存对象类型;v1:缓存结构版本,未来字段结构变化时可以切到v2;1:具体商品 ID。
前端同学可以把 key 类比成 localStorage 的 key,但后端 key 更需要规范。因为 Redis 是共享服务,key 乱起名很容易和别的模块冲突,也很难排查。
3.4 value:当前项目缓存的是 JSON 字符串
当前项目用 StringRedisTemplate,所以 key 和 value 都按字符串处理。正常商品详情的 value 是 ProductDetailResponse 序列化后的 JSON。也就是说,缓存命中时不再重新查商品表和分类表,而是直接把 JSON 反序列化成 ProductDetailResponse 返回。
这是一种很常见的"缓存接口响应对象"做法。它的好处是命中后非常直接,不需要再组装分类名、状态枚举等字段。代价是:如果 Response 结构变化,旧缓存里的 JSON 可能不兼容,所以 key 里带了 v1 版本号,必要时可以升级为 v2。
3.5 TTL:缓存必须有过期时间
TTL 是 Time To Live,表示这个 key 还能存活多久。当前项目在 application.yml 中配置了:
yaml
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}
正常商品详情默认缓存 10 分钟,不存在商品或分类不可用的空值标记默认缓存 30 秒。为什么不能永久缓存?因为商品标题、分类、状态可能变化。如果没有 TTL,一次旧缓存可能长期占用内存,还可能长期让用户看到旧数据。
RedisOperatorClient.setEx 的注释也强调:写入字符串时必须同时设置过期时间,防止业务缓存永久占用内存或长期保存旧数据。
3.6 HIT、MISS、NULL_HIT、DEGRADED、BYPASS
当前项目没有让 lookup 直接返回 ProductDetailResponse 或 null,而是返回 ProductDetailCacheLookup。这样做是为了区分不同状态:
| 状态 | 含义 | Facade 怎么处理 |
|---|---|---|
HIT |
Redis 命中正常商品详情 | 直接返回缓存响应 |
MISS |
Redis 没有这个 key,或坏缓存被删除 | 回源 MySQL 查询 |
NULL_HIT |
命中短 TTL 空值标记 | 直接恢复 404 业务异常 |
DEGRADED |
Redis 访问异常 | 降级查 MySQL |
BYPASS |
配置关闭缓存 | 直接查 MySQL |
这比简单返回 null 清楚很多。因为 null 可能代表"没缓存",也可能代表"Redis 挂了",还可能代表"缓存里明确记录这个商品不存在"。后端代码必须把这些情况分开,否则很容易产生错误行为。
3.7 空值缓存:防止不存在 ID 反复打 MySQL
如果某个商品 ID 不存在,第一次请求查 MySQL 返回 404。如果什么都不缓存,那么攻击者或爬虫反复请求同一个不存在 ID,就会每次都打到 MySQL。这个问题叫缓存穿透的一种表现。
当前项目对稳定的 404 做短 TTL 空值缓存。value 不是正常 JSON,而是类似:
text
__NULL__:PRODUCT_NOT_FOUND
__NULL__:CATEGORY_NOT_FOUND
当下次请求同一个 ID 时,lookup 看到 __NULL__: 前缀,就恢复对应业务码,直接抛 404,不再查 MySQL。为什么空值 TTL 只有 30 秒?因为不存在的数据未来可能被创建,分类也可能恢复可用。短 TTL 可以减少穿透,又不会让"曾经不存在"的结果停留太久。
3.8 降级:Redis 挂了,商品详情仍然尽量可用
ProductDetailCacheService.lookup 读取 Redis 时捕获 RuntimeException,返回 DEGRADED。ProductFacade.getPublishedProduct 对 DEGRADED 的处理和 MISS 类似:继续查 MySQL。
这叫 fail-open,适合商品详情这种"缓存只是性能层"的场景。Redis 不可用时,接口可能慢一点,但不要直接不可用。相反,如果某个业务强依赖 Redis 分布式锁或限流,Redis 挂了是否 fail-open 就要重新评估。不同场景策略不同,不能机械套用。
3.9 Cache Aside:旁路缓存模式
当前项目使用的是 Cache Aside。应用代码先查缓存,缓存没有再查数据库,数据库查到后由应用代码写回缓存。写数据时先更新数据库,再删除缓存。
这个模式对前端同学可以类比为:先查本地 cache,没有再调接口;接口成功后把结果放回 cache;用户编辑成功后清掉旧 cache,下次重新加载。但后端要更严谨,因为缓存对所有用户共享,而且可能有并发读写。
4. 在本项目中对应哪些文件
本篇对应的真实文件如下:
| 文件 | 作用 |
|---|---|
backend/service/pom.xml |
引入 spring-boot-starter-data-redis,它会带来 Spring Data Redis 和默认 Lettuce 客户端能力 |
docker-compose.dev.yml |
定义本地 Redis 容器,端口 6379:6379,开启 appendonly |
backend/service/src/main/resources/application.yml |
配置 spring.data.redis 连接信息和 mall.cache.product-detail 业务缓存参数 |
backend/service/src/main/java/com/example/fullstackmall/service/cache/RedisOperatorClient.java |
封装 Redis 字符串 get、setEx、delete |
backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheProperties.java |
用 @ConfigurationProperties 绑定商品详情缓存配置 |
backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheStatus.java |
枚举缓存查询状态:HIT、NULL_HIT、MISS、DEGRADED、BYPASS |
backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheLookup.java |
用对象表达一次缓存查询结果,避免只靠 null 传递语义 |
backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheService.java |
负责商品详情缓存协议:key、JSON、TTL、空值、坏缓存删除、降级 |
backend/service/src/main/java/com/example/fullstackmall/service/product/ProductFacade.java |
在 getPublishedProduct 中编排 Cache Aside;在创建和状态变更后删除缓存 |
backend/service/src/test/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheServiceTest.java |
单测缓存协议,不启动真实 Redis |
backend/service/src/test/java/com/example/fullstackmall/service/product/ProductFacadeCacheTest.java |
单测 Facade 对 HIT、MISS、NULL_HIT、DEGRADED 的处理 |
backend/service/src/test/java/com/example/fullstackmall/service/product/cache/RedisContainerTest.java |
使用真实 Redis 容器验证集成行为 |
这里的设计非常适合入门学习,因为它没有一上来使用复杂抽象,而是把缓存拆成了几个清晰职责。你可以先把 RedisOperatorClient 当成"后端 Redis API client",把 ProductDetailCacheService 当成"商品详情缓存领域服务",把 ProductFacade 当成"决定先查缓存还是查数据库的编排层"。
5. Mermaid 图:把 Redis 缓存链路画出来
5.1 Redis、MySQL 和 Spring Boot 的位置
这张图说明:浏览器不会直接访问 Redis。前端只访问 Spring Boot HTTP 接口;Redis 是后端内部依赖。不要把 Redis 地址、Redis key、Redis 密码暴露给浏览器。
5.2 商品详情 Cache Aside 查询流程
这张图是本篇最重要的图。你要能把它和 ProductFacade.getPublishedProduct 的代码逐行对应起来。
5.3 缓存状态机
这个状态机能帮你理解为什么 ProductDetailCacheLookup 比返回 null 更好。每个状态都有不同业务含义和后续处理。
5.4 写数据库后删除缓存
注意顺序:先写 MySQL 成功,再删除缓存。不能先删缓存再写库失败,否则缓存没了但数据库没变;也不能写库成功后什么都不做,否则旧缓存继续被读到。
6. 逐段读源码
6.1 backend/service/pom.xml:引入 Spring Data Redis
当前项目在 backend/service/pom.xml 中引入:
xml
<!-- Spring Data Redis:使用 Lettuce 连接 Redis,并提供 StringRedisTemplate。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
对前端同学来说,可以把 starter 理解为"安装一个后端依赖包并自动配置一部分能力"。在 Node.js 里你可能安装 axios,然后自己创建实例;在 Spring Boot 里,引入 starter 后,Spring Boot 会根据 application.yml 中的 spring.data.redis 自动创建连接工厂和 StringRedisTemplate 等 Bean。
Lettuce 是 Java Redis 客户端,负责底层网络连接、命令发送、响应接收。你平时业务代码不直接操作 Lettuce,而是通过 Spring Data Redis 提供的模板类。
6.2 application.yml:Redis 连接配置和业务缓存配置
Redis 连接配置在:
yaml
spring:
data:
redis:
host: ${REDIS_HOST:localhost}
port: ${REDIS_PORT:6379}
timeout: ${REDIS_TIMEOUT:2s}
这说明默认连接本机 6379 端口,超时时间是 2 秒。${REDIS_HOST:localhost} 这种写法表示:如果环境变量 REDIS_HOST 存在,就用环境变量;否则用默认值 localhost。这和前端项目里用 VITE_API_BASE_URL 配置接口地址有点像,只是后端配置的是内部依赖。
商品详情缓存的业务配置在:
yaml
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 用于本地排障和测试。设置为 false 时,商品详情直接查 MySQL,不访问 Redis。value-ttl 是正常详情缓存时间,null-ttl 是空值标记缓存时间。
6.3 ProductDetailCacheProperties:把 YAML 绑定成 Java 对象
ProductDetailCacheProperties 使用:
java
@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);
}
这表示 Spring Boot 会把 mall.cache.product-detail 下的配置绑定到这个 Java 对象。10m 会被转换成 Duration.ofMinutes(10),30s 会被转换成 Duration.ofSeconds(30)。
前端也会有配置对象,例如:
ts
// 前端侧示意代码:配置对象类比,不代表仓库里存在这个文件
export const productDetailCacheConfig = {
enabled: true,
valueTtlMs: 10 * 60 * 1000,
nullTtlMs: 30 * 1000,
}
区别是:后端配置通常要支持不同环境注入,例如本地、测试、生产的 Redis 地址和 TTL 可能不同,不能把所有值写死在业务代码里。
6.4 RedisOperatorClient:封装底层 Redis 字符串命令
RedisOperatorClient 很短,但很重要:
java
@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);
}
}
它把底层命令集中起来:
| 方法 | Redis 命令语义 | 作用 |
|---|---|---|
get |
GET key |
读取字符串 value |
setEx |
SET key value EX/PX |
写入 value 并设置 TTL |
delete |
DEL key |
删除缓存 |
为什么不在 ProductFacade 里直接注入 StringRedisTemplate?因为业务层不应该到处散落底层 Redis 命令。集中封装后,未来如果要统一加日志、监控、序列化策略、异常处理,就只改一个地方。这和前端不建议每个组件都直接 fetch,而是封装 API client,是同一个思想。
6.5 ProductDetailCacheStatus:用枚举消除含糊状态
ProductDetailCacheStatus 定义:
java
public enum ProductDetailCacheStatus {
HIT,
NULL_HIT,
MISS,
DEGRADED,
BYPASS
}
初学者可能会问:为什么不直接返回 ProductDetailResponse,没有就返回 null?因为 null 太含糊。Redis key 不存在是 null,Redis 异常也可能被你包装成 null,缓存里明确记录"商品不存在"也可能被误解成 null。一旦状态混淆,Facade 就不知道该返回 404、查 MySQL,还是提示系统异常。
用枚举能把业务语义写清楚,也能让测试覆盖每个分支。
6.6 ProductDetailCacheLookup:缓存查询结果对象
ProductDetailCacheLookup 里有 3 个字段:
java
private ProductDetailCacheStatus status;
private ProductDetailResponse response;
private ApiCode nullApiCode;
只有 HIT 时才有 response;只有 NULL_HIT 时才有 nullApiCode。它还提供了静态工厂方法:hit、nullHit、miss、degraded、bypass。
这是一种很适合后端的表达方式:不要让调用方猜 null 的含义,而是用对象把状态和必要数据一起带出来。前端 TypeScript 可以类比为 discriminated union:
ts
// 前端侧示意代码:用联合类型类比后端 Lookup 对象
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:读缓存的完整分支
lookup 是读缓存的核心方法。第一步判断开关:
java
if (!properties.isEnabled()) {
return ProductDetailCacheLookup.bypass();
}
如果缓存关闭,直接返回 BYPASS,由 Facade 查 MySQL。第二步构造 key 并读取 Redis:
java
String key = buildKey(productId);
String cachedValue = redisOperatorClient.get(key);
读取 Redis 包在 try-catch 里。如果 Redis 抛异常,返回 DEGRADED。这体现了"缓存故障不能让原本可用的商品详情接口一起不可用"。
如果 cachedValue == null,说明 key 不存在,返回 MISS。如果 value 以 __NULL__: 开头,就尝试解析业务码。合法时返回 NULL_HIT,非法时删除坏缓存并返回 MISS。
最后,如果是普通字符串,就尝试用 ObjectMapper.readValue 反序列化成 ProductDetailResponse。成功就是 HIT;失败说明缓存里的 JSON 坏了,删除这个 key,然后返回 MISS 回源 MySQL。
这里有两个细节很值得学习:
- 坏缓存不要一直留着,否则每次请求都会重复解析失败,直到 TTL 到期;
- 删除坏缓存失败也不能让接口直接崩掉,所以删除异常也只记录降级日志。
6.8 put 和 putNull:回填正常值和空值
MySQL 查询成功后,put 会把 ProductDetailResponse 序列化成 JSON,然后用正常 TTL 写入 Redis:
java
String json = objectMapper.writeValueAsString(response);
redisOperatorClient.setEx(buildKey(productId), json, properties.getValueTtl());
序列化失败或 Redis 写入失败只记录日志,不影响本次接口返回。因为 MySQL 已经查到了真实数据,缓存写失败不应该让用户拿不到商品详情。
MySQL 返回稳定 404 时,putNull 会写短 TTL 空值:
java
redisOperatorClient.setEx(
buildKey(productId),
NULL_PREFIX + apiCode.name(),
properties.getNullTtl()
);
但它只缓存 PRODUCT_NOT_FOUND 和 CATEGORY_NOT_FOUND。其他业务冲突或系统异常不能被伪装成"不存在"。比如库存不足、状态不允许、数据库异常,这些都不应该写成空值缓存。
6.9 evict:写库成功后删除缓存
evict 用于删除商品详情缓存:
java
redisOperatorClient.delete(buildKey(productId));
ProductFacade.createProduct 在保存商品成功后调用 evict(product.getId())。注释里提到"提前猜 ID"的空值缓存:如果有人在商品创建前请求了一个未来会出现的 ID,Redis 里可能已经有短 TTL 空值标记。创建成功后删除一次,可以避免新商品短时间内仍被空值缓存挡住。
ProductFacade.changeStatus 在商品状态变化后也调用 evict(productId)。因为公开详情是否可见和商品状态有关:从草稿上架、上架下架、下架再上架,都可能改变公开接口返回。写库成功后删除旧缓存,下次请求就会重新从 MySQL 构建最新详情。
删除失败不回滚数据库。为什么?因为 MySQL 写入才是主业务结果,Redis 删除失败只是缓存一致性风险,最终 TTL 会兜底。这里体现了缓存作为性能层的定位。
6.10 ProductFacade.getPublishedProduct:真正的 Cache Aside 编排
核心代码结构是:
java
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 和 BYPASS 都回源 MySQL
ProductEntity product = productDbService.requirePublishedById(productId);
CategoryEntity category = categoryDbService.requireEnabledCategory(product.getCategoryId());
ProductDetailResponse response = toDetailResponse(product, category);
productDetailCacheService.put(productId, response);
return response;
这里的分工很清楚:CacheService 不直接查 MySQL,它只管缓存协议;DbService 不关心 Redis,它只管数据库;Facade 负责业务编排,决定哪个状态直接返回,哪个状态回源,哪个状态恢复 404。
这就是后端分层设计的价值:每一层都不做所有事情,但组合起来能形成完整链路。
7. 本地运行 / curl / redis-cli 验证
下面假设你在项目根目录,并且 Docker 可用。
7.1 启动 Redis 和 MySQL
bash
docker compose -f docker-compose.dev.yml up -d mysql redis
查看容器状态:
bash
docker compose -f docker-compose.dev.yml ps
测试 Redis 是否可用:
bash
docker exec -it fullstack-mall-redis redis-cli ping
正常应返回:
text
PONG
7.2 启动后端服务
bash
mvn -pl service -am spring-boot:run
如果你本地 Maven 仓库或 Java 版本按前面章节配置过,也可以继续沿用之前的启动方式。服务默认端口是 8080。
7.3 第一次请求商品详情:预期 MISS 后回源 MySQL
先请求一个已上架商品详情,例如:
bash
curl -sS 'http://localhost:8080/api/products/1'
第一次请求时,如果 Redis 里还没有 key,ProductDetailCacheService 会记录 MISS,Facade 回源 MySQL,成功后 PUT 写入 Redis。你可以在服务日志里关注:
text
product_detail_cache event=MISS productId=1
product_detail_cache event=PUT productId=1 ttl=PT10M
具体日志格式可能受运行环境影响,但事件名是你排查缓存链路的重要线索。
7.4 第二次请求商品详情:预期 HIT
再次请求同一个商品:
bash
curl -sS 'http://localhost:8080/api/products/1'
如果缓存写入成功,第二次应命中 Redis,日志里会出现:
text
product_detail_cache event=HIT productId=1
命中后不应该再查商品表和分类表。后续第 8 篇我们会更完整地读商品详情链路。
7.5 用 redis-cli 查看 key 和 TTL
进入 Redis:
bash
docker exec -it fullstack-mall-redis redis-cli
查看 key:
redis
keys mall:product:detail:v1:*
查看某个 key 的 TTL:
redis
ttl mall:product:detail:v1:1
查看 value:
redis
get mall:product:detail:v1:1
如果你看到一段 JSON 字符串,说明正常商品详情已经被缓存。注意:生产环境不建议频繁用 keys 扫描大 keyspace,本地学习可以用;正式环境一般用 scan。
7.6 请求不存在商品:观察空值缓存
请求一个不存在的商品 ID:
bash
curl -i 'http://localhost:8080/api/products/999999'
第一次会查 MySQL 并返回 404,同时写入短 TTL 空值标记。你可以查看:
bash
docker exec -it fullstack-mall-redis redis-cli get mall:product:detail:v1:999999
可能看到:
text
__NULL__:PRODUCT_NOT_FOUND
再看 TTL:
bash
docker exec -it fullstack-mall-redis redis-cli ttl mall:product:detail:v1:999999
它应该是一个较短时间,接近 30 秒。
7.7 手动删除缓存,模拟失效
bash
docker exec -it fullstack-mall-redis redis-cli del mall:product:detail:v1:1
删除后再次请求 /api/products/1,应该重新 MISS 并回源 MySQL。这个动作对应后端的 productDetailCacheService.evict(productId)。
7.8 关闭缓存验证 BYPASS
你可以用环境变量关闭商品详情缓存:
bash
MALL_PRODUCT_CACHE_ENABLED=false mvn -pl service -am spring-boot:run
此时请求商品详情应该绕过 Redis,直接查 MySQL。这个配置适合本地排障:如果你怀疑接口返回旧数据来自缓存,可以先关闭缓存验证 MySQL 链路是否正确。
7.9 停掉 Redis 验证 DEGRADED
本地学习时可以停掉 Redis:
bash
docker stop fullstack-mall-redis
再请求商品详情。如果 MySQL 正常,接口应该尽量还能返回数据,只是日志里会出现 DEGRADED。验证完成后重启 Redis:
bash
docker start fullstack-mall-redis
这个练习能帮助你理解:缓存是性能层,不应该让商品详情这种核心读取接口完全依赖 Redis。
8. 常见错误
8.1 把 Redis 当成真实数据源
最常见错误是:Redis 里有什么就信什么,Redis 没有就认为业务数据不存在。当前项目没有这样做。Redis MISS、DEGRADED、BYPASS 都会回源 MySQL。只有 NULL_HIT 这种明确空值标记才恢复 404,而且空值 TTL 很短。
8.2 缓存不设置 TTL
没有 TTL 的缓存可能永久占用内存,也可能长期保存旧数据。当前项目通过 setEx 强制写入时设置过期时间。你以后新增缓存时,也要问自己:这个 key 最多能活多久?过期后能不能从真实数据源重建?
8.3 key 命名太随意
例如只用 product:1,未来订单、后台、测试脚本都可能出现相似 key,排查困难。当前项目使用 mall:product:detail:v1:{id},把系统、模块、对象、版本、业务 ID 都写清楚。
8.4 更新数据库后忘记删除缓存
如果管理员把商品下架,但旧缓存还在,用户可能继续看到旧商品详情。当前项目在创建商品、变更状态后都调用 evict。后续如果新增"修改商品标题""修改副标题""修改分类"等功能,也要考虑删除对应详情缓存。
8.5 删除缓存放在写数据库之前
如果先删缓存,再写数据库失败,缓存已经没了但数据没变;如果并发请求刚好在中间进来,还可能把旧数据库数据重新写回缓存。当前项目的代码注释强调"数据库写成功后删除旧缓存"。这是更稳妥的基本顺序。
8.6 Redis 异常直接让接口失败
如果商品详情只是把 Redis 当性能层,Redis 短暂不可用时,最好降级查 MySQL。当前项目的 lookup、put、putNull、evict 都捕获 Redis 相关运行时异常并记录日志,避免缓存故障放大成业务故障。
8.7 用 null 表达所有缓存状态
null 无法区分 MISS、Redis 异常、空值缓存、关闭缓存。当前项目用 ProductDetailCacheLookup 和 ProductDetailCacheStatus 明确表达状态,这是非常值得学习的代码可读性设计。
8.8 空值缓存时间太长
空值缓存能缓解穿透,但时间太长会导致"刚创建出来的数据仍然被当成不存在"。当前项目空值默认 30 秒,并且创建商品成功后会删除对应 key,降低这个风险。
8.9 在生产环境乱用 keys
keys mall:product:* 在本地学习很方便,但生产环境 key 很多时可能阻塞 Redis。正式排查应优先使用 scan,或者通过业务日志、监控、管理工具定位 key。本篇为了学习直观,才使用 keys。
8.10 前端以为清 localStorage 就清了后端缓存
前端清理 token、localStorage、Pinia store,只影响当前浏览器。Redis 是后端共享缓存,必须由后端代码或运维命令删除。用户刷新页面不能保证 Redis 缓存变化。
9. 本章小练习
练习 1:画出商品详情缓存 key
阅读 ProductDetailCacheService.buildKey,写出商品 ID 为 1、20、999999 时对应的 Redis key。然后解释 v1 的意义是什么。
练习 2:用自己的话解释 5 个缓存状态
不要看文档,自己写下 HIT、MISS、NULL_HIT、DEGRADED、BYPASS 的含义,以及 ProductFacade 分别怎么处理。
练习 3:观察正常缓存 TTL
启动 Redis 和后端后,请求 /api/products/1 两次,再用 redis-cli ttl mall:product:detail:v1:1 查看 TTL。观察第二次请求日志是否出现 HIT。
练习 4:观察空值缓存
请求 /api/products/999999,然后用 redis-cli get mall:product:detail:v1:999999 查看 value。解释为什么它不是一个 JSON 商品对象,而是 __NULL__: 开头的标记。
练习 5:模拟 Redis 故障
停掉 Redis 后请求商品详情,观察接口是否还能从 MySQL 返回。然后回答:为什么这个项目选择商品详情 fail-open?如果是支付回调幂等锁,也一定能 fail-open 吗?
练习 6:找出需要删除商品详情缓存的写操作
阅读 ProductFacade.createProduct 和 ProductFacade.changeStatus,找出 evict 调用。然后思考:如果后续新增"管理员修改商品副标题",是否也应该删除商品详情缓存?为什么?
练习 7:补一段前端缓存类比代码
写一段"前端侧示意代码":先查 Map,没有则请求接口,请求成功后写入 Map,并设置一个过期时间。然后对比它和后端 Redis Cache Aside 有哪些相同点、哪些不同点。
10. 再补一层:缓存一致性、穿透、击穿和雪崩先怎么理解
虽然本篇主要是 Redis 入门,但你迟早会在后端面试或项目评审里听到 3 个词:缓存穿透、缓存击穿、缓存雪崩。先不用害怕,我们用当前项目的商品详情缓存来建立第一版理解。
缓存穿透指的是:请求的数据在缓存里没有,在数据库里也没有,于是每次请求都会越过缓存打到数据库。比如有人反复请求 /api/products/999999,如果项目什么都不缓存,每一次都会查 MySQL,最后都得到 404。当前项目的 putNull 就是在处理这个问题:对于 PRODUCT_NOT_FOUND 和 CATEGORY_NOT_FOUND 这种稳定 404,写入短 TTL 空值标记,下次同一个不存在 ID 来了,直接 NULL_HIT 返回 404,避免反复查库。
缓存击穿指的是:某个热点 key 正好过期,大量请求同时进来,全部发现 MISS,然后一起打到数据库。比如首页正在展示一个热门商品,很多人同时点详情,刚好 mall:product:detail:v1:1 过期,就可能产生瞬时数据库压力。本项目当前还没有加互斥锁或 singleflight 合并回源,因为学习项目先保持简单;但你要知道,真实高并发场景可能需要给热点 key 加互斥构建、逻辑过期、后台刷新等机制。
缓存雪崩指的是:大量 key 在同一时间过期,或者 Redis 整体不可用,导致大量请求同时回源数据库。当前项目通过两个基础手段降低风险:第一,Redis 读取异常时返回 DEGRADED,让接口降级到 MySQL,而不是直接失败;第二,正常缓存和空值缓存设置了不同 TTL。真实生产中还会给 TTL 加随机抖动,例如 10 分钟上下浮动几十秒,避免大量 key 在同一秒同时失效。
再说缓存一致性。只要缓存和数据库同时存在,就一定要面对"谁更新、谁删除、什么时候删除"的问题。当前项目采用的是最基础也最常见的策略:读时 Cache Aside,写时先改 MySQL,成功后删除 Redis。它不追求缓存和数据库每一毫秒都完全一致,而是接受很短时间的不一致,并通过删除缓存、TTL 兜底、下次回源重建来收敛。对于商品详情这种展示型数据,这是合理的;对于支付状态、库存扣减、订单金额,就不能直接照搬,必须结合事务、幂等和并发控制重新设计。
前端同学可以这样类比:你在页面里缓存了商品详情,后台管理员改了商品标题,你的页面本地缓存可能还显示旧标题。前端通常通过刷新、失效时间、重新请求来解决。后端 Redis 也是类似思想,只是影响范围更大:它不是一个用户的页面缓存,而是所有用户共享的服务端缓存。所以后端缓存设计更强调 key 规范、TTL、写后失效、异常降级和可观测日志。
最后补一个工程化观察:当前项目并没有只靠人工 curl 判断缓存是否正确,还写了测试。ProductDetailCacheServiceTest 用 mock 的 RedisOperatorClient 验证 MISS、HIT、NULL_HIT、坏 JSON 删除、Redis 读失败降级、不同 TTL、关闭缓存等分支;ProductFacadeCacheTest 验证 Facade 在命中缓存时不访问数据库,在 MISS 时查库并回填,在稳定 404 时写空值,在状态更新成功后删除缓存;RedisContainerTest 则更接近真实环境,用 Redis 容器验证写入、读取、空值和删除。对于前端同学来说,这类似你既写纯函数单测,也写接口集成测试:单测证明分支逻辑,集成测试证明真实依赖能连通。以后你改缓存逻辑时,不要只看接口返回成功,还要确认这些分支测试仍然成立。
还有一个容易被忽略的点:缓存日志本身也是学习和排障工具。当前项目在缓存服务里记录了 event=MISS、event=HIT、event=PUT、event=NULL_HIT、event=DEGRADED、event=EVICT、event=CORRUPTED 等事件。你本地验证时不要只看浏览器响应,也要看服务端日志。后端开发经常需要把"接口返回慢"拆成"是否命中缓存、是否回源数据库、Redis 是否超时、是否写入失败"这些更细的证据。能读懂日志,是前端转后端非常重要的一步。真正上线后的问题往往不能靠猜,而要靠这些可观测证据一步步缩小范围,并形成稳定的后端排查习惯。
11. 下一章预告:商品详情完整请求链路
本篇先补了 Redis 和缓存基础。下一篇我们会把前面所有知识串起来,完整阅读商品详情接口:浏览器请求 GET /api/products/{id},进入 ProductController,调用 ProductFacade.getPublishedProduct,先查 ProductDetailCacheService,缓存命中则直接返回,缓存未命中则通过 ProductDbService 和 CategoryDbService 查询 MySQL,组装 ProductDetailResponse,再写入 Redis,最后返回统一 ApiResponse。
那一篇会是本系列第一次真正把 Controller、Facade、MyBatis-Plus、MySQL、Redis、统一响应、业务异常全部串成一条链路。你会看到后端接口为什么不是"查表返回"这么简单,而是由很多边界条件组成:商品必须上架,分类必须启用,缓存可能命中,缓存可能坏掉,Redis 可能不可用,商品不存在要空值缓存,更新后要删除旧缓存。
如果说前 7 篇是在打基础,第 8 篇就是把基础拼成真实项目能力。
12. 本篇总结
本篇你需要带走 10 个结论:
- Redis 是独立的服务端内存数据服务,不是浏览器缓存,也不是 Java 普通 Map;
- MySQL 是真实数据源,Redis 是性能层,缓存可以丢失但必须能从 MySQL 重建;
- 当前项目通过
spring-boot-starter-data-redis、Lettuce 和StringRedisTemplate访问 Redis; RedisOperatorClient封装底层GET、SET with TTL、DEL,避免业务层散落 Redis 命令;- 商品详情 key 使用
mall:product:detail:v1:{id},包含系统、模块、对象、版本和业务 ID; - 正常商品详情缓存的是
ProductDetailResponseJSON,默认 TTL 是 10 分钟; - 不存在商品或分类不可用会写短 TTL 空值标记,默认 30 秒,用于缓解缓存穿透;
ProductDetailCacheLookup明确区分HIT、MISS、NULL_HIT、DEGRADED、BYPASS,比返回null更清楚;- 商品详情采用 Cache Aside:先查 Redis,MISS / DEGRADED / BYPASS 回源 MySQL,成功后回填缓存;
- 写数据库成功后删除旧缓存,Redis 故障时记录降级并尽量不影响商品详情主链路。
当你以后看到一个接口"加缓存"的需求,不要只想到"把结果 set 到 Redis"。你要先问:真实数据源是谁?key 怎么命名?value 存什么结构?TTL 多久?未命中怎么办?不存在数据要不要缓存?写操作后怎么失效?Redis 挂了接口应该失败还是降级?这些问题能回答清楚,才算真正入门后端缓存设计。