第 60 章:缓存设计与 Spring Cache
学习目标
- 理解缓存的本质:空间换时间
- 掌握 Spring Cache 注解(@Cacheable / @CachePut / @CacheEvict)
- 学会 Redis 集成与序列化配置
- 了解缓存设计的常见模式
一、为什么需要缓存?
本质:用空间换时间。把热点数据从慢设备(DB)搬到快设备(内存)。
| 存储介质 | 读延迟 | 适用场景 |
|---|---|---|
| CPU 缓存 | 1 ns | 寄存器、L1/L2/L3 |
| 内存 | 100 ns | JVM 堆内缓存(Caffeine) |
| Redis | 0.1 ms | 分布式缓存 ← 最常用 |
| SSD | 100 us | 数据库 |
| HDD | 10 ms | 已淘汰 |
二、Redis 集成
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>yaml
spring:
redis:
host: localhost
port: 6379
password: ${REDIS_PASSWORD}
timeout: 3s
database: 0 # 0-15 共 16 个逻辑库
lettuce: # Lettuce 比 Jedis 更现代(基于 Netty,支持异步)
pool:
max-active: 16
max-idle: 8
min-idle: 4
max-wait: 100msjava
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// ① key 用 String 序列化
StringRedisSerializer keySer = new StringRedisSerializer();
// ② value 用 JSON 序列化(跨语言、可读)
Jackson2JsonRedisSerializer<Object> valSer =
new Jackson2JsonRedisSerializer<>(objectMapper, Object.class);
template.setKeySerializer(keySer);
template.setHashKeySerializer(keySer);
template.setValueSerializer(valSer);
template.setHashValueSerializer(valSer);
template.afterPropertiesSet();
return template;
}
}三、Spring Cache 注解(最常用)
启用缓存
java
@SpringBootApplication
@EnableCaching // ① 一行开启
public class TaskflowApplication { }三个核心注解
java
@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {
private final UserMapper userMapper;
// ② 查:缓存里有就走缓存,没有就执行方法,结果写入缓存
@Cacheable(value = "user", key = "#id", unless = "#result == null")
public User getById(Long id) {
log.info("查询数据库 id={}", id); // 只有第一次会打印
return userMapper.selectById(id);
}
// ③ 改:方法执行后,把返回值更新到缓存
@CachePut(value = "user", key = "#user.id")
public User update(User user) {
userMapper.updateById(user);
return user;
}
// ④ 删:方法执行后,清除缓存
@CacheEvict(value = "user", key = "#id")
public void delete(Long id) {
userMapper.deleteById(id);
}
// ⑤ 清空整个缓存
@CacheEvict(value = "user", allEntries = true)
public void clearAll() { ... }
}Key 生成策略
java
// ① 默认:用方法参数拼成 key
@Cacheable(value = "user") // key = SimpleKey []
public List<User> list() { ... }
@Cacheable(value = "user", key = "#id") // key = "1"
public User get(Long id) { ... }
@Cacheable(value = "user", key = "#user.id") // 对象属性
public User save(User user) { ... }
// ② 多参数
@Cacheable(value = "order", key = "#userId + ':' + #status")
public List<Order> listByUser(Long userId, Integer status) { ... }
// ③ SpEL 高级用法
@Cacheable(value = "user", key = "T(String).valueOf(#id).concat(':').concat(#type)")
public User getUser(Long id, String type) { ... }条件缓存
java
// ① unless:返回结果满足条件时不缓存
@Cacheable(value = "user", key = "#id", unless = "#result == null") // null 不缓存
public User getById(Long id) { ... }
// ② condition:方法执行前判断,满足才缓存
@Cacheable(value = "user", key = "#id", condition = "#id > 0")
public User getById(Long id) { ... }
// ③ 同步加载:缓存击穿保护(见下章)
@Cacheable(value = "user", key = "#id", sync = true)
public User getById(Long id) { ... }四、多级缓存(Caffeine + Redis)
热点数据:用本地缓存(Caffeine)减少 Redis 压力。 冷数据:从 Redis 读,走网络但可共享。
xml
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>java
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
// ① Caffeine 本地缓存配置
CaffeineCacheManager caffeine =
new CaffeineCacheManager("dict", "config", "staticData");
caffeine.setCaffeine(Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats());
// ② Redis 分布式缓存配置
RedisCacheConfiguration redisCfg = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofHours(1))
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(JacksonRedisSerializer.create()));
RedisCacheManager redis = RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(redisCfg)
.withInitialCacheConfigurations(Map.of(
"user", redisCfg.entryTtl(Duration.ofMinutes(30)),
"order", redisCfg.entryTtl(Duration.ofHours(2)),
"session", redisCfg.entryTtl(Duration.ofMinutes(30))
))
.build();
// ③ 复合 CacheManager:先查 Caffeine,再查 Redis
// Spring 提供 CompositeCacheManager
return new CompositeCacheManager(caffeine, redis);
}
}java
// @Cacheable 会先查 Caffeine,再查 Redis
@Cacheable(value = "dict", key = "#type")
public List<Dict> getDict(String type) {
return dictMapper.selectByType(type);
}五、缓存设计模式
Cache Aside(旁路缓存,最常用)
java
// ✅ 读
@Cacheable(value = "user", key = "#id")
public User getById(Long id) {
return userMapper.selectById(id);
}
// ✅ 写(先更 DB,再删缓存)
@Transactional
public User update(User user) {
userMapper.updateById(user);
cacheManager.getCache("user").evict(user.getId()); // 主动失效
return user;
}为什么"先更 DB 再删缓存",不能反过来?
- 先删缓存再更 DB:DB 更新失败,缓存空着,下次读会回填旧数据,脏数据长期存在。
- 先更 DB 再删缓存:删缓存失败,最多导致一次脏读,下次请求能纠正。
Read/Write Through(读写穿透)
应用只跟缓存交互,缓存自己负责和 DB 同步。Redis 6 的 client-side caching 接近这个模式。
Write Behind(异步写回)
写操作先写缓存,缓存异步批量落库。性能最高但有丢数据风险(宕机时队列数据丢失)。慎用。
六、缓存粒度设计
关键问题:缓存什么?粒度多大?
java
// ❌ 缓存整个列表
@Cacheable(value = "userList", key = "#deptId")
public List<User> listByDept(Long deptId) { ... }
// 问题:列表里任意一条数据变化都要清整个缓存
// ✅ 缓存单个对象
@Cacheable(value = "user", key = "#id")
public User getById(Long id) { ... }
// 单条失效只影响一个 key
// ✅ 列表缓存 + 详情缓存组合
@Cacheable(value = "user:list", key = "#deptId")
public List<Long> listIdsByDept(Long deptId) { ... } // 只缓存 ID 列表
@Cacheable(value = "user", key = "#id")
public User getById(Long id) { ... } // 详情单独缓存经验:
- 读多写少的对象 → 单个缓存
- 经常整体查询的列表 → 列表缓存 + 短 TTL
- 极个别热点数据(如配置、字典) → 永久缓存 + 主动失效
七、缓存预热
java
@Component
@RequiredArgsConstructor
@Slf4j
public class CacheWarmer implements ApplicationRunner { // 启动后执行
private final DictService dictService;
@Override
public void run(ApplicationArguments args) {
log.info("开始缓存预热...");
List<DictType> types = dictService.listAllTypes();
for (DictType type : types) {
dictService.getDict(type.getCode()); // 触发 @Cacheable
}
log.info("缓存预热完成,共加载 {} 个字典", types.size());
}
}八、缓存使用红线
java
// ❌ 红线 1:缓存里不放 NULL
// 攻击者用不存在的 ID 一直请求,缓存里全是 NULL,浪费内存
@Cacheable(value = "user", key = "#id", unless = "#result == null") // ✅ 加 unless
// ❌ 红线 2:大对象不缓存
// 一个 User 对象 10KB,缓存 10 万个就是 1GB
@Cacheable(value = "user", key = "#id") // ❌
@Cacheable(value = "user:summary", key = "#id") // ✅ 缓存精简的摘要对象
// ❌ 红线 3:复杂集合不缓存
@Cacheable(value = "allUsers")
public List<User> listAll() { ... } // 10 万用户 → 缓存爆炸
// ❌ 红线 4:写后忘删缓存
@Cacheable(value = "user", key = "#id")
public User getById(Long id) { ... } // 读缓存
@Transactional
public void update(User user) { // 写 DB
userMapper.updateById(user); // ❌ 忘记删缓存,下次读到旧数据
}
// ✅ 强制规范:所有写方法必须加 @CacheEvict 或手动删缓存九、本章小结
| 要点 | 关键 |
|---|---|
| 核心注解 | @Cacheable(读)@CachePut(写)@CacheEvict(删) |
| 启动 | @EnableCaching |
| 序列化 | key 用 String,value 用 JSON |
| 多级缓存 | Caffeine(L1,本地)+ Redis(L2,分布式) |
| 模式 | Cache Aside(读时读缓存,写时先 DB 再清缓存) |
| Key | 用 SpEL 拼 #id、#userId + ':' + #status |
| 条件 | unless 控制结果不缓存的条件 |
| 预热 | ApplicationRunner 启动后加载热点数据 |
| 同步 | @Cacheable(sync = true) 防击穿 |
| 红线 | NULL 不缓存、大对象不缓存、写后必清缓存 |
动手练习
练习 1:基础题
集成 Redis + Spring Cache,给 UserService.getById 加缓存,验证第二次查询不再走数据库。
练习 2:进阶题
实现一个两级缓存(Caffeine + Redis),写一个 CacheAsideTemplate 工具类封装「先 DB 再清缓存」模式。
练习 3:思考题
你的系统中"商品详情"和"商品列表"应该如何设计缓存?考虑:商品改名/调价、列表分页、缓存穿透。
下一章:第 61 章:缓存三大问题 →