一、先搞懂最核心的问题:什么是缓存?为什么要有三级缓存?

1. 通俗类比

你是班级的学习委员,每天有1000个同学来问你同一个问题:三年级上册语文第3课的中心思想是什么?

  • 最差办法:每次有人问,你都跑10分钟去老师办公室,翻厚厚的教学档案找答案。1000个人问,你要跑10000分钟,累死不说,还会把老师办公室挤爆。

  • 第一次优化:你把答案抄在便签纸上,贴在课桌抽屉里。有人问,拉开抽屉1秒就能找到答案,不用跑办公室了。但1000个人都来翻抽屉,还是会乱,而且每次开抽屉都要花一点时间。

  • 第二次优化:你把答案直接背下来,记在脑子里。有人问,张嘴0.001秒就答完,连抽屉都不用开。

这就是三级缓存的本质:把最热、最常用的数据,放在离用户最近、速度最快的地方,尽量不去碰最慢的「底层档案」

2. 专业定义与一一对应

我们后端开发说的三级缓存,特指Java高并发场景下的标准架构,每一层和类比完全对应:

层级

技术组件

通俗类比

核心定位

速度级别

一级缓存

Caffeine(JVM本地缓存)

你脑子里记的答案

离用户最近,极致提速,扛热点请求

纳秒级(比Redis快1000倍)

二级缓存

Redis(分布式缓存)

课桌抽屉里的便签纸

全局共享数据,兜底缓存,减少数据库压力

毫秒级

三级存储

MySQL(关系型数据库)

老师办公室的教学档案

数据唯一权威源,永久持久化存储

毫秒/秒级(比内存慢10万倍)

3. 彻底解决你之前的核心误区

问:Caffeine是JVM本地缓存,请求都已经打到服务器了,用它还有啥用?

答:请求已经到你这个「学习委员」这里了,用脑子直接答,比跑10分钟去老师办公室快10万倍。它不是用来挡请求「到不了服务器」,而是请求到了服务器之后,别再往后端的Redis、MySQL打,直接在本机内存就把结果返回,极致提速+给下游大幅减负。


二、三级缓存的完整执行链路(一步不落,小学生能跟着走明白)

1. 正常读请求链路(同学来问问题)

客户端请求 → Nginx负载均衡 → Gateway网关 → 【Java业务服务(JVM进程)】
→ 1. 查Caffeine一级缓存 → 命中:直接返回结果,链路结束
→ 未命中 → 2. 查Redis二级缓存 → 命中:回写Caffeine + 返回结果,链路结束
→ 未命中 → 3. 查MySQL数据库 → 查到数据:写Redis + 写Caffeine + 返回结果
→ 未查到:返回空/报错

对应类比:

  1. 同学问问题,你先想「脑子里有没有背过答案」,有就直接答;

  2. 脑子里没有,就开抽屉找便签,有就抄到脑子里,再答;

  3. 抽屉里也没有,就跑老师办公室翻档案,找到后抄到便签、背到脑子里,再答。

2. 数据更新/写请求链路(老师改了标准答案)

客户端更新请求 → Nginx → Gateway → 【Java业务服务】
→ 1. 先更新MySQL数据库(唯一权威源,必须先改这里)
→ 2. 同步删除Redis二级缓存
→ 3. 异步延迟执行「第二次删除Redis」(延迟双删兜底)
→ 4. 发布广播消息,通知所有服务实例删除本地Caffeine对应缓存

对应类比:

  1. 老师先改教学档案(保证标准答案正确);

  2. 你撕掉抽屉里的旧便签;

  3. 等一会再检查一遍抽屉,把同学偷偷贴回去的旧便签撕掉;

  4. 通知全班所有学习委员,把脑子里的旧答案忘掉。


三、可直接运行的极简代码样例(SpringBoot整合)

1. 第一步:引入核心依赖(pom.xml)

<!-- SpringBoot核心 -->
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.7.18</version>
    <relativePath/>
</parent>

<dependencies>
    <!-- Web基础 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Caffeine本地缓存 -->
    <dependency>
        <groupId>com.github.ben-manes.caffeine</groupId>
        <artifactId>caffeine</artifactId>
        <version>2.9.3</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>

    <!-- Redis分布式缓存 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <!-- 数据库操作(MyBatis-Plus极简版) -->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.5.3.1</version>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
        <version>8.0.33</version>
    </dependency>

    <!-- 工具类 -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

2. 第二步:配置文件(application.yml)

server:
  port: 8080

# 数据库配置
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: 你的数据库密码

  # Redis配置
  redis:
    host: localhost
    port: 6379
    password: 你的Redis密码(没有就空着)
    database: 0

  # 缓存配置
  cache:
    type: caffeine # 一级缓存默认用Caffeine
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=60s # 最大缓存1万条,写入后60秒过期

# 开启SQL日志打印
mybatis-plus:
  configuration:
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

3. 第三步:核心配置类

3.1 开启缓存与异步(启动类)

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableAsync;

@SpringBootApplication
@EnableCaching // 开启缓存注解
@EnableAsync // 开启异步(用于延迟双删)
@MapperScan("com.example.demo.mapper") // 扫描Mapper接口
public class CacheDemoApplication {
    public static void main(String[] args) {
        SpringApplication.run(CacheDemoApplication.class, args);
    }
}

3.2 Redis广播配置(用于Caffeine多实例一致性)

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;

@Configuration
public class RedisConfig {
    // Redis消息监听容器,用于接收缓存失效广播
    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        return container;
    }
}

4. 第四步:业务代码实现

4.1 实体类(商品示例)

import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;

@Data
@TableName("t_product")
public class Product implements Serializable {
    private static final long serialVersionUID = 1L;

    @TableId(type = IdType.AUTO)
    private Long id; // 商品ID
    private String name; // 商品名称
    private Integer stock; // 商品库存
    private String detail; // 商品详情
}

4.2 Mapper接口(数据库操作)

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.demo.entity.Product;
import org.springframework.stereotype.Repository;

@Repository
public interface ProductMapper extends BaseMapper<Product> {
}

4.3 核心Service层(三级缓存完整逻辑)

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.example.demo.entity.Product;
import com.example.demo.mapper.ProductMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;

@Service
@RequiredArgsConstructor
public class ProductService {
    private final ProductMapper productMapper;
    private final RedisTemplate<String, Object> redisTemplate;
    private final CacheManager cacheManager; // Caffeine缓存管理器

    // Redis缓存前缀
    private static final String REDIS_CACHE_PREFIX = "product:cache:";
    // 缓存失效广播主题
    public static final String CACHE_INVALID_TOPIC = "product:cache:invalid";
    // 延迟双删等待时间(500ms,根据业务压测调整)
    private static final long DELAY_TIME = 500;

    /**
     * 读请求:三级缓存完整链路
     * @Cacheable 自动查Caffeine一级缓存,未命中才执行方法体
     */
    @Cacheable(value = "product-caffeine", key = "#id", unless = "#result == null")
    public Product getProductById(Long id) {
        // 1. Caffeine未命中,先查Redis二级缓存
        String redisKey = REDIS_CACHE_PREFIX + id;
        Product product = (Product) redisTemplate.opsForValue().get(redisKey);
        if (product != null) {
            return product; // Redis命中,直接返回,Spring自动回写Caffeine
        }

        // 2. Redis未命中,查MySQL三级存储
        product = productMapper.selectById(id);
        if (product != null) {
            // 查到数据,回写Redis(设置10分钟过期,兜底)
            redisTemplate.opsForValue().set(redisKey, product, 10, TimeUnit.MINUTES);
        }
        return product; // Spring自动回写Caffeine
    }

    /**
     * 写请求:更新数据+缓存一致性处理
     */
    public void updateProduct(Product product) {
        // 1. 先更新MySQL(唯一权威源)
        productMapper.updateById(product);
        Long id = product.getId();
        String redisKey = REDIS_CACHE_PREFIX + id;

        // 2. 第一次删除Redis缓存
        redisTemplate.delete(redisKey);

        // 3. 异步延迟双删(兜底)
        asyncDeleteCache(redisKey, id);

        // 4. 发布广播,通知所有服务实例删除本地Caffeine缓存
        redisTemplate.convertAndSend(CACHE_INVALID_TOPIC, id);
    }

    /**
     * 异步延迟双删:不阻塞主线程
     */
    @Async
    public void asyncDeleteCache(String redisKey, Long productId) {
        try {
            // 延迟等待,保证并发读请求的脏数据已经回写Redis
            Thread.sleep(DELAY_TIME);
            // 第二次删除Redis缓存
            redisTemplate.delete(redisKey);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    /**
     * 本地Caffeine缓存删除方法(供广播监听器调用)
     */
    public void removeLocalCache(Long productId) {
        Cache caffeineCache = cacheManager.getCache("product-caffeine");
        if (caffeineCache != null) {
            caffeineCache.evict(productId);
        }
    }
}

4.4 广播监听器(多实例Caffeine一致性)

import com.example.demo.service.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.listener.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;

@Component
@RequiredArgsConstructor
public class CacheInvalidListener implements MessageListener {
    private final RedisMessageListenerContainer container;
    private final ProductService productService;

    // 项目启动后,订阅缓存失效主题
    @PostConstruct
    public void subscribe() {
        container.addMessageListener(this, new PatternTopic(ProductService.CACHE_INVALID_TOPIC));
    }

    // 收到广播消息,删除本地Caffeine缓存
    @Override
    public void onMessage(Message message, byte[] pattern) {
        Long productId = Long.valueOf(new String(message.getBody()));
        productService.removeLocalCache(productId);
    }
}

4.5 Controller层(接口暴露)

import com.example.demo.entity.Product;
import com.example.demo.service.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/product")
@RequiredArgsConstructor
public class ProductController {
    private final ProductService productService;

    // 查商品接口(三级缓存链路)
    @GetMapping("/{id}")
    public Product getProduct(@PathVariable Long id) {
        return productService.getProductById(id);
    }

    // 更新商品接口(缓存一致性处理)
    @PutMapping
    public String updateProduct(@RequestBody Product product) {
        productService.updateProduct(product);
        return "更新成功";
    }
}

四、核心痛点:三级缓存的一致性怎么保证?

核心原则

分布式场景下,绝对强一致性几乎无法实现(成本极高),生产环境通用方案是「最终一致性」,数据库是数据的唯一权威源,所有缓存都只是副本

1. 二级缓存(Redis)与数据库的一致性:延迟双删+TTL兜底

为什么不能只用「先更库、再删Redis」两步?

极端并发场景下会出现永久脏数据,时序如下:

  1. 线程A读请求:Redis缓存过期,查MySQL拿到旧值,还没写回Redis;

  2. 线程B写请求:更新MySQL为新值,删除Redis(此时Redis里没有key,删除无效);

  3. 线程A把旧值写回Redis,Redis永久留存脏数据。

延迟双删的核心作用

第二次延迟删除,干掉「两次删除窗口内,并发读请求回写的脏数据」,彻底避免永久不一致。

  • 必须延迟:延迟时间必须大于读请求「查库+写回Redis」的最大耗时,保证脏数据已经落库,第二次删除才能命中;

  • 必须异步:不能用Thread.sleep阻塞主线程,要用异步线程执行,不影响接口响应时间;

  • 终极兜底:给所有Redis key设置TTL过期时间,哪怕所有删除都失败,最多TTL时间后缓存自动过期,重新加载最新数据。

2. 一级缓存(Caffeine)多实例一致性:广播失效+短TTL兜底

Caffeine是单机隔离的:服务部署了3台机器,每台的Caffeine都是独立的,A机器更新了缓存,B机器的Caffeine还是旧数据。

生产环境2种成熟方案

  1. 短TTL兜底(90%非核心场景够用)

  1. 给Caffeine的key设置30s~1min的短过期时间,哪怕有脏数据,最多1分钟后自动过期,重新从Redis加载最新数据,零开发成本。

  1. Redis广播主动失效(高并发核心场景首选)

  1. 数据更新后,通过Redis发布订阅功能,给所有服务实例发广播,通知它们删除本地对应的Caffeine缓存,不一致窗口缩小到毫秒级,代码见上面的监听器实现。

3. 终极兜底规则

订单、支付、账户余额等核心交易数据,禁止使用Caffeine本地缓存,Redis缓存也需慎用,优先直接读取数据库,从根源上避免一致性问题导致的资损。


五、缓存淘汰策略详解(Caffeine vs Redis)

为什么要有淘汰策略?

你的脑子记不了太多东西,抽屉也放不了太多便签,内存满了就必须删掉不常用的数据,避免内存溢出(OOM)。

1. Caffeine的核心淘汰算法:W-TinyLFU(工业界命中率天花板)

小学生能懂的通俗解释

你的脑子记东西有3个规则:

  1. 临时备忘录(Window LRU窗口区):刚听到的新问题,先记在临时备忘录里,哪怕之前没听过,也能先记住,不会马上忘掉,解决新热点进不来的问题;

  2. 长期记忆(TinyLFU主区):经常被问的问题,就记在长期记忆里,满了就把最少被问的问题扔掉;

  3. 记忆衰减:每隔一段时间,就把所有问题的被问次数打个折,避免很久以前的热门问题,现在没人问了还占着脑子的空间。

专业核心优势

  • 用Count-Min Sketch数据结构记录访问频率,100万key仅需几十KB内存,开销极低;

  • 完美解决传统LRU(突发流量冲掉热点数据)、LFU(新热点进不来、内存开销大)的致命缺陷;

  • 命中率远超Redis的原生淘汰算法,是Java本地缓存的终极方案。

2. Redis的缓存淘汰策略

核心前提

Redis出厂默认配置是noeviction:内存满了不淘汰任何key,直接拒绝所有写请求,生产环境必须手动修改。

8种淘汰策略(3大类)

分类

策略

核心规则

生产常用度

不淘汰(默认)

noeviction

内存达上限拒绝所有写操作,仅支持读

几乎不用

仅针对带过期时间的key

volatile-lru

优先淘汰最近最少使用的带过期key

常用

volatile-lfu

优先淘汰访问频率最低的带过期key(Redis4.0+)

常用

volatile-random

随机淘汰带过期key

少用

volatile-ttl

优先淘汰即将过期的key

少用

针对所有key

allkeys-lru

全量key优先淘汰最近最少使用的

最常用

allkeys-lfu

全量key优先淘汰访问频率最低的

高并发热点场景常用

allkeys-random

全量key随机淘汰

几乎不用

和Caffeine的核心差异

Redis的LRU/LFU都是近似算法:为了保证高并发性能,Redis不会遍历所有key找最冷的数据,而是默认随机采样5个key,淘汰其中最冷的,精度和命中率远不如Caffeine的W-TinyLFU。


六、面试高频考点(满分回答直接背)

1. 什么是三级缓存?每一层的作用是什么?

满分回答:

三级缓存是Java高并发场景下的标准缓存架构,分为三层:

  1. 一级缓存:Caffeine JVM本地缓存,运行在业务服务进程内,无网络开销,纳秒级读取,核心作用是扛热点请求,极致降低接口延迟,减少Redis访问压力;

  2. 二级缓存:Redis分布式缓存,独立部署的中间件,全局共享数据,毫秒级读取,核心作用是兜底缓存,减少数据库访问压力,解决本地缓存单机隔离的问题;

  3. 三级存储:MySQL数据库,数据唯一权威源,持久化到磁盘,核心作用是永久存储全量业务数据。

  4. 核心逻辑是:离用户越近,速度越快,存的内容越少,只存最热的热点数据,尽量减少对底层数据库的访问。

2. 为什么要用Caffeine本地缓存?直接用Redis不行吗?

满分回答:

直接用Redis完全可以,但用Caffeine能带来3个核心收益:

  1. 极致降延迟:Caffeine是JVM堆内内存访问,纳秒级返回,而同机房Redis也有TCP网络IO+序列化开销,毫秒级返回,速度差了上千倍,热点接口耗时能直接压到最低;

  2. 大幅降低下游压力:高并发热点请求,只有第一次会打到Redis,后续全在本地返回,Redis的QPS压力能降90%以上,从根源上避免缓存击穿、雪崩;

  3. 兜底容错:Redis集群宕机时,Caffeine里的热点缓存还能正常提供服务,避免所有请求直接打到数据库,把库打挂。

  4. 当然,Caffeine也有局限:单机隔离、重启数据丢失、容量有限,所以只能作为一级缓存,和Redis配合使用。

3. 缓存更新的正确流程是什么?先更库还是先删缓存?

满分回答:

生产环境标准的缓存更新模式是Cache Aside旁路缓存模式,正确流程是:先更新数据库,再删除缓存,绝对不能反过来,也不能用更新缓存替代删除缓存。

  1. 先更库的原因:数据库是唯一权威源,必须先保证权威源的数据正确,再操作缓存;如果先删缓存,更新数据库的时间窗口内,所有读请求都会穿透到数据库,高并发下会打挂库;

  2. 只删缓存不更新缓存的原因:删除操作是幂等的,删多少次结果都一样;而并发更新缓存会导致缓存和数据库永久不一致,比如两个写请求并发更新,A先更库,B再更库,B先更缓存,A再更缓存,最终缓存和库完全不一致。

  3. 高并发场景下,还要加延迟双删兜底,避免极端并发下的永久脏数据。

4. 什么是延迟双删?为什么要延迟?为什么要删两次?

满分回答:

延迟双删是缓存一致性的兜底方案,指的是更新数据库后,先同步删除一次Redis缓存,再异步延迟固定时间,第二次删除Redis缓存。

  1. 为什么要删两次:单次删除只能删除当前已有的缓存,无法处理「两次删除窗口内,并发读请求回写的脏数据」,第二次删除就是为了干掉这些脏数据,避免永久不一致;

  2. 为什么要延迟:脏数据的回写发生在第一次删除和数据库更新完成之后,如果更新完立刻删第二次,此时并发读请求还没完成脏数据回写,删除无效;延迟时间必须大于读请求「查库+写回Redis」的最大耗时,保证脏数据已经落库,第二次删除才能精准命中。

5. 缓存穿透、缓存击穿、缓存雪崩是什么?三级缓存怎么解决?

满分回答:

这是缓存三大经典问题,三级缓存能完美解决:

  1. 缓存穿透:请求查询一个不存在的key,直接穿透到数据库,解决:Caffeine和Redis都缓存空值,加布隆过滤器拦截不存在的key;

  2. 缓存击穿:热点key过期,大量并发请求直接打到数据库,解决:三级缓存用Caffeine本地缓存扛热点,热点key设置永不过期,加互斥锁控制只有一个请求去查库;

  3. 缓存雪崩:大量key同时过期,所有请求打到数据库,解决:key过期时间加随机值,避免同时过期,Caffeine和Redis的过期时间错开,服务集群部署,Redis搭建集群避免单点故障。


七、生产环境最佳实践 vs 错误踩坑案例

最佳实践

错误踩坑案例

错误原因

读链路严格遵循:Caffeine → Redis → MySQL

读请求直接查Redis/MySQL,不用Caffeine

浪费本地缓存的极致性能,给Redis和数据库造成不必要的压力

写请求:先更新MySQL → 再删Redis → 延迟双删

写请求:先删Redis → 再更新MySQL

高并发下,更新数据库的时间窗口内,所有读请求都穿透到数据库,极易打挂库

写请求只删缓存,绝对不更新缓存

写请求更新完数据库,直接更新Redis缓存

并发写请求会导致缓存和数据库永久不一致,删除是幂等的,更新不是

Caffeine设置短TTL+广播失效保证一致性

Caffeine不设TTL,永久缓存

数据更新后,本地缓存永远是旧数据,永久不一致

给所有Redis key设置TTL兜底

Redis key不设过期时间,永久有效

哪怕所有删除操作都失败,缓存永远是脏数据,没有兜底

核心交易数据不用Caffeine,甚至不用Redis

订单、支付、余额数据用Caffeine缓存

一致性要求极高,本地缓存脏数据会直接导致资损

Caffeine最大容量严格限制,不超过JVM堆内存的10%

Caffeine设置超大容量,无限制缓存数据

占用过多JVM堆内存,导致频繁Full GC,甚至OOM

延迟双删用异步线程执行,不阻塞主线程

延迟双删用Thread.sleep阻塞主线程

拉长接口响应时间,高并发下会导致线程池阻塞,服务不可用


八、最终一句话总结

三级缓存的本质,是用分层的架构,平衡「速度、容量、一致性」三者的关系:用Caffeine扛住最热的请求,极致提速;用Redis做全局共享的兜底缓存,减少数据库压力;用MySQL做唯一权威源,保证数据永久可靠。所有的设计,都是为了在高并发场景下,让系统更快、更稳、更安全。

两块二每分钟