07|(前端转后全栈)为什么后端也要缓存?从前端缓存思维理解 Redis

本篇写给已经熟悉前端 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,而是分成几层:

  1. RedisOperatorClient 封装底层 Redis 字符串命令;
  2. ProductDetailCacheProperties 接收商品详情缓存配置;
  3. ProductDetailCacheService 负责商品详情缓存 key、JSON、TTL、空值、删除和降级;
  4. ProductDetailCacheLookup 用结构化对象表达 HITMISSNULL_HITDEGRADEDBYPASS
  5. ProductFacade.getPublishedProduct 编排 Redis 和 MySQL 的查询顺序。

本篇解决这些问题:

  1. Redis 到底是什么,和 MySQL、Java 内存、前端缓存有什么区别;
  2. 为什么商品详情适合做缓存,而订单创建、支付回调不能随便缓存;
  3. 什么是 key、value、TTL,为什么业务缓存必须设置过期时间;
  4. StringRedisTemplate、Lettuce、Spring Data Redis 分别是什么;
  5. 当前项目如何通过 RedisOperatorClient 封装 GETSET EXDEL
  6. 什么是缓存命中 HIT、未命中 MISS、空值命中 NULL_HIT
  7. Redis 故障时为什么要降级查 MySQL,而不是让接口直接失败;
  8. 数据库更新后为什么要删除缓存,而不是盲目相信旧缓存;
  9. 如何用 Docker、redis-cli、curl 和日志验证缓存行为;
  10. 初学 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 查询、事务更新 GETSETDELEXPIRE
当前项目角色 商品真实数据源 商品详情缓存层
数据丢失影响 严重,业务数据丢失 可从 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 直接返回 ProductDetailResponsenull,而是返回 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,返回 DEGRADEDProductFacade.getPublishedProductDEGRADED 的处理和 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 字符串 getsetExdelete
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 枚举缓存查询状态:HITNULL_HITMISSDEGRADEDBYPASS
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 对 HITMISSNULL_HITDEGRADED 的处理
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 的位置

flowchart LR Browser[浏览器 / 前端页面] -->|HTTP GET /api/products/id| App[Spring Boot Service] App -->|GET key| Redis[(Redis)] Redis -->|HIT JSON| App App -->|MISS / DEGRADED| MySQL[(MySQL)] MySQL -->|商品真实数据| App App -->|SET key value TTL| Redis App -->|ApiResponse JSON| Browser

这张图说明:浏览器不会直接访问 Redis。前端只访问 Spring Boot HTTP 接口;Redis 是后端内部依赖。不要把 Redis 地址、Redis key、Redis 密码暴露给浏览器。

5.2 商品详情 Cache Aside 查询流程

flowchart TD A[ProductFacade.getPublishedProduct] --> B[ProductDetailCacheService.lookup] B --> C{缓存状态} C -->|HIT| D[直接返回 ProductDetailResponse] C -->|NULL_HIT| E[恢复 PRODUCT_NOT_FOUND / CATEGORY_NOT_FOUND] C -->|MISS| F[查询 MySQL 商品和分类] C -->|DEGRADED| F C -->|BYPASS| F F --> G{MySQL 结果} G -->|找到商品详情| H[put 正常 JSON 到 Redis valueTtl] H --> I[返回响应] G -->|稳定 404| J[putNull 空值标记 nullTtl] J --> K[抛原业务 404]

这张图是本篇最重要的图。你要能把它和 ProductFacade.getPublishedProduct 的代码逐行对应起来。

5.3 缓存状态机

stateDiagram-v2 [*] --> BYPASS: enabled=false [*] --> GET: enabled=true GET --> DEGRADED: Redis 异常 GET --> MISS: key 不存在 GET --> NULL_HIT: value 以 __NULL__: 开头且业务码合法 GET --> HIT: JSON 反序列化成功 GET --> MISS: 坏 JSON / 非法空值标记并删除 MISS --> DB: 回源 MySQL DEGRADED --> DB: fail-open BYPASS --> DB: 绕过缓存 HIT --> [*]: 返回响应 NULL_HIT --> [*]: 返回 404

这个状态机能帮你理解为什么 ProductDetailCacheLookup 比返回 null 更好。每个状态都有不同业务含义和后续处理。

5.4 写数据库后删除缓存

sequenceDiagram autonumber participant Admin as 管理员请求 participant Facade as ProductFacade participant MySQL as MySQL participant Cache as ProductDetailCacheService participant Redis as Redis Admin->>Facade: 修改商品状态 / 创建商品 Facade->>MySQL: save / updateById MySQL-->>Facade: 写入成功 Facade->>Cache: evict(productId) Cache->>Redis: DEL mall:product:detail:v1:{id} Redis-->>Cache: 删除结果 Facade-->>Admin: 返回最新商品响应

注意顺序:先写 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。它还提供了静态工厂方法:hitnullHitmissdegradedbypass

这是一种很适合后端的表达方式:不要让调用方猜 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。

这里有两个细节很值得学习:

  1. 坏缓存不要一直留着,否则每次请求都会重复解析失败,直到 TTL 到期;
  2. 删除坏缓存失败也不能让接口直接崩掉,所以删除异常也只记录降级日志。

6.8 putputNull:回填正常值和空值

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_FOUNDCATEGORY_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。当前项目的 lookupputputNullevict 都捕获 Redis 相关运行时异常并记录日志,避免缓存故障放大成业务故障。

8.7 用 null 表达所有缓存状态

null 无法区分 MISS、Redis 异常、空值缓存、关闭缓存。当前项目用 ProductDetailCacheLookupProductDetailCacheStatus 明确表达状态,这是非常值得学习的代码可读性设计。

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 个缓存状态

不要看文档,自己写下 HITMISSNULL_HITDEGRADEDBYPASS 的含义,以及 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.createProductProductFacade.changeStatus,找出 evict 调用。然后思考:如果后续新增"管理员修改商品副标题",是否也应该删除商品详情缓存?为什么?

练习 7:补一段前端缓存类比代码

写一段"前端侧示意代码":先查 Map,没有则请求接口,请求成功后写入 Map,并设置一个过期时间。然后对比它和后端 Redis Cache Aside 有哪些相同点、哪些不同点。

10. 再补一层:缓存一致性、穿透、击穿和雪崩先怎么理解

虽然本篇主要是 Redis 入门,但你迟早会在后端面试或项目评审里听到 3 个词:缓存穿透、缓存击穿、缓存雪崩。先不用害怕,我们用当前项目的商品详情缓存来建立第一版理解。

缓存穿透指的是:请求的数据在缓存里没有,在数据库里也没有,于是每次请求都会越过缓存打到数据库。比如有人反复请求 /api/products/999999,如果项目什么都不缓存,每一次都会查 MySQL,最后都得到 404。当前项目的 putNull 就是在处理这个问题:对于 PRODUCT_NOT_FOUNDCATEGORY_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=MISSevent=HITevent=PUTevent=NULL_HITevent=DEGRADEDevent=EVICTevent=CORRUPTED 等事件。你本地验证时不要只看浏览器响应,也要看服务端日志。后端开发经常需要把"接口返回慢"拆成"是否命中缓存、是否回源数据库、Redis 是否超时、是否写入失败"这些更细的证据。能读懂日志,是前端转后端非常重要的一步。真正上线后的问题往往不能靠猜,而要靠这些可观测证据一步步缩小范围,并形成稳定的后端排查习惯。

11. 下一章预告:商品详情完整请求链路

本篇先补了 Redis 和缓存基础。下一篇我们会把前面所有知识串起来,完整阅读商品详情接口:浏览器请求 GET /api/products/{id},进入 ProductController,调用 ProductFacade.getPublishedProduct,先查 ProductDetailCacheService,缓存命中则直接返回,缓存未命中则通过 ProductDbServiceCategoryDbService 查询 MySQL,组装 ProductDetailResponse,再写入 Redis,最后返回统一 ApiResponse

那一篇会是本系列第一次真正把 Controller、Facade、MyBatis-Plus、MySQL、Redis、统一响应、业务异常全部串成一条链路。你会看到后端接口为什么不是"查表返回"这么简单,而是由很多边界条件组成:商品必须上架,分类必须启用,缓存可能命中,缓存可能坏掉,Redis 可能不可用,商品不存在要空值缓存,更新后要删除旧缓存。

如果说前 7 篇是在打基础,第 8 篇就是把基础拼成真实项目能力。

12. 本篇总结

本篇你需要带走 10 个结论:

  1. Redis 是独立的服务端内存数据服务,不是浏览器缓存,也不是 Java 普通 Map;
  2. MySQL 是真实数据源,Redis 是性能层,缓存可以丢失但必须能从 MySQL 重建;
  3. 当前项目通过 spring-boot-starter-data-redis、Lettuce 和 StringRedisTemplate 访问 Redis;
  4. RedisOperatorClient 封装底层 GETSET with TTLDEL,避免业务层散落 Redis 命令;
  5. 商品详情 key 使用 mall:product:detail:v1:{id},包含系统、模块、对象、版本和业务 ID;
  6. 正常商品详情缓存的是 ProductDetailResponse JSON,默认 TTL 是 10 分钟;
  7. 不存在商品或分类不可用会写短 TTL 空值标记,默认 30 秒,用于缓解缓存穿透;
  8. ProductDetailCacheLookup 明确区分 HITMISSNULL_HITDEGRADEDBYPASS,比返回 null 更清楚;
  9. 商品详情采用 Cache Aside:先查 Redis,MISS / DEGRADED / BYPASS 回源 MySQL,成功后回填缓存;
  10. 写数据库成功后删除旧缓存,Redis 故障时记录降级并尽量不影响商品详情主链路。

当你以后看到一个接口"加缓存"的需求,不要只想到"把结果 set 到 Redis"。你要先问:真实数据源是谁?key 怎么命名?value 存什么结构?TTL 多久?未命中怎么办?不存在数据要不要缓存?写操作后怎么失效?Redis 挂了接口应该失败还是降级?这些问题能回答清楚,才算真正入门后端缓存设计。

相关推荐
IT小盘1 小时前
05-企业项目统一接入多个大模型-适配器模式实战
前端·人工智能·适配器模式
952361 小时前
Docker - 基础
运维·后端·docker·容器
swipe1 小时前
06|(前端转后全栈)登录后端到底在做什么?JWT、Spring Security 和权限链路
前端·后端·全栈
海上彼尚2 小时前
Nodejs也能写Agent - 22.LangGraph篇 - 上下文工程
前端·javascript·人工智能·langchain·node.js
阳光是sunny3 小时前
LangGraph实战教程:预定义状态MessagesState与AgentState
前端·人工智能·后端
我叫黑大帅3 小时前
add()和 __add__() 写法哪个更好呢?
后端·python·面试
muddjsv3 小时前
CSS选择器体系精讲:按身份、结构、状态精准匹配元素(零基础吃透)
前端·css
GuWenyue3 小时前
等AI回复卡顿到劝退?Vue3+DeepSeek流式输出实战,70行代码实现打字机效果
前端·人工智能·客户端