Chapter 06

Spring Cache 与 Redis

缓存抽象设计,注解式缓存,RedisTemplate 直接操作,分布式 Session 与缓存一致性策略。

为什么需要缓存

数据库是大多数 Web 应用的性能瓶颈。即便是高性能的 PostgreSQL,单实例 QPS 也通常在数千到数万之间,而 Redis 单机 QPS 可达 10 万+。合理使用缓存可以带来:

核心概念名词解释

@Cacheable
标注在方法上,第一次调用时执行方法并将结果存入缓存;后续相同参数的调用直接返回缓存值,不执行方法体。适用于读多写少的查询方法。
@CacheEvict
在方法执行后(或前)删除指定的缓存条目。通常在数据更新或删除操作时使用,确保缓存与数据库保持同步。allEntries=true 可清空整个缓存命名空间。
@CachePut
每次执行方法并将结果更新到缓存,不会跳过方法执行。适用于更新操作——即更新数据库又更新缓存,保持一致性。
CacheManager
缓存管理器,是 Spring Cache 抽象的核心接口。通过配置不同的 CacheManager 实现(RedisCacheManager、CaffeineCacheManager),可以无缝切换缓存后端,业务代码无需改变。
RedisTemplate<K, V>
Spring Data Redis 提供的 Redis 操作模板,支持所有 Redis 数据结构(String/Hash/List/Set/ZSet)。StringRedisTemplate 是其预配置子类,key 和 value 均使用 String 序列化。
TTL(Time To Live)
缓存条目的生存时间,过期后自动删除。设置合理的 TTL 是缓存策略的关键——TTL 太短缓存效果差,TTL 太长数据陈旧风险高。不同业务场景应设置不同 TTL。

缓存命中与未命中流程

客户端请求 GET /users/123 │ ▼ @Cacheable("users", key="#id") │ ├── 查找缓存键 "users::123" │ ├── 【缓存命中 Cache Hit】 │ │ │ └── 直接返回缓存值(不执行方法体) │ 耗时:<1ms │ └── 【缓存未命中 Cache Miss】 │ ▼ 执行方法体 │ ▼ 查询数据库 SELECT * FROM users WHERE id=123 │ 耗时:5-50ms ▼ 将结果存入 Redis(设置 TTL=30min) │ └── 返回结果给客户端 【数据更新时】 @CacheEvict("users", key="#id") ← 更新/删除数据后清除缓存 下次请求将触发 Cache Miss → 重新加载最新数据

配置 Redis 缓存

@Configuration
@EnableCaching  // 开启 Spring Cache 注解支持
public class CacheConfig {

    @Bean
    public RedisCacheConfiguration defaultCacheConfig() {
        return RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(30))   // 默认 30 分钟 TTL
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()))
            .disableCachingNullValues();  // null 不缓存(防止缓存穿透)
    }

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
        // 为不同缓存设置不同 TTL
        Map<String, RedisCacheConfiguration> configs = new HashMap<>();
        configs.put("users", defaultCacheConfig().entryTtl(Duration.ofMinutes(10)));
        configs.put("products", defaultCacheConfig().entryTtl(Duration.ofHours(2)));

        return RedisCacheManager.builder(factory)
            .cacheDefaults(defaultCacheConfig())
            .withInitialCacheConfigurations(configs)
            .build();
    }
}

注解式缓存使用

@Service
@RequiredArgsConstructor
public class UserServiceImpl implements UserService {

    private final UserRepository userRepository;

    // 查询时缓存(key = "users::123")
    @Cacheable(value = "users", key = "#id")
    @Transactional(readOnly = true)
    public UserResponseDTO findById(Long id) {
        // 只有缓存未命中时才执行此方法
        return userRepository.findById(id)
            .map(this::toDTO)
            .orElseThrow(() -> new EntityNotFoundException("用户不存在"));
    }

    // 更新后刷新缓存(执行方法并更新缓存)
    @CachePut(value = "users", key = "#result.id")
    @Transactional
    public UserResponseDTO update(Long id, UserUpdateDTO dto) {
        User user = userRepository.findById(id)
            .orElseThrow(() -> new EntityNotFoundException("用户不存在"));
        user.setUsername(dto.getUsername());
        return toDTO(userRepository.save(user));
    }

    // 删除时清除缓存
    @CacheEvict(value = "users", key = "#id")
    @Transactional
    public void delete(Long id) {
        userRepository.deleteById(id);
    }
}

RedisTemplate 直接操作

当需要更精细的 Redis 控制时(计数器、排行榜、分布式锁),直接使用 RedisTemplate:

@Service
@RequiredArgsConstructor
public class RedisService {

    private final StringRedisTemplate redisTemplate;

    // String 操作:计数器
    public void incrementLoginCount(Long userId) {
        String key = "login:count:" + userId;
        redisTemplate.opsForValue().increment(key);
        redisTemplate.expire(key, Duration.ofDays(7));
    }

    // Hash 操作:存储用户会话信息
    public void saveUserSession(String sessionId, Map<String, String> userInfo) {
        String key = "session:" + sessionId;
        redisTemplate.opsForHash().putAll(key, userInfo);
        redisTemplate.expire(key, Duration.ofHours(2));
    }

    // ZSet 操作:商品热度排行榜
    public void recordProductView(Long productId) {
        redisTemplate.opsForZSet().incrementScore(
            "product:hot", productId.toString(), 1.0);
    }

    // 获取 Top 10 热门商品
    public Set<String> getTop10Products() {
        return redisTemplate.opsForZSet().reverseRange("product:hot", 0, 9);
    }

    // 分布式锁(简单实现)
    public boolean tryLock(String lockKey, String lockValue, long seconds) {
        Boolean result = redisTemplate.opsForValue()
            .setIfAbsent(lockKey, lockValue, Duration.ofSeconds(seconds));
        return Boolean.TRUE.equals(result);
    }
}

分布式 Session

<!-- Spring Session + Redis -->
<dependency>
    <groupId>org.springframework.session</groupId>
    <artifactId>spring-session-data-redis</artifactId>
</dependency>
spring:
  session:
    store-type: redis      # Session 存储到 Redis
    timeout: 2h            # Session 过期时间
  data:
    redis:
      host: localhost
      port: 6379
      password: ${REDIS_PASSWORD}
Warning 缓存与数据库的一致性是经典难题。最常见的可靠策略是"先写数据库,再删缓存"(Cache Aside Pattern):更新时先更新 DB,再删除缓存(而不是更新缓存)。下次读请求时缓存未命中,重新从 DB 加载最新数据填充缓存。避免使用"先删缓存,再写数据库",因为并发场景下极易读到旧数据。
Danger 注意缓存雪崩(大量缓存同时过期)、缓存穿透(查询不存在的数据)、缓存击穿(热点 key 过期瞬间大量请求打穿到DB)三大经典问题。分别用 TTL 加随机抖动、布隆过滤器/空值缓存、分布式锁或不过期策略来应对。

三大缓存问题的代码防护

缓存穿透(Cache Penetration)
查询数据库中根本不存在的数据(如 id=-1),缓存永远未命中,每次都打到数据库。解决:查询结果为空时也缓存空值(设置较短 TTL);或使用布隆过滤器(Bloom Filter)在缓存层前过滤明显不存在的请求。
缓存雪崩(Cache Avalanche)
大量缓存 key 在同一时刻同时过期(如促销活动结束),请求全部穿透到数据库,造成数据库压力剧增甚至宕机。解决:TTL 加随机抖动(在基础 TTL 上加 ±N 秒随机值),使各 key 过期时间分散。
缓存击穿(Cache Breakdown)
某个极热门的 key 过期瞬间,大量并发请求同时发现缓存未命中,全部去查数据库,相当于"并发的缓存穿透"。解决:互斥锁(Mutex Lock)——只允许一个线程查询数据库并重建缓存,其他线程等待;或设置热点 key 永不过期(逻辑过期)。
// 防缓存穿透:空值缓存 + 短 TTL
@Cacheable(value = "users", key = "#id", unless = "false")
public UserResponseDTO findById(Long id) {
    return userRepository.findById(id)
        .map(this::toDTO)
        .orElse(null);   // 返回 null 也会被缓存(unless = "false" 表示始终缓存)
}

// TTL 加随机抖动(防雪崩)
public RedisCacheConfiguration withJitter(Duration baseTtl) {
    long jitterSeconds = new Random().nextLong(60);   // ±60 秒随机
    return RedisCacheConfiguration.defaultCacheConfig()
        .entryTtl(baseTtl.plusSeconds(jitterSeconds));
}

// 互斥锁防击穿(只允许一个请求重建缓存)
public UserResponseDTO findByIdWithMutex(Long id) throws InterruptedException {
    String cacheKey = "users::" + id;
    String lockKey  = "lock:users:" + id;

    // 1. 先查缓存
    Object cached = redisTemplate.opsForValue().get(cacheKey);
    if (cached != null) return (UserResponseDTO) cached;

    // 2. 缓存未命中,尝试获取互斥锁(setIfAbsent = SETNX)
    Boolean locked = redisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", Duration.ofSeconds(10));

    if (Boolean.TRUE.equals(locked)) {
        try {
            // 3. 获锁成功:查 DB,写缓存
            UserResponseDTO dto = userRepository.findById(id)
                .map(this::toDTO).orElse(null);
            redisTemplate.opsForValue().set(cacheKey, dto, Duration.ofMinutes(10));
            return dto;
        } finally {
            redisTemplate.delete(lockKey);  // 释放锁
        }
    } else {
        // 4. 未获锁:短暂等待后重试
        Thread.sleep(50);
        return findByIdWithMutex(id);
    }
}
本章小结 Spring Cache 抽象提供了声明式缓存(@Cacheable/@CachePut/@CacheEvict),通过切换 CacheManager 可以无缝更换缓存后端;RedisTemplate 适合需要精细控制的场景(计数器、排行榜、分布式锁)。缓存一致性最佳策略是 Cache Aside(先写 DB,再删缓存);生产环境必须防范缓存穿透(空值缓存)、雪崩(TTL 抖动)、击穿(互斥锁或逻辑过期)三大问题。