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

《Spring Boot 中基于 Spring Cache 与 Redis 的多级缓存实战:一致性、穿透防护与性能调优》

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

Spring Boot 中基于 Spring Cache 与 Redis 的多级缓存实战:一致性、穿透防护与性能调优

在中大型 Spring Boot 项目里,单纯“把 Redis 接上”通常还不够。

我自己在做商品、配置、用户画像这类读多写少的场景时,最先遇到的问题不是“怎么缓存”,而是:

  • 本地缓存和 Redis 怎么配合才不打架?
  • 更新数据库后,为什么接口偶尔还能读到旧值?
  • 热点 key 突然失效时,为什么数据库被打穿?
  • Spring Cache 用起来很省事,但它默认并不会帮你自动处理多级缓存一致性

这篇文章我不打算只讲概念,而是带你从 Spring Cache + Caffeine(一级缓存)+ Redis(二级缓存) 搭出一个可运行方案,再把一致性、缓存穿透、热点问题和性能调优一起收掉。


背景与问题

先明确一个目标:我们要的不是“有缓存”,而是“稳定、可控、可排查的多级缓存体系”。

典型读路径通常是:

  1. 先查本地缓存(JVM 内,速度最快)
  2. 未命中再查 Redis
  3. Redis 未命中再查数据库
  4. 查到后回填 Redis 和本地缓存

这样做的价值很直接:

  • 本地缓存:减少网络开销,极低延迟
  • Redis:多实例共享,避免每个节点都打数据库
  • 数据库:最终数据源

但问题也随之而来:

1. 多级缓存一致性

最常见的坑是:

  • A 节点更新数据库并删除 Redis
  • B 节点本地缓存还没失效
  • 一段时间内 B 节点仍然返回旧数据

这就是典型的 分布式本地缓存一致性问题

2. 缓存穿透

用户查一个根本不存在的数据,如果每次都直接落到数据库,攻击或者脏流量一来,DB 压力就很大。

3. 缓存击穿

某个热点 key 恰好过期,瞬间大量请求同时穿透到数据库。

4. 缓存雪崩

大量 key 在同一时间段失效,导致 Redis 命中率骤降,数据库扛不住。


前置知识与环境准备

本文示例基于以下技术栈:

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

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-validation</artifactId>
    </dependency>
</dependencies>

核心原理

先看整体结构。

flowchart TD
    A[请求进入] --> B{本地缓存 Caffeine 命中?}
    B -- 是 --> C[直接返回]
    B -- 否 --> D{Redis 命中?}
    D -- 是 --> E[回填本地缓存并返回]
    D -- 否 --> F[查询数据库]
    F --> G{数据存在?}
    G -- 是 --> H[写入 Redis]
    H --> I[写入本地缓存]
    I --> J[返回结果]
    G -- 否 --> K[写入空值/短 TTL 防穿透]
    K --> L[返回空]

一级缓存与二级缓存的职责

建议分工很明确:

  • Caffeine
    • 保存热点、超高频、短 TTL 数据
    • 适合单机内快速命中
  • Redis
    • 共享跨实例数据
    • 保存业务缓存主副本
  • 数据库
    • 最终一致的数据源

为什么 Spring Cache 不够直接

Spring Cache 的优点是注解简单:

  • @Cacheable
  • @CachePut
  • @CacheEvict

但它更像一个抽象层,并没有天然替你搞定:

  • 多级缓存协调
  • 本地缓存的跨节点失效
  • 热点 key 的互斥重建
  • 空值缓存策略
  • TTL 随机抖动

所以实际项目里,Spring Cache 适合做统一入口,底层需要自定义 CacheManager 或组合策略


方案设计:读写路径怎么定

我推荐一个比较稳妥、落地成本也不高的策略:

读路径

  • 本地缓存 -> Redis -> DB
  • DB 查到后回填两级缓存
  • 不存在的数据写入空值缓存,TTL 要短

写路径

  • 先更新数据库
  • 再删除 Redis
  • 再删除本地缓存
  • 最好结合消息通知清理其他节点本地缓存

这是经典的 Cache Aside Pattern

为什么不是“先删缓存再更新数据库”?

因为如果先删缓存,再更新数据库,在数据库提交前这段时间有并发读进来,就可能读到旧值并重新写回缓存,造成脏数据回流。

相对更稳妥的是:

  1. 更新数据库
  2. 删除缓存

如果对一致性要求更高,再叠加:

  • 延迟双删
  • MQ 通知
  • binlog 订阅刷新缓存

下面用图看一下。

sequenceDiagram
    participant Client as 客户端
    participant App as 应用服务
    participant DB as 数据库
    participant Redis as Redis
    participant Local as 本地缓存

    Client->>App: 更新请求
    App->>DB: 更新数据
    DB-->>App: 提交成功
    App->>Redis: 删除缓存
    App->>Local: 删除本地缓存
    App-->>Client: 返回成功

    Note over App,Local: 多实例场景下,还需广播清理其他节点本地缓存

实战代码(可运行)

下面我们做一个简化但可跑的商品查询示例。

目录思路如下:

  • ProductController
  • ProductService
  • ProductRepository
  • MultiLevelCacheService
  • CacheConfig

第一步:基础配置

application.yml

server:
  port: 8080

spring:
  cache:
    type: simple
  data:
    redis:
      host: localhost
      port: 6379
      timeout: 3000ms

logging:
  level:
    org.springframework.data.redis: info

这里 spring.cache.type 不直接依赖默认实现,我们会自己控制多级缓存逻辑。


第二步:定义实体与模拟仓储

Product.java

package com.example.cachedemo.model;

import java.io.Serializable;
import java.math.BigDecimal;

public class Product implements Serializable {
    private Long id;
    private String name;
    private BigDecimal price;
    private Long version;

    public Product() {
    }

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

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public BigDecimal getPrice() {
        return price;
    }

    public Long getVersion() {
        return version;
    }

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

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

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

    public void setVersion(Long version) {
        this.version = version;
    }
}

ProductRepository.java

这里先用内存 Map 模拟数据库,方便本地跑通。

package com.example.cachedemo.repository;

import com.example.cachedemo.model.Product;
import org.springframework.stereotype.Repository;

import jakarta.annotation.PostConstruct;
import java.math.BigDecimal;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;

@Repository
public class ProductRepository {

    private final Map<Long, Product> store = new ConcurrentHashMap<>();

    @PostConstruct
    public void init() {
        store.put(1L, new Product(1L, "iPhone", new BigDecimal("6999.00"), 1L));
        store.put(2L, new Product(2L, "MacBook", new BigDecimal("12999.00"), 1L));
    }

    public Optional<Product> findById(Long id) {
        simulateDbCost();
        return Optional.ofNullable(store.get(id));
    }

    public Product save(Product product) {
        simulateDbCost();
        long newVersion = product.getVersion() == null ? 1L : product.getVersion() + 1;
        product.setVersion(newVersion);
        store.put(product.getId(), product);
        return product;
    }

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

第三步:配置 Caffeine 与 RedisTemplate

CacheConfig.java

package com.example.cachedemo.config;

import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.util.concurrent.TimeUnit;

@Configuration
public class CacheConfig {

    @Bean
    public com.github.benmanes.caffeine.cache.Cache<String, Object> caffeineCache() {
        return Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(10_000)
                .expireAfterWrite(30, TimeUnit.SECONDS)
                .recordStats()
                .build();
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();

        template.setConnectionFactory(factory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer);
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(serializer);
        template.afterPropertiesSet();
        return template;
    }

    @Bean
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
        return new StringRedisTemplate(factory);
    }
}

第四步:实现多级缓存服务

这里是核心逻辑:

  • 本地缓存查不到,再查 Redis
  • Redis 查不到,再查 DB
  • 使用短期空值缓存防穿透
  • 用分布式锁减少热点 key 击穿
  • 更新后删除两级缓存

MultiLevelCacheService.java

package com.example.cachedemo.service;

import com.example.cachedemo.model.Product;
import com.example.cachedemo.repository.ProductRepository;
import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.Optional;
import java.util.UUID;

@Service
public class MultiLevelCacheService {

    private static final String CACHE_PREFIX = "product:";
    private static final String LOCK_PREFIX = "lock:product:";
    private static final String NULL_VALUE = "__NULL__";

    private final Cache<String, Object> caffeineCache;
    private final RedisTemplate<String, Object> redisTemplate;
    private final StringRedisTemplate stringRedisTemplate;
    private final ProductRepository productRepository;

    public MultiLevelCacheService(Cache<String, Object> caffeineCache,
                                  RedisTemplate<String, Object> redisTemplate,
                                  StringRedisTemplate stringRedisTemplate,
                                  ProductRepository productRepository) {
        this.caffeineCache = caffeineCache;
        this.redisTemplate = redisTemplate;
        this.stringRedisTemplate = stringRedisTemplate;
        this.productRepository = productRepository;
    }

    public Product getProduct(Long id) {
        String key = CACHE_PREFIX + id;

        Object localValue = caffeineCache.getIfPresent(key);
        if (localValue != null) {
            if (NULL_VALUE.equals(localValue)) {
                return null;
            }
            return (Product) localValue;
        }

        Object redisValue = redisTemplate.opsForValue().get(key);
        if (redisValue != null) {
            caffeineCache.put(key, redisValue);
            if (NULL_VALUE.equals(redisValue)) {
                return null;
            }
            return (Product) redisValue;
        }

        String lockKey = LOCK_PREFIX + id;
        String lockValue = UUID.randomUUID().toString();
        boolean locked = Boolean.TRUE.equals(
                stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, Duration.ofSeconds(5))
        );

        try {
            if (locked) {
                Optional<Product> dbResult = productRepository.findById(id);
                if (dbResult.isPresent()) {
                    Product product = dbResult.get();
                    redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(5));
                    caffeineCache.put(key, product);
                    return product;
                } else {
                    redisTemplate.opsForValue().set(key, NULL_VALUE, Duration.ofSeconds(60));
                    caffeineCache.put(key, NULL_VALUE);
                    return null;
                }
            } else {
                Thread.sleep(50);
                Object retryRedisValue = redisTemplate.opsForValue().get(key);
                if (retryRedisValue != null) {
                    caffeineCache.put(key, retryRedisValue);
                    if (NULL_VALUE.equals(retryRedisValue)) {
                        return null;
                    }
                    return (Product) retryRedisValue;
                }
                return productRepository.findById(id).orElse(null);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return productRepository.findById(id).orElse(null);
        } finally {
            if (locked) {
                String current = stringRedisTemplate.opsForValue().get(lockKey);
                if (lockValue.equals(current)) {
                    stringRedisTemplate.delete(lockKey);
                }
            }
        }
    }

    public Product updateProduct(Product product) {
        Product saved = productRepository.save(product);
        String key = CACHE_PREFIX + product.getId();

        redisTemplate.delete(key);
        caffeineCache.invalidate(key);

        return saved;
    }

    public void evictProduct(Long id) {
        String key = CACHE_PREFIX + id;
        redisTemplate.delete(key);
        caffeineCache.invalidate(key);
    }
}

第五步:暴露接口

ProductService.java

package com.example.cachedemo.service;

import com.example.cachedemo.model.Product;
import org.springframework.stereotype.Service;

@Service
public class ProductService {

    private final MultiLevelCacheService multiLevelCacheService;

    public ProductService(MultiLevelCacheService multiLevelCacheService) {
        this.multiLevelCacheService = multiLevelCacheService;
    }

    public Product getById(Long id) {
        return multiLevelCacheService.getProduct(id);
    }

    public Product update(Product product) {
        return multiLevelCacheService.updateProduct(product);
    }
}

ProductController.java

package com.example.cachedemo.controller;

import com.example.cachedemo.model.Product;
import com.example.cachedemo.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 getById(@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);
    }
}

第六步:如何验证它真的生效

你可以按下面顺序测试。

1. 首次查询

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

第一次会走“DB -> Redis -> 本地缓存”。

2. 第二次查询

再次执行:

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

此时应优先命中本地缓存,响应会更快。

3. 查询不存在的数据

curl http://localhost:8080/products/999

会写入空值缓存,避免每次都访问 DB。

4. 更新商品

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

更新后会删除 Redis 和本地缓存。下次查询重新加载新值。


用 Spring Cache 注解怎么接入

如果你希望业务层继续享受 Spring Cache 注解带来的简洁写法,一个常见做法是:

  • 查询接口继续用 @Cacheable
  • 更新接口用 @CacheEvict
  • 多级缓存能力下沉到自定义 CacheManager 或统一缓存服务

例如:

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

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

但这里要提醒一句:只加注解,不代表你已经拥有了完整的多级缓存能力。

尤其在这些场景里,你还是得补自定义逻辑:

  • 空值缓存
  • 逻辑过期
  • 分布式锁
  • 跨节点本地缓存失效广播

多实例下一致性怎么做

单机演示里,删除本地缓存已经够了;但生产通常是多实例部署。

这时一个节点更新缓存,只能清掉自己的 JVM 本地缓存,其他节点并不知道。解决思路一般有 3 类:

方案 1:Redis Pub/Sub 广播失效

  • 更新成功后发布失效消息
  • 所有应用实例订阅频道
  • 收到消息后清理各自本地缓存

这是实现成本较低、效果也不错的方案。

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

方案 2:MQ 异步通知

比 Pub/Sub 更可靠,适合对消息送达要求更高的场景。

方案 3:订阅 binlog

由数据变更驱动缓存刷新,一致性更强,但复杂度也高。

我的建议

  • 一般中型系统:DB 更新 + 删除 Redis + 广播本地缓存失效
  • 强一致要求更高:考虑延迟双删或 MQ
  • 极高复杂度场景:再评估 Canal / binlog 路线

常见坑与排查

这一部分最值得细看,因为缓存问题通常不是“不会写”,而是“出问题时不好查”。

坑 1:序列化方式不一致

症状:

  • Redis 里明明有值,程序反序列化报错
  • 不同服务版本读不到旧缓存

原因通常是:

  • JDK 序列化与 JSON 序列化混用
  • 类结构变更但缓存未清

建议:

  • 统一使用 JSON 序列化
  • 给关键缓存对象保留兼容字段
  • 发布前评估是否需要清理历史缓存

坑 2:本地缓存没有跨节点失效

症状:

  • 某台机器读到旧数据,另一台是新的
  • 重启应用后问题消失

排查思路:

  1. 确认 Redis 已删
  2. 确认当前实例本地缓存是否已删
  3. 看其他实例是否收到失效通知

坑 3:@Cacheable 方法内部调用失效

症状:

  • 明明加了 @Cacheable,但缓存不生效

原因:

Spring AOP 代理机制导致 类内部自调用 绕过代理。

错误示例:

public Product outer(Long id) {
    return this.query(id);
}

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

建议:

  • 拆到独立 Service
  • 或通过代理对象调用

坑 4:空值缓存时间过长

症状:

  • 某数据刚创建,接口却持续查不到

原因:

之前缓存了空值,TTL 过长,导致新数据不能及时被读取。

建议:

  • 空值 TTL 一般设短一些,比如 30~120 秒
  • 对新增操作主动清理对应空值缓存

坑 5:热点 key 失效引发数据库毛刺

症状:

  • 某个时间点数据库 QPS 突然飙升
  • Redis 命中率瞬时下降

建议:

  • 热点 key 加互斥锁或 single flight
  • 做逻辑过期而非物理同时过期
  • 对 TTL 加随机抖动

安全/性能最佳实践

1. 给 TTL 加随机值,避免雪崩

不要所有 key 都固定 5 分钟过期。

更好的做法:

long ttlSeconds = 300 + ThreadLocalRandom.current().nextInt(60);

这样可以把同一批 key 的失效时间打散。

2. 对不存在数据做缓存,但 TTL 要短

推荐策略:

  • 空值缓存 TTL:30~120 秒
  • 正常数据 TTL:几分钟到几十分钟
  • 热点本地缓存 TTL:10~60 秒更常见

3. 不要把大对象整个塞进本地缓存

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

如果对象很大或数量很多,会带来:

  • Full GC 风险
  • 内存占用不可控
  • 实例间热点分布不均

建议:

  • 只缓存高频小对象
  • 控制 maximumSize
  • 监控命中率与淘汰率

4. 更新缓存优先删,不要轻易强写

很多人喜欢“更新 DB 后顺手 set 缓存”,但在并发场景下反而可能覆盖新值。

对于大部分业务,推荐:

  • 更新 DB
  • 删除缓存
  • 由下次读请求懒加载重建

5. 热点数据考虑逻辑过期

对于极热 key,与其在失效时让用户请求一起去重建,不如:

  • 缓存中同时保存业务值和逻辑过期时间
  • 请求线程先返回旧值
  • 后台线程异步刷新

这样牺牲一点实时性,换更稳定的吞吐。

6. Redis 不是越多 key 越好

缓存也有成本:

  • 网络 IO
  • 内存成本
  • 序列化开销
  • 运维复杂度

适合缓存的数据一般有这些特征:

  • 读多写少
  • 可容忍短暂不一致
  • 查询代价高
  • 热点明显

7. 补监控,不然优化很容易凭感觉

至少监控这些指标:

  • 本地缓存命中率
  • Redis 命中率
  • DB 回源率
  • 平均查询耗时
  • 热点 key 排名
  • 缓存删除失败次数
  • 锁竞争次数

我自己排查缓存问题时,最怕的就是“没有命中率,没有回源率,只能猜”。


逐步验证清单

如果你准备把这套方案迁到项目里,可以按这个顺序推进:

阶段 1:先跑通功能

  • 查询能命中本地缓存
  • 本地未命中能命中 Redis
  • Redis 未命中能回源 DB
  • 更新后两级缓存能删除

阶段 2:补防护

  • 空值缓存防穿透
  • 热点 key 锁防击穿
  • TTL 随机抖动防雪崩

阶段 3:补一致性

  • 多实例本地缓存失效广播
  • 关键更新链路做日志埋点
  • 排查缓存删除失败重试策略

阶段 4:补可观测性

  • Caffeine stats 指标输出
  • Redis 命中率统计
  • DB 回源量监控
  • 慢查询和热点 key 排查面板

什么时候不建议上多级缓存

虽然本文在讲“怎么做”,但我也想说下“什么时候别急着做”。

以下情况,可以先别上复杂多级缓存:

  • 数据实时一致性要求极高,不能容忍秒级旧数据
  • 数据写入频繁,本地缓存失效成本很高
  • 系统规模还小,单层 Redis 足够
  • 团队缺少监控与排障能力,先上只会增加复杂度

简单永远比复杂更珍贵。
如果你当前只是单实例服务、读压力也不高,先用 Spring Cache + Redis 单层缓存就行,别一开始就堆满方案。


总结

这篇文章我们从实战角度搭了一套 Spring Boot + Spring Cache 思路 + Caffeine + Redis 的多级缓存方案,核心点可以收敛成下面几条:

  1. 读路径:本地缓存 -> Redis -> DB
  2. 写路径:先更新 DB,再删除缓存
  3. 防穿透:缓存空值,TTL 要短
  4. 防击穿:热点 key 加锁或逻辑过期
  5. 防雪崩:TTL 加随机抖动
  6. 一致性重点:多实例下要广播本地缓存失效
  7. 性能优化前提:一定要有命中率、回源率、耗时监控

如果你现在正在做一个中级复杂度的 Spring Boot 系统,我的建议很务实:

  • 第一步:先落地单层 Redis + Cache Aside
  • 第二步:热点场景再补本地缓存
  • 第三步:多实例后补广播失效
  • 第四步:再根据压测结果决定是否加逻辑过期、MQ、延迟双删

不要一上来就追求“最完整方案”,而是先做出 可运行、可解释、可排查 的缓存体系。这样真出问题时,你能知道问题在哪,而不是只能靠重启服务“碰运气”。


分享到:

上一篇
《大模型应用中的 RAG 实战:从向量检索、重排序到效果评估的完整落地指南》
下一篇
《Node.js 中基于 Worker Threads 与消息队列的高并发任务处理实战-117》