一、先搞懂最核心的问题:什么是缓存?为什么要有三级缓存?
1. 通俗类比
你是班级的学习委员,每天有1000个同学来问你同一个问题:三年级上册语文第3课的中心思想是什么?
最差办法:每次有人问,你都跑10分钟去老师办公室,翻厚厚的教学档案找答案。1000个人问,你要跑10000分钟,累死不说,还会把老师办公室挤爆。
第一次优化:你把答案抄在便签纸上,贴在课桌抽屉里。有人问,拉开抽屉1秒就能找到答案,不用跑办公室了。但1000个人都来翻抽屉,还是会乱,而且每次开抽屉都要花一点时间。
第二次优化:你把答案直接背下来,记在脑子里。有人问,张嘴0.001秒就答完,连抽屉都不用开。
这就是三级缓存的本质:把最热、最常用的数据,放在离用户最近、速度最快的地方,尽量不去碰最慢的「底层档案」。
2. 专业定义与一一对应
我们后端开发说的三级缓存,特指Java高并发场景下的标准架构,每一层和类比完全对应:
3. 彻底解决你之前的核心误区
问:Caffeine是JVM本地缓存,请求都已经打到服务器了,用它还有啥用?
答:请求已经到你这个「学习委员」这里了,用脑子直接答,比跑10分钟去老师办公室快10万倍。它不是用来挡请求「到不了服务器」,而是请求到了服务器之后,别再往后端的Redis、MySQL打,直接在本机内存就把结果返回,极致提速+给下游大幅减负。
二、三级缓存的完整执行链路(一步不落,小学生能跟着走明白)
1. 正常读请求链路(同学来问问题)
客户端请求 → Nginx负载均衡 → Gateway网关 → 【Java业务服务(JVM进程)】
→ 1. 查Caffeine一级缓存 → 命中:直接返回结果,链路结束
→ 未命中 → 2. 查Redis二级缓存 → 命中:回写Caffeine + 返回结果,链路结束
→ 未命中 → 3. 查MySQL数据库 → 查到数据:写Redis + 写Caffeine + 返回结果
→ 未查到:返回空/报错对应类比:
同学问问题,你先想「脑子里有没有背过答案」,有就直接答;
脑子里没有,就开抽屉找便签,有就抄到脑子里,再答;
抽屉里也没有,就跑老师办公室翻档案,找到后抄到便签、背到脑子里,再答。
2. 数据更新/写请求链路(老师改了标准答案)
客户端更新请求 → Nginx → Gateway → 【Java业务服务】
→ 1. 先更新MySQL数据库(唯一权威源,必须先改这里)
→ 2. 同步删除Redis二级缓存
→ 3. 异步延迟执行「第二次删除Redis」(延迟双删兜底)
→ 4. 发布广播消息,通知所有服务实例删除本地Caffeine对应缓存对应类比:
老师先改教学档案(保证标准答案正确);
你撕掉抽屉里的旧便签;
等一会再检查一遍抽屉,把同学偷偷贴回去的旧便签撕掉;
通知全班所有学习委员,把脑子里的旧答案忘掉。
三、可直接运行的极简代码样例(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.StdOutImpl3. 第三步:核心配置类
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」两步?
极端并发场景下会出现永久脏数据,时序如下:
线程A读请求:Redis缓存过期,查MySQL拿到旧值,还没写回Redis;
线程B写请求:更新MySQL为新值,删除Redis(此时Redis里没有key,删除无效);
线程A把旧值写回Redis,Redis永久留存脏数据。
延迟双删的核心作用
用第二次延迟删除,干掉「两次删除窗口内,并发读请求回写的脏数据」,彻底避免永久不一致。
必须延迟:延迟时间必须大于读请求「查库+写回Redis」的最大耗时,保证脏数据已经落库,第二次删除才能命中;
必须异步:不能用Thread.sleep阻塞主线程,要用异步线程执行,不影响接口响应时间;
终极兜底:给所有Redis key设置TTL过期时间,哪怕所有删除都失败,最多TTL时间后缓存自动过期,重新加载最新数据。
2. 一级缓存(Caffeine)多实例一致性:广播失效+短TTL兜底
Caffeine是单机隔离的:服务部署了3台机器,每台的Caffeine都是独立的,A机器更新了缓存,B机器的Caffeine还是旧数据。
生产环境2种成熟方案
短TTL兜底(90%非核心场景够用)
给Caffeine的key设置30s~1min的短过期时间,哪怕有脏数据,最多1分钟后自动过期,重新从Redis加载最新数据,零开发成本。
Redis广播主动失效(高并发核心场景首选)
数据更新后,通过Redis发布订阅功能,给所有服务实例发广播,通知它们删除本地对应的Caffeine缓存,不一致窗口缩小到毫秒级,代码见上面的监听器实现。
3. 终极兜底规则
订单、支付、账户余额等核心交易数据,禁止使用Caffeine本地缓存,Redis缓存也需慎用,优先直接读取数据库,从根源上避免一致性问题导致的资损。
五、缓存淘汰策略详解(Caffeine vs Redis)
为什么要有淘汰策略?
你的脑子记不了太多东西,抽屉也放不了太多便签,内存满了就必须删掉不常用的数据,避免内存溢出(OOM)。
1. Caffeine的核心淘汰算法:W-TinyLFU(工业界命中率天花板)
小学生能懂的通俗解释
你的脑子记东西有3个规则:
临时备忘录(Window LRU窗口区):刚听到的新问题,先记在临时备忘录里,哪怕之前没听过,也能先记住,不会马上忘掉,解决新热点进不来的问题;
长期记忆(TinyLFU主区):经常被问的问题,就记在长期记忆里,满了就把最少被问的问题扔掉;
记忆衰减:每隔一段时间,就把所有问题的被问次数打个折,避免很久以前的热门问题,现在没人问了还占着脑子的空间。
专业核心优势
用Count-Min Sketch数据结构记录访问频率,100万key仅需几十KB内存,开销极低;
完美解决传统LRU(突发流量冲掉热点数据)、LFU(新热点进不来、内存开销大)的致命缺陷;
命中率远超Redis的原生淘汰算法,是Java本地缓存的终极方案。
2. Redis的缓存淘汰策略
核心前提
Redis出厂默认配置是noeviction:内存满了不淘汰任何key,直接拒绝所有写请求,生产环境必须手动修改。
8种淘汰策略(3大类)
和Caffeine的核心差异
Redis的LRU/LFU都是近似算法:为了保证高并发性能,Redis不会遍历所有key找最冷的数据,而是默认随机采样5个key,淘汰其中最冷的,精度和命中率远不如Caffeine的W-TinyLFU。
六、面试高频考点(满分回答直接背)
1. 什么是三级缓存?每一层的作用是什么?
满分回答:
三级缓存是Java高并发场景下的标准缓存架构,分为三层:
一级缓存:Caffeine JVM本地缓存,运行在业务服务进程内,无网络开销,纳秒级读取,核心作用是扛热点请求,极致降低接口延迟,减少Redis访问压力;
二级缓存:Redis分布式缓存,独立部署的中间件,全局共享数据,毫秒级读取,核心作用是兜底缓存,减少数据库访问压力,解决本地缓存单机隔离的问题;
三级存储:MySQL数据库,数据唯一权威源,持久化到磁盘,核心作用是永久存储全量业务数据。
核心逻辑是:离用户越近,速度越快,存的内容越少,只存最热的热点数据,尽量减少对底层数据库的访问。
2. 为什么要用Caffeine本地缓存?直接用Redis不行吗?
满分回答:
直接用Redis完全可以,但用Caffeine能带来3个核心收益:
极致降延迟:Caffeine是JVM堆内内存访问,纳秒级返回,而同机房Redis也有TCP网络IO+序列化开销,毫秒级返回,速度差了上千倍,热点接口耗时能直接压到最低;
大幅降低下游压力:高并发热点请求,只有第一次会打到Redis,后续全在本地返回,Redis的QPS压力能降90%以上,从根源上避免缓存击穿、雪崩;
兜底容错:Redis集群宕机时,Caffeine里的热点缓存还能正常提供服务,避免所有请求直接打到数据库,把库打挂。
当然,Caffeine也有局限:单机隔离、重启数据丢失、容量有限,所以只能作为一级缓存,和Redis配合使用。
3. 缓存更新的正确流程是什么?先更库还是先删缓存?
满分回答:
生产环境标准的缓存更新模式是Cache Aside旁路缓存模式,正确流程是:先更新数据库,再删除缓存,绝对不能反过来,也不能用更新缓存替代删除缓存。
先更库的原因:数据库是唯一权威源,必须先保证权威源的数据正确,再操作缓存;如果先删缓存,更新数据库的时间窗口内,所有读请求都会穿透到数据库,高并发下会打挂库;
只删缓存不更新缓存的原因:删除操作是幂等的,删多少次结果都一样;而并发更新缓存会导致缓存和数据库永久不一致,比如两个写请求并发更新,A先更库,B再更库,B先更缓存,A再更缓存,最终缓存和库完全不一致。
高并发场景下,还要加延迟双删兜底,避免极端并发下的永久脏数据。
4. 什么是延迟双删?为什么要延迟?为什么要删两次?
满分回答:
延迟双删是缓存一致性的兜底方案,指的是更新数据库后,先同步删除一次Redis缓存,再异步延迟固定时间,第二次删除Redis缓存。
为什么要删两次:单次删除只能删除当前已有的缓存,无法处理「两次删除窗口内,并发读请求回写的脏数据」,第二次删除就是为了干掉这些脏数据,避免永久不一致;
为什么要延迟:脏数据的回写发生在第一次删除和数据库更新完成之后,如果更新完立刻删第二次,此时并发读请求还没完成脏数据回写,删除无效;延迟时间必须大于读请求「查库+写回Redis」的最大耗时,保证脏数据已经落库,第二次删除才能精准命中。
5. 缓存穿透、缓存击穿、缓存雪崩是什么?三级缓存怎么解决?
满分回答:
这是缓存三大经典问题,三级缓存能完美解决:
缓存穿透:请求查询一个不存在的key,直接穿透到数据库,解决:Caffeine和Redis都缓存空值,加布隆过滤器拦截不存在的key;
缓存击穿:热点key过期,大量并发请求直接打到数据库,解决:三级缓存用Caffeine本地缓存扛热点,热点key设置永不过期,加互斥锁控制只有一个请求去查库;
缓存雪崩:大量key同时过期,所有请求打到数据库,解决:key过期时间加随机值,避免同时过期,Caffeine和Redis的过期时间错开,服务集群部署,Redis搭建集群避免单点故障。
七、生产环境最佳实践 vs 错误踩坑案例
八、最终一句话总结
三级缓存的本质,是用分层的架构,平衡「速度、容量、一致性」三者的关系:用Caffeine扛住最热的请求,极致提速;用Redis做全局共享的兜底缓存,减少数据库压力;用MySQL做唯一权威源,保证数据永久可靠。所有的设计,都是为了在高并发场景下,让系统更快、更稳、更安全。