GitHub仓库 :github.com/2530622506/...
01|(前端转全栈)前端人第一次打开 Spring Boot 项目,应该先看哪里?
02|(前端转全栈)从 pnpm dev 到 Spring Boot 启动:后端服务到底怎么跑起来?
03|(前端转全栈)Axios 请求进了后端之后:Controller、Request、Response 是怎么接住它的?
04|(前端转全栈)前端状态为什么不够用?从页面数据到 MySQL 持久化
05|(前端转全栈)不手写一堆 SQL,后端怎么操作数据库?MyBatis-Plus 入门
06|(前端转全栈)登录后端到底在做什么?JWT、Spring Security 和权限链路
07|(前端转全栈)为什么后端也要缓存?从前端缓存思维理解 Redis
08|(前端转全栈)一个商品详情接口背后的完整链路:HTTP、Redis、MySQL 与 JSON
09|(前端转全栈)商品为什么不能随便上下架?后端状态机思维入门
10|(前端转全栈)库存扣减为什么最容易出事故?SKU、并发与原子更新
11|(前端转全栈)购物车不能只存在前端:用户维度数据如何在后端落库
12|(前端转全栈)点击提交订单后,后端如何用事务守住价格、库存和订单?
13|(前端转全栈)支付成功不等于结束:回调、幂等、超时关单和状态竞争
本篇面向前端工程师,基于当前
fullstack-mall后端真实代码讲 Redis Cache Aside。文中的 Vue / TypeScript 片段都只是"前端侧示意代码",仓库本身没有独立前端源码。本篇重点文件是ProductFacade、ProductDetailCacheService、RedisOperatorClient、ProductDetailCacheLookup、ProductDetailCacheProperties、application.yml和相关测试。
1. 这篇解决什么问题
前面我们已经从商品、SKU、购物车、订单、支付把电商后端主链路读了一遍。现在回到一个读多写少的核心接口:公开商品详情。
http
GET /api/products/{id}
如果没有缓存,每一次商品详情请求都要走:Controller → Facade → DbService → Mapper → MySQL。这在学习项目里没有问题,但真实电商里商品详情页会被很多用户反复打开,而且同一个商品在短时间内通常不会频繁变化。把每次请求都打到 MySQL,就像前端页面每次组件渲染都重新请求接口,而不是复用已有的 server state,一定会浪费资源。
本项目在阶段 9 给商品详情增加了 Redis Cache Aside,也就是"旁路缓存"。它不是把 Redis 变成数据库,也不是让所有业务都依赖 Redis,而是在读商品详情时先看缓存:命中就直接返回,未命中就查 MySQL,然后把结果写回 Redis。写商品时不直接更新缓存,而是在 MySQL 成功后删除旧缓存,下一次读取再重新构建新缓存。
这篇要解决 8 个问题:
- 前端同学怎么理解 Redis 和 Cache Aside;
- 为什么 Redis 只是性能层,不是商品数据的事实来源;
- 本项目商品详情缓存 key、value、TTL 怎么设计;
HIT、MISS、NULL_HIT、DEGRADED、BYPASS分别是什么意思;- 为什么要缓存 404 空值;
- Redis 挂了为什么商品详情仍然应该可用;
- 管理端创建商品、改状态、改副标题后为什么要删除缓存;
- 如何用 curl、日志、Redis CLI 和测试验证缓存行为。
如果你是前端,可以把本篇看成"把 SWR / React Query / Pinia 中的缓存经验搬到服务端",但服务端多了几个前端不常遇到的约束:缓存跨用户共享,缓存内容会被很多后端实例读取,缓存失效必须和数据库写入顺序配合,缓存异常不能扩大成核心接口故障。
2. 用前端知识类比
前端做列表页或详情页时,经常会写这样的逻辑:先从内存缓存读取,如果没有,再请求接口;请求成功后,把结果放到缓存里;如果用户修改了数据,就让旧缓存失效。
前端侧示意代码:
ts
// 前端侧示意代码:这是帮助理解的缓存模型,不是本仓库真实前端源码。
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)
}
后端 Redis Cache Aside 和这个思路非常像:
| 前端概念 | 后端对应 | 本项目文件 |
|---|---|---|
| memory cache / React Query cache | Redis 中的字符串缓存 | RedisOperatorClient |
| query key | Redis key | mall:product:detail:v1:{productId} |
| queryFn | 回源 MySQL | ProductDbService.requirePublishedById |
| stale time / cache time | TTL | valueTtl、nullTtl |
| invalidateQueries | 删除缓存 | ProductDetailCacheService.evict |
| error boundary / fallback | Redis 异常降级查 MySQL | DEGRADED |
| not found 缓存 | 空值缓存 | __NULL__:PRODUCT_NOT_FOUND |
但是后端缓存比前端缓存更危险。前端缓存只影响当前浏览器,错了刷新页面就可能恢复;Redis 缓存会影响所有用户、所有后端实例。如果商品下架后旧缓存还在,匿名用户可能继续看到本不该公开的商品;如果 Redis 故障直接抛到 Controller,本来 MySQL 可以返回的商品详情也会变成 500。所以本项目没有让 Controller 或 Facade 随便调用 Redis,而是把缓存细节收束在 ProductDetailCacheService。
前端同学要建立一个重要意识:Redis 不是"更快的数据库",而是"可丢弃、可重建、可过期的性能层"。只要你把 Redis 当数据库,就会开始把重要状态只放在 Redis 里,最终遇到重启丢数据、缓存与数据库不一致、并发写覆盖等问题。商品详情缓存正确的心态是:Redis 有就用,没有就查 MySQL;Redis 错了就删除或绕过;MySQL 才是事实来源。
3. 后端核心概念讲解
3.1 Redis 是独立服务,不是 Java Map
RedisOperatorClient 底层使用的是 StringRedisTemplate。这说明 Redis 不在 JVM 进程里,而是一个独立网络服务。Java 代码每次 get、setEx、delete 都要通过网络连接 Redis。它比 MySQL 快,但仍然可能出现连接失败、超时、序列化错误、服务重启等问题。
本项目只封装了 3 个基本动作:
java
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);
}
setEx 中的 EX 可以理解成"设置值的同时设置过期时间"。如果没有 TTL,缓存可能永久保存旧商品详情,这对可见性和商品信息更新都很危险。
3.2 Cache Aside 的读流程
Cache Aside 的读流程是:
它叫旁路缓存,是因为业务代码仍然知道 MySQL 的存在。Redis 不主动同步 MySQL,MySQL 也不自动更新 Redis。是否读缓存、什么时候回源、什么时候写缓存、什么时候删除缓存,都由业务层决定。
3.3 Cache Aside 的写流程
写流程不是"更新 MySQL 后顺手更新 Redis",而是"更新 MySQL 后删除 Redis"。本项目在商品创建、状态变更、副标题修改后都调用 productDetailCacheService.evict(productId)。
为什么不直接更新缓存?因为商品详情 Response 可能由多张表组装而来,不只是 mall_product 一张表。未来如果商品详情加入分类、SKU、图片、卖家信息,你每次写入都要重新拼完整 JSON,容易漏字段。删除缓存更简单:让下一次读请求基于数据库重新构建。
3.4 空值缓存解决缓存穿透
如果有人不断请求不存在的商品 ID,比如 /api/products/99999999,Redis 每次都是 MISS,然后 MySQL 每次都要查一次,结果都是不存在。这叫缓存穿透。解决办法之一是缓存短 TTL 的"空值标记"。本项目使用 __NULL__: 前缀保存稳定 404:
text
mall:product:detail:v1:99999999 = __NULL__:PRODUCT_NOT_FOUND
下次再请求同一个不存在商品,ProductDetailCacheService.lookup 会返回 NULL_HIT,ProductFacade 直接恢复成 PRODUCT_NOT_FOUND 业务错误,不再打 MySQL。空值缓存 TTL 比正常商品短,本项目默认 30 秒,避免"后来真的创建了这个 ID"时被旧空值挡太久。
3.5 降级和自愈
缓存是性能层,所以 Redis 读失败不能让商品详情接口失败。本项目把 Redis 异常转换成 DEGRADED,让 ProductFacade 继续查 MySQL。这就是 fail-open。它和前端降级类似:如果本地缓存损坏,就重新请求接口,而不是让页面崩溃。
坏 JSON 也是一种问题。如果 Redis 里保存了无法反序列化成 ProductDetailResponse 的字符串,每次读取都会失败。ProductDetailCacheService 的处理方式是删除这个坏值,然后当 MISS 处理,让请求回源 MySQL。这个动作叫自愈。自愈的重点不是"掩盖错误",而是避免坏缓存持续影响后续请求。
4. 在本项目中对应哪些文件
| 文件 | 你应该关注什么 |
|---|---|
backend/service/src/main/java/com/example/fullstackmall/service/product/ProductFacade.java |
getPublishedProduct 如何编排缓存和数据库,写操作后如何 evict |
backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheService.java |
key、JSON、正常 TTL、空值 TTL、坏 JSON、自愈、降级 |
backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheLookup.java |
用结构化对象表达 HIT、MISS、NULL_HIT、DEGRADED、BYPASS |
backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheStatus.java |
缓存查询状态枚举 |
backend/service/src/main/java/com/example/fullstackmall/service/product/cache/ProductDetailCacheProperties.java |
@ConfigurationProperties 绑定缓存开关和 TTL |
backend/service/src/main/java/com/example/fullstackmall/service/cache/RedisOperatorClient.java |
对 StringRedisTemplate 做薄封装 |
backend/service/src/main/resources/application.yml |
Redis 连接和 mall.cache.product-detail 配置 |
ProductDetailCacheServiceTest |
不依赖真实 Redis,用 mock 验证缓存细节 |
ProductFacadeCacheTest |
验证 Facade 编排:命中不查库、MISS 回填、DEGRADED 回源 |
RedisContainerTest |
显式开启后用真实 Redis 容器验证 TTL、GET、DEL |
整体关系如下:
5. 逐段读源码
5.1 ProductFacade.getPublishedProduct 是缓存编排入口
关键代码在 ProductFacade.getPublishedProduct。它先调用:
java
ProductDetailCacheLookup cacheLookup = productDetailCacheService.lookup(productId);
然后按照状态分支处理:
HIT:直接返回缓存中的ProductDetailResponse;NULL_HIT:把空值缓存里的业务码恢复成BusinessException;MISS、DEGRADED、BYPASS:都继续查 MySQL;- MySQL 成功后调用
productDetailCacheService.put(productId, response)回填缓存; - MySQL 返回稳定 404 时调用
putNull写短 TTL 空值。
这段代码最值得前端同学学习的是"缓存状态显式化"。不要用一个 null 表示所有结果。null 到底是没查到 key、Redis 出错、缓存关闭、还是缓存了空值?如果都混在一起,调用方只能猜。本项目用 ProductDetailCacheLookup 把每种状态都命名清楚,业务分支就变得可读。
5.2 ProductDetailCacheService.lookup 负责读缓存
lookup 的第一步是看缓存开关:
java
if (!properties.isEnabled()) {
return ProductDetailCacheLookup.bypass();
}
这对本地排查很有用。你怀疑 Redis 干扰结果时,可以用 MALL_PRODUCT_CACHE_ENABLED=false 启动服务,让商品详情直接查 MySQL。
第二步是读取 Redis:
java
try {
cachedValue = redisOperatorClient.get(key);
} catch (RuntimeException exception) {
return ProductDetailCacheLookup.degraded();
}
这里 catch 的不是为了偷懒,而是明确执行 fail-open。商品详情的事实来源是 MySQL,Redis 只是加速层。如果 Redis 暂时不可用,接口慢一点可以接受,直接 500 不可接受。
第三步是区分 MISS、NULL_HIT、正常 JSON 和坏 JSON。坏 JSON 会触发 evictBrokenValue,删除之后返回 MISS。这样下次请求不会重复踩同一个坏值。
5.3 put 和 putNull 的 TTL 不同
正常商品详情使用 valueTtl,默认 10 分钟;空值缓存使用 nullTtl,默认 30 秒。为什么不同?
正常商品详情被缓存更久,是为了减少热点商品对数据库的压力。空值缓存时间短,是为了防穿透,同时避免把"暂时不存在"的结论保存太久。前端也有类似经验:接口成功数据可以缓存久一点,错误状态通常不应该长时间缓存。
5.4 evict 为什么吞掉 Redis 删除异常
evict 的注释很重要:数据库写成功后删除旧缓存,删除失败不回滚数据库,由 TTL 作为最终兜底。换句话说,写商品时 MySQL 已经成功了,如果 Redis 删除失败,不能因为缓存失败把数据库修改回滚。否则用户会看到"修改失败",但业务真正需要的是数据库更新成功。
这也是后端和前端不同的地方。前端修改本地缓存失败,通常刷新页面即可;后端如果把 Redis 删除失败上升为业务失败,就会制造更大的可用性问题。本项目选择"记录日志 + TTL 兜底"。代价是短时间内可能读到旧缓存;收益是核心写入不被缓存系统拖垮。
5.5 ProductFacade 的写后失效点
本项目有 3 类商品写操作会删除商品详情缓存:
- 创建商品后调用
evict(product.getId()),用于清理可能由"提前猜 ID"产生的空值缓存; - 状态变更后调用
evict(productId),因为上架、下架会影响公开详情可见性; - 修改副标题后调用
evict(productId),因为公开详情 Response 里的subtitle已经变化。
特别注意状态变更。商品从 ON_SALE 改成 OFF_SALE 后,如果不删缓存,匿名用户可能继续通过公开详情看到下架商品。这不是性能问题,而是业务可见性错误。
6. 本地运行和 curl 验证
6.1 启动 MySQL 和 Redis
bash
# 在项目根目录启动本地依赖
docker compose -f docker-compose.dev.yml up -d mysql redis
然后启动后端:
bash
cd backend
mvn -Dmaven.repo.local=$PWD/.m2-repository -pl service -am spring-boot:run
Redis 默认配置在 application.yml:host=localhost、port=6379、timeout=2s。商品详情缓存配置在 mall.cache.product-detail:enabled=true、value-ttl=10m、null-ttl=30s。
6.2 连续请求商品详情观察缓存
bash
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
第一次请求大概率是 MISS 并回源 MySQL,第二次请求应该命中 Redis。你可以观察服务端日志中的 product_detail_cache event=MISS、event=PUT、event=HIT。
6.3 验证空值缓存
bash
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
如果返回 __NULL__:PRODUCT_NOT_FOUND,说明空值缓存生效。再次请求同一个 ID 时,后端不需要查 MySQL,而是直接从空值缓存恢复 404。
6.4 验证写后失效
登录管理员后修改副标题:
bash
ADMIN_TOKEN='粘贴管理员 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":"缓存失效验证副标题"}'
docker exec fullstack-mall-redis redis-cli GET mall:product:detail:v1:1
如果删除成功,GET 应该返回空。下一次访问 GET /api/products/1 会重新查 MySQL,并把新副标题写入缓存。
6.5 关闭缓存排查问题
bash
MALL_PRODUCT_CACHE_ENABLED=false \
SERVER_PORT=18080 \
java -jar service/target/fullstack-mall-service-0.0.1-SNAPSHOT.jar
关闭后 ProductDetailCacheService.lookup 返回 BYPASS,商品详情直接查数据库。排查"为什么页面看到旧数据"时,这个开关很有用:如果关闭缓存后看到新值,问题在 Redis 旧值或失效;如果关闭缓存后仍是旧值,问题在 MySQL 写入或 Response 组装。
7. 常见错误
7.1 把 Redis 当事实来源
不要让"Redis 里有什么"决定商品真实状态。商品是否上架、分类是否启用、副标题是什么,都以 MySQL 为准。Redis 只是缓存 ProductDetailResponse 的 JSON 副本。
7.2 写数据库前先删缓存
如果先删缓存,然后数据库写失败,下一次读会回源旧数据并重新缓存旧值。正确顺序是 MySQL 成功后再删缓存。
7.3 只更新缓存,不删缓存
直接更新缓存看似实时,但容易漏掉组合字段,也容易和数据库事务边界不一致。本项目选择写后失效,让下一次读取重建。
7.4 空值缓存 TTL 太长
空值缓存用于防穿透,不是永久保存"不存在"。如果 TTL 太长,某个商品后来被创建或重新上架,用户可能仍然看到 404。本项目默认 30 秒,是一种学习项目中的温和折中。
7.5 Redis 异常直接抛出
公开商品详情是核心读接口。Redis 挂了应该慢一点查 MySQL,而不是直接 500。只有把 Redis 用作事实来源的场景,才需要另行设计强依赖失败策略。
7.6 不处理坏 JSON
缓存值可能因为手动调试、版本升级、序列化变化而变坏。如果不删除坏值,每次请求都会重复失败。自愈删除坏值可以让系统自动回到 MISS → 回源 → 回填的健康路径。
7.7 缓存 key 没有版本号
本项目 key 前缀是 mall:product:detail:v1:。v1 看似多余,但未来 ProductDetailResponse 字段结构大改时,可以切到 v2,旧缓存自然不会被新代码误读。
7.8 认为所有接口都适合缓存
商品详情适合缓存,因为它读多写少,允许短时间最终一致。库存扣减、订单支付、幂等键、支付回调这些强一致写链路不能简单依赖缓存。前端同学要区分"展示型读数据"和"交易型写数据"。
8. 本章小练习
- 用自己的话解释
HIT、MISS、NULL_HIT、DEGRADED、BYPASS的区别。 - 找到
ProductDetailCacheService.buildKey,说明为什么 key 里要有v1。 - 连续请求两次
GET /api/products/1,观察日志中是否出现MISS、PUT、HIT。 - 请求一个不存在商品,查看 Redis 中的空值缓存和 TTL。
- 修改商品副标题后,检查 Redis key 是否被删除。
- 如果 Redis 里手动写入一段坏 JSON,说明本项目会如何自愈。
- 解释为什么 Redis 删除失败时不应该回滚已经成功的 MySQL 修改。
- 画出"关闭缓存后直接查 MySQL"的链路图。
9. 下一章预告:管理员修改商品副标题完整开发实战
下一篇会把"管理员修改商品副标题"当成一个真实需求,从数据库字段、DTO、Facade 契约、DbService、Controller、权限、缓存失效、手动 curl 验证、常见调试点一路讲完。它会更像你进入后端团队后接到的第一个小需求:看起来只是加一个字段,实际上会穿过 SQL、Entity、Request、Response、Service、Controller、Security、缓存和测试。掌握这个过程,比背诵 Spring 注解更重要。
10. 本篇总结
本篇你需要带走这些结论:Redis 是独立服务,不是 Java Map;商品详情缓存使用 Cache Aside;读时先查 Redis,MISS 或 DEGRADED 再查 MySQL;写时先改 MySQL,再删除缓存;正常值和空值要使用不同 TTL;空值缓存用于缓解缓存穿透;Redis 异常要 fail-open;坏 JSON 要删除自愈;缓存 key 要带业务前缀和版本;公开商品详情缓存属于性能优化,不能替代数据库事实来源。
如果你能说清楚"为什么写后删除缓存,而不是更新缓存",并能用 curl 验证 MISS → PUT → HIT → EVICT → MISS 这条链路,说明你已经掌握了本项目 Redis 缓存最核心的后端思维。
附录 A:把 Cache Aside 讲到能自己设计
前面已经读完当前工程的商品详情缓存,但真正能独立写缓存,还需要把几个设计问题想清楚。第一,缓存什么粒度?本项目缓存的是公开商品详情 Response,而不是单独缓存 ProductEntity。这样做的好处是返回给前端时不需要再组装,命中后路径最短;代价是这个缓存依赖所有会影响详情 Response 的数据,一旦商品、分类或未来的图片、SKU 摘要变化,都要考虑失效。前端同学可以类比:你是缓存接口响应,还是缓存很多小原子状态?缓存接口响应简单,但失效范围更粗;缓存原子状态灵活,但组装成本高。
第二,缓存 key 是否稳定?mall:product:detail:v1:{productId} 至少包含 4 层含义:mall 表示业务系统,product 表示模块,detail 表示场景,v1 表示结构版本,最后才是商品 id。不要只写 product:{id},因为项目变大后 key 会冲突,也不容易批量排查。版本号尤其重要。假设第 20 篇以后你把商品详情 Response 改成包含图片列表,旧 Redis 里仍然是老 JSON,如果 key 没有版本,你就要写复杂兼容逻辑;如果有 v1,可以直接把新代码切到 v2,旧缓存自然过期。
第三,缓存值是否应该压缩或拆字段?当前项目用 Jackson 把 ProductDetailResponse 序列化成 JSON 字符串,适合学习和排查,因为 redis-cli GET 直接能看懂。真实高流量项目可能会考虑压缩、二进制序列化、Hash 结构或本地二级缓存,但这些优化要在你能证明瓶颈后再做。前端同学刚转后端时,不要一上来追求复杂缓存架构。先把 key、TTL、失效、降级、观测做好,比引入更多中间件更重要。
第四,哪些错误可以缓存?本项目只缓存稳定的 404:PRODUCT_NOT_FOUND 和 CATEGORY_NOT_FOUND。不要把所有异常都缓存成空值。比如数据库临时故障、Redis 超时、权限错误、参数错误、库存冲突,都不应该变成"这个商品不存在"。缓存错误时必须问自己:这个错误是不是对同一个 key 在短时间内稳定成立?如果不是,就不要缓存。前端也类似:接口 500 不应该写进长时间缓存,否则用户刷新也看不到恢复后的数据。
第五,TTL 多长合适?没有标准答案,要看业务可接受的陈旧时间。商品标题、副标题、描述允许几分钟最终一致,10 分钟 TTL 可以接受;商品上下架影响可见性,所以写操作必须主动 evict,不能只等 TTL;库存和价格如果用于下单结算,就不能只相信缓存里的展示值,订单创建时必须重新查数据库和 SKU 当前状态。本项目在订单篇已经强调:订单价格不能相信前端传参,同样也不能相信商品详情缓存里的展示价格。
附录 B:5 种缓存状态的真实含义
ProductDetailCacheStatus 里有 5 个状态,不是为了显得复杂,而是为了让业务层不猜。
HIT 表示 Redis 中存在正常 JSON,且能反序列化成 ProductDetailResponse。这种情况下不查 MySQL,性能最好。但也要接受它可能不是最新值,因为缓存天然允许短时间陈旧。
MISS 表示 Redis 没有这个 key,或者有坏值但已经被删除。MISS 不是错误,只是说明本次需要回源。很多缓存系统正常工作时,大量 key 第一次访问都会 MISS,然后回填。
NULL_HIT 表示命中了短 TTL 空值标记。它不是简单的 null,而是携带一个业务码,例如 PRODUCT_NOT_FOUND。这样 Facade 可以恢复出正确错误响应,而不是随便返回 404 文案。
DEGRADED 表示 Redis 操作抛异常,比如连接失败、超时或命令错误。它和 MISS 不同:MISS 是 Redis 正常告诉你没数据,DEGRADED 是 Redis 自己不可用。二者后续都回源 MySQL,但日志和运维意义不同。MISS 太多可能是缓存刚启动或 TTL 太短;DEGRADED 太多说明 Redis 服务或网络有问题。
BYPASS 表示配置关闭缓存。它通常用于本地排查和测试,不代表 Redis 故障。关闭缓存后,所有商品详情都直接查 MySQL。你要能通过日志区分 BYPASS 和 DEGRADED,否则排查时会把"我自己关闭了缓存"误判为"Redis 坏了"。
这 5 个状态也体现了后端可观测性思维:业务结果、系统状态、配置行为要分清楚。前端同学写页面时也可以借鉴,不要用一个 loading=false && data=null 表示所有失败;最好区分未请求、请求中、成功、业务空、网络错误、权限错误。
附录 C:为什么缓存一致性只能做到"合理",不能做到"魔法"
很多初学者会问:有没有一种缓存方案,既永远命中,又永远最新,又 Redis 挂了不影响,又写入没有额外成本?答案是没有。缓存系统本质是在性能、一致性、复杂度、可用性之间取舍。
本项目选择的是适合商品详情的取舍:读路径优先性能,写路径优先数据库正确性,缓存异常优先接口可用性,一致性通过主动失效和 TTL 兜底实现。它不是强一致缓存。后台刚修改副标题,Redis 删除失败时,用户可能在 TTL 结束前读到旧副标题;但系统不会因为 Redis 删除失败导致商品修改失败。这个取舍对商品详情可以接受,对支付状态就不一定可以接受。
前端同学可以这样理解:你用 React Query 设置 staleTime=10min,就默认允许 10 分钟内复用旧数据;如果 mutation 后 invalidateQueries 失败,页面可能旧一会儿。但如果是支付成功状态,你不会只靠前端缓存判断,而会重新请求服务端。后端 Redis 也是同理:展示型数据可以缓存,交易型数据要回到数据库和事务。
附录 D:面试和代码评审会问什么
如果你把本项目写进简历,说做了 Redis 商品详情缓存,面试官很可能追问:缓存 key 怎么设计?为什么有版本?正常值和空值 TTL 为什么不同?如何防缓存穿透?Redis 挂了怎么办?写操作为什么删除缓存而不是更新缓存?删除缓存失败怎么办?缓存中坏 JSON 怎么处理?如何证明缓存命中没有查数据库?如何用测试覆盖这些行为?
你可以用本项目回答:key 使用 mall:product:detail:v1:{id};正常值默认 10 分钟,空值默认 30 秒;稳定 404 写 __NULL__: 空值标记;Redis 读写异常不影响商品详情主流程,读失败返回 DEGRADED 并回源 MySQL,写失败和删除失败记录日志;坏 JSON 删除后当 MISS 处理;商品创建、状态变更、副标题修改后都 evict;ProductFacadeCacheTest 验证编排,ProductDetailCacheServiceTest 验证细节,RedisContainerTest 验证真实 Redis TTL。
代码评审时,你也可以反过来检查别人写的缓存:有没有把 Redis 异常直接抛给 Controller?有没有缓存非稳定错误?有没有忘记写后失效?有没有把库存这类强一致数据只放 Redis?有没有可观测日志?有没有测试坏 JSON 和 Redis 故障?这些问题比"会不会写 redisTemplate.opsForValue().get"更能体现后端能力。
附录 E:把商品详情缓存落到一次真实排查
假设你在本地遇到这样一个问题:管理员已经把商品副标题从"旧副标题"改成"新副标题",后台管理接口返回成功,数据库里也能看到 mall_product.subtitle 已经变了,但匿名用户访问 GET /api/products/{id} 时,页面仍然展示旧副标题。前端同学第一反应通常是检查页面状态、浏览器缓存、接口是否真的重新请求;这些都要查,但后端排查还要继续往下走,因为本项目商品详情前面有 Redis 缓存层。
第一步,先确认前端是否真的请求了公开详情接口,而不是管理端接口。管理端看到新值,不代表公开详情一定新,因为公开详情经过 ProductFacade.getPublishedProduct 和 ProductDetailCacheService.lookup。第二步,拿到响应里的 traceId,回到日志里找这一次请求是否出现缓存命中相关日志。如果命中了 HIT,就说明返回值来自 Redis,而不是刚刚查出来的 MySQL。第三步,用 redis-cli GET mall:product:detail:v1:{productId} 看缓存里是不是旧 JSON。如果是旧值,再去检查写接口是否调用了 productDetailCacheService.evict(productId)。
这个排查过程非常适合作为前端转后端的训练:前端只看到"接口返回旧数据",后端要能把旧数据拆成多个来源:可能是浏览器缓存,可能是 CDN,可能是 Redis,可能是数据库事务没提交,可能是读写不在同一个库,可能是 DTO 组装时用错字段。本项目目前没有 CDN 和读写分离,所以重点就是 Controller 路径、Facade 缓存编排、Redis key、MySQL 字段和缓存失效点。你以后进入更复杂项目时,也是先从简单链路排除,再扩大范围。
如果你想把这个问题变成一个稳定的练习,可以按下面顺序操作:先访问公开详情制造缓存;再调用管理员副标题修改接口;然后立刻再次访问公开详情;最后观察是否返回新副标题。正确结果应该是新值,因为 updateSubtitle 在数据库更新成功后会删除缓存。假如你临时注释掉 evict,就能复现"缓存旧值"问题。注意这只是学习实验,不要把这种破坏性修改提交到代码库。
附录 F:缓存命中率不是唯一目标
很多教程讲 Redis 时会强调命中率,但对业务系统来说,命中率只是一个指标,不是唯一目标。商品详情接口的目标至少有 4 个:第一,匿名访问足够快;第二,商品不存在时不要反复打数据库;第三,Redis 故障时接口仍然能读 MySQL;第四,管理端更新后公开详情不要长期返回旧值。命中率高只能证明 Redis 被用上了,不能证明业务正确。
前端可以类比图片懒加载:图片加载得快很好,但如果展示了错误商品图,体验再快也没有意义。缓存也是一样。命中旧数据、命中错误权限数据、命中过期价格,都是"快但错"。所以本项目缓存的是公开商品详情,而不是用户私有数据;并且创建、上下架、修改副标题这些写操作都会删除对应商品缓存。公开详情允许短时间最终一致,但管理端写成功后应该尽快让下次读取回源生成新缓存。
实际工作中设计缓存指标时,可以分成 3 类。性能指标包括平均耗时、P95 耗时、Redis 命中率、MySQL 查询次数。正确性指标包括写后旧值持续时间、空值缓存误伤次数、坏 JSON 自愈次数。稳定性指标包括 Redis 异常次数、降级次数、接口 5xx 数量。前端同学看监控时也要建立这种多指标意识:只看一个数字,往往会掩盖真实问题。
附录 G:为什么本项目没有一开始引入复杂缓存方案
你可能听过缓存击穿、缓存雪崩、缓存预热、分布式锁、本地二级缓存、消息队列删缓存等概念。它们都真实存在,但不适合作为本系列第一个 Redis 实战的起点。学习后端最容易掉进"名词驱动开发":看到一个名词就想用,最后代码越来越复杂,却不清楚每一层复杂性解决了什么问题。
本项目现在选择的是最小闭环:Cache Aside、TTL、空值缓存、写后失效、Redis 异常降级、坏 JSON 自愈。这个组合已经覆盖了学习型商城里最核心的读缓存问题。只有当你能用压测或日志证明某个商品在缓存失效瞬间被大量并发请求打到数据库,才需要讨论互斥锁或逻辑过期;只有当你能证明大量 key 同时过期造成数据库抖动,才需要讨论 TTL 随机化;只有当后台频繁批量更新商品并要求毫秒级一致,才需要讨论消息队列或订阅 binlog。
前端同学可以把这件事类比为组件状态管理。一个页面只有两个组件共享状态时,用 props 和事件就够了;几十个页面共享、需要持久化、需要调试工具时,再引入 Pinia 或 Redux。后端中间件也是一样:不要因为 Redis 很常见就把所有问题都推给 Redis。先写出正确的数据库查询和清晰的业务边界,再用缓存优化热点读,这是更稳的成长路线。
附录 H:本章自测题参考答案
如果面试官问"Redis 挂了商品详情还能不能访问",本项目的参考答案是:能访问,但性能会回退到 MySQL。因为 ProductDetailCacheService.lookup 捕获 Redis 读取异常后返回 DEGRADED,ProductFacade.getPublishedProduct 收到 DEGRADED 后继续走 ProductDbService 和 CategoryDbService 回源。写缓存失败也不会让接口失败,因为商品详情的事实来源是 MySQL,缓存只是性能层。
如果问"为什么商品不存在也要缓存",参考答案是:为了防止缓存穿透。大量请求不存在的商品 id,如果每次都 MISS 后查 MySQL,会给数据库制造无意义压力。本项目用 putNull 保存短 TTL 的空值,并记录对应业务 code,下次命中 NULL_HIT 时直接恢复业务异常。但空值 TTL 必须比正常值短,避免商品后续被创建或状态变化后长时间仍被认为不存在。
如果问"修改商品副标题后要不要更新 Redis 中的 JSON",参考答案是:本项目选择删除缓存而不是更新缓存。删除更简单,也避免多个写入口组装缓存值不一致。下次公开详情请求会回源 MySQL,重新组装 ProductDetailResponse 并写入新缓存。对于学习阶段和大多数业务后台来说,这个策略更容易验证和维护。
附录 I:缓存代码评审时最容易漏掉的 6 个问题
评审缓存代码时,不要只看"有没有用 Redis"。第一,看 key 是否有清晰命名空间和版本;第二,看 TTL 是否区分正常值和空值;第三,看 Redis 异常是否会拖垮主流程;第四,看反序列化失败是否能删除坏缓存自愈;第五,看所有写入口是否都失效同一个 key;第六,看业务层是否仍然以 MySQL 为准。只要其中一项缺失,缓存就可能从性能优化变成故障来源。
本项目商品详情缓存基本覆盖了这 6 点,所以它适合作为你以后写缓存功能的模板。你可以先复制这套思路,而不是复制具体代码。因为不同项目的序列化方式、Redis 客户端、日志框架可能不同,但设计问题是不变的:读不到怎么办,写失败怎么办,旧数据怎么办,不存在怎么办,如何验证怎么办。
附录 J:从浏览器缓存理解服务端缓存责任
浏览器缓存通常只影响当前用户或当前设备,出问题时清缓存、强制刷新就能临时绕过;服务端 Redis 缓存影响的是所有访问同一个 key 的用户,所以责任更重。一个商品详情 key 如果保存了旧值,所有匿名用户都可能看到旧值;一个空值缓存如果 TTL 过长,所有用户都可能在一段时间内认为商品不存在。这就是为什么后端缓存必须有统一 key 规则、统一失效点和可观测日志。
前端同学写页面缓存时,常会在组件卸载或路由切换时清理状态;后端写缓存时,也要为每个写操作找到对应的清理动作。创建、更新、上下架、删除、批量导入、后台修复数据,都可能改变公开详情。当前项目先覆盖核心写入口,后续如果新增图片、价格展示或分类名称修改,就要继续扩展失效范围。缓存不是写完就结束,而是随着业务演进持续维护的一层契约。