在之前的文章中,我们已经构建了一个功能相对完整的应用,它能处理Web请求、访问数据库、管理事务、发送异步消息。但随着用户量和数据量的增长,性能问题往往会逐渐显现。你是否发现:
-
某些接口响应越来越慢,因为底层需要执行复杂的数据库查询?
-
同一个用户的基本信息(很少变动)在短时间内被反复从数据库加载?
-
调用外部依赖服务的API耗时较长,拖慢了整个请求的处理?
这些场景都指向了一个共同的性能瓶颈:频繁访问慢速资源或重复执行昂贵计算 。如果我们能将这些操作的结果缓存起来,下次需要时直接从缓存(通常是内存或像Redis这样的高速存储)读取,性能将得到显著提升。
想象一下,你经常需要查阅一本厚重的工具书(数据库/慢服务)里的某个定义(数据)。每次都去翻书很慢。如果你把最常用的几个定义抄在一张便签(缓存)上贴在桌子旁,下次需要时看一眼便签就行了,速度是不是快多了?
Spring Boot通过spring-boot-starter-cache提供了一套简洁、强大的缓存抽象,让我们能够通过简单的注解(如@Cacheable, @CachePut, @CacheEvict)就能为方法添加缓存逻辑,而无需关心底层的具体缓存实现(是内存缓存还是Redis)。
读完本文,你将学会:
-
理解缓存的核心价值和常见策略(缓存命中、失效、穿透、雪崩)。
-
掌握Spring Boot Cache抽象层的基本原理和优势。
-
集成并使用基于内存的高性能缓存库Caffeine。
-
集成并使用流行的分布式缓存解决方案Redis。
-
熟练运用@Cacheable, @CachePut, @CacheEvict等核心注解管理缓存。
-
了解如何配置缓存的过期时间、大小限制等策略。
准备好为你的应用插上缓存的翅膀,让它飞得更快了吗?
一、为什么需要缓存?核心价值与挑战
核心价值:
-
提升性能 (Performance Boost): 缓存通常存储在比原始数据源(如数据库、磁盘、远程服务)快得多的介质中(内存>Redis>SSD>HDD)。从缓存读取数据能极大缩短响应时间。
-
降低后端负载 (Reduced Backend Load): 通过服务缓存中的数据,减少了对数据库、外部API等后端资源的直接访问次数,降低了它们的压力,提高了系统的整体吞吐量。
-
提高可用性 (Increased Availability - 某种程度上): 即使后端数据源暂时不可用,如果缓存中有所需数据,应用仍然可以提供部分服务(取决于缓存策略和数据时效性要求)。
常见缓存策略与挑战:
-
缓存命中率 (Hit Rate): 衡量缓存效果的关键指标。命中率越高,缓存带来的性能提升越明显。需要合理设计缓存Key和缓存策略来提高命中率。
-
缓存失效策略 (Eviction Policy): 当缓存空间不足时,需要决定淘汰哪些缓存项。常见的策略有LRU(最近最少使用)、LFU(最不经常使用)、FIFO(先进先出)等。
-
缓存更新策略: 当原始数据发生变化时,如何更新缓存?
-
读时更新 (Cache-Aside Pattern): 应用程序先读缓存,缓存未命中则读数据库,然后将结果写入缓存。更新数据时,先更新数据库,然后删除 (invalidate) 缓存。下次读取时会重新加载。这是最常用的策略。
-
写时更新 (Write-Through): 应用程序更新数据时,同时更新数据库和缓存。实现简单,但可能写入性能稍差。
-
写后更新 (Write-Behind/Write-Back): 应用程序只更新缓存,由缓存系统异步地将更新批量写入数据库。写入性能最好,但可能丢失数据(如果缓存系统宕机)。
-
-
缓存穿透 (Cache Penetration): 查询一个数据库中根本不存在 的数据。缓存中自然也没有,导致每次请求都直接打到数据库,缓存失去意义。解决方案: 缓存空结果(设置较短过期时间),布隆过滤器。
-
缓存击穿 (Cache Breakdown): 一个热点Key 在缓存过期失效的瞬间,大量并发请求同时涌入去查询数据库,导致数据库压力骤增。解决方案: 分布式锁(只允许一个请求去加载数据并写缓存),热点数据永不过期(后台异步刷新)。
-
缓存雪崩 (Cache Avalanche): 大量缓存Key在同一时间集体失效 (例如,设置了相同的固定过期时间),导致所有请求瞬间全部打到数据库,造成数据库崩溃。解决方案: 设置随机的过期时间,多级缓存,限流降级。
-
数据一致性: 缓存数据与数据库数据之间可能存在短暂的不一致。需要根据业务容忍度选择合适的更新策略。
二、Spring Cache 抽象:屏蔽底层差异
Spring Cache抽象的核心思想是面向注解编程。开发者只需要在需要缓存的方法上添加几个简单的注解,Spring就会在运行时通过AOP代理自动处理缓存的读写逻辑。
优势:
-
代码侵入性低: 业务代码无需关心具体的缓存API,保持简洁。
-
易于切换缓存实现: 只需修改配置和依赖,即可从内存缓存切换到Redis缓存,无需修改业务代码。
-
声明式缓存: 缓存逻辑通过注解声明,清晰易懂。
核心注解:
-
@EnableCaching: 在配置类上启用Spring Cache功能。
-
@Cacheable(cacheNames="...", key="..."): 主要用于查询操作。方法执行前,会根据cacheNames和key检查缓存。
-
如果缓存命中,直接返回缓存中的值,方法体不会执行。
-
如果缓存未命中,执行方法体,并将方法的返回值放入缓存,然后返回结果。
-
-
@CachePut(cacheNames="...", key="..."): 主要用于更新 操作。无论缓存是否存在,方法体都会执行 ,并将方法的返回值更新到缓存中。适用于希望保持缓存与最新数据同步的场景。
-
@CacheEvict(cacheNames="...", key="...", allEntries=false, beforeInvocation=false): 主要用于删除/失效操作。根据cacheNames和key从缓存中移除数据。
-
allEntries = true: 清空指定cacheNames下的所有缓存项。
-
beforeInvocation = true: 在方法执行前清除缓存(默认为false,即方法成功执行后清除)。
-
-
@Caching: 用于组合多个缓存注解在同一个方法上。
-
@CacheConfig: 类级别的注解,可以统一定义该类下所有缓存操作的cacheNames等公共属性。
缓存Key的生成:
Spring Cache默认会根据方法参数生成Key。对于无参方法,使用SimpleKey.EMPTY。对于有参方法,使用参数的hashCode()和equals()组合生成。通常需要自定义Key以获得更好的控制和可读性。可以使用SpEL (Spring Expression Language) 来自定义Key。
常用SpEL表达式:
-
#root.methodName: 当前方法名。
-
#root.args: 方法参数数组。
-
#argName 或 #pX: 按名称或索引访问方法参数(需要编译时保留参数名或使用索引)。
-
#result: (仅用于@CachePut, @CacheEvict的condition/unless)方法的返回值。
三、实战1:集成高性能内存缓存 Caffeine
Caffeine是一个基于Java 8开发的高性能、近乎最优的本地(内存)缓存库。非常适合单体应用或对缓存一致性要求不高的场景。
1. 添加依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
2. 启用缓存 (@EnableCaching):
在你的主启动类或任何一个配置类上添加@EnableCaching。
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching; // 导入
@SpringBootApplication
@EnableCaching // 启用缓存功能
public class YourApplication {
public static void main(String[] args) {
SpringApplication.run(YourApplication.class, args);
}
}
3. 配置Caffeine (application.yml - 可选但推荐):
可以配置缓存的规格,如初始容量、最大容量、过期策略等。
spring:
cache:
type: caffeine # 明确指定使用caffeine (如果类路径有多个缓存实现)
cache-names: # (可选) 预定义缓存名称, 否则会自动创建
- users
- products
caffeine:
spec: > # 使用Caffeine的规格字符串进行配置
initialCapacity=100, # 初始容量
maximumSize=500, # 最大缓存条目数
expireAfterWrite=10m, # 写入后10分钟过期
# expireAfterAccess=5m, # 最后访问后5分钟过期
recordStats # (可选) 开启缓存统计信息 (可通过 /actuator/caches 查看)
# 可以为不同的cache-name定义不同的spec, 但配置稍复杂, 通常通过代码配置CacheManager Bean实现
如果省略spring.cache.caffeine.spec,Caffeine会使用默认配置。更复杂的、针对不同cacheName的个性化配置通常通过自定义CacheManager Bean来实现。
4. 在Service方法上使用缓存注解:
package com.example.service;
import com.example.model.User;
import com.example.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
@Service
public class UserService {
private static final Logger log = LoggerFactory.getLogger(UserService.class);
private final UserRepository userRepository;
public static final String CACHE_NAME_USERS = "users"; // 定义缓存名称常量
@Autowired
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
// --- @Cacheable ---
// cacheNames/value: 指定缓存的名称 (可以有多个)
// key: 自定义缓存Key, 使用SpEL。'user:'是前缀, #id是方法参数id的值
// unless: 条件表达式 (SpEL), 如果为true, 则不缓存结果 (例如, 结果为空时不缓存)
@Cacheable(cacheNames = CACHE_NAME_USERS, key = "'user:' + #id", unless = "#result == null")
public User getUserById(Long id) {
log.info("Fetching user from DB for id: {}", id); // 缓存未命中时会打印
Optional<User> userOptional = userRepository.findById(id);
return userOptional.orElse(null);
}
// --- @CachePut ---
// 每次都会执行方法体, 并将返回值更新到缓存
// 通常用于更新操作后刷新缓存
@Transactional
@CachePut(cacheNames = CACHE_NAME_USERS, key = "'user:' + #result.id") // 使用返回值的id作为key
public User updateUser(User user) {
log.info("Updating user in DB: {}", user);
// 假设userRepository.save执行更新逻辑并返回更新后的User对象
User updatedUser = userRepository.save(user);
return updatedUser;
}
// --- @CacheEvict ---
// 用于删除缓存项
@Transactional
@CacheEvict(cacheNames = CACHE_NAME_USERS, key = "'user:' + #id") // 删除指定id的缓存
public void deleteUser(Long id) {
log.info("Deleting user from DB for id: {}", id);
userRepository.deleteById(id);
// 方法执行成功后, 对应的缓存项会被移除
}
// --- @CacheEvict (清空缓存) ---
@CacheEvict(cacheNames = CACHE_NAME_USERS, allEntries = true)
public void clearUserCache() {
log.info("Clearing all entries from users cache.");
// 通常用于批量操作或需要强制刷新所有缓存的场景
}
// @Cacheable 的另一个例子: 使用用户名作为Key
@Cacheable(cacheNames = CACHE_NAME_USERS, key = "'user:name:' + #name", unless = "#result == null")
public User findUserByName(String name) {
log.info("Fetching user from DB by name: {}", name);
// 假设有 findByName 方法
return userRepository.findByName(name);
}
}
现在,当你多次调用getUserById(1L)时,只有第一次会打印"Fetching user from DB...",后续调用会直接从Caffeine内存缓存返回结果,速度极快。
四、实战2:集成分布式缓存 Redis
当应用需要部署多个实例(集群)时,内存缓存(如Caffeine)无法在实例间共享,每个实例都有自己的缓存副本,可能导致数据不一致。这时就需要分布式缓存 ,如Redis。Redis是一个高性能的内存键值数据库,常被用作分布式缓存、消息队列、分布式锁等。
1. 添加依赖:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<!-- 排除 Lettuce (默认连接池), 如果想用 Jedis -->
<!-- <exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions> -->
</dependency>
<!-- 如果排除了 Lettuce, 则需要引入 Jedis -->
<!-- <dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency> -->
spring-boot-starter-data-redis 包含了与Redis交互的核心库 (默认使用Lettuce连接池) 以及对Spring Cache的支持。
2. 配置Redis连接信息 (application.yml):
spring:
cache:
type: redis # 明确指定使用redis
redis:
host: localhost # Redis服务器地址
port: 6379 # Redis端口
# password: your_redis_password # 如果有密码
# database: 0 # 使用的Redis数据库索引 (默认0)
# lettuce: # (可选) 配置Lettuce连接池
# pool:
# max-active: 8
# max-idle: 8
# min-idle: 0
# max-wait: -1ms # 负数表示无限等待
3. 启用缓存 (@EnableCaching): (如果之前没加过)
确保你的应用已经启用了缓存。
4. (重要)配置Redis序列化方式:
Spring Boot默认使用JDK序列化来存储缓存对象到Redis,这有几个缺点:可读性差、占用空间大、存在安全风险、跨语言不兼容。强烈推荐 配置为JSON序列化。
在配置类中自定义RedisCacheManager Bean:
package com.example.config;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
@Configuration
public class RedisCacheConfig {
@Bean
public CacheManager cacheManager(RedisConnectionFactory factory) {
// 1. 配置序列化器
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
// 配置缓存键的序列化器 (通常使用String)
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
// 配置缓存值的序列化器 (使用JSON)
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
// (可选) 配置默认的缓存过期时间
.entryTtl(Duration.ofMinutes(30)); // 默认30分钟过期
// (可选) 禁止缓存 null 值 (防止缓存穿透)
// .disableCachingNullValues();
// 2. 构建 RedisCacheManager
RedisCacheManager cacheManager = RedisCacheManager.builder(factory)
.cacheDefaults(config) // 应用默认配置
// (可选) 为特定的缓存名称配置不同的过期时间等
// .withCacheConfiguration("users", config.entryTtl(Duration.ofHours(1)))
// .withCacheConfiguration("products", config.entryTtl(Duration.ofDays(1)))
.build();
return cacheManager;
}
}
这个配置将确保你的缓存Key是可读的字符串,缓存Value是以JSON格式存储在Redis中。
5. 在Service方法上使用缓存注解:
好消息是,你之前为Caffeine编写的带有@Cacheable, @CachePut, @CacheEvict注解的UserService代码,无需任何修改,就可以直接在Redis缓存下工作! 这就是Spring Cache抽象的魅力所在。
当你运行应用并调用相关方法时,Spring Cache会自动将数据缓存到配置好的Redis服务器中。你可以使用Redis客户端(如redis-cli)查看缓存内容(由于配置了JSON序列化,值是可读的JSON字符串)。
五、选择Caffeine还是Redis?
-
Caffeine (内存缓存):
-
优点: 速度极快(内存读写),无网络开销,集成简单。
-
缺点: 缓存数据只在当前应用实例内存中,无法跨实例共享;应用重启缓存丢失;缓存容量受限于应用内存。
-
适用场景: 单体应用,对一致性要求不高但追求极致性能的场景,或者作为多级缓存的第一级(L1缓存)。
-
-
Redis (分布式缓存):
-
优点: 缓存数据独立存储,可被多个应用实例共享;数据可持久化(配置得当);支持更丰富的数据结构;容量可扩展。
-
缺点: 相比内存缓存有网络开销,速度稍慢(但仍然非常快);需要额外部署和维护Redis服务。
-
适用场景: 分布式/集群应用,需要跨实例共享缓存,对数据一致性有一定要求,需要持久化或更大缓存容量的场景。
-
实践中,也常将两者结合使用(多级缓存): 先查Caffeine (L1),未命中再查Redis (L2),都未命中再查数据库。Spring Cache本身不直接支持多级缓存,但可以通过自定义CacheManager或使用第三方库实现。
六、总结:为性能插上翅膀
缓存是提升应用性能、降低后端负载的必备利器。Spring Boot Cache提供了一套优雅的抽象,通过简单的注解即可为方法添加缓存逻辑,同时屏蔽了底层缓存实现的差异。无论是高性能的内存缓存Caffeine,还是强大的分布式缓存Redis,都可以通过简单的配置和依赖集成到Spring Boot应用中。
理解缓存的核心概念、挑战和Spring Cache注解的使用,并根据应用场景(单体/分布式,一致性要求,性能目标)选择合适的缓存技术(或组合),将使你能够有效地利用缓存为应用加速。