跳转到内容
123xiao | 无名键客

《Spring Boot 中基于 Spring Cache 与 Redis 的多级缓存实战:提升接口性能与一致性控制》

字数: 0 阅读时长: 1 分钟

Spring Boot 中基于 Spring Cache 与 Redis 的多级缓存实战:提升接口性能与一致性控制

很多业务接口一开始“跑得还行”,但一到流量上来,就会出现两个很典型的问题:

  1. 数据库被热点请求打穿
  2. 同一个数据被反复查询,接口 RT 抖动明显

如果你只用本地缓存,单机很快,但一到集群环境就容易出现节点间数据不一致;如果你只用 Redis,虽然共享没问题,但每次都要走网络,热点接口在高并发下依然会有额外开销。

这时候,多级缓存就很适合:本地缓存负责“快”,Redis 负责“共享”,数据库负责“准”

这篇文章我会带你从零搭一个可运行的方案,基于:

  • Spring Boot
  • Spring Cache
  • Caffeine(一级本地缓存)
  • Redis(二级分布式缓存)

重点不只是“怎么配”,还包括:

  • 如何设计缓存读取链路
  • 如何做更新后的缓存一致性控制
  • 如何避免缓存穿透、击穿、雪崩
  • 如何排查“明明删了缓存却还是读到旧值”这种烦人问题

一、背景与问题

先看一个很常见的场景:商品详情接口 /products/{id}

  • 商品基础信息变化频率低
  • 读取频率高
  • 数据库查询涉及多表 join 或复杂组装
  • 同一个商品会被频繁访问

如果没有缓存,调用链是这样的:

flowchart LR
    A[客户端请求] --> B[应用服务]
    B --> C[数据库]
    C --> B
    B --> A

问题在于:

  • 热点数据会把数据库压住
  • 每次都查库,接口性能上不去
  • 数据库连接池容易耗尽

如果只上 Redis:

flowchart LR
    A[客户端请求] --> B[应用服务]
    B --> C[Redis]
    C -->|未命中| D[数据库]
    D --> C
    C --> B
    B --> A

这已经不错了,但还不够极致。因为:

  • Redis 访问仍然是网络 IO
  • 超高频热点数据适合优先在 JVM 内存里命中
  • 同一节点上的重复请求,其实没必要每次都走 Redis

所以更理想的方案是:

flowchart LR
    A[客户端请求] --> B[应用服务]
    B --> C[Caffeine 本地缓存]
    C -->|未命中| D[Redis]
    D -->|未命中| E[数据库]
    E --> D
    D --> C
    C --> B
    B --> A

这就是典型的多级缓存


二、前置知识与环境准备

本文示例环境:

  • JDK 17
  • Spring Boot 3.x
  • Spring Cache
  • Spring Data Redis
  • Caffeine
  • Maven
  • Redis 6+

1. 本文目标

实现一个商品查询/更新接口:

  • 查询时:先查本地缓存,再查 Redis,最后查数据库
  • 更新时:更新数据库后,删除两级缓存
  • 结合 Spring Cache 简化业务代码
  • 支持基本的 TTL 与序列化配置

2. Maven 依赖

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <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>
    </dependency>

    <dependency>
        <groupId>com.github.ben-manes.caffeine</groupId>
        <artifactId>caffeine</artifactId>
    </dependency>

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>

    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>
</dependencies>

三、核心原理

1. Spring Cache 在这里扮演什么角色?

Spring Cache 不是缓存实现本身,它更像一个统一的缓存抽象层。你可以通过注解:

  • @Cacheable
  • @CachePut
  • @CacheEvict

把缓存逻辑从业务代码里抽出来。

但 Spring Cache 默认更适合“单一缓存后端”。要做多级缓存,我们通常会:

  • 自定义 CacheManager
  • 或封装一个组合型 Cache

这篇文章采用第二种方式:自定义 MultiLevelCache

2. 多级缓存的读取流程

读取顺序一般是:

  1. 查本地缓存 Caffeine
  2. 未命中再查 Redis
  3. Redis 命中后回填本地缓存
  4. Redis 也未命中,再查数据库
  5. 查库成功后写入 Redis 和本地缓存

用时序图更直观:

sequenceDiagram
    participant Client as 客户端
    participant App as 应用服务
    participant L1 as Caffeine
    participant L2 as Redis
    participant DB as MySQL/H2

    Client->>App: 查询商品(id)
    App->>L1: get(id)
    alt L1 命中
        L1-->>App: 返回数据
    else L1 未命中
        App->>L2: get(id)
        alt L2 命中
            L2-->>App: 返回数据
            App->>L1: put(id, value)
        else L2 未命中
            App->>DB: select by id
            DB-->>App: 返回数据
            App->>L2: put(id, value)
            App->>L1: put(id, value)
        end
    end
    App-->>Client: 返回商品信息

3. 一致性控制的基本思路

最常见的更新策略不是“先更新缓存”,而是:

先更新数据库,再删除缓存

原因很简单:缓存是派生数据,DB 才是数据源。

推荐流程:

  1. 更新数据库
  2. 删除 Redis 缓存
  3. 删除本地缓存

为什么不是反过来?因为集群下本地缓存不止一个节点,通常还需要配合缓存失效通知,否则其他机器仍会读到旧值。

这篇文章先实现“单节点内两级缓存一致删除”,并在最佳实践部分讲扩展到集群的方法。


四、项目结构设计

示例结构如下:

src/main/java/com/example/cache
├── CacheDemoApplication.java
├── config
│   ├── CacheConfig.java
│   └── RedisConfig.java
├── controller
│   └── ProductController.java
├── entity
│   └── Product.java
├── repository
│   └── ProductRepository.java
├── service
│   └── ProductService.java
└── cache
    ├── MultiLevelCache.java
    └── MultiLevelCacheManager.java

五、实战代码(可运行)

1. 实体与仓库

Product.java

package com.example.cache.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.io.Serializable;
import java.math.BigDecimal;

@Entity
public class Product implements Serializable {

    @Id
    private Long id;
    private String name;
    private BigDecimal price;

    public Product() {
    }

    public Product(Long id, String name, BigDecimal price) {
        this.id = id;
        this.name = name;
        this.price = price;
    }

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public BigDecimal getPrice() {
        return price;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public void setName(String name) {
        this.name = name;
    }

    public void setPrice(BigDecimal price) {
        this.price = price;
    }
}

ProductRepository.java

package com.example.cache.repository;

import com.example.cache.entity.Product;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {
}

2. Redis 配置

RedisConfig.java

这里重点是序列化,别用 JDK 默认序列化。我早期项目里吃过这个亏:Redis 里全是不可读二进制,排查很痛苦,跨服务兼容性也差。

package com.example.cache.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheWriter;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.*;

@Configuration
public class RedisConfig {

    @Bean
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);

        ObjectMapper mapper = new ObjectMapper();
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        mapper.activateDefaultTyping(
                LaissezFaireSubTypeValidator.instance,
                ObjectMapper.DefaultTyping.NON_FINAL
        );

        Jackson2JsonRedisSerializer<Object> serializer =
                new Jackson2JsonRedisSerializer<>(mapper, Object.class);

        StringRedisSerializer stringSerializer = new StringRedisSerializer();

        template.setKeySerializer(stringSerializer);
        template.setHashKeySerializer(stringSerializer);
        template.setValueSerializer(serializer);
        template.setHashValueSerializer(serializer);
        template.afterPropertiesSet();
        return template;
    }
}

3. 多级缓存实现

MultiLevelCache.java

package com.example.cache.cache;

import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.cache.Cache.ValueWrapper;
import org.springframework.data.redis.core.RedisTemplate;

import java.time.Duration;
import java.util.concurrent.Callable;

public class MultiLevelCache implements org.springframework.cache.Cache {

    private final String name;
    private final Cache<Object, Object> caffeineCache;
    private final RedisTemplate<Object, Object> redisTemplate;
    private final Duration ttl;

    public MultiLevelCache(String name,
                           Cache<Object, Object> caffeineCache,
                           RedisTemplate<Object, Object> redisTemplate,
                           Duration ttl) {
        this.name = name;
        this.caffeineCache = caffeineCache;
        this.redisTemplate = redisTemplate;
        this.ttl = ttl;
    }

    private String buildKey(Object key) {
        return name + "::" + key;
    }

    @Override
    public String getName() {
        return name;
    }

    @Override
    public Object getNativeCache() {
        return this;
    }

    @Override
    public ValueWrapper get(Object key) {
        Object value = caffeineCache.getIfPresent(key);
        if (value != null) {
            return () -> value;
        }

        value = redisTemplate.opsForValue().get(buildKey(key));
        if (value != null) {
            caffeineCache.put(key, value);
            Object finalValue = value;
            return () -> finalValue;
        }
        return null;
    }

    @Override
    @SuppressWarnings("unchecked")
    public <T> T get(Object key, Class<T> type) {
        ValueWrapper wrapper = get(key);
        if (wrapper == null) {
            return null;
        }
        Object value = wrapper.get();
        if (type != null && !type.isInstance(value)) {
            throw new IllegalStateException("缓存数据类型不匹配");
        }
        return (T) value;
    }

    @Override
    @SuppressWarnings("unchecked")
    public <T> T get(Object key, Callable<T> valueLoader) {
        Object value = caffeineCache.getIfPresent(key);
        if (value != null) {
            return (T) value;
        }

        value = redisTemplate.opsForValue().get(buildKey(key));
        if (value != null) {
            caffeineCache.put(key, value);
            return (T) value;
        }

        try {
            T loaded = valueLoader.call();
            if (loaded != null) {
                put(key, loaded);
            }
            return loaded;
        } catch (Exception e) {
            throw new ValueRetrievalException(key, valueLoader, e);
        }
    }

    @Override
    public void put(Object key, Object value) {
        caffeineCache.put(key, value);
        redisTemplate.opsForValue().set(buildKey(key), value, ttl);
    }

    @Override
    public ValueWrapper putIfAbsent(Object key, Object value) {
        ValueWrapper existing = get(key);
        if (existing == null) {
            put(key, value);
        }
        return existing;
    }

    @Override
    public void evict(Object key) {
        caffeineCache.invalidate(key);
        redisTemplate.delete(buildKey(key));
    }

    @Override
    public void clear() {
        caffeineCache.invalidateAll();
    }
}

MultiLevelCacheManager.java

package com.example.cache.cache;

import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.data.redis.core.RedisTemplate;

import java.time.Duration;
import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

public class MultiLevelCacheManager implements CacheManager {

    private final RedisTemplate<Object, Object> redisTemplate;
    private final Duration ttl;
    private final Map<String, Cache> cacheMap = new ConcurrentHashMap<>();

    public MultiLevelCacheManager(RedisTemplate<Object, Object> redisTemplate, Duration ttl) {
        this.redisTemplate = redisTemplate;
        this.ttl = ttl;
    }

    @Override
    public Cache getCache(String name) {
        return cacheMap.computeIfAbsent(name, cacheName -> new MultiLevelCache(
                cacheName,
                Caffeine.newBuilder()
                        .maximumSize(1000)
                        .expireAfterWrite(Duration.ofSeconds(30))
                        .build(),
                redisTemplate,
                ttl
        ));
    }

    @Override
    public Collection<String> getCacheNames() {
        return cacheMap.keySet();
    }
}

4. CacheManager 注册

CacheConfig.java

package com.example.cache.config;

import com.example.cache.cache.MultiLevelCacheManager;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;

import java.time.Duration;

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager(RedisTemplate<Object, Object> redisTemplate) {
        return new MultiLevelCacheManager(redisTemplate, Duration.ofMinutes(5));
    }
}

5. Service 层:缓存读写与更新

ProductService.java

package com.example.cache.service;

import com.example.cache.entity.Product;
import com.example.cache.repository.ProductRepository;
import jakarta.annotation.PostConstruct;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;

@Service
public class ProductService {

    private final ProductRepository productRepository;

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

    @PostConstruct
    public void init() {
        if (productRepository.count() == 0) {
            productRepository.save(new Product(1L, "iPhone", new BigDecimal("6999")));
            productRepository.save(new Product(2L, "MacBook", new BigDecimal("12999")));
        }
    }

    @Cacheable(cacheNames = "product", key = "#id")
    public Product getById(Long id) {
        simulateSlowQuery();
        return productRepository.findById(id).orElse(null);
    }

    @CacheEvict(cacheNames = "product", key = "#product.id")
    public Product update(Product product) {
        return productRepository.save(product);
    }

    private void simulateSlowQuery() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}

这里用了 @CacheEvict,意思是更新数据库成功后,把缓存删掉。下次查询再重新加载。

对大多数读多写少场景,这比 @CachePut 更稳,因为你不会把“半旧半新”的数据直接推入缓存链路。


6. Controller

ProductController.java

package com.example.cache.controller;

import com.example.cache.entity.Product;
import com.example.cache.service.ProductService;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/products")
public class ProductController {

    private final ProductService productService;

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

    @GetMapping("/{id}")
    public Product get(@PathVariable Long id) {
        return productService.getById(id);
    }

    @PutMapping("/{id}")
    public Product update(@PathVariable Long id, @RequestBody Product product) {
        product.setId(id);
        return productService.update(product);
    }
}

7. 启动类

CacheDemoApplication.java

package com.example.cache;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class CacheDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(CacheDemoApplication.class, args);
    }
}

8. application.yml

spring:
  datasource:
    url: jdbc:h2:mem:testdb;MODE=MYSQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
  data:
    redis:
      host: localhost
      port: 6379

logging:
  level:
    org.springframework.cache: debug

六、逐步验证清单

启动应用后,按下面步骤验证。

1. 第一次查询

curl http://localhost:8080/products/1

预期:

  • 第一次会慢一些,大约 1 秒
  • 因为要走数据库

2. 第二次查询

curl http://localhost:8080/products/1

预期:

  • 基本瞬时返回
  • 优先命中本地缓存

3. 更新商品

curl -X PUT http://localhost:8080/products/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"iPhone 15","price":7999}'

4. 再次查询

curl http://localhost:8080/products/1

预期:

  • 第一次更新后的查询可能再次走数据库重建缓存
  • 返回新数据而不是旧数据

七、常见坑与排查

这一部分非常关键。缓存方案最怕的不是“不快”,而是“看起来快,但偷偷返回旧数据”。

1. @Cacheable 不生效

现象

调用方法后还是每次都查库。

排查点

  • 是否加了 @EnableCaching
  • 方法是不是 public
  • 是否发生了同类内部调用

比如下面这样通常不会触发缓存代理:

public Product test(Long id) {
    return this.getById(id);
}

因为这是类内部直接调用,没有走 Spring AOP 代理。

解决方式

  • 从外部 Bean 调用
  • 或拆成两个 Service
  • 或获取代理对象再调用

这是 Spring Cache 初学者最容易踩的坑之一。


2. 更新后仍读到旧值

现象

明明执行了 @CacheEvict,但查询还是老数据。

可能原因

原因一:事务提交时机

如果你在事务未提交前删缓存,另一个请求可能马上查库,查到的还是旧数据,然后又把旧值写回缓存。

建议

  • 更新数据库与删缓存最好考虑事务边界
  • 对强一致要求高的场景,可在事务提交后再删除缓存
  • 或使用消息队列/订阅通知做二次删除

一个常见增强策略是“延迟双删”:

  1. 更新 DB
  2. 删除缓存
  3. 延迟几百毫秒后再删一次

虽然不完美,但对很多场景有效。


3. 集群环境下本地缓存不一致

现象

A 节点更新后,A 节点是新数据,B 节点仍读到旧数据。

原因

本地缓存天然是节点私有的。

解决思路

  • Redis Pub/Sub 广播失效消息
  • 使用 MQ 通知所有节点清本地缓存
  • 或降低一级缓存 TTL

我自己的经验是:本地缓存不要配太长 TTL。它的职责是挡住瞬时热点,而不是长期保存真相。


4. 缓存穿透

现象

大量请求访问不存在的 ID,每次都打到 DB。

解决思路

  • 缓存空值
  • 参数校验
  • 布隆过滤器

如果业务允许,null 结果也可以短暂缓存 30~60 秒。

不过要注意 Spring Cache 对空值支持方式要统一,不然容易出现序列化或类型判断问题。


5. 缓存雪崩

现象

一批 key 同时过期,瞬间大量请求打到下游。

解决思路

  • TTL 加随机值
  • 热点数据预热
  • 限流与降级
  • 多级缓存兜底

比如 Redis TTL 设成 300~360 秒,而不是所有 key 固定 300 秒。


6. 缓存击穿

现象

某个热点 key 失效瞬间,大量并发同时回源。

解决思路

  • 热点 key 加互斥锁
  • 使用逻辑过期
  • 本地缓存短时保底

如果你的热点很明显,比如商品详情 Top100,可以考虑主动预热,而不是完全被动等待首次请求加载。


八、安全/性能最佳实践

1. 序列化要统一

建议:

  • key 使用字符串
  • value 使用 JSON
  • 不要混用多种序列化方式

否则你很容易遇到:

  • 老数据读不出来
  • 类版本变更后反序列化失败
  • 运维排查困难

2. TTL 分层设置

一级缓存和二级缓存不应该完全一样。

一个比较实用的经验值:

  • Caffeine:10~30 秒
  • Redis:5~10 分钟

为什么?

  • 本地缓存短 TTL,降低不一致窗口
  • Redis 长 TTL,降低 DB 压力

3. Key 设计要可控

推荐格式:

业务名:实体名:主键

比如:

product:detail:1

而不是随手拼接一串难以维护的字符串。

本文示例里为了对接 Spring Cache,用了 cacheName::key 的形式,实际项目中可以进一步规范。


4. 给热点接口加监控

别只看“有没有缓存”,要看:

  • 本地缓存命中率
  • Redis 命中率
  • DB 回源比例
  • 缓存重建耗时
  • 热点 key 排名

如果没有这些指标,缓存调优基本靠猜。

建议至少打出:

  • 查询总次数
  • L1 命中次数
  • L2 命中次数
  • DB 查询次数

5. 防止大对象进入本地缓存

本地缓存虽然快,但它吃的是 JVM 堆内存。

如果把超大对象、长列表、复杂聚合结果直接丢进去,容易导致:

  • Full GC 增加
  • 老年代膨胀
  • 服务抖动

建议:

  • 只缓存热点、体积可控的数据
  • 设置 maximumSize
  • 控制对象字段与层级

6. 谨慎追求“强一致”

多级缓存天生更偏向最终一致性。如果你面对的是:

  • 库存扣减
  • 账户余额
  • 优惠券核销

这类强一致场景,不建议直接套本文方案当通用模板。应该优先考虑:

  • 数据库事务
  • 分布式锁
  • 消息保证
  • 原子操作

缓存更适合“读优化”,而不是替代核心一致性机制。


九、进阶扩展:集群下如何同步本地缓存失效

如果你的服务部署成多实例,那么更新时只删当前节点本地缓存是不够的。

可以引入 Redis Pub/Sub:

flowchart TD
    A[节点A更新数据] --> B[更新数据库]
    B --> C[删除 Redis 缓存]
    C --> D[删除节点A本地缓存]
    D --> E[发布失效消息到 Redis Channel]
    E --> F[节点B订阅消息并删除本地缓存]
    E --> G[节点C订阅消息并删除本地缓存]

基本思路:

  1. 数据更新后,删 Redis
  2. 删除当前节点本地缓存
  3. 发布一个缓存失效事件
  4. 其他节点收到事件后删除各自本地缓存

这样才能把“本地快”和“集群可控”结合起来。


十、方案边界与适用场景

这个方案比较适合:

  • 读多写少
  • 热点明显
  • 可接受短暂最终一致性
  • 单条查询或轻量聚合查询

不太适合:

  • 高频写入
  • 强一致资金类场景
  • 超大对象缓存
  • 多维复杂条件查询且 key 难以稳定设计的场景

一句话总结:

多级缓存不是银弹,它擅长给高频读接口提速,但不负责解决所有一致性问题。


十一、总结

这篇文章我们完成了一个基于 Spring Boot + Spring Cache + Redis + Caffeine 的多级缓存实战,核心收获有三点:

  1. 性能链路上:本地缓存负责极致低延迟,Redis 负责共享与削峰,数据库只在必要时回源
  2. 一致性上:优先采用“更新 DB,删除缓存”的策略,别急着直接写缓存
  3. 工程上:重点不是注解本身,而是 TTL、序列化、失效通知、监控这些细节

如果你准备在项目里落地,我建议按这个顺序推进:

  • 先把单机版两级缓存跑通
  • 再补上监控与命中率统计
  • 最后在集群环境引入本地缓存失效广播

这样最稳,也最容易验证收益。

如果你只记住一句实践建议,那就是:

一级缓存 TTL 要短,二级缓存 TTL 要稳,更新优先删缓存,监控一定要补齐。

这套方案用在商品详情、配置查询、字典数据、用户资料页这类接口上,通常都能拿到很不错的性能收益。


分享到:

上一篇
《安卓逆向实战:中级开发者如何用 Frida 定位并绕过常见 APK 签名校验逻辑》
下一篇
《从贡献者视角读懂开源项目:如何高效完成一次真实可合并的 PR》