Skip to content

第 6 章 Spring Boot 整合缓存 ​

写给新手的话:

上一章我们学了 Redis,知道了缓存的概念。这一章我们来学 Spring Boot 怎么优雅地使用缓存。

你可能会问:上一章不是已经学了 RedisTemplate 操作缓存了吗?为什么还要学这个?

答案是:声明式缓存更优雅。

用 RedisTemplate 的话,你得在业务代码里手动写缓存逻辑:先查缓存、没有再查数据库、然后放缓存……

用声明式缓存的话,你只要加一个注解,Spring 就自动帮你搞定了。业务代码干干净净,看不到缓存逻辑。

学习建议:

  • 重点掌握几个核心注解的用法
  • 理解缓存的工作原理
  • 知道不同缓存实现的区别
  • 了解缓存常见问题和解决方案

准备好了吗?我们开始吧!


6.1 先搞明白:缓存那些事儿 ​

6.1.1 什么是缓存 ​

缓存,就是把数据存到一个更快的地方,下次用的时候直接从快的地方拿,不用再去慢的地方取。

打个比方:

  • 你平时用的书,放在书桌上(缓存),随手就能拿到
  • 不常用的书,放在书架上(数据库),要拿得走过去
  • 书桌空间有限(缓存容量小),书架空间大(数据库容量大)
  • 书桌拿书快(缓存速度快),书架拿书慢(数据库速度慢)

这就是缓存的核心思想:空间换时间。

6.1.2 为什么需要缓存 ​

为什么要加缓存?直接查数据库不行吗?

行,但是有问题:

  1. 数据库性能瓶颈:数据库读写是有上限的,高并发场景下扛不住
  2. 响应慢:每次都查数据库,磁盘 IO 慢,用户体验差
  3. 重复计算:有些数据计算很费时间,每次都重新算不划算
  4. 数据库压力大:所有请求都打在数据库上,数据库容易挂

加了缓存之后:

  • 读请求先查缓存,有就直接返回,速度快很多
  • 缓存没有才查数据库,然后放到缓存里
  • 数据库压力大大降低
  • 响应速度提升几个数量级

举个例子:

  • 查数据库:100ms
  • 查 Redis:1ms
  • 差了 100 倍!

而且数据库并发能力有限,Redis 能扛几万 QPS。

6.1.3 缓存的适用场景 ​

适合用缓存的:

  • 读多写少的数据(商品信息、用户信息、文章内容)
  • 热点数据(首页、排行榜)
  • 计算成本高的数据(统计报表、复杂查询结果)
  • 不要求强一致性的数据

不适合用缓存的:

  • 写多读少的数据
  • 要求强一致性的数据(钱、库存)
  • 数据更新特别频繁的
  • 数据量特别大且访问没规律的(缓存命中率低)

6.1.4 Spring Boot 缓存抽象 ​

Spring 提供了一套缓存抽象(Cache Abstraction),统一了各种缓存技术的使用方式。

就像 JDBC 统一了各种数据库的访问方式一样,Spring Cache 统一了各种缓存技术的使用方式。

核心思想:

  • 你用注解来声明缓存逻辑
  • Spring 自动帮你处理缓存的读写
  • 底层缓存实现可以切换:默认缓存、Ehcache、Redis、Caffeine 等
  • 切换实现不用改业务代码,改配置就行

支持的缓存实现:

缓存实现特点适用场景
默认(ConcurrentHashMap)简单,内存缓存开发测试、单节点
Ehcache本地缓存,功能丰富单节点应用
Redis分布式缓存,性能好分布式系统、生产环境
Caffeine高性能本地缓存单节点,追求性能
Guava CacheGoogle 的本地缓存单节点
...还有很多

重点:Spring Cache 是一套抽象,不是具体实现。 你学会了这套注解,不管底层用什么缓存,用法都是一样的。


6.2 Spring Boot 默认缓存:快速入门 ​

我们先从最简单的默认缓存开始,学习缓存注解的用法。

6.2.1 环境准备 ​

步骤 1:创建项目,加依赖

创建 Spring Boot 项目,选择依赖:

  • Spring Web
  • Spring Cache(spring-boot-starter-cache)
  • Spring Data JPA(用来操作数据库)
  • MySQL Driver
  • Lombok

pom.xml 关键依赖:

xml
<!-- 缓存 starter -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

<!-- JPA(用来操作数据库) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>

<!-- MySQL 驱动 -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>

步骤 2:配置数据库

yaml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/chapter06?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
  
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true

步骤 3:开启缓存

在启动类上加 @EnableCaching 注解:

java
@SpringBootApplication
@EnableCaching  // 开启缓存
public class Chapter06Application {
    public static void main(String[] args) {
        SpringApplication.run(Chapter06Application.class, args);
    }
}

就这一个注解,缓存就开启了。 不加的话,缓存注解不生效。

步骤 4:准备实体类和 Repository

Book 实体:

java
@Entity
@Table(name = "book")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Book implements Serializable {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    private String name;
    private String author;
    private String press;
    private String status;
}

注意:实体类要实现 Serializable 接口,因为有些缓存实现需要序列化。 默认缓存不用序列化也行,但养成好习惯。

BookRepository:

java
@Repository
public interface BookRepository extends JpaRepository<Book, Integer> {
}

6.2.2 第一个缓存例子:@Cacheable ​

我们来写一个查询图书的接口,加上缓存。

BookService:

java
@Service
public class BookServiceImpl implements BookService {

    @Autowired
    private BookRepository bookRepository;

    /**
     * 根据ID查询图书(加缓存)
     */
    @Cacheable(cacheNames = "book", key = "#id")
    public Book findById(Integer id) {
        System.out.println("查询数据库,ID:" + id);
        return bookRepository.findById(id).orElse(null);
    }
}

BookController:

java
@RestController
@RequestMapping("/book")
public class BookController {

    @Autowired
    private BookService bookService;

    @GetMapping("/findById/{id}")
    public Book findById(@PathVariable Integer id) {
        return bookService.findById(id);
    }
}

测试一下:

启动项目,访问 http://localhost:8080/book/findById/1

第一次访问:

  • 控制台打印:查询数据库,ID:1
  • 说明走了数据库查询

第二次访问(同一个 ID):

  • 控制台没有打印
  • 说明没走数据库,直接从缓存拿的

🎉 恭喜!第一个缓存例子跑通了!

@Cacheable 注解的工作流程:

调用方法前 → 先查缓存
                ↓
          缓存有数据?
           /        \
         是          否
         /            \
    直接返回      执行方法(查数据库)
                      ↓
                  结果放入缓存
                      ↓
                    返回结果

6.2.3 @Cacheable 注解详解 ​

@Cacheable 是最常用的缓存注解,标记在方法上,表示这个方法的结果要缓存。

常用属性:

属性说明例子
cacheNames / value缓存名称(可以多个)cacheNames = "book"
key缓存的 key(SpEL 表达式)key = "#id"
condition缓存条件(满足才缓存)condition = "#id > 0"
unless不缓存条件(满足就不缓存)unless = "#result == null"
keyGeneratorkey 生成器-
cacheManager缓存管理器-
sync是否同步(防止击穿)sync = true

cacheNames:缓存名称

缓存的名字,相当于缓存的命名空间。不同的 cacheNames 是相互隔离的。

java
// 单个缓存名
@Cacheable(cacheNames = "book")
public Book findById(Integer id) { ... }

// 多个缓存名
@Cacheable(cacheNames = {"book", "product"})
public Book findById(Integer id) { ... }

key:缓存的 key

缓存的 key,用 SpEL 表达式写。默认是用方法参数生成 key。

java
// 用参数 id 作为 key
@Cacheable(cacheNames = "book", key = "#id")
public Book findById(Integer id) { ... }

// 用多个参数组合作为 key
@Cacheable(cacheNames = "book", key = "#name + '_' + #author")
public Book findByNameAndAuthor(String name, String author) { ... }

// 用方法名 + 参数作为 key
@Cacheable(cacheNames = "book", key = "#root.methodName + ':' + #id")
public Book findById(Integer id) { ... }

key 是缓存的唯一标识,相同 key 的数据会被覆盖。 一般用业务 ID 作为 key 就可以了。

condition:缓存条件

满足条件才缓存,不满足就不缓存。

java
// 只有 id > 10 才缓存
@Cacheable(cacheNames = "book", key = "#id", condition = "#id > 10")
public Book findById(Integer id) { ... }

unless:不缓存条件

满足条件就不缓存,跟 condition 相反。

java
// 结果为 null 就不缓存
@Cacheable(cacheNames = "book", key = "#id", unless = "#result == null")
public Book findById(Integer id) { ... }

condition 是在方法执行前判断,unless 是在方法执行后判断。 所以 unless 可以拿到返回结果(#result),condition 不行。

sync:同步缓存

缓存击穿的时候用,同一个 key 只让一个请求去查数据库,其他的等。

java
@Cacheable(cacheNames = "book", key = "#id", sync = true)
public Book findById(Integer id) { ... }

这个后面讲缓存击穿的时候会细说。

6.2.4 @CachePut:更新缓存 ​

@CachePut 用来更新缓存。方法执行完之后,把结果更新到缓存里。

跟 @Cacheable 的区别:

  • @Cacheable:先查缓存,有就直接返回,不执行方法
  • @CachePut:每次都执行方法,执行完更新缓存
java
/**
 * 更新图书(同时更新缓存)
 */
@CachePut(cacheNames = "book", key = "#id")
public Book updateById(Integer id, String name) {
    Book book = bookRepository.findById(id).orElse(null);
    if (book != null) {
        book.setName(name);
        bookRepository.save(book);
    }
    return book;
}

工作流程:

执行方法(更新数据库)
        ↓
    方法返回结果
        ↓
  把结果更新到缓存
        ↓
      返回结果

注意:@CachePut 每次都会执行方法,不管缓存里有没有。 所以一般用在更新操作上。

6.2.5 @CacheEvict:删除缓存 ​

@CacheEvict 用来删除缓存。

java
/**
 * 删除图书(同时删除缓存)
 */
@CacheEvict(cacheNames = "book", key = "#id")
public void delById(Integer id) {
    bookRepository.deleteById(id);
}

常用属性:

属性说明
key要删除的 key
allEntries是否删除这个缓存下的所有 key
beforeInvocation是否在方法执行前删除

删除所有:

java
// 删除 book 缓存下的所有数据
@CacheEvict(cacheNames = "book", allEntries = true)
public void clearCache() {
    // ...
}

方法执行前删除:

java
@CacheEvict(cacheNames = "book", key = "#id", beforeInvocation = true)
public void delById(Integer id) {
    // ...
}

默认是方法执行后删除。如果方法执行过程中报错了,缓存就不会删。 加 beforeInvocation = true 的话,方法执行前就删,不管方法成功失败都删。

一般用默认的(方法执行后删)就行。 删数据库成功了才删缓存,不然数据库没删掉缓存先删了,就不一致了。

6.2.6 @CacheConfig:类级别配置 ​

如果一个类里的方法都用同一个缓存名,可以在类上加 @CacheConfig,统一配置。

java
@Service
@CacheConfig(cacheNames = "book")  // 类级别配置,所有方法都用 book 这个缓存
public class BookServiceImpl implements BookService {

    @Cacheable(key = "#id")  // 不用写 cacheNames 了,继承类上的
    public Book findById(Integer id) { ... }

    @CachePut(key = "#id")
    public Book updateById(Integer id, String name) { ... }

    @CacheEvict(key = "#id")
    public void delById(Integer id) { ... }
}

这样类里的方法就不用每个都写 cacheNames = "book" 了。

@CacheConfig 可以配置:

  • cacheNames:缓存名称
  • keyGenerator:key 生成器
  • cacheManager:缓存管理器
  • cacheResolver:缓存解析器

6.2.7 @Caching:组合注解 ​

有时候一个操作要同时操作多个缓存,可以用 @Caching 组合多个注解。

java
@Caching(
    cacheable = {
        @Cacheable(cacheNames = "book", key = "#id")
    },
    put = {
        @CachePut(cacheNames = "book:name", key = "#result.name")
    },
    evict = {
        @CacheEvict(cacheNames = "book:list", allEntries = true)
    }
)
public Book findById(Integer id) {
    // ...
}

可以组合 @Cacheable、@CachePut、@CacheEvict。

一般用得不多,复杂场景才用。

6.2.8 SpEL 表达式 ​

缓存注解里的 key、condition、unless 都支持 SpEL(Spring Expression Language)表达式。

常用的 SpEL 变量:

变量说明例子
#参数名方法参数#id、#name
#result方法返回值(@Cacheable 的 condition 不能用)#result.name
#root.method方法对象#root.method.name
#root.methodName方法名#root.methodName
#root.target目标对象#root.target
#root.targetClass目标类#root.targetClass
#root.caches缓存列表#root.caches[0].name
#root.args方法参数数组#root.args[0]

常用运算符:

java
// 字符串拼接
key = "#id + '_' + #name"

// 算术运算
key = "#id + 100"

// 比较运算
condition = "#id > 10"

// 逻辑运算
condition = "#id > 10 and #name != null"

// 三元运算
unless = "#result == null ? true : false"

// 调用方法
key = "#user.getId()"

SpEL 功能很强大,不用全记,常用的记住就行。

6.2.9 默认缓存的特点 ​

Spring Boot 默认用的是 ConcurrentHashMap 作为缓存实现。

优点:

  • 简单,不用额外安装
  • 开箱即用
  • 适合开发测试

缺点:

  • 存在内存里,应用重启就没了
  • 单节点的,分布式环境下不能共享
  • 没有过期时间
  • 容量有限,不能存太多

生产环境一般不用默认缓存,要么用 Redis,要么用 Caffeine/Ehcache。


6.3 整合 Ehcache ​

6.3.1 Ehcache 是什么 ​

Ehcache 是一个纯 Java 的进程内缓存框架,功能比较丰富。

特点:

  • 本地缓存,速度快
  • 支持持久化(可以存到磁盘)
  • 支持过期时间
  • 支持淘汰策略(LRU、LFU、FIFO)
  • 支持分布式(有 Ehcache 集群方案)

适用场景:

  • 单节点应用
  • 对性能要求高,不想依赖外部缓存
  • 数据量不大,能放内存里

6.3.2 整合步骤 ​

步骤 1:加依赖

xml
<dependency>
    <groupId>net.sf.ehcache</groupId>
    <artifactId>ehcache</artifactId>
</dependency>

加了 ehcache 的依赖,Spring Boot 自动就会用 Ehcache 作为缓存实现。 不用改业务代码,注解还是那些注解。

步骤 2:配置文件

在 resources 下创建 ehcache.xml:

xml
<?xml version="1.0" encoding="UTF-8"?>
<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="http://ehcache.org/ehcache.xsd">

    <!-- 磁盘缓存位置 -->
    <diskStore path="java.io.tmpdir/ehcache"/>

    <!-- 默认缓存配置 -->
    <defaultCache
            maxElementsInMemory="10000"
            eternal="false"
            timeToIdleSeconds="120"
            timeToLiveSeconds="120"
            maxElementsOnDisk="10000000"
            diskPersistent="false"
            diskExpiryThreadIntervalSeconds="120"
            memoryStoreEvictionPolicy="LRU"/>

    <!-- book 缓存配置 -->
    <cache name="book"
           maxElementsInMemory="1000"
           eternal="false"
           timeToIdleSeconds="300"
           timeToLiveSeconds="600"
           overflowToDisk="false"
           memoryStoreEvictionPolicy="LRU"/>

</ehcache>

配置项说明:

配置项说明
name缓存名称,跟 @Cacheable 的 cacheNames 对应
maxElementsInMemory内存中最大缓存数量
eternal是否永久有效(true 的话过期时间没用)
timeToIdleSeconds最大空闲时间(多久没访问就过期)
timeToLiveSeconds最大存活时间(从创建开始算,不管有没有访问)
overflowToDisk内存满了是否写到磁盘
diskPersistent磁盘持久化(重启还在)
memoryStoreEvictionPolicy内存淘汰策略

淘汰策略:

  • LRU:最近最少使用(最常用)
  • LFU:最不经常使用
  • FIFO:先进先出

步骤 3:指定使用 Ehcache

在 application.yml 里指定缓存类型:

yaml
spring:
  cache:
    type: ehcache
    ehcache:
      config: classpath:ehcache.xml

一般加了依赖自动就识别了,不用特意指定 type。 但显式指定更清楚,推荐写上。

步骤 4:测试

启动项目,跟之前一样测试。 你会发现代码完全不用改,只是底层缓存实现变了。

这就是 Spring Cache 抽象的好处!

6.3.3 Ehcache 小结 ​

优点:

  • 本地缓存,速度快
  • 功能丰富,支持过期、淘汰、持久化
  • 不用额外安装,依赖就行

缺点:

  • 本地缓存,分布式环境下不共享
  • 数据存在应用内存里,占用堆内存
  • 集群环境下数据不一致

Ehcache 适合单节点应用。 现在分布式系统多,一般用 Redis 更多。


6.4 整合 Redis 缓存(重点) ​

6.4.1 为什么用 Redis 做缓存 ​

Ehcache 是本地缓存,每个节点各存各的,分布式环境下有问题:

  • 缓存不共享,每个节点都要查一次数据库
  • 缓存不一致,一个节点更新了,其他节点不知道
  • 缓存利用率低,重复数据占内存

Redis 是分布式缓存,所有节点共用一个 Redis:

  • 缓存共享,查过一次所有节点都能用
  • 数据一致
  • 支持集群,容量大
  • 支持持久化
  • 功能丰富

所以生产环境,尤其是分布式系统,一般都用 Redis 做缓存。

6.4.2 整合步骤 ​

步骤 1:加依赖

xml
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

步骤 2:配置 Redis

yaml
spring:
  redis:
    host: localhost
    port: 6379
    password: 
    database: 0
    timeout: 3000ms
  
  cache:
    type: redis  # 指定用 Redis 缓存
    redis:
      time-to-live: 3600000  # 默认过期时间,单位毫秒(1小时)
      key-prefix: "cache:"   # key 前缀
      use-key-prefix: true   # 是否使用前缀
      cache-null-values: false  # 是否缓存 null 值

缓存配置说明:

配置说明
time-to-live缓存过期时间,默认不过期
key-prefixkey 的前缀
use-key-prefix是否使用前缀
cache-null-values是否缓存 null 值(防止缓存穿透)

注意:过期时间是全局的,所有缓存都一样。 如果想给不同的缓存设置不同的过期时间,需要自定义配置。

步骤 3:实体类实现 Serializable

java
@Entity
@Table(name = "book")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Book implements Serializable {  // 必须实现序列化
    // ...
}

Redis 默认用 JDK 序列化,对象必须实现 Serializable 接口。 不然会报错。

步骤 4:测试

启动项目,访问接口。 然后去 Redis 里看看,是不是有缓存数据了?

用 Redis 客户端(比如 redis-cli)查看:

keys *

你应该能看到类似 cache:book::1 这样的 key。

🎉 Redis 缓存整合成功!

6.4.3 自定义 Redis 缓存配置(推荐) ​

默认的 Redis 缓存用的是 JDK 序列化,存进去是二进制,看着乱码。 而且过期时间是全局的,不能每个缓存单独设置。

一般我们会自定义 Redis 缓存配置,用 JSON 序列化,看着清楚,也更灵活。

配置类:

java
@Configuration
@EnableCaching
public class RedisCacheConfig {

    /**
     * 缓存管理器
     */
    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory) {
        // JSON 序列化器
        Jackson2JsonRedisSerializer<Object> jsonSerializer = 
            new Jackson2JsonRedisSerializer<>(Object.class);
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        objectMapper.activateDefaultTyping(
            LaissezFaireSubTypeValidator.instance,
            ObjectMapper.DefaultTyping.NON_FINAL
        );
        jsonSerializer.setObjectMapper(objectMapper);

        // String 序列化器
        StringRedisSerializer stringSerializer = new StringRedisSerializer();

        // 缓存配置
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofHours(1))  // 默认过期时间 1 小时
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(stringSerializer))  // key 用 String 序列化
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(jsonSerializer))  // value 用 JSON 序列化
                .computePrefixWith(name -> "cache:" + name + ":")  // key 前缀
                .disableCachingNullValues();  // 不缓存 null

        // 不同缓存的单独配置
        Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
        
        // book 缓存,过期时间 30 分钟
        cacheConfigurations.put("book", defaultConfig.entryTtl(Duration.ofMinutes(30)));
        
        // user 缓存,过期时间 1 小时
        cacheConfigurations.put("user", defaultConfig.entryTtl(Duration.ofHours(1)));

        // 构建缓存管理器
        return RedisCacheManager.builder(factory)
                .cacheDefaults(defaultConfig)
                .withInitialCacheConfigurations(cacheConfigurations)
                .build();
    }
}

这样配置之后:

  • key 是 String 类型,看着清楚
  • value 是 JSON 格式,人能看懂
  • 不同的缓存可以设置不同的过期时间
  • 不缓存 null 值(防止缓存穿透的话可以打开)

实际项目中一般都会自定义配置,用 JSON 序列化。 默认的 JDK 序列化太丑了,也不方便调试。

6.4.4 Redis 缓存的 key 格式 ​

默认情况下,Redis 缓存的 key 格式是:

cache:book::1
  • cache::前缀(我们配置的)
  • book:缓存名称(cacheNames)
  • :::分隔符
  • 1:key 值

这个格式可以通过配置修改,一般默认的就行。

6.4.5 缓存 null 值与缓存穿透 ​

前面提到了 cache-null-values 这个配置,要不要缓存 null 值呢?

场景:

  • 查询一个不存在的 ID
  • 缓存没有,查数据库也没有
  • 返回 null
  • 下次再查,还是缓存没有,又查数据库
  • 如果有人恶意用不存在的 ID 疯狂请求,数据库就挂了

这就是缓存穿透。

解决方案之一:缓存 null 值

把不存在的查询结果也缓存起来(存个 null),下次直接返回 null,不用查数据库。

yaml
spring:
  cache:
    redis:
      cache-null-values: true  # 缓存 null 值

优点:

  • 简单,配置一下就行
  • 能解决缓存穿透

缺点:

  • 占空间(存了很多 null)
  • 如果这个 key 之后真的有数据了,缓存里还是 null,要等过期

一般建议打开,尤其是查询接口比较多的系统。 过期时间设短一点就行。


6.5 缓存进阶:常见问题与解决方案 ​

缓存不是银弹,用不好会有很多问题。我们来看看常见的几个问题。

6.5.1 缓存穿透 ​

什么是缓存穿透?

查询一个一定不存在的数据,缓存没有,数据库也没有。 每次请求都打到数据库,缓存形同虚设。

如果有人恶意用不存在的 key 疯狂请求,数据库压力就很大,甚至挂掉。

请求 → 缓存没有 → 查数据库 → 数据库也没有 → 返回 null
   ↑                                        ↓
   └────────────────────────────────────────┘
           每次都这样,缓存没用

解决方案:

方案一:缓存 null 值

把不存在的也缓存起来,存个 null。 下次再查,直接返回 null,不用查数据库。

yaml
spring:
  cache:
    redis:
      cache-null-values: true

优点:简单,配置就行 缺点:占空间,数据新增时有短暂不一致

方案二:布隆过滤器(Bloom Filter)

布隆过滤器是一种数据结构,可以快速判断"某个数据一定不存在"。

把所有存在的 key 都放到布隆过滤器里。 请求过来先过布隆过滤器,如果过滤器说不存在,直接返回,不用查缓存和数据库。

优点:空间效率高 缺点:有一定误判率(说存在的可能不存在,但说不存在的一定不存在),实现稍复杂

一般场景用缓存 null 值就够了。 数据量特别大、攻击风险高的场景用布隆过滤器。

6.5.2 缓存击穿 ​

什么是缓存击穿?

某个热点 key,在缓存过期的瞬间,大量并发请求同时过来。 缓存没了,所有请求都打到数据库,数据库压力骤增。

热点 key 过期
     ↓
大量请求同时到达
     ↓
缓存都没有
     ↓
所有请求都去查数据库
     ↓
数据库压力山大

跟穿透的区别:

  • 穿透:数据不存在
  • 击穿:数据存在,只是缓存刚好过期了

解决方案:

方案一:互斥锁

缓存失效的时候,只让一个请求去查数据库,其他请求等着。 查完把缓存放好,其他请求直接从缓存拿。

Spring Cache 的 sync = true 就是干这个的:

java
@Cacheable(cacheNames = "book", key = "#id", sync = true)
public Book findById(Integer id) {
    // ...
}

优点:简单,效果好 缺点:会阻塞其他请求

方案二:热点数据永不过期

特别热的 key,不设过期时间,永远不过期。 后台异步更新缓存。

优点:不会有击穿问题 缺点:数据更新不及时,需要额外的更新机制

一般用互斥锁方案就够了,Spring Cache 自带支持。

6.5.3 缓存雪崩 ​

什么是缓存雪崩?

大量 key 在同一时间过期,或者 Redis 挂了,导致所有请求都打到数据库。 数据库承受不住,就挂了。

大量 key 同时过期 / Redis 宕机
           ↓
    所有请求都打数据库
           ↓
      数据库挂了

跟击穿的区别:

  • 击穿:一个热点 key 过期
  • 雪崩:大量 key 同时过期,或者 Redis 挂了

解决方案:

针对大量 key 同时过期:

方案一:过期时间加随机值

给每个 key 的过期时间加一个随机数,避免同时过期。

java
// 基础时间 1 小时 + 随机 0~10 分钟
int ttl = 3600 + new Random().nextInt(600);

方案二:缓存预热

系统启动的时候,把热点数据提前加载到缓存里。 不用等用户请求才加载。

针对 Redis 挂了:

方案一:Redis 集群

搞 Redis 集群,一主多从,哨兵模式或者集群模式。 一个挂了还有其他的,高可用。

方案二:服务降级 / 熔断

Redis 挂了的时候,降级处理,比如直接返回默认值、或者查本地缓存。 不要让所有请求都打到数据库。

生产环境 Redis 一定要做集群,保证高可用。 单机 Redis 是有单点风险的。

6.5.4 缓存与数据库一致性 ​

问题: 缓存和数据库的数据不一致怎么办?

比如:更新了数据库,但缓存没更新,或者更新失败了。

Cache Aside 模式(推荐):

这是最常用的模式:

读:
1. 先读缓存,有就返回
2. 没有就读数据库
3. 数据库结果放入缓存
4. 返回

写:
1. 先更新数据库
2. 再删除缓存(注意:是删除,不是更新)

为什么是删除缓存,不是更新缓存?

  1. 并发安全:如果两个写请求同时来,更新缓存可能导致脏数据
  2. 简单:删了就行,下次读的时候再加载
  3. 懒加载:不用的数据不用更新,省空间

为什么是先更数据库,再删缓存?

如果先删缓存,再更数据库:

  • 删完缓存,还没更数据库的时候,有个读请求过来
  • 读请求发现缓存没了,查数据库(旧数据),放到缓存
  • 然后数据库更新了
  • 结果:缓存里是旧数据,数据库是新数据 → 不一致

先更数据库,再删缓存:

  • 更完数据库,还没删缓存的时候,有个读请求过来
  • 读请求从缓存拿到旧数据
  • 然后缓存被删了
  • 下次读就是新数据了
  • 结果:只有短暂的不一致,最终是一致的

这是业界的最佳实践:先更数据库,再删缓存。 保证最终一致性,不是强一致性。 大部分业务场景,最终一致就够了。

Spring Cache 的 @CachePut 是更新缓存,不是删除。 那是不是不符合最佳实践?

确实,@CachePut 是更新缓存。 但对于单表、简单场景,用 @CachePut 也没问题,因为是在同一个事务里。

复杂场景、多表更新的,建议自己控制缓存,手动删。

6.5.5 缓存预热 ​

什么是缓存预热?

系统刚启动的时候,缓存是空的。 如果一下子有大量请求过来,都打在数据库上,可能扛不住。

缓存预热就是:系统启动的时候,提前把热点数据加载到缓存里。

怎么做:

  • 启动完成后,自动加载热点数据
  • 用 @PostConstruct 或者 ApplicationRunner
  • 或者定时任务,定期刷新热点缓存

例子:

java
@Component
public class CachePreLoader implements ApplicationRunner {

    @Autowired
    private BookService bookService;

    @Override
    public void run(ApplicationArguments args) {
        // 预热热点图书
        System.out.println("开始预热缓存...");
        List<Integer> hotIds = Arrays.asList(1, 2, 3, 4, 5);
        for (Integer id : hotIds) {
            bookService.findById(id);
        }
        System.out.println("缓存预热完成");
    }
}

不是所有项目都需要缓存预热。 数据量不大、流量不高的项目,不用也行。 大流量、高并发的系统,建议做缓存预热。


6.6 企业最佳实践 ​

6.6.1 缓存使用原则 ​

  1. 能不用就不用:缓存增加了系统复杂度,不是必须的就别加
  2. 先考虑一致性要求:强一致性的别用缓存,或者用其他方案
  3. 热点数据才缓存:不常访问的数据缓存了也没用,还占空间
  4. 设置合理的过期时间:不要太长(不一致风险大),也不要太短(命中率低)
  5. 监控缓存命中率:命中率低的话,缓存就没意义了

6.6.2 缓存粒度控制 ​

缓存什么?

  • 不要什么都缓存,只缓存热点的、读多写少的
  • 粒度要合适,不要太大也不要太小

缓存粒度选择:

  • 对象级缓存:缓存整个对象(比如用户信息)
  • 列表级缓存:缓存列表数据
  • 页面级缓存:缓存整个页面(很少用)

一般用对象级缓存比较多。

6.6.3 缓存命名规范 ​

key 的命名要规范,方便管理和排查问题。

建议格式:

业务名:模块名:唯一标识

例子:

  • cache:user:1001
  • cache:book:1
  • cache:product:list:page1_size10

好处:

  • 一看就知道是哪个业务的
  • 方便批量删除(比如按前缀删)
  • 避免 key 冲突

6.6.4 缓存监控 ​

生产环境一定要监控缓存:

  • 命中率:命中率低说明缓存没效果
  • QPS:缓存的访问量
  • 内存使用:别把内存占满了
  • 过期 key 数量
  • 大 key:别存太大的 value

Redis 有很多监控工具,比如 RedisInsight、Prometheus + Grafana 等。

6.6.5 缓存降级 ​

Redis 挂了怎么办?不能整个系统都挂了。

降级方案:

  • 本地缓存兜底:Redis 挂了,用本地缓存(比如 Caffeine)
  • 直接查数据库:但要限流,别把数据库打挂了
  • 返回默认值:非核心数据,返回默认值或者空

高可用系统一定要考虑降级方案。 不能因为缓存挂了,整个系统就瘫了。


6.7 新手常见问题排查 ​

问题 1:缓存不生效 ​

症状:加了 @Cacheable 注解,但每次都查数据库,缓存没生效。

可能原因:

  1. 没加 @EnableCaching
  2. 注解加在 private 方法上(Spring AOP 不支持 private 方法)
  3. 同类内部调用(同一个类里的方法调用,AOP 不生效)
  4. key 不对,每次 key 都不一样
  5. 异常了,缓存没写入

排查:

  • 检查启动类有没有 @EnableCaching
  • 方法是不是 public 的
  • 是不是同类内部调用(是的话要自己注入自己,或者用 AopContext)
  • 打印一下 key 看看对不对

问题 2:Redis 里的 key 是乱码 ​

症状:Redis 里的 key 和 value 都是 \xAC\xED... 这种乱码

原因:默认用的 JDK 序列化

解决:自定义 RedisCacheConfiguration,用 String 和 JSON 序列化

问题 3:缓存和数据库不一致 ​

症状:数据库更新了,但缓存里还是旧数据

可能原因:

  1. 更新的时候没删缓存
  2. 删缓存失败了
  3. 并发场景下的一致性问题
  4. 过期时间太长

解决:

  • 确保更新操作加了 @CacheEvict 或者 @CachePut
  • 检查删除缓存有没有成功
  • 用 Cache Aside 模式(先更数据库,再删缓存)
  • 设置合理的过期时间,保证最终一致

问题 4:@Cacheable 的 condition 不生效 ​

症状:condition 写了,但好像没起作用

可能原因:

  • SpEL 表达式写错了
  • condition 里用了 #result(condition 是方法执行前判断的,拿不到返回值)

解决:

  • 检查 SpEL 表达式
  • 要根据返回值判断的话,用 unless,不要用 condition

问题 5:同类内部调用缓存不生效 ​

症状:同一个类里,方法 A 调用方法 B,B 上的缓存注解不生效

原因:Spring Cache 是基于 AOP 的,同类内部调用不走代理,注解不生效

解决:

  1. 把方法放到不同的类里(推荐)
  2. 自己注入自己(@Autowired 注入自己,然后用注入的对象调用)
  3. 用 AopContext.currentProxy() 获取代理对象

最好的办法是把缓存方法单独放一个类里,结构更清晰。

问题 6:Redis 连接失败 ​

症状:启动报错,连不上 Redis

可能原因:

  • Redis 没启动
  • 地址端口不对
  • 密码不对
  • 防火墙没开
  • 配置了 type: redis 但没装 Redis

解决:

  • 确认 Redis 启动了
  • 检查配置
  • 测试连通性

6.8 本章小结 ​

恭喜你!缓存这一章学完了!

你都学了什么 ​

缓存基础:

  • 什么是缓存、为什么需要缓存
  • 缓存的适用场景
  • Spring Boot 缓存抽象

声明式缓存注解(重点):

  • @EnableCaching:开启缓存
  • @Cacheable:查询缓存
  • @CachePut:更新缓存
  • @CacheEvict:删除缓存
  • @CacheConfig:类级别配置
  • @Caching:组合注解
  • SpEL 表达式

缓存实现:

  • 默认缓存(ConcurrentHashMap)
  • 整合 Ehcache
  • 整合 Redis 缓存(重点)
  • 自定义 Redis 缓存配置(JSON 序列化、过期时间)

缓存进阶:

  • 缓存穿透:原因、解决方案(缓存 null、布隆过滤器)
  • 缓存击穿:原因、解决方案(互斥锁、永不过期)
  • 缓存雪崩:原因、解决方案(随机过期、集群、降级)
  • 缓存与数据库一致性:Cache Aside 模式
  • 缓存预热

企业最佳实践:

  • 缓存使用原则
  • 缓存粒度控制
  • 命名规范
  • 缓存监控
  • 缓存降级

动手实践清单 ​

实践项做完打勾
能开启 Spring Cache(@EnableCaching)☐
会用 @Cacheable 加缓存☐
会用 @CachePut 更新缓存☐
会用 @CacheEvict 删除缓存☐
会用 @CacheConfig 类级别配置☐
理解 SpEL 表达式在缓存中的用法☐
能整合 Ehcache☐
能整合 Redis 缓存☐
会自定义 Redis 缓存配置(JSON 序列化)☐
理解缓存穿透、击穿、雪崩及解决方案☐
理解 Cache Aside 模式☐
能排查缓存常见问题☐

面试常问 ​

  1. Spring Cache 常用的注解有哪些?分别什么作用?

    • @EnableCaching:开启缓存
    • @Cacheable:查询缓存,有就直接返回
    • @CachePut:更新缓存,每次都执行方法
    • @CacheEvict:删除缓存
    • @CacheConfig:类级别统一配置
  2. @Cacheable 和 @CachePut 的区别?

    • @Cacheable:先查缓存,有就不执行方法
    • @CachePut:每次都执行方法,执行完更新缓存
    • 一个用于查询,一个用于更新
  3. 缓存穿透、击穿、雪崩分别是什么?怎么解决?

    • 穿透:查不存在的数据 → 缓存 null 值、布隆过滤器
    • 击穿:热点 key 过期 → 互斥锁、永不过期
    • 雪崩:大量 key 同时过期 / Redis 挂了 → 随机过期、集群、降级
  4. 缓存和数据库怎么保证一致性?

    • Cache Aside 模式:先更数据库,再删缓存
    • 最终一致性,不是强一致
    • 设置过期时间兜底
  5. 为什么是删除缓存,不是更新缓存?

    • 并发场景下更新缓存可能有脏数据
    • 删除更简单,懒加载
    • 不用的数据不用更新,省空间
  6. Spring Cache 支持哪些缓存实现?

    • 默认(ConcurrentHashMap)
    • Ehcache
    • Redis
    • Caffeine
    • Guava
    • 等等
  7. Redis 缓存和本地缓存(Ehcache/Caffeine)怎么选?

    • 单节点、数据量小:本地缓存,速度更快
    • 分布式系统、数据量大:Redis,共享、可扩展
    • 也可以组合用:本地缓存做一级,Redis 做二级

给实习生的建议 ​

  1. 先掌握基本用法:几个核心注解先搞明白,能用上。

  2. 理解原理很重要:缓存的问题(穿透、击穿、雪崩)是面试必问的,一定要理解。

  3. 不要滥用缓存:不是什么都要缓存。加了缓存,就多了一层复杂度,多了一致性问题。

  4. 注意数据一致性:用缓存就要接受最终一致。强一致的场景别用缓存。

  5. 线上操作要小心:别乱清缓存,尤其是生产环境。清缓存可能导致数据库压力骤增。

  6. 做好监控:缓存命中率、内存使用这些都要监控,出问题能及时发现。

下一章我们学习 Spring Security,安全管理。加油!


基于 Vite 强力驱动 | 纯静态轻量托管