Spring Cloud 学习与实践(11):Redis 缓存、穿透、击穿、雪崩与数据一致性

文章目录

  • [Spring Cloud 学习与实践(11):Redis 缓存、穿透、击穿、雪崩与数据一致性](#Spring Cloud 学习与实践(11):Redis 缓存、穿透、击穿、雪崩与数据一致性)
    • 1、本章目标
    • [2、为什么先给 cloud-product 加缓存](#2、为什么先给 cloud-product 加缓存)
    • [3、第一阶段:让 cloud-product 能连接 Redis](#3、第一阶段:让 cloud-product 能连接 Redis)
      • [3.1 先确认 Redis 本身可用](#3.1 先确认 Redis 本身可用)
      • [3.2 引入 Redis 依赖:cloud-product/pom.xml](#3.2 引入 Redis 依赖:cloud-product/pom.xml)
      • [3.3 配置 Redis 地址:cloud-product-dev.yaml](#3.3 配置 Redis 地址:cloud-product-dev.yaml)
      • [3.4 验证商品服务 Redis 连接:RedisTestController.java](#3.4 验证商品服务 Redis 连接:RedisTestController.java)
      • [3.5 通过 Gateway 测试 Redis 写入和读取](#3.5 通过 Gateway 测试 Redis 写入和读取)
    • [4、第二阶段:商品详情接口加入 Redis 缓存](#4、第二阶段:商品详情接口加入 Redis 缓存)
      • [4.1 商品详情原来的问题](#4.1 商品详情原来的问题)
      • [4.2 设计商品详情缓存 key:ProductCacheKey.java](#4.2 设计商品详情缓存 key:ProductCacheKey.java)
      • [4.3 改造商品详情查询:ProductController.java](#4.3 改造商品详情查询:ProductController.java)
      • [4.4 为什么先把对象转成 JSON 字符串](#4.4 为什么先把对象转成 JSON 字符串)
      • [4.5 加日志观察缓存命中](#4.5 加日志观察缓存命中)
      • [4.6 验证商品详情缓存](#4.6 验证商品详情缓存)
    • 5、第三阶段:缓存穿透演练与空值缓存修复
      • [5.1 先复现一个真实问题:不存在的商品会怎样](#5.1 先复现一个真实问题:不存在的商品会怎样)
      • [5.2 复现缓存穿透](#5.2 复现缓存穿透)
      • [5.3 修复思路:缓存空值](#5.3 修复思路:缓存空值)
      • [5.4 增加空值缓存标记:ProductCacheKey.java](#5.4 增加空值缓存标记:ProductCacheKey.java)
      • [5.5 改造 getById:识别空值缓存](#5.5 改造 getById:识别空值缓存)
      • [5.6 验证缓存穿透修复](#5.6 验证缓存穿透修复)
      • [5.7 空值缓存的边界](#5.7 空值缓存的边界)
    • 6、第四阶段:缓存击穿演练与互斥锁修复
      • [6.1 缓存击穿和缓存穿透不是一回事](#6.1 缓存击穿和缓存穿透不是一回事)
      • [6.2 先复现缓存击穿](#6.2 先复现缓存击穿)
      • [6.3 修复思路:Redis 互斥锁](#6.3 修复思路:Redis 互斥锁)
      • [6.4 增加锁 key:ProductCacheKey.java](#6.4 增加锁 key:ProductCacheKey.java)
      • [6.5 实现简单 Redis 互斥锁:tryLock / unlock](#6.5 实现简单 Redis 互斥锁:tryLock / unlock)
      • [6.6 改造 getById:加入互斥锁](#6.6 改造 getById:加入互斥锁)
      • [6.7 为什么抢到锁后还要二次检查 Redis](#6.7 为什么抢到锁后还要二次检查 Redis)
      • [6.8 验证缓存击穿修复](#6.8 验证缓存击穿修复)
    • [7、第五阶段:缓存雪崩演练与随机 TTL 修复](#7、第五阶段:缓存雪崩演练与随机 TTL 修复)
      • [7.1 缓存雪崩是什么](#7.1 缓存雪崩是什么)
      • [7.2 先观察固定 TTL 的问题](#7.2 先观察固定 TTL 的问题)
      • [7.3 修复思路:基础 TTL + 随机 TTL](#7.3 修复思路:基础 TTL + 随机 TTL)
      • [7.4 增加随机 TTL 方法](#7.4 增加随机 TTL 方法)
      • [7.5 替换正常商品缓存 TTL](#7.5 替换正常商品缓存 TTL)
      • [7.6 验证随机 TTL](#7.6 验证随机 TTL)
    • 8、第六阶段:商品数据变更后删除缓存
      • [8.1 为什么会有缓存一致性问题](#8.1 为什么会有缓存一致性问题)
      • [8.2 本章采用的方案:写数据库后删除缓存](#8.2 本章采用的方案:写数据库后删除缓存)
      • [8.3 扣减库存成功后删除缓存:ProductServiceImpl.java](#8.3 扣减库存成功后删除缓存:ProductServiceImpl.java)
      • [8.4 验证删除缓存是否生效](#8.4 验证删除缓存是否生效)
    • 9、本章总结
    • 10、知识扩展:未展开但后续值得继续研究的点
      • [10.1 缓存策略的几种常见模式](#10.1 缓存策略的几种常见模式)
      • [10.2 布隆过滤器如何更优雅地解决缓存穿透](#10.2 布隆过滤器如何更优雅地解决缓存穿透)
      • [10.3 Redisson 分布式锁与手写互斥锁的区别](#10.3 Redisson 分布式锁与手写互斥锁的区别)
      • [10.4 更严格的缓存一致性方案](#10.4 更严格的缓存一致性方案)
    • [11、下一章预告:RabbitMQ 异步消息与订单后续处理](#11、下一章预告:RabbitMQ 异步消息与订单后续处理)

Spring Cloud 学习与实践(11):Redis 缓存、穿透、击穿、雪崩与数据一致性

经过第10章,我们已经用 Sentinel 为系统装上了"保护阀",解决了流量突增时服务被打垮的问题。但系统还有一个更普遍的痛点------热点数据每次都要查 MySQL,高频访问下数据库压力越来越大。第11章将引入 Redis,围绕商品详情接口,从接入缓存开始,逐步演练穿透、击穿、雪崩的应对方案,以及缓存一致性的基本保证。

前面第 10 章,我们用 Sentinel 解决的是:

text 复制代码
流量来了以后,怎么保护服务不被打垮。

这一章继续往下走,开始接入 Redis。

Redis 要解决的问题不是"限流",而是另一个更常见的问题:

text 复制代码
热点数据能不能少查 MySQL?

以当前项目里的商品详情接口为例:

http 复制代码
GET /products/1

这个接口天然适合做缓存,因为它是典型的"读多写少"场景。

如果每次访问商品详情都去查 MySQL,功能当然能跑,但高频访问下数据库压力会越来越大。

所以第 11 章的主线不是"Redis 知识大全",而是围绕商品详情接口一步一步演练:

text 复制代码
先让 cloud-product 能连接 Redis
    ↓
商品详情第一次查 MySQL,第二次查 Redis
    ↓
不存在的商品会不会穿透到 MySQL
    ↓
热点 key 失效时会不会一堆请求同时查 MySQL
    ↓
大量 key 同时过期怎么办
    ↓
扣库存后缓存还是旧数据怎么办

本章仍然坚持一个原则:

text 复制代码
先复现问题,再修复问题。

1、本章目标

本章完成以下内容:

text 复制代码
1. cloud-product 接入 Redis
2. 使用 StringRedisTemplate 做连通性测试
3. 给商品详情接口增加手写缓存
4. 验证第一次查 MySQL,第二次查 Redis
5. 使用 TTL 避免缓存永久不过期
6. 复现缓存穿透,并用空值缓存修复
7. 复现缓存击穿,并用 Redis 互斥锁修复
8. 复现固定 TTL 带来的集中过期风险,并用随机 TTL 缓解缓存雪崩
9. 扣减库存后删除商品详情缓存,保证基本数据一致性

最后形成的商品详情查询链路是:

text 复制代码
请求商品详情
    ↓
先查 Redis
    ↓
Redis 有正常商品 JSON:直接返回
    ↓
Redis 有空值标记:直接返回商品不存在
    ↓
Redis 没有:尝试获取互斥锁
    ↓
抢到锁:查 MySQL,重建缓存
    ↓
没抢到锁:短暂等待后重试 Redis

2、为什么先给 cloud-product 加缓存

本章仍然先从 cloud-product 开始。

原因很直接:商品详情是一个非常适合缓存的接口。

它有几个特点:

text 复制代码
1. 查询频率高
2. 数据结构简单
3. 读多写少
4. 很容易观察是否查了 MySQL
5. 和前面 Sentinel 的 JMeter 演练可以自然衔接

暂时不先给订单创建接口加缓存。

订单创建涉及库存扣减、订单写入、事务和一致性问题,不适合作为第一个缓存练习点。

这一章先把商品详情缓存链路跑透。


3、第一阶段:让 cloud-product 能连接 Redis

3.1 先确认 Redis 本身可用

在写代码之前,先确认本地 Redis 服务是否正常。

bash 复制代码
redis-cli ping

预期:

text 复制代码
PONG

再测试写入和读取:

bash 复制代码
redis-cli set cloud:demo:test hello
redis-cli get cloud:demo:test

预期:

text 复制代码
hello

这一步看起来很简单,但不要省。

如果 Redis 服务本身没启动,后面 Spring Boot 报错时,很容易误判成 Java 代码问题。


3.2 引入 Redis 依赖:cloud-product/pom.xml

cloud-product/pom.xml 中增加:

xml 复制代码
<!-- Redis:用于商品详情缓存、缓存穿透/击穿/雪崩演练 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

这一章先不急着加:

xml 复制代码
spring-boot-starter-cache

也不急着用:

java 复制代码
@Cacheable

原因是:学习阶段先用 StringRedisTemplate 手写缓存,能更清楚地看到缓存链路:

text 复制代码
查 Redis
    ↓
Redis 没有
    ↓
查 MySQL
    ↓
写 Redis
    ↓
下一次命中 Redis

等这条链路理解清楚后,再看 @Cacheable 会更自然。


3.3 配置 Redis 地址:cloud-product-dev.yaml

打开 Nacos 中的:

text 复制代码
cloud-product-dev.yaml

补充 Redis 配置。

如果本地 Redis 没有密码,配置如下:

yaml 复制代码
spring:
  redis:
    host: 127.0.0.1
    port: 6379
    database: 0
    timeout: 3000ms

3.4 验证商品服务 Redis 连接:RedisTestController.java

先不急着改商品详情接口。

这一阶段先加一个临时测试接口,确认 cloud-product 能通过 Spring 连接 Redis。

文件位置:

text 复制代码
cloud-product
└── src/main/java
    └── com.example.cloud.product.controller
        └── RedisTestController.java

代码如下:

java 复制代码
package com.example.cloud.product.controller;

import com.example.cloud.common.result.Result;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.*;

import java.time.Duration;

/**
 * Redis 连通性测试接口。
 *
 * 这个 Controller 只用于第 11 章第一阶段演练:
 * 确认 cloud-product 能正常连接 Redis。
 *
 * 演练完成后可以保留一段时间用于排查,
 * 也可以在笔记发布前删除。
 */
@RestController
@RequestMapping("/redis/test")
@RequiredArgsConstructor
public class RedisTestController {

    private final StringRedisTemplate stringRedisTemplate;

    /**
     * 写入一个测试 key。
     *
     * 示例:
     * POST /redis/test?key=hello&value=world
     */
    @PostMapping
    public Result<Void> set(
            @RequestParam String key,
            @RequestParam String value
    ) {
        String redisKey = "cloud:product:test:" + key;

        /*
         * 设置 5 分钟过期时间。
         *
         * 这里不要写成永久 key,
         * 避免测试数据长期留在 Redis 中。
         */
        stringRedisTemplate.opsForValue().set(
                redisKey,
                value,
                Duration.ofMinutes(5)
        );

        return Result.success();
    }

    /**
     * 读取测试 key。
     *
     * 示例:
     * GET /redis/test?key=hello
     */
    @GetMapping
    public Result<String> get(
            @RequestParam String key
    ) {
        String redisKey = "cloud:product:test:" + key;

        String value = stringRedisTemplate
                .opsForValue()
                .get(redisKey);

        return Result.success(value);
    }
}

这里用的是 StringRedisTemplate

它适合操作字符串类型的 key/value。学习阶段用它有一个好处:用 redis-cli 查看时,key 和 value 都比较直观。


3.5 通过 Gateway 测试 Redis 写入和读取

先登录拿 token:

http 复制代码
POST http://localhost:9000/api/auth/login
Content-Type: application/json

{
  "username": "zhangsan",
  "password": "123456"
}

通过 Gateway 写入 Redis:

http 复制代码
### Redis 写入测试
POST http://localhost:9000/api/product/redis/test?key=hello&value=world
Authorization: Bearer {{token}}

再读取:

http 复制代码
### Redis 读取测试
GET http://localhost:9000/api/product/redis/test?key=hello
Authorization: Bearer {{token}}

预期返回:

json 复制代码
{
  "code": 0,
  "message": "success",
  "data": "world"
}

也可以直接用命令行确认:

bash 复制代码
redis-cli get cloud:product:test:hello

预期:

text 复制代码
world

查看 TTL:

bash 复制代码
redis-cli ttl cloud:product:test:hello

预期是一个小于 300 的正数。

这一步只证明了一件事:

text 复制代码
cloud-product 能正常连接并操作 Redis。

还不能说明商品详情已经走缓存。


4、第二阶段:商品详情接口加入 Redis 缓存

4.1 商品详情原来的问题

现在的商品详情接口是:

http 复制代码
GET http://localhost:9000/api/product/products/1

在没有缓存之前,每次请求都会查 MySQL。

链路是:

text 复制代码
请求商品详情
    ↓
cloud-product
    ↓
ProductController
    ↓
ProductService
    ↓
MySQL

这当然能跑,但热点商品访问频率高时,MySQL 压力会变大。

所以这一节要改成:

text 复制代码
请求商品详情
    ↓
先查 Redis
    ↓
Redis 有:直接返回
    ↓
Redis 没有:查 MySQL
    ↓
查到后写 Redis
    ↓
返回商品详情

4.2 设计商品详情缓存 key:ProductCacheKey.java

Redis 是全局 key 空间,key 不要随便起。

商品详情缓存 key 设计为:

text 复制代码
cloud:product:detail:{id}

例如:

text 复制代码
cloud:product:detail:1
cloud:product:detail:2

新建文件:

text 复制代码
cloud-product
└── src/main/java
    └── com.example.cloud.product.constant
        └── ProductCacheKey.java

代码如下:

java 复制代码
package com.example.cloud.product.constant;

/**
 * 商品服务 Redis 缓存 key 常量。
 *
 * 为什么要单独建这个类?
 *
 * 商品详情缓存 key 后面会在查询、删除、更新等多个地方用到。
 * 如果把字符串散落在代码里,后续修改 key 前缀时很容易漏改。
 */
public final class ProductCacheKey {

    private ProductCacheKey() {
    }

    /**
     * 商品详情缓存 key 前缀。
     *
     * 完整 key 示例:
     * cloud:product:detail:1
     */
    public static final String PRODUCT_DETAIL_PREFIX =
            "cloud:product:detail:";

    public static String productDetailKey(Long productId) {
        return PRODUCT_DETAIL_PREFIX + productId;
    }
}

它只是一个普通常量类,不需要加 @Component


4.3 改造商品详情查询:ProductController.java

你的真实 ProductController 返回的是:

java 复制代码
Result<Product>

不是 Result<ProductDTO>

这一点后面写笔记时要按真实项目来。

ProductController 中注入:

java 复制代码
private final StringRedisTemplate stringRedisTemplate;

private final ObjectMapper objectMapper;

需要的 import:

java 复制代码
import com.example.cloud.product.constant.ProductCacheKey;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.StringRedisTemplate;

import java.time.Duration;

商品详情方法改造如下:

java 复制代码
/**
 * 根据 ID 查询商品详情。
 *
 * 查询顺序:
 *
 * 1. 先查 Redis
 * 2. Redis 命中,直接返回
 * 3. Redis 未命中,再查 MySQL
 * 4. MySQL 查到后写入 Redis
 * 5. 最后返回商品数据
 */
@GetMapping("/{id}")
@SentinelResource(
        value = "productDetailById",
        blockHandler = "productDetailBlockHandler"
)
public Result<Product> getById(@PathVariable Long id)
        throws JsonProcessingException {

    String redisKey = ProductCacheKey.productDetailKey(id);

    /*
     * 第一步:先查 Redis。
     *
     * 如果 Redis 中已经有商品详情 JSON,
     * 就不再查询 MySQL。
     */
    String productJson = stringRedisTemplate
            .opsForValue()
            .get(redisKey);

    if (productJson != null) {
        Product product = objectMapper.readValue(
                productJson,
                Product.class
        );

        return Result.success(product);
    }

    /*
     * 第二步:Redis 未命中,再查 MySQL。
     */
    Product product = productService.getById(id);

    if (product == null) {
        return Result.fail(
                ErrorCode.NOT_FOUND,
                "商品不存在"
        );
    }

    /*
     * 第三步:把 MySQL 查到的商品写入 Redis。
     *
     * 这里设置 30 分钟过期时间,
     * 避免缓存永久不过期。
     */
    stringRedisTemplate.opsForValue().set(
            redisKey,
            objectMapper.writeValueAsString(product),
            Duration.ofMinutes(30)
    );

    return Result.success(product);
}

这里有个实际学习中的选择:缓存逻辑先写在 Controller 里。

严格来说,缓存更适合放在 Service 层,或者封装成独立组件。

但这一章为了让链路直观,先放在 Controller 中演练:

text 复制代码
请求进来
    ↓
先查 Redis
    ↓
再查 MySQL

后面如果要重构,可以再把缓存逻辑下沉。


4.4 为什么先把对象转成 JSON 字符串

这一章用的是 StringRedisTemplate

所以商品对象写入 Redis 前要先转成 JSON:

java 复制代码
objectMapper.writeValueAsString(product)

从 Redis 读出来后,再转回对象:

java 复制代码
objectMapper.readValue(productJson, Product.class)

这样做的好处是:用 redis-cli get cloud:product:detail:1 能直接看到 JSON 内容,学习阶段更直观。


4.5 加日志观察缓存命中

为了更清楚地观察是否命中缓存,建议在 ProductController 上加:

java 复制代码
@Slf4j

然后在命中和未命中处加日志:

java 复制代码
log.info("商品详情命中 Redis 缓存,id={}", id);
java 复制代码
log.info("商品详情未命中 Redis,查询 MySQL,id={}", id);

第一次请求应该看到:

text 复制代码
商品详情未命中 Redis,查询 MySQL,id=1

第二次请求应该看到:

text 复制代码
商品详情命中 Redis 缓存,id=1

这个比只看 MyBatis 日志更直观。


4.6 验证商品详情缓存

先删除旧缓存:

bash 复制代码
redis-cli del cloud:product:detail:1

第一次访问:

http 复制代码
GET http://localhost:9000/api/product/products/1
Authorization: Bearer {{token}}

预期:

text 复制代码
Redis 未命中
查询 MySQL
Redis 生成 cloud:product:detail:1
TTL 是正数且小于 1800

查看 Redis:

bash 复制代码
redis-cli get cloud:product:detail:1
redis-cli ttl cloud:product:detail:1

第二次访问:

http 复制代码
GET http://localhost:9000/api/product/products/1
Authorization: Bearer {{token}}

预期:

text 复制代码
Redis 命中
不再查询 MySQL

到这里,商品详情已经真正走上了缓存链路。


5、第三阶段:缓存穿透演练与空值缓存修复

5.1 先复现一个真实问题:不存在的商品会怎样

商品 id=1 能缓存起来,因为它在数据库里存在。

但如果请求一个不存在的商品呢?

例如:

http 复制代码
GET /products/999999

当前逻辑是:

text 复制代码
查 Redis:没有
    ↓
查 MySQL:也没有
    ↓
返回商品不存在

下一次再查 999999,还是一样:

text 复制代码
查 Redis:没有
    ↓
查 MySQL:也没有
    ↓
返回商品不存在

这就是缓存穿透。

缓存穿透不是 Redis 坏了,而是:

text 复制代码
请求的数据既不在 Redis,也不在 MySQL。

所以请求会一次次穿过 Redis,直接打到 MySQL。


5.2 复现缓存穿透

连续请求两次:

http 复制代码
GET http://localhost:9000/api/product/products/999999
Authorization: Bearer {{token}}

未修复前,两次都会看到类似日志:

text 复制代码
商品详情未命中 Redis,查询 MySQL,id=999999

这就说明:

text 复制代码
不存在的数据没有进入缓存,
所以每次请求都会查 MySQL。

5.3 修复思路:缓存空值

最简单的修复方式是:

text 复制代码
MySQL 查不到商品时,也往 Redis 写一个空值标记。

例如:

text 复制代码
key: cloud:product:detail:999999
value: ""
ttl: 2分钟

下一次再查这个 ID:

text 复制代码
Redis 能查到 key
    ↓
发现 value 是空字符串
    ↓
直接返回商品不存在
    ↓
不再查 MySQL

为什么空值 TTL 不要太长?

因为如果未来真的新增了这个商品 ID,空值缓存还没过期,就会短时间内仍然返回"商品不存在"。

所以空值缓存 TTL 要短一点,本章使用:

text 复制代码
2 分钟

5.4 增加空值缓存标记:ProductCacheKey.java

修改 ProductCacheKey

java 复制代码
package com.example.cloud.product.constant;

/**
 * 商品服务 Redis 缓存 key 常量。
 */
public final class ProductCacheKey {

    private ProductCacheKey() {
    }

    /**
     * 商品详情缓存 key 前缀。
     */
    public static final String PRODUCT_DETAIL_PREFIX =
            "cloud:product:detail:";

    /**
     * 空值缓存标记。
     *
     * 当 MySQL 中不存在某个商品时,
     * 往 Redis 写入这个标记,
     * 用来防止同一个不存在 ID 反复打到 MySQL。
     */
    public static final String EMPTY_VALUE = "";

    public static String productDetailKey(Long productId) {
        return PRODUCT_DETAIL_PREFIX + productId;
    }
}

5.5 改造 getById:识别空值缓存

新的逻辑是:

text 复制代码
Redis 有正常 JSON:返回商品
Redis 有空值标记:返回商品不存在
Redis 没有 key:查 MySQL
MySQL 没有:写空值标记,返回商品不存在
MySQL 有:写商品 JSON,返回商品

代码如下:

java 复制代码
@GetMapping("/{id}")
@SentinelResource(
        value = "productDetailById",
        blockHandler = "productDetailBlockHandler"
)
public Result<Product> getById(@PathVariable Long id)
        throws JsonProcessingException {

    String redisKey = ProductCacheKey.productDetailKey(id);

    String productJson = stringRedisTemplate
            .opsForValue()
            .get(redisKey);

    /*
     * Redis 命中。
     *
     * 注意:
     * 空字符串 "" 也是一种命中,
     * 表示这个商品在数据库中不存在。
     *
     * 所以这里不能简单判断 productJson != null 后就直接反序列化。
     */
    if (productJson != null) {
        if (ProductCacheKey.EMPTY_VALUE.equals(productJson)) {
            log.info("商品详情命中 Redis 空值缓存,id={}", id);

            return Result.fail(
                    ErrorCode.NOT_FOUND,
                    "商品不存在"
            );
        }

        log.info("商品详情命中 Redis 缓存,id={}", id);

        Product product = objectMapper.readValue(
                productJson,
                Product.class
        );

        return Result.success(product);
    }

    log.info("商品详情未命中 Redis,查询 MySQL,id={}", id);

    Product product = productService.getById(id);

    if (product == null) {
        /*
         * MySQL 也没有查到商品。
         *
         * 写入空值缓存,防止同一个不存在的 ID
         * 被反复请求时一直打到 MySQL。
         *
         * 空值缓存 TTL 要短一些。
         */
        stringRedisTemplate.opsForValue().set(
                redisKey,
                ProductCacheKey.EMPTY_VALUE,
                Duration.ofMinutes(2)
        );

        return Result.fail(
                ErrorCode.NOT_FOUND,
                "商品不存在"
        );
    }

    stringRedisTemplate.opsForValue().set(
            redisKey,
            objectMapper.writeValueAsString(product),
            Duration.ofMinutes(30)
    );

    return Result.success(product);
}

这里有一个容易写错的地方。

不要把:

java 复制代码
if (productJson != null) {

改成:

java 复制代码
if (StringUtils.hasText(productJson)) {

因为空字符串 "" 本身就是我们故意写入的空值缓存标记。

如果用 hasText(),空字符串会被当成"未命中",空值缓存就失效了。


5.6 验证缓存穿透修复

第一次请求:

http 复制代码
GET http://localhost:9000/api/product/products/999999
Authorization: Bearer {{token}}

预期:

text 复制代码
返回商品不存在
日志显示:未命中 Redis,查询 MySQL
Redis 中写入 cloud:product:detail:999999
value 是空字符串
TTL 小于 120 秒的正数

第二次请求:

http 复制代码
GET http://localhost:9000/api/product/products/999999
Authorization: Bearer {{token}}

预期:

text 复制代码
返回商品不存在
日志显示:命中 Redis 空值缓存
不再查询 MySQL

缓存穿透修复完成。


5.7 空值缓存的边界

空值缓存能挡住:

text 复制代码
同一个不存在 ID 被反复请求

但如果有人恶意请求大量随机不存在 ID:

text 复制代码
999001
999002
999003
999004
...

Redis 里也会产生大量空值 key。

真实项目里还可以配合:

text 复制代码
参数校验
布隆过滤器
IP 限流
Sentinel 限流
网关层风控

本章先掌握最容易理解的空值缓存方案。


6、第四阶段:缓存击穿演练与互斥锁修复

6.1 缓存击穿和缓存穿透不是一回事

缓存穿透是:

text 复制代码
请求一个根本不存在的数据。

缓存击穿是:

text 复制代码
请求一个存在的热点数据,
但这个热点 key 突然过期了。

比如商品 1 是热点商品。

正常情况下:

text 复制代码
大量请求
    ↓
命中 Redis:cloud:product:detail:1
    ↓
不查 MySQL

但如果这个 key 正好过期,一瞬间 10 个请求同时进来:

text 复制代码
10 个请求同时访问 /products/1
        ↓
大家都发现 Redis 没有
        ↓
大家都去查 MySQL
        ↓
MySQL 瞬间被打很多次

这就是缓存击穿。


6.2 先复现缓存击穿

先删除热点商品缓存:

bash 复制代码
redis-cli del cloud:product:detail:1

JMeter 并发请求:

http 复制代码
GET http://localhost:9000/api/product/products/1
Authorization: Bearer {{token}}

JMeter 设置:

text 复制代码
线程数:10
Ramp-Up:0 或 1 秒
循环次数:1

未修复前,你会看到多个请求都打印:

text 复制代码
商品详情未命中 Redis,查询 MySQL,id=1

这说明多个请求同时穿过 Redis,打到了 MySQL。


6.3 修复思路:Redis 互斥锁

互斥锁的思路是:

text 复制代码
Redis 未命中
    ↓
尝试抢锁
    ↓
抢到锁的请求:查 MySQL,写 Redis,释放锁
    ↓
没抢到锁的请求:短暂等待,再重新查 Redis

也就是说,同一时刻只允许一个请求去查 MySQL 并重建缓存。

其它请求不要一窝蜂冲到数据库。


6.4 增加锁 key:ProductCacheKey.java

继续修改 ProductCacheKey

java 复制代码
package com.example.cloud.product.constant;

/**
 * 商品服务 Redis 缓存 key 常量。
 */
public final class ProductCacheKey {

    private ProductCacheKey() {
    }

    public static final String PRODUCT_DETAIL_PREFIX =
            "cloud:product:detail:";

    public static final String PRODUCT_DETAIL_LOCK_PREFIX =
            "cloud:product:lock:detail:";

    public static final String EMPTY_VALUE = "";

    public static String productDetailKey(Long productId) {
        return PRODUCT_DETAIL_PREFIX + productId;
    }

    public static String productDetailLockKey(Long productId) {
        return PRODUCT_DETAIL_LOCK_PREFIX + productId;
    }
}

商品详情缓存 key:

text 复制代码
cloud:product:detail:1

对应的锁 key:

text 复制代码
cloud:product:lock:detail:1

6.5 实现简单 Redis 互斥锁:tryLock / unlock

ProductController 中增加两个方法:

java 复制代码
/**
 * 尝试获取 Redis 互斥锁。
 *
 * setIfAbsent 对应 Redis 的 SET NX 语义:
 * key 不存在时才写入成功。
 *
 * 这里必须设置过期时间,
 * 防止服务异常退出后锁永远不释放。
 */
private boolean tryLock(String lockKey) {
    Boolean success = stringRedisTemplate
            .opsForValue()
            .setIfAbsent(
                    lockKey,
                    "1",
                    Duration.ofSeconds(10)
            );

    return Boolean.TRUE.equals(success);
}

/**
 * 释放 Redis 互斥锁。
 *
 * 当前学习项目先直接删除 lockKey。
 *
 * 注意:
 * 真实生产环境中还要考虑"误删别人锁"的问题,
 * 通常会给锁 value 设置唯一标识,
 * 删除时先判断 value 是否属于当前线程 / 当前请求。
 */
private void unlock(String lockKey) {
    stringRedisTemplate.delete(lockKey);
}

这只是学习版互斥锁,不是生产级分布式锁。

生产环境至少要考虑:

text 复制代码
1. 锁 value 使用唯一标识
2. 删除锁时先判断 value 是否匹配
3. 判断和删除最好用 Lua 脚本保证原子性
4. 或者直接使用 Redisson

6.6 改造 getById:加入互斥锁

这一版逻辑变成:

text 复制代码
先查 Redis
没命中就抢锁
抢到锁才查 MySQL 并重建缓存
没抢到锁就等一会儿再查 Redis

代码如下:

java 复制代码
@GetMapping("/{id}")
@SentinelResource(
        value = "productDetailById",
        blockHandler = "productDetailBlockHandler"
)
public Result<Product> getById(@PathVariable Long id)
        throws JsonProcessingException, InterruptedException {

    String redisKey = ProductCacheKey.productDetailKey(id);
    String lockKey = ProductCacheKey.productDetailLockKey(id);

    String productJson = stringRedisTemplate
            .opsForValue()
            .get(redisKey);

    if (productJson != null) {
        if (ProductCacheKey.EMPTY_VALUE.equals(productJson)) {
            log.info("商品详情命中 Redis 空值缓存,id={}", id);

            return Result.fail(
                    ErrorCode.NOT_FOUND,
                    "商品不存在"
            );
        }

        log.info("商品详情命中 Redis 缓存,id={}", id);

        Product product = objectMapper.readValue(
                productJson,
                Product.class
        );

        return Result.success(product);
    }

    boolean locked = tryLock(lockKey);

    if (!locked) {
        log.info("未获取到商品详情缓存重建锁,等待后重试,id={}", id);

        Thread.sleep(100);

        String retryJson = stringRedisTemplate
                .opsForValue()
                .get(redisKey);

        if (retryJson != null) {
            if (ProductCacheKey.EMPTY_VALUE.equals(retryJson)) {
                log.info("等待后命中 Redis 空值缓存,id={}", id);

                return Result.fail(
                        ErrorCode.NOT_FOUND,
                        "商品不存在"
                );
            }

            log.info("等待后命中 Redis 缓存,id={}", id);

            Product product = objectMapper.readValue(
                    retryJson,
                    Product.class
            );

            return Result.success(product);
        }

        return Result.fail(
                42900,
                "系统繁忙,请稍后再试"
        );
    }

    try {
        /*
         * 抢到锁后最好再查一次 Redis。
         *
         * 因为在当前请求获得锁之前,
         * 可能其它请求已经完成了缓存重建。
         */
        productJson = stringRedisTemplate
                .opsForValue()
                .get(redisKey);

        if (productJson != null) {
            if (ProductCacheKey.EMPTY_VALUE.equals(productJson)) {
                log.info("加锁后二次检查命中 Redis 空值缓存,id={}", id);

                return Result.fail(
                        ErrorCode.NOT_FOUND,
                        "商品不存在"
                );
            }

            log.info("加锁后二次检查命中 Redis 缓存,id={}", id);

            Product product = objectMapper.readValue(
                    productJson,
                    Product.class
            );

            return Result.success(product);
        }

        log.info("获取到缓存重建锁,查询 MySQL,id={}", id);

        Product product = productService.getById(id);

        if (product == null) {
            stringRedisTemplate.opsForValue().set(
                    redisKey,
                    ProductCacheKey.EMPTY_VALUE,
                    Duration.ofMinutes(2)
            );

            return Result.fail(
                    ErrorCode.NOT_FOUND,
                    "商品不存在"
            );
        }

        stringRedisTemplate.opsForValue().set(
                redisKey,
                objectMapper.writeValueAsString(product),
                Duration.ofMinutes(30)
        );

        return Result.success(product);
    } finally {
        unlock(lockKey);
    }
}

6.7 为什么抢到锁后还要二次检查 Redis

这个点很重要。

可能出现这种情况:

text 复制代码
请求 A 发现 Redis 没有
请求 B 也发现 Redis 没有

请求 A 抢到锁,查 MySQL,写 Redis,释放锁

请求 B 后来抢到了锁

如果请求 B 抢到锁后直接查 MySQL,就又多查了一次数据库。

所以请求 B 抢到锁后应该先再查一次 Redis。

如果 Redis 已经有了,说明前一个请求已经重建好了缓存,请求 B 直接返回缓存即可。

这就是:

text 复制代码
加锁后的二次检查

6.8 验证缓存击穿修复

删除缓存和锁:

bash 复制代码
redis-cli del cloud:product:detail:1
redis-cli del cloud:product:lock:detail:1

JMeter 并发请求商品 1:

http 复制代码
GET http://localhost:9000/api/product/products/1
Authorization: Bearer {{token}}

JMeter 设置:

text 复制代码
线程数:10
Ramp-Up:0 或 1 秒
循环次数:1

出现"系统繁忙,请稍后再试"是当前学习版逻辑的预期现象。

因为当前逻辑是:

text 复制代码
没抢到锁
    ↓
sleep 100ms
    ↓
再查 Redis
    ↓
如果缓存还没重建完成
    ↓
返回系统繁忙

这版逻辑的目标不是保证每个并发请求都成功,而是先保证:

text 复制代码
不能让所有请求一起打到 MySQL。

7、第五阶段:缓存雪崩演练与随机 TTL 修复

7.1 缓存雪崩是什么

缓存击穿是:

text 复制代码
一个热点 key 过期。

缓存雪崩是:

text 复制代码
大量 key 在同一时间过期。

比如一批商品缓存都设置为:

text 复制代码
30 分钟

如果它们是同一时间写入 Redis 的,那么它们也可能在同一时间过期。

到期瞬间,如果大量请求进来:

text 复制代码
cloud:product:detail:1 过期
cloud:product:detail:2 过期
cloud:product:detail:3 过期
cloud:product:detail:4 过期
        ↓
大量请求同时查不到 Redis
        ↓
大量请求同时打到 MySQL

这就是缓存雪崩。

一句话:

text 复制代码
缓存击穿是一个热点 key 失效;
缓存雪崩是一批 key 集体失效。

7.2 先观察固定 TTL 的问题

先用命令行设置几个测试 key:

bash 复制代码
redis-cli set cloud:product:detail:test:1 value1 ex 60
redis-cli set cloud:product:detail:test:2 value2 ex 60
redis-cli set cloud:product:detail:test:3 value3 ex 60
redis-cli set cloud:product:detail:test:4 value4 ex 60
redis-cli set cloud:product:detail:test:5 value5 ex 60

再查看 TTL:

bash 复制代码
redis-cli ttl cloud:product:detail:test:1
redis-cli ttl cloud:product:detail:test:2
redis-cli ttl cloud:product:detail:test:3
redis-cli ttl cloud:product:detail:test:4
redis-cli ttl cloud:product:detail:test:5

可以看到这些 key 的 TTL 很接近。

这说明:

text 复制代码
如果一批缓存同一时间写入,并且 TTL 完全一样,
它们就可能集中在同一时间过期。

7.3 修复思路:基础 TTL + 随机 TTL

商品详情缓存原来是:

text 复制代码
固定 30 分钟

现在改成:

text 复制代码
30 分钟 + 0 到 10 分钟随机值

例如:

text 复制代码
商品 1:31 分钟
商品 2:36 分钟
商品 3:39 分钟

这样可以把过期时间打散。


7.4 增加随机 TTL 方法

ProductController 中增加:

java 复制代码
import java.util.concurrent.ThreadLocalRandom;

然后新增方法:

java 复制代码
/**
 * 商品详情缓存 TTL。
 *
 * 基础时间:30 分钟
 * 随机时间:0 ~ 10 分钟
 *
 * 为什么要加随机时间?
 *
 * 如果所有商品详情缓存都固定 30 分钟过期,
 * 同一批写入的缓存就可能在同一时间集中过期,
 * 从而造成缓存雪崩。
 *
 * 增加随机 TTL 后,
 * 不同商品 key 的过期时间会被打散。
 */
private Duration productDetailTtl() {
    long randomMinutes = ThreadLocalRandom
            .current()
            .nextLong(0, 11);

    return Duration.ofMinutes(30 + randomMinutes);
}

这里:

java 复制代码
nextLong(0, 11)

表示生成 0 到 10 的随机整数。


7.5 替换正常商品缓存 TTL

把正常商品缓存写入处:

java 复制代码
stringRedisTemplate.opsForValue().set(
        redisKey,
        objectMapper.writeValueAsString(product),
        Duration.ofMinutes(30)
);

改成:

java 复制代码
stringRedisTemplate.opsForValue().set(
        redisKey,
        objectMapper.writeValueAsString(product),
        productDetailTtl()
);

注意:

text 复制代码
空值缓存仍然保留 2 分钟,不使用 30 + 随机 TTL。

空值缓存只是为了防止穿透,不应该缓存太久。


7.6 验证随机 TTL

删除商品详情缓存:

bash 复制代码
redis-cli del cloud:product:detail:1

依次访问多个商品详情:

http 复制代码
GET http://localhost:9000/api/product/products/1
Authorization: Bearer {{token}}
http 复制代码
GET http://localhost:9000/api/product/products/2
Authorization: Bearer {{token}}
http 复制代码
GET http://localhost:9000/api/product/products/3
Authorization: Bearer {{token}}

查看 TTL:

bash 复制代码
redis-cli ttl cloud:product:detail:1
redis-cli ttl cloud:product:detail:2
redis-cli ttl cloud:product:detail:3

预期:

text 复制代码
都大于约 1800 秒,
但彼此不完全一样。

缓存雪崩的随机 TTL 缓解方案验证完成。


8、第六阶段:商品数据变更后删除缓存

8.1 为什么会有缓存一致性问题

缓存加上以后,商品详情读取变快了。

但新问题出现了:

text 复制代码
如果商品库存变了,Redis 里的商品详情还会不会是旧的?

假设 Redis 里缓存的商品 1 是:

json 复制代码
{
  "id": 1,
  "name": "测试商品",
  "stock": 100
}

创建订单后,商品服务扣库存:

text 复制代码
stock: 100 → 99

MySQL 已经变了。

但如果 Redis 中的:

text 复制代码
cloud:product:detail:1

没有被处理,它仍然可能是旧库存:

text 复制代码
stock: 200

这就会导致:

text 复制代码
数据库是 199
缓存还是 200
商品详情接口继续返回旧库存

这就是缓存和数据库一致性问题。


8.2 本章采用的方案:写数据库后删除缓存

常见处理方式有两种:

text 复制代码
1. 更新数据库后,更新缓存
2. 更新数据库后,删除缓存

本章选择:

text 复制代码
更新数据库后,删除缓存

原因是更简单,也更适合当前项目。

链路变成:

text 复制代码
扣减库存成功
    ↓
删除商品详情缓存
    ↓
下一次查询商品详情
    ↓
Redis 未命中
    ↓
重新查 MySQL
    ↓
写入新的商品详情缓存

这里有一个边界要守住:

text 复制代码
谁修改商品数据,谁负责删除商品缓存。

不要在 cloud-order 里删除商品缓存。

因为缓存 key 属于商品服务内部实现。

如果订单服务也知道:

text 复制代码
cloud:product:detail:1

那就说明订单服务和商品服务的缓存细节耦合了。

所以应该在:

text 复制代码
cloud-product

内部删除缓存。


8.3 扣减库存成功后删除缓存:ProductServiceImpl.java

修改文件:

text 复制代码
cloud-product
└── src/main/java
    └── com.example.cloud.product.service.impl
        └── ProductServiceImpl.java

注入:

java 复制代码
private final StringRedisTemplate stringRedisTemplate;

需要 import:

java 复制代码
import com.example.cloud.product.constant.ProductCacheKey;
import org.springframework.data.redis.core.StringRedisTemplate;

在扣库存成功后删除缓存:

java 复制代码
@Override
@Transactional(rollbackFor = Exception.class)
public void deductStock(Long id, Integer quantity) {
    if (quantity == null || quantity <= 0) {
        throw new BizException(
                ErrorCode.PARAM_ERROR,
                "扣减数量必须大于 0"
        );
    }

    int rows = productMapper.deductStock(id, quantity);

    if (rows == 0) {
        throw new BizException(
                ErrorCode.BUSINESS_ERROR,
                "商品库存不足"
        );
    }

    /*
     * 扣减库存成功后,删除商品详情缓存。
     *
     * 为什么不是更新缓存?
     *
     * 当前学习项目选择更简单的 Cache Aside 策略:
     * 写数据库后删除缓存。
     *
     * 下一次查询商品详情时,
     * 会重新查 MySQL 并写入最新缓存。
     */
    String redisKey = ProductCacheKey.productDetailKey(id);

    stringRedisTemplate.delete(redisKey);

    log.info("扣减库存成功,删除商品详情缓存,id={},redisKey={}",
            id,
            redisKey);
}

注意:

text 复制代码
只有扣库存成功后才删除缓存。

如果库存不足,数据库没有变化,就不需要删除缓存。


8.4 验证删除缓存是否生效

删除旧缓存:

bash 复制代码
redis-cli del cloud:product:detail:1

第一次查询商品详情,生成缓存:

http 复制代码
GET http://localhost:9000/api/product/products/1
Authorization: Bearer {{token}}

确认 Redis 有缓存:

bash 复制代码
redis-cli get cloud:product:detail:1

创建订单扣减库存:

http 复制代码
POST http://localhost:9000/api/order/orders
Authorization: Bearer {{token}}
Content-Type: application/json

{
  "productId": 1,
  "quantity": 1
}

预期 cloud-product 日志出现:

text 复制代码
扣减库存成功,删除商品详情缓存,id=1,redisKey=cloud:product:detail:1

查看 Redis:

bash 复制代码
redis-cli get cloud:product:detail:1

预期:

text 复制代码
(nil)

再次查询商品详情:

http 复制代码
GET http://localhost:9000/api/product/products/1
Authorization: Bearer {{token}}

预期:

text 复制代码
Redis 未命中
重新查 MySQL
重新写入 Redis
返回最新库存

到这里,商品详情缓存链路才算比较完整。


9、本章总结

问题 核心特征 Redis 和 MySQL 的状态 举个例子 本章解决方式
缓存穿透 请求的数据根本不存在 Redis 没有,MySQL 也没有 请求 id=999999 的商品 缓存空值,让"不存在"也能被 Redis 记住一小段时间
缓存击穿 请求的数据存在,但热点 key 正好失效 Redis 突然没有,MySQL 有数据 爆款商品 id=1 的缓存刚好过期,大量请求同时进来 Redis 互斥锁,只允许一个请求查 MySQL 并重建缓存
缓存雪崩 大量 key 在同一时间失效 Redis 大量 key 同时没有,MySQL 有数据 一批商品缓存都设置 30 分钟,同一时间集中到期 随机 TTL,把不同 key 的过期时间打散

简而言之就是:

  • 穿透 :查不存在的数据,缓存和数据库都没有,用空值缓存挡住。
  • 击穿 :热点 key 突然失效,大量请求同时打数据库,用互斥锁控制只有一个请求重建缓存。
  • 雪崩 :大量 key 同时失效,数据库瞬间承压,用随机 TTL把过期时间打散。

10、知识扩展:未展开但后续值得继续研究的点

本章主线围绕商品详情接口,把 Redis 缓存的几个核心问题完整跑了一遍:

text 复制代码
查缓存
写缓存
空值缓存防穿透
互斥锁防击穿
随机 TTL 防雪崩
写数据库后删除缓存

这些内容已经足够支撑当前学习项目继续往后走。

不过 Redis 缓存体系里还有一些更完整、更生产化的方案。本节只做扩展说明,不继续改代码,避免把本章从"商品详情缓存实战"写成"Redis 知识大全"。

10.1 缓存策略的几种常见模式

缓存模式 读数据时怎么做 写数据时怎么做 谁负责维护缓存 适合场景
Cache Aside 应用先查缓存,缓存没有再查数据库,查到后写缓存 应用更新数据库后删除缓存 应用自己维护 最常见,适合本章这种业务代码可控的场景
Read Through 应用只查缓存,缓存层负责在未命中时加载数据库 通常配合 Write Through 使用 缓存层维护 缓存中间件能力较强、希望应用少关心加载逻辑的场景
Write Through 应用写缓存,缓存层同步写数据库 写缓存和写数据库同步完成 缓存层维护 对一致性要求较高,但能接受写入链路变长的场景
Write Behind 应用先写缓存,缓存层异步写数据库 先写缓存,稍后异步落库 缓存层维护 写入性能要求高,但要额外处理数据丢失和一致性风险的场景

本章实际采用的是:

text 复制代码
Cache Aside

也叫旁路缓存模式。

它的基本流程是:

text 复制代码
读数据:
    先查缓存
    缓存没有,再查数据库
    查到后写入缓存

写数据:
    先更新数据库
    再删除缓存

本章商品详情接口就是这个模式:

text 复制代码
GET /products/{id}
    ↓
先查 Redis
    ↓
Redis 未命中
    ↓
查 MySQL
    ↓
写入 Redis

扣减库存时也是这个模式:

text 复制代码
扣减 MySQL 库存成功
    ↓
删除商品详情缓存
    ↓
下一次查询重新加载最新数据

除了 Cache Aside,还有几种常见缓存模式。

text 复制代码
Read Through:
    应用只访问缓存。
    缓存没有数据时,由缓存层负责加载数据库。

Write Through:
    应用写缓存。
    缓存层同步把数据写入数据库。

Write Behind:
    应用先写缓存。
    缓存层稍后异步写入数据库。

简单理解:

text 复制代码
Cache Aside:
    应用自己维护缓存和数据库。

Read / Write Through:
    缓存层帮应用处理读写数据库。

Write Behind:
    先写缓存,后面异步落库,性能高,但一致性和可靠性要求更高。

当前项目选择 Cache Aside,是因为它最直观,也最适合学习阶段:

text 复制代码
什么时候查 Redis
什么时候查 MySQL
什么时候写 Redis
什么时候删 Redis

这些动作都能在代码里看清楚。

10.2 布隆过滤器如何更优雅地解决缓存穿透

本章解决缓存穿透的方法是:

text 复制代码
缓存空值

也就是:

text 复制代码
MySQL 查不到商品
    ↓
Redis 写入空字符串
    ↓
下次同一个不存在 ID 直接命中空值缓存

这个方案简单有效,但有一个边界:

text 复制代码
如果有人不断请求大量随机不存在的 ID,
Redis 里会产生大量空值 key。

例如:

text 复制代码
/products/999001
/products/999002
/products/999003
/products/999004
...

这时可以考虑布隆过滤器。

布隆过滤器可以理解成一个"存在性预判器":

text 复制代码
请求商品详情前,
先问布隆过滤器:
这个商品 ID 有没有可能存在?

如果布隆过滤器判断:

text 复制代码
一定不存在

就可以直接返回"商品不存在",不用查 Redis,也不用查 MySQL。

如果布隆过滤器判断:

text 复制代码
可能存在

再继续走正常缓存链路:

text 复制代码
查 Redis
    ↓
Redis 未命中
    ↓
查 MySQL

布隆过滤器的特点是:

text 复制代码
1. 空间占用小
2. 判断速度快
3. 可以确定"某个值一定不存在"
4. 但不能百分百确定"某个值一定存在"

也就是说,它可能会有误判:

text 复制代码
布隆过滤器说可能存在,
但数据库里其实不存在。

这叫误判。

但它不会把真正存在的数据判断成"一定不存在"。

所以布隆过滤器适合用来挡掉大量明显不存在的请求。

在商品详情场景中,可以把所有合法商品 ID 提前加入布隆过滤器:

text 复制代码
商品 1
商品 2
商品 3
...

请求进来时先判断商品 ID 是否可能存在。

如果一定不存在,就直接拦掉。

当前项目没有引入布隆过滤器,是因为本章重点是先掌握最基础的缓存问题链路。等后续项目复杂度上来,再考虑把"空值缓存 + 布隆过滤器"组合起来使用。

10.3 Redisson 分布式锁与手写互斥锁的区别

对比项 本章手写 Redis 互斥锁 Redisson 分布式锁
学习价值 很高,能看清 setIfAbsent、锁 key、过期时间、释放锁的过程 更偏工程封装,适合会原理后使用
加锁方式 手动调用 setIfAbsent(lockKey, "1", Duration.ofSeconds(10)) 使用 RLock 等 API
锁标识 示例里 value 固定为 "1",不够严谨 内部会维护更完整的锁信息
释放锁 直接 delete(lockKey),存在误删别人锁的风险 封装了解锁逻辑
自动续期 没有,需要自己处理 支持看门狗续期机制
可重入能力 没有实现 支持可重入锁
适合场景 学习缓存击穿和互斥锁原理 真实项目中的分布式锁落地

本章为了演示缓存击穿,手写了一个简单 Redis 互斥锁:

java 复制代码
setIfAbsent(lockKey, "1", Duration.ofSeconds(10))

它的核心思想是:

text 复制代码
同一个热点 key 失效时,
只允许一个请求抢到锁并重建缓存。

这对学习非常直观。

但它不是生产级分布式锁。

当前手写版本至少有几个问题:

text 复制代码
1. 锁 value 固定写成 "1",不能区分是谁加的锁
2. unlock 直接 delete,可能误删别人的锁
3. 锁过期时间写死,如果业务执行超过锁时间,锁可能提前失效
4. 没有自动续期能力
5. 没有可重入、公平锁、读写锁等高级能力

更严谨的 Redis 锁至少应该做到:

text 复制代码
1. 加锁时 value 使用唯一标识
2. 解锁时先判断 value 是否属于自己
3. 判断和删除要保证原子性
4. 业务没执行完时,要考虑锁续期

Redisson 是 Java 里常用的 Redis 客户端之一,它已经封装了很多分布式锁能力,例如:

text 复制代码
RLock
公平锁
读写锁
锁自动释放
看门狗续期

所以可以这样理解:

text 复制代码
本章手写互斥锁:
    用来理解原理。

Redisson:
    更适合真实项目落地。

当前章节不直接引入 Redisson,是为了避免学习主线从"缓存问题"变成"分布式锁框架学习"。

等后续项目需要更严谨的分布式锁时,再单独接入 Redisson 会更合适。

10.4 更严格的缓存一致性方案

方案 基本思路 优点 风险或成本 当前项目是否采用
写数据库后删除缓存 先更新 MySQL,再删除 Redis 缓存 简单、直观、最适合当前学习项目 删除缓存失败时仍可能有旧缓存 已采用
延迟双删 删除缓存、更新数据库、延迟一会儿再删一次缓存 降低并发读写导致旧数据回写缓存的概率 延迟时间不好确定,不是万能方案 暂不采用,只做扩展理解
MQ 异步删除缓存 数据变更后发送消息,由消费者删除或刷新缓存 可以解耦,失败后也方便重试 引入 MQ 后链路更复杂 后续 RabbitMQ 章节可继续铺垫
binlog 同步缓存 监听数据库变更日志,再同步删除或更新缓存 对业务代码侵入较小,适合复杂系统 部署和运维复杂度更高 当前学习项目暂不需要
定时补偿任务 定期扫描并修复缓存和数据库不一致 可以作为兜底方案 实时性较弱,只能补偿 暂不采用

本章采用的是最常见、也最容易理解的方案:

text 复制代码
写数据库后删除缓存

也就是:

text 复制代码
扣减库存成功
    ↓
删除商品详情缓存
    ↓
下一次查询重新加载最新数据

这个方案已经能解决当前项目里最直观的问题:

text 复制代码
MySQL 库存变了,
Redis 不能继续返回旧库存。

但真实项目里,缓存一致性还有很多更复杂的边界。

比如:

text 复制代码
数据库更新成功了,但删除缓存失败怎么办?

删除缓存之后,立刻有请求进来,
这个请求会不会读到旧数据并重新写回缓存?

如果数据库有主从延迟,
删除缓存后立刻查询,从库还没同步完成怎么办?

如果多个服务都可能修改商品数据,
缓存删除逻辑应该放在哪里?

所以在更复杂的系统里,还可能使用:

text 复制代码
延迟双删
消息队列异步删除缓存
订阅 binlog 同步缓存
缓存失效重试机制
最终一致性补偿任务

延迟双删可以简单理解成:

text 复制代码
先删除缓存
    ↓
更新数据库
    ↓
延迟一小段时间
    ↓
再删除一次缓存

它的目的,是尽量降低并发读写导致旧数据重新进入缓存的概率。

不过延迟双删也不是银弹。

它依赖延迟时间的选择,时间太短可能没效果,时间太长又会增加不确定性。

所以本章暂时不引入这些复杂方案,只保留一个最核心的原则:

text 复制代码
缓存不是数据源,MySQL 才是数据源。
数据发生变化后,缓存必须失效。

当前项目先使用:

text 复制代码
写数据库后删除缓存

已经足够支撑本阶段学习。

11、下一章预告:RabbitMQ 异步消息与订单后续处理

第 11 章解决的是:

text 复制代码
热点数据怎么少查 MySQL。

下一章继续进入微服务中另一个高频能力:

text 复制代码
消息队列

前面创建订单时,很多事情都是同步完成的。

但真实业务里,有些事情不一定要在下单接口里立即完成,比如:

text 复制代码
发送通知
记录操作日志
订单超时未支付关闭
库存变更后通知其它系统

这些就适合用消息队列来做异步解耦。

下一章将进入:

text 复制代码
RabbitMQ 异步消息与订单后续处理

要重点解决:

text 复制代码
1. 为什么需要消息队列
2. 什么是生产者、消费者、交换机、队列、路由键
3. cloud-order 如何发送消息
4. 其它服务如何消费订单消息
5. 消息丢失、重复消费、消费失败如何处理
6. 异步解耦和最终一致性如何理解