Skip to content
第 60 / 250 章后端⏱ 10 分钟阅读

第 60 章:缓存设计与 Spring Cache

学习目标

  • 理解缓存的本质:空间换时间
  • 掌握 Spring Cache 注解(@Cacheable / @CachePut / @CacheEvict)
  • 学会 Redis 集成与序列化配置
  • 了解缓存设计的常见模式

一、为什么需要缓存?

本质:用空间换时间。把热点数据从慢设备(DB)搬到快设备(内存)。

存储介质读延迟适用场景
CPU 缓存1 ns寄存器、L1/L2/L3
内存100 nsJVM 堆内缓存(Caffeine)
Redis0.1 ms分布式缓存 ← 最常用
SSD100 us数据库
HDD10 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: 100ms
java
@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 再删缓存",不能反过来?

  1. 先删缓存再更 DB:DB 更新失败,缓存空着,下次读会回填旧数据,脏数据长期存在
  2. 先更 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 章:缓存三大问题

本站基于 VitePress 构建 · 由 Codebook 团队维护