给应用加速:Spring Boot集成缓存 (Caffeine & Redis) 实战

在之前的文章中,我们已经构建了一个功能相对完整的应用,它能处理Web请求、访问数据库、管理事务、发送异步消息。但随着用户量和数据量的增长,性能问题往往会逐渐显现。你是否发现:

  • 某些接口响应越来越慢,因为底层需要执行复杂的数据库查询?

  • 同一个用户的基本信息(很少变动)在短时间内被反复从数据库加载?

  • 调用外部依赖服务的API耗时较长,拖慢了整个请求的处理?

这些场景都指向了一个共同的性能瓶颈:频繁访问慢速资源或重复执行昂贵计算 。如果我们能将这些操作的结果缓存起来,下次需要时直接从缓存(通常是内存或像Redis这样的高速存储)读取,性能将得到显著提升。

想象一下,你经常需要查阅一本厚重的工具书(数据库/慢服务)里的某个定义(数据)。每次都去翻书很慢。如果你把最常用的几个定义抄在一张便签(缓存)上贴在桌子旁,下次需要时看一眼便签就行了,速度是不是快多了?

Spring Boot通过spring-boot-starter-cache提供了一套简洁、强大的缓存抽象,让我们能够通过简单的注解(如@Cacheable, @CachePut, @CacheEvict)就能为方法添加缓存逻辑,而无需关心底层的具体缓存实现(是内存缓存还是Redis)。

读完本文,你将学会:

  • 理解缓存的核心价值和常见策略(缓存命中、失效、穿透、雪崩)。

  • 掌握Spring Boot Cache抽象层的基本原理和优势。

  • 集成并使用基于内存的高性能缓存库Caffeine

  • 集成并使用流行的分布式缓存解决方案Redis

  • 熟练运用@Cacheable, @CachePut, @CacheEvict等核心注解管理缓存。

  • 了解如何配置缓存的过期时间、大小限制等策略。

准备好为你的应用插上缓存的翅膀,让它飞得更快了吗?

一、为什么需要缓存?核心价值与挑战

核心价值:

  1. 提升性能 (Performance Boost): 缓存通常存储在比原始数据源(如数据库、磁盘、远程服务)快得多的介质中(内存>Redis>SSD>HDD)。从缓存读取数据能极大缩短响应时间。

  2. 降低后端负载 (Reduced Backend Load): 通过服务缓存中的数据,减少了对数据库、外部API等后端资源的直接访问次数,降低了它们的压力,提高了系统的整体吞吐量。

  3. 提高可用性 (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注解的使用,并根据应用场景(单体/分布式,一致性要求,性能目标)选择合适的缓存技术(或组合),将使你能够有效地利用缓存为应用加速。

相关推荐
缺点内向1 天前
Java:创建、读取或更新 Excel 文档
java·excel
带刺的坐椅1 天前
Solon v3.4.7, v3.5.6, v3.6.1 发布(国产优秀应用开发框架)
java·spring·solon
四谎真好看1 天前
Java 黑马程序员学习笔记(进阶篇18)
java·笔记·学习·学习笔记
桦说编程1 天前
深入解析CompletableFuture源码实现(2)———双源输入
java·后端·源码
java_t_t1 天前
ZIP工具类
java·zip
lang201509281 天前
Spring Boot优雅关闭全解析
java·spring boot·后端
pengzhuofan1 天前
第10章 Maven
java·maven
百锦再1 天前
Vue Scoped样式混淆问题详解与解决方案
java·前端·javascript·数据库·vue.js·学习·.net
刘一说1 天前
Spring Boot 启动慢?启动过程深度解析与优化策略
java·spring boot·后端
壹佰大多1 天前
【spring如何扫描一个路径下被注解修饰的类】
java·后端·spring