Spring 缓存机制深入实战:@Cacheable 与 @CacheEvict 注解解析

2,198 阅读13分钟

响应时间动辄几秒、数据库连接池告警不断、服务器 CPU 使用率飙升到 90%——这些性能问题你是否也遇到过?在高并发业务场景中,如何避免重复计算和频繁数据库访问是开发人员面临的常见挑战。Spring 框架的缓存机制通过简单的注解配置,就能在不改变现有代码结构的前提下,大幅提升系统响应速度和吞吐量。

Spring 缓存注解基础

Spring 自 3.1 版本开始引入缓存抽象层,开发者只需通过添加注解就能实现方法级别的缓存功能。这套机制基于 AOP 实现,对原有业务代码几乎零侵入,而且支持多种缓存存储方案,从简单的本地缓存到分布式缓存如 Redis 都能无缝集成。

缓存工作流程图:

graph TD
    A[客户端请求] --> B{缓存中是否存在?}
    B -->|是| C[返回缓存数据]
    B -->|否| D[执行方法]
    D --> E[存入缓存]
    E --> F[返回结果]

@Cacheable 注解详解

基本作用

@Cacheable 注解标记的方法,其返回值会被自动缓存。当首次调用该方法时,方法会被执行并将结果存入缓存;后续使用相同参数调用时,Spring 会直接从缓存返回结果,不再执行方法内部逻辑。

关键参数

  • value/cacheNames:指定缓存名称(必填)
  • key:缓存的键,支持 SpEL 表达式定制
  • condition方法执行前判断的缓存条件
  • unless方法执行后判断的否定缓存条件
  • keyGenerator:自定义键生成器,与key互斥
  • cacheManager:自定义缓存管理器
  • sync:是否使用同步模式,防止缓存击穿

基本使用示例

import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
public class UserService {

    @Cacheable(value = "userCache", key = "#id")
    public User getUserById(Long id) {
        // 模拟数据库查询
        System.out.println("从数据库查询用户: " + id);
        return userRepository.findById(id).orElse(null);
    }
}

当你多次调用getUserById(1L)方法时,只有第一次会打印"从数据库查询用户: 1",后续调用直接返回缓存结果,提高响应速度。

SpEL 表达式与参数引用

当方法有多个参数时,使用 SpEL 表达式引用参数:

// 单参数方法,可直接使用参数名
@Cacheable(value = "userCache", key = "#id")
public User getUserById(Long id) {
    return userRepository.findById(id).orElse(null);
}

// 多参数方法,建议使用@Param增强可读性
@Cacheable(value = "userCache", key = "#id + ':' + #role")
public List<User> getUsersByRole(@Param("id") Long id, @Param("role") String role) {
    return userRepository.findByIdAndRole(id, role);
}

// 引用参数对象的属性
@Cacheable(value = "userCache", key = "#user.id")
public User updateUserCache(User user) {
    return userRepository.findById(user.getId()).orElse(null);
}

condition 与 unless 的区别

这两个参数都用于控制缓存条件,但作用时机不同:

// condition在方法执行前判断
// 若id <= 0,则不走缓存,直接执行方法
@Cacheable(value = "userCache", key = "#id", condition = "#id > 0")
public User getUserById(Long id) {
    System.out.println("只有id > 0时才走缓存逻辑");
    return userRepository.findById(id).orElse(null);
}

// unless在方法执行后判断
// 方法一定会执行,但结果为null时不会被缓存
@Cacheable(value = "userCache", key = "#id", unless = "#result == null")
public User getUserById(Long id) {
    System.out.println("方法执行了,但null结果不会缓存");
    return userRepository.findById(id).orElse(null);
}

简单理解:condition决定是否使用缓存,unless决定是否将结果放入缓存。

@CacheEvict 注解详解

基本作用

@CacheEvict 注解标记的方法执行后,会清除指定的缓存数据。当我们更新或删除数据时,通常需要清除相关缓存,确保数据一致性。

关键参数

  • value/cacheNames:要清除的缓存名称
  • key:要清除的缓存键
  • allEntries:是否清除所有缓存项目
  • beforeInvocation:是否在方法执行前清除缓存

基本使用示例

@Service
public class UserService {

    @CacheEvict(value = "userCache", key = "#user.id")
    public void updateUser(User user) {
        System.out.println("更新用户并清除缓存: " + user.getId());
        userRepository.save(user);
    }
}

当调用updateUser方法更新用户信息时,对应用户的缓存会被自动清除,保证后续查询能获取到最新数据。

allEntries 参数使用时机

@CacheEvict(value = "userCache", allEntries = true)
public void importUsers(List<User> users) {
    System.out.println("批量导入用户,清空所有用户缓存");
    userRepository.saveAll(users);
}

allEntries = true会清空整个缓存区域的所有数据。这适用于大批量数据更新场景,但要注意:频繁使用会导致缓存命中率急剧下降,应谨慎使用。

beforeInvocation 参数的重要性

@CacheEvict(value = "userCache", key = "#id", beforeInvocation = true)
public void deleteUser(Long id) {
    System.out.println("删除用户: " + id);
    userRepository.deleteById(id);
    // 如果这里发生异常,缓存也已被清除
    if (someCondition) {
        throw new RuntimeException("操作失败");
    }
}

默认情况下,缓存在方法成功执行后才会被清除。设置beforeInvocation = true可确保无论方法是否抛出异常,缓存都会被清除,这对处理事务回滚时的缓存一致性尤为重要。

@CachePut 注解—更灵活的缓存更新方式

Spring 还提供了@CachePut 注解,用于更新缓存但不影响方法执行。

@CachePut 特点

  • 始终执行方法,并用返回值更新缓存
  • 不会像@Cacheable 那样先检查缓存
  • 适合在更新操作后立即刷新缓存数据
@CachePut(value = "userCache", key = "#user.id")
public User updateAndRefreshCache(User user) {
    // 更新用户
    User updatedUser = userRepository.save(user);
    // 返回值会更新到缓存
    return updatedUser;
}

三种缓存注解的区别:

graph TB
    A[缓存操作选择] --> B{"需要读取还是更新?"}
    B -->|读取| C{"希望跳过方法?"}
    C -->|"是,有缓存直接返回"| D["@Cacheable"]
    C -->|"否,始终执行方法"| E["@CachePut"]
    B -->|清除| F["@CacheEvict"]

注解协同工作流程

缓存注解如何在真实业务流程中工作:

sequenceDiagram
    participant 客户端
    participant 缓存
    participant 服务层
    participant 数据库

    客户端->>服务层: 请求数据(@Cacheable)
    服务层->>缓存: 检查缓存

    alt 缓存命中
        缓存-->>服务层: 返回缓存数据
        服务层-->>客户端: 返回结果
    else 缓存未命中
        服务层->>数据库: 查询数据库
        数据库-->>服务层: 返回数据
        服务层->>缓存: 存入缓存
        服务层-->>客户端: 返回结果
    end

    客户端->>服务层: 更新数据
    alt 使用@CacheEvict
        服务层->>数据库: 更新数据库
        服务层->>缓存: 清除相关缓存
    else 使用@CachePut
        服务层->>数据库: 更新数据库
        服务层->>缓存: 更新缓存数据
    end
    服务层-->>客户端: 返回结果

缓存配置与自定义实现

基础配置

使用缓存注解前,需要启用缓存功能并配置缓存管理器:

import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableCaching // 必须添加此注解
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        // 简单的内存缓存实现,仅适用于单体应用
        return new ConcurrentMapCacheManager("userCache", "productCache", "stockCache");
    }
}

自定义键生成器

默认情况下,Spring 使用方法参数作为缓存键。我们可以自定义键生成逻辑:

@Configuration
public class CacheKeyConfig {

    @Bean("methodNameKeyGenerator")
    public KeyGenerator keyGenerator() {
        return (target, method, params) -> {
            StringBuilder sb = new StringBuilder();
            sb.append(target.getClass().getSimpleName());
            sb.append(".");
            sb.append(method.getName());
            sb.append(":");
            for (Object param : params) {
                if (param != null) {
                    sb.append(param.toString());
                    sb.append("-");
                } else {
                    sb.append("null-");
                }
            }
            return sb.toString();
        };
    }
}

// 使用自定义KeyGenerator
@Cacheable(value = "userCache", keyGenerator = "methodNameKeyGenerator")
public User getUserById(Long id) {
    // 缓存键会是: "UserService.getUserById:1-"
    return userRepository.findById(id).orElse(null);
}

缓存序列化方式选择

选择合适的序列化方式对缓存性能影响很大:

序列化方式性能特点优点缺点推荐场景
JDK 原生序列化速度较慢,体积较大原生支持,无需额外依赖仅支持 Java,序列化体积大内部系统临时测试
JSON 序列化中等速度,体积小跨语言,可读性好,体积小类型信息丢失,复杂对象处理困难多语言访问的数据
Protostuff极快速度,极小体积性能最佳,体积最小需要额外依赖,跨语言支持复杂对性能要求极高的内部系统

Redis 缓存配置

生产环境通常使用 Redis 作为分布式缓存:

@Configuration
@EnableCaching
public class RedisCacheConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        // 默认缓存配置
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofHours(1)) // 默认1小时过期
                .serializeValuesWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(
                                new GenericJackson2JsonRedisSerializer()
                        )
                );

        // 不同缓存名称配置不同过期时间,避免缓存雪崩
        Map<String, RedisCacheConfiguration> configMap = new HashMap<>();
        configMap.put("userCache", defaultConfig.entryTtl(
                Duration.ofHours(2)
                .plusMinutes(new Random().nextInt(60)))); // 随机过期时间
        configMap.put("productCache", defaultConfig.entryTtl(Duration.ofHours(4)));
        configMap.put("stockCache", defaultConfig.entryTtl(Duration.ofMinutes(30)));

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(defaultConfig)
                .withInitialCacheConfigurations(configMap)
                .build();
    }
}

缓存预热实现

对于高频访问的核心数据,可以在系统启动时预先加载到缓存:

@Component
public class CacheWarmer implements CommandLineRunner {

    private final ProductService productService;

    public CacheWarmer(ProductService productService) {
        this.productService = productService;
    }

    @Override
    public void run(String... args) {
        System.out.println("开始预热商品缓存...");

        // 异步加载热门商品,避免影响系统启动速度
        CompletableFuture.runAsync(() -> {
            try {
                // 等待应用完全启动
                Thread.sleep(5000);

                // 获取热门商品ID列表(实际项目中可从数据库查询)
                List<Long> hotProductIds = Arrays.asList(1L, 2L, 3L, 4L, 5L);

                // 调用缓存方法加载数据
                hotProductIds.forEach(id -> {
                    try {
                        productService.getProduct(id);
                        System.out.println("预热商品: " + id);
                    } catch (Exception e) {
                        System.err.println("预热商品失败: " + id);
                    }
                });

                System.out.println("商品缓存预热完成");
            } catch (Exception e) {
                System.err.println("缓存预热异常: " + e.getMessage());
            }
        });
    }
}

实际应用案例:商品库存管理

下面是一个完整的商品库存管理示例,展示了三个缓存注解的协同使用:

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    // 查询商品,结果会被缓存
    @Cacheable(value = "productCache", key = "#productId",
               condition = "#productId > 0", unless = "#result == null")
    public Product getProduct(Long productId) {
        System.out.println("从数据库查询商品: " + productId);
        try {
            return productRepository.findById(productId).orElse(null);
        } catch (Exception e) {
            // 异常处理,避免缓存异常结果
            System.err.println("查询商品异常: " + e.getMessage());
            return null;
        }
    }

    // 查询库存,使用sync防止缓存击穿
    @Cacheable(value = "stockCache", key = "#productId",
               unless = "#result <= 0", sync = true)
    public int getProductStock(Long productId) {
        System.out.println("从数据库查询库存: " + productId);
        Product product = productRepository.findById(productId)
                .orElseThrow(() -> new RuntimeException("商品不存在"));
        return product.getStock();
    }

    // 更新库存并刷新缓存
    @CachePut(value = "stockCache", key = "#productId")
    @CacheEvict(value = "productCache", key = "#productId") // 同时清除商品缓存
    @Transactional // 确保事务一致性
    public Integer updateStock(Long productId, int newStock) {
        System.out.println("更新商品库存: " + productId);
        Product product = productRepository.findById(productId)
                .orElseThrow(() -> new RuntimeException("商品不存在"));
        product.setStock(newStock);
        productRepository.save(product);
        return newStock; // 返回新库存值,由@CachePut缓存
    }

    // 下架商品,提前清除相关缓存
    @CacheEvict(value = {"productCache", "stockCache"}, key = "#productId",
                beforeInvocation = true)
    @Transactional
    public void removeProduct(Long productId) {
        System.out.println("下架商品: " + productId);
        productRepository.deleteById(productId);
    }

    // 批量导入商品,清空缓存
    @CacheEvict(value = {"productCache", "stockCache"}, allEntries = true)
    @Transactional
    public void importProducts(List<Product> products) {
        System.out.println("批量导入商品,清空所有缓存");
        productRepository.saveAll(products);
    }
}

缓存监控与管理

在生产环境中,监控缓存性能对排查问题至关重要:

@Configuration
public class CacheMonitorConfig {

    @Bean
    public CacheManagerCustomizer<ConcurrentMapCacheManager> cacheManagerCustomizer(MeterRegistry registry) {
        return cacheManager -> {
            cacheManager.setCacheNames(Arrays.asList("userCache", "productCache", "stockCache"));
            cacheManager.setAllowNullValues(false);

            // 为每个缓存添加监控指标
            cacheManager.getCacheNames().forEach(cacheName -> {
                Cache cache = cacheManager.getCache(cacheName);
                // 注册缓存命中率、未命中次数等指标
                registry.gauge(cacheName + ".size",
                        cache, c -> ((ConcurrentMapCache)c).getNativeCache().size());
            });
        };
    }
}

通过 Spring Boot Actuator 暴露缓存指标:

# application.properties
management.endpoints.web.exposure.include=health,info,metrics,prometheus
management.metrics.tags.application=${spring.application.name}

多级缓存架构

大型系统通常采用多级缓存提高性能和可用性:

graph TD
    A[客户端请求] -->|读取| B[本地缓存<br>Caffeine]
    B -->|未命中| C[分布式缓存<br>Redis]
    C -->|未命中| D[数据库]
    D -->|写入| C
    C -->|写入| B

    E[数据更新] -->|更新| D
    E -->|失效| C
    E -->|失效| B

    linkStyle 5 stroke:#FFD600
    linkStyle 6 stroke:#FFD600,fill:none
    linkStyle 7 stroke:#FFD600,fill:none

高级应用场景

缓存版本控制

当数据结构变更时,通过版本号避免缓存兼容性问题:

@Cacheable(value = "userCache", key = "#id + '_v2'")
public UserV2 getUserById(Long id) {
    // 返回新版本用户对象
    UserV2 userV2 = new UserV2();
    // 从旧数据转换...
    return userV2;
}

异步缓存加载

对于耗时计算,可使用异步加载减少用户等待:

@Cacheable(value = "reportCache", key = "#reportId")
public CompletableFuture<Report> generateReportAsync(String reportId) {
    return CompletableFuture.supplyAsync(() -> {
        System.out.println("异步生成报表: " + reportId);
        // 耗时操作
        return reportGenerator.createReport(reportId);
    }, asyncExecutor);
}

布隆过滤器防止缓存穿透

@Service
public class UserServiceWithBloomFilter {

    private final BloomFilter<Long> userIdFilter;
    private final UserRepository userRepository;

    public UserServiceWithBloomFilter(UserRepository userRepository) {
        this.userRepository = userRepository;

        // 创建布隆过滤器(实际应用中可使用Redis布隆过滤器)
        this.userIdFilter = BloomFilter.create(
            Funnels.longFunnel(),
            10000,  // 预期元素数量
            0.01    // 误判率
        );

        // 初始化过滤器,加载所有ID(生产环境应该异步加载)
        userRepository.findAllIds().forEach(userIdFilter::put);
    }

    @Cacheable(value = "userCache", key = "#id", condition = "#this.mightExist(#id)")
    public User getUserById(Long id) {
        return userRepository.findById(id).orElse(null);
    }

    // 前置判断,过滤不存在的ID
    public boolean mightExist(Long id) {
        return userIdFilter.mightContain(id);
    }
}

缓存粒度设计最佳实践

根据数据特性选择合适的缓存粒度:

  1. 字段级缓存:仅缓存高频访问字段
// 只缓存用户名,适用于频繁查询但不需要完整用户信息的场景
@Cacheable(value = "userNameCache", key = "#userId")
public String getUserName(Long userId) {
    User user = userRepository.findById(userId).orElse(null);
    return user != null ? user.getName() : null;
}
  1. 对象级缓存:缓存完整对象,适用于整体查询
@Cacheable(value = "userCache", key = "#id")
public User getFullUser(Long id) {
    return userRepository.findById(id).orElse(null);
}
  1. 列表缓存:注意控制数量和更新策略
// 根据角色缓存用户列表,需要使用复合键
@Cacheable(value = "userListCache", key = "'role:' + #role")
public List<User> getUsersByRole(String role) {
    return userRepository.findByRole(role);
}

// 更新单个用户时,清除包含该用户的所有列表缓存
@CacheEvict(value = "userListCache", allEntries = true)
public void updateUserRole(User user) {
    userRepository.save(user);
}

缓存击穿防护对比

不同场景下的缓存击穿防护方案:

防护机制实现方式性能影响适用场景
@Cacheable(sync=true)JVM 级别的锁单机有效,阻塞当前 JVM 线程单体应用,中低并发
分布式锁 (Redisson)Redis 分布式锁集群有效,有网络开销分布式系统,中高并发
热点数据永不过期定时任务异步刷新无阻塞,额外维护成本高并发,可接受短时不一致

分布式锁实现示例(需要引入 Redisson 依赖):

@Service
public class HotProductService {

    private final RedissonClient redissonClient;
    private final ProductRepository productRepository;

    // 注入Redisson客户端(需要添加依赖)
    public HotProductService(RedissonClient redissonClient, ProductRepository productRepository) {
        this.redissonClient = redissonClient;
        this.productRepository = productRepository;
    }

    public Product getHotProduct(Long productId) {
        // 先查缓存
        Product product = cacheManager.getCache("hotProductCache")
            .get(productId, Product.class);
        if (product != null) {
            return product;
        }

        // 缓存未命中,使用分布式锁防止击穿
        RLock lock = redissonClient.getLock("product_lock:" + productId);
        try {
            // 尝试获取锁,最多等待500ms,锁有效期10s
            if (lock.tryLock(500, 10000, TimeUnit.MILLISECONDS)) {
                try {
                    // 再次检查缓存,双重检查
                    product = cacheManager.getCache("hotProductCache")
                        .get(productId, Product.class);
                    if (product != null) {
                        return product;
                    }

                    // 缓存确实不存在,查询数据库
                    product = productRepository.findById(productId).orElse(null);
                    if (product != null) {
                        cacheManager.getCache("hotProductCache").put(productId, product);
                    }
                    return product;
                } finally {
                    // 确保释放锁
                    lock.unlock();
                }
            } else {
                // 获取锁超时,返回可能的旧数据或默认值
                return cacheManager.getCache("hotProductCache")
                    .get(productId, Product.class);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException("获取分布式锁被中断");
        }
    }
}

延迟双删策略详解

解决缓存与数据库一致性问题的高级方案:

@Service
public class ConsistentUserService {

    private final UserRepository userRepository;
    private final CacheManager cacheManager;

    public ConsistentUserService(UserRepository userRepository, CacheManager cacheManager) {
        this.userRepository = userRepository;
        this.cacheManager = cacheManager;
    }

    @Transactional
    public void updateUserWithDelayDoubleDeletion(User user) {
        Long userId = user.getId();

        // 第一次删除缓存
        cacheManager.getCache("userCache").evict(userId);

        // 更新数据库
        userRepository.save(user);

        // 异步延迟再次删除缓存
        CompletableFuture.runAsync(() -> {
            try {
                // 延迟时间需大于业务读取的最大响应时间
                Thread.sleep(500);
                cacheManager.getCache("userCache").evict(userId);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        });
    }
}

为什么需要延迟双删?

想象以下场景:

  1. 线程 A 删除缓存,准备更新数据库
  2. 此时线程 B 查询,发现缓存不存在,从数据库读取旧数据
  3. 线程 B 将旧数据写入缓存
  4. 线程 A 完成数据库更新

结果:缓存中仍然是旧数据!

延迟双删通过等待所有可能的并发读取完成后,再次删除缓存,确保不会有旧数据被重新写入缓存。延迟时间应大于业务读取的最大响应时间,通常为 500ms~2s。

性能对比测试

基于 JMeter 测试结果(100 并发用户,持续 60 秒,10,000 条数据):

graph LR
    A[无缓存] --> B[平均响应: 350ms<br>吞吐量: 285 TPS<br>数据库负载: 高]
    C[本地缓存] --> D[平均响应: 0.5ms<br>吞吐量: 1850 TPS<br>数据库负载: 低]
    E[Redis缓存] --> F[平均响应: 5ms<br>吞吐量: 1650 TPS<br>数据库负载: 低]

    style B fill:#f9a,stroke:#333
    style D fill:#afa,stroke:#333
    style F fill:#aaf,stroke:#333

在 90%缓存命中率情况下:

  • 本地缓存性能提升:响应时间降低 700 倍,吞吐量提升 6.5 倍
  • Redis 缓存性能提升:响应时间降低 70 倍,吞吐量提升 5.8 倍

缓存常见问题与解决方案

缓存穿透

问题:查询不存在的数据频繁落到数据库

解决方案

  1. 缓存空结果(设置较短过期时间)
  2. 布隆过滤器快速判断(高效但有误判率)
// 缓存空结果示例
@Cacheable(value = "userCache", key = "#id")
public User getUserById(Long id) {
    User user = userRepository.findById(id).orElse(null);
    if (user == null) {
        // 对于不存在的结果,设置一个空对象,避免频繁查询数据库
        cacheManager.getCache("userCache").put(id, NullValue.INSTANCE);
    }
    return user;
}

缓存雪崩

问题:大量缓存同时过期

解决方案

  1. 设置随机过期时间,错开失效时间点
  2. 多级缓存架构,提供降级处理
  3. 热点数据永不过期(后台定时刷新)
// 随机过期时间配置
.entryTtl(Duration.ofHours(2)
        .plusMinutes(new Random().nextInt(60)))

数据一致性策略

不同业务场景下的缓存一致性策略对比:

策略实现方式一致性级别适用场景
强一致性更新 DB 前删缓存+事务实时一致,性能较低金融交易、库存管理
最终一致性延迟双删、异步更新短暂不一致,性能较好商品信息、用户资料
弱一致性定时刷新、过期自动更新可能较长不一致,性能最佳访问量统计、推荐数据

总结

注解主要作用关键参数适用场景
@Cacheable缓存方法返回值value、key、condition、unless、sync查询操作,减少重复计算
@CacheEvict清除缓存数据value、key、allEntries、beforeInvocation更新/删除操作,保证数据一致性
@CachePut更新缓存数据value、key、condition、unless更新操作后刷新缓存