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

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

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

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

在业务量上来之后,很多系统都会遇到一个很现实的问题:数据库扛不住、接口延迟抖动、热点数据反复查
这时候,单纯加一个 Redis 往往不够,因为:

  • 本地 JVM 内访问仍然比远程 Redis 更快
  • Redis 能抗大部分读流量,但热点 key 依然会集中
  • 缓存更新不当,很容易出现“数据库是新的,缓存还是旧的”
  • 空值、恶意 key、批量失效会带来缓存穿透、击穿、雪崩

这篇文章我不打算只讲概念,而是带你从 0 到 1 做一个 Spring Boot + Spring Cache + Redis 的多级缓存方案,并把一致性、穿透防护、性能调优这些实战里最常踩的坑一起讲透。


背景与问题

先看一个典型场景:商品详情页。

一个接口 /products/{id},读多写少,访问量高。
如果每次都查数据库,会出现这些问题:

  1. 数据库压力大:大量相同请求反复打到 MySQL
  2. 接口延迟高:数据库查询链路长,抖动明显
  3. 热点数据集中:某些商品在秒杀、活动期间会被持续访问
  4. 数据一致性难控制:商品价格修改后,缓存怎么同步更新?
  5. 缓存异常场景多:空数据、热点失效、批量过期都需要处理

很多团队的第一反应是:
“加 Redis 缓存就好了。”

但真落地时会发现,仅有 Redis 还不够。
更常见的做法是:

  • 一级缓存:本地缓存(Caffeine)
  • 二级缓存:Redis
  • 最终数据源:MySQL

也就是典型的多级缓存架构


前置知识与环境准备

本文示例环境:

  • JDK 17
  • Spring Boot 3.x
  • Spring Cache
  • Redis 7.x
  • Caffeine
  • 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.mysql</groupId>
        <artifactId>mysql-connector-j</artifactId>
        <scope>runtime</scope>
    </dependency>

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

application.yml

spring:
  datasource:
    url: jdbc:mysql://127.0.0.1:3306/demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
    username: root
    password: root

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true

  data:
    redis:
      host: 127.0.0.1
      port: 6379
      timeout: 3000ms

  cache:
    type: none

server:
  port: 8080

这里把 spring.cache.type 设为 none,是因为我们后面会自己装配一个多级 CacheManager


核心原理

什么是多级缓存

多级缓存的目标很直接:
优先走最快的缓存层,逐层回退,最后才查数据库。

完整链路通常是:

  1. 先查本地缓存 Caffeine
  2. 本地没有,再查 Redis
  3. Redis 没有,再查数据库
  4. 查到后回填 Redis 和本地缓存

Mermaid 图先看整体结构:

flowchart LR
    A[客户端请求] --> B[Spring Cache]
    B --> C{一级缓存 Caffeine}
    C -- 命中 --> D[返回数据]
    C -- 未命中 --> E{二级缓存 Redis}
    E -- 命中 --> F[写回本地缓存]
    F --> D
    E -- 未命中 --> G[数据库 MySQL]
    G --> H[回填 Redis]
    H --> I[回填 Caffeine]
    I --> D

Spring Cache 在这里扮演什么角色

Spring Cache 本质上是一个缓存抽象层,它不关心底层到底是 Redis、Caffeine 还是别的,只提供统一注解:

  • @Cacheable:查缓存,没有则执行方法并写缓存
  • @CachePut:执行方法,并把结果更新到缓存
  • @CacheEvict:删除缓存
  • @Caching:组合使用

也就是说,我们可以通过自定义 CacheManagerCache,把“多级缓存逻辑”塞进 Spring Cache 体系里。

一致性问题的本质

缓存一致性说白了就是一个时间差问题:

  • 数据库更新了
  • 缓存删除/更新还没完成
  • 另一个请求刚好读到了旧缓存

严格意义上的强一致,在高并发缓存系统里代价很高。
大部分业务追求的是:

  • 最终一致
  • 短时间可接受的弱一致
  • 热点关键数据做更严谨控制

更新策略常见有三种:

  1. 先更新数据库,再删除缓存
  2. 先删除缓存,再更新数据库
  3. 更新数据库后,异步通知失效本地缓存

实战里更推荐的是:

更新数据库成功后,删除 Redis 和本地缓存,而不是直接更新缓存值

原因很简单:直接更新缓存容易把复杂逻辑、序列化问题、并发覆盖问题带进来。
删除缓存虽然会导致一次回源,但整体更稳。

为什么要额外处理穿透、击穿、雪崩

这三个词太像了,很多人第一次看会混。我用最直白的话区分:

  • 缓存穿透:查一个根本不存在的数据,缓存和数据库都没有,请求每次都打到数据库
  • 缓存击穿:一个热点 key 失效瞬间,大量并发同时回源数据库
  • 缓存雪崩:大量 key 在同一时间失效,数据库整体被冲垮

应对思路:

  • 穿透:缓存空值、布隆过滤器、参数校验
  • 击穿:互斥锁、逻辑过期、热点永不过期
  • 雪崩:过期时间加随机值、分批失效、多级缓存兜底

方案设计:Spring Cache + Caffeine + Redis

我们这次的实现思路是:

  • 使用 CaffeineCache 作为一级缓存
  • 使用 RedisCache 作为二级缓存
  • 自定义 MultiLevelCache,内部组合两个缓存
  • 自定义 MultiLevelCacheManager
  • 业务层继续使用 @Cacheable@CacheEvict

这样业务代码不会被缓存细节污染。

读写流程

sequenceDiagram
    participant U as 用户请求
    participant S as Service
    participant L1 as Caffeine
    participant L2 as Redis
    participant DB as MySQL

    U->>S: 查询商品(id)
    S->>L1: get(id)
    alt L1命中
        L1-->>S: 返回
        S-->>U: 响应
    else L1未命中
        S->>L2: get(id)
        alt L2命中
            L2-->>S: 返回
            S->>L1: put(id, value)
            S-->>U: 响应
        else L2未命中
            S->>DB: select by id
            DB-->>S: 数据/空
            S->>L2: put(id, value)
            S->>L1: put(id, value)
            S-->>U: 响应
        end
    end

实战代码(可运行)

下面给出一套可以直接跑起来的简化版代码。


1. 实体类与仓库

Product.java

package com.example.demo.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

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

@Entity
@Table(name = "product")
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.demo.repository;

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

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

2. 自定义多级缓存实现

MultiLevelCache.java

package com.example.demo.cache;

import org.springframework.cache.Cache;
import org.springframework.cache.support.SimpleValueWrapper;

import java.util.concurrent.Callable;

public class MultiLevelCache implements Cache {

    private final String name;
    private final Cache caffeineCache;
    private final Cache redisCache;

    public MultiLevelCache(String name, Cache caffeineCache, Cache redisCache) {
        this.name = name;
        this.caffeineCache = caffeineCache;
        this.redisCache = redisCache;
    }

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

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

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

        value = redisCache.get(key);
        if (value != null) {
            caffeineCache.put(key, value.get());
            return value;
        }
        return null;
    }

    @Override
    public <T> T get(Object key, Class<T> type) {
        ValueWrapper value = get(key);
        if (value == null) {
            return null;
        }
        Object obj = value.get();
        if (type != null && !type.isInstance(obj)) {
            return null;
        }
        return (T) obj;
    }

    @Override
    public <T> T get(Object key, Callable<T> valueLoader) {
        ValueWrapper value = get(key);
        if (value != null) {
            return (T) value.get();
        }
        try {
            T loaded = valueLoader.call();
            put(key, loaded);
            return loaded;
        } catch (Exception e) {
            throw new RuntimeException("load cache value failed", e);
        }
    }

    @Override
    public void put(Object key, Object value) {
        redisCache.put(key, value);
        caffeineCache.put(key, value);
    }

    @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) {
        redisCache.evict(key);
        caffeineCache.evict(key);
    }

    @Override
    public boolean evictIfPresent(Object key) {
        redisCache.evict(key);
        return caffeineCache.evictIfPresent(key);
    }

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

    @Override
    public boolean invalidate() {
        redisCache.clear();
        return caffeineCache.invalidate();
    }
}

3. 自定义 CacheManager

MultiLevelCacheManager.java

package com.example.demo.cache;

import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;

import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Set;

public class MultiLevelCacheManager implements CacheManager {

    private final CacheManager caffeineCacheManager;
    private final CacheManager redisCacheManager;

    public MultiLevelCacheManager(CacheManager caffeineCacheManager, CacheManager redisCacheManager) {
        this.caffeineCacheManager = caffeineCacheManager;
        this.redisCacheManager = redisCacheManager;
    }

    @Override
    public Cache getCache(String name) {
        Cache caffeine = caffeineCacheManager.getCache(name);
        Cache redis = redisCacheManager.getCache(name);
        if (caffeine == null || redis == null) {
            return null;
        }
        return new MultiLevelCache(name, caffeine, redis);
    }

    @Override
    public Collection<String> getCacheNames() {
        Set<String> names = new LinkedHashSet<>();
        names.addAll(caffeineCacheManager.getCacheNames());
        names.addAll(redisCacheManager.getCacheNames());
        return names;
    }
}

4. 缓存配置

CacheConfig.java

package com.example.demo.config;

import com.example.demo.cache.MultiLevelCacheManager;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
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.*;

import java.time.Duration;
import java.util.HashMap;
import java.util.Map;

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager caffeineCacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager("product");
        cacheManager.setCaffeine(
                Caffeine.newBuilder()
                        .initialCapacity(100)
                        .maximumSize(1000)
                        .expireAfterWrite(Duration.ofMinutes(5))
        );
        return cacheManager;
    }

    @Bean
    public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.activateDefaultTyping(
                BasicPolymorphicTypeValidator.builder().allowIfSubType(Object.class).build(),
                ObjectMapper.DefaultTyping.NON_FINAL,
                JsonTypeInfo.As.PROPERTY
        );

        GenericJackson2JsonRedisSerializer serializer =
                new GenericJackson2JsonRedisSerializer(objectMapper);

        RedisSerializationContext.SerializationPair<Object> pair =
                RedisSerializationContext.SerializationPair.fromSerializer(serializer);

        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
                .serializeValuesWith(pair)
                .disableCachingNullValues()
                .entryTtl(Duration.ofMinutes(30));

        Map<String, RedisCacheConfiguration> configMap = new HashMap<>();
        configMap.put("product", defaultConfig.entryTtl(Duration.ofMinutes(30)));

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

    @Primary
    @Bean
    public CacheManager cacheManager(CacheManager caffeineCacheManager,
                                     CacheManager redisCacheManager) {
        return new MultiLevelCacheManager(caffeineCacheManager, redisCacheManager);
    }
}

这里我先用了 disableCachingNullValues(),后面讲缓存穿透时会说明为什么有时你反而要允许缓存空值。


5. 业务服务

ProductService.java

package com.example.demo.service;

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

import java.math.BigDecimal;
import java.util.Optional;

@Service
public class ProductService {

    private final ProductRepository productRepository;

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

    @Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
    public Product getById(Long id) {
        System.out.println("query db, id = " + id);
        Optional<Product> optional = productRepository.findById(id);
        return optional.orElse(null);
    }

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

    @Transactional
    public Product init(Long id) {
        Product product = new Product(id, "MacBook Pro", new BigDecimal("19999.00"));
        return productRepository.save(product);
    }
}

这里有一个关键点:

  • 查询用 @Cacheable
  • 更新后用 @CacheEvict

这就是前面提到的“更新数据库后删除缓存”策略。


6. 控制器

ProductController.java

package com.example.demo.controller;

import com.example.demo.entity.Product;
import com.example.demo.service.ProductService;
import jakarta.validation.constraints.NotNull;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

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

    private final ProductService productService;

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

    @PostMapping("/init/{id}")
    public Product init(@PathVariable Long id) {
        return productService.init(id);
    }

    @GetMapping("/{id}")
    public Product get(@PathVariable @NotNull 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 -X POST http://localhost:8080/products/init/1

2)第一次查询

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

控制台会输出:

query db, id = 1

说明回源数据库了。

3)第二次查询

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

这次不会再打印数据库日志,说明命中缓存。

4)更新数据

curl -X PUT http://localhost:8080/products/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"MacBook Pro M3","price":20999.00}'

5)再次查询

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

会再次打印数据库日志,然后返回新值。
这说明缓存已被删除,并重新加载。


一致性设计:别追求“看起来很美”的强一致

如果你刚接触缓存,很容易想做成“数据库一更新,缓存立即 100% 正确”。
但高并发系统里,这通常不是免费午餐。

推荐策略:先更新数据库,再删除缓存

这是最经典也最稳的方案。

流程如下:

flowchart TD
    A[更新请求] --> B[更新数据库]
    B --> C{是否成功}
    C -- 否 --> D[返回失败]
    C -- 是 --> E[删除Redis缓存]
    E --> F[删除本地缓存]
    F --> G[下次查询时重建缓存]

优点:

  • 实现简单
  • 不容易写入脏数据
  • 适合绝大多数读多写少场景

为什么不推荐“更新缓存值”?

因为你可能会遇到:

  • 缓存对象和数据库对象字段不一致
  • 部分字段是动态计算值,不适合直接覆盖
  • 分布式环境下多个实例并发更新,覆盖顺序难控制

我自己踩过的坑就是:
数据库里改了 3 个字段,缓存只更新了 2 个,结果接口返回的对象“半新半旧”,排查非常恶心。

本地缓存一致性怎么处理

单机时好办,@CacheEvict 直接删本地缓存即可。
但多实例部署时,一个节点删了自己的 Caffeine,别的节点还留着旧值。

这时要加一个失效通知机制,比如:

  • Redis Pub/Sub
  • RocketMQ / Kafka 广播缓存失效事件
  • 统一缓存变更中心

思路是:

  1. 更新数据库成功
  2. 删除 Redis
  3. 发布 product:1 已失效消息
  4. 所有服务实例收到消息后删除自己的本地缓存

如果不做这一步,多级缓存的一致性就只停留在单机层面。


穿透防护:空值缓存 + 参数校验 + 可选布隆过滤器

1. 参数校验是第一道门

id <= 0、超长字符串、明显非法格式,压根不应该进入缓存和数据库。

@GetMapping("/{id}")
public Product get(@PathVariable @NotNull Long id) {
    if (id <= 0) {
        throw new IllegalArgumentException("invalid id");
    }
    return productService.getById(id);
}

2. 缓存空值

如果某个 id 根本不存在,数据库查一次后应该短时间记住“它不存在”,否则每次都会打到数据库。

要实现这一点,你可以:

  • 允许 Redis 缓存 null 包装对象
  • 或者缓存一个特殊占位值,比如 NULL_VALUE

下面给一个简单思路,使用自定义占位对象:

NullValue.java

package com.example.demo.cache;

import java.io.Serializable;

public final class NullValue implements Serializable {
    public static final NullValue INSTANCE = new NullValue();
    private NullValue() {
    }
}

然后在缓存加载时把 null 替换成占位对象,读取时再转回 null。
实际项目里你也可以直接使用 Spring 的 org.springframework.cache.support.NullValue

注意:空值缓存 TTL 要短,比如 1~5 分钟,避免数据后来插入了却长时间读不到。

3. 布隆过滤器适合超大规模 key

如果你的 key 空间非常大,比如用户 ID、商品 ID、券码,且恶意探测明显,可以在 Redis 前面加布隆过滤器:

  • 存在概率高:继续查缓存
  • 一定不存在:直接返回

但布隆过滤器不是银弹:

  • 有误判率
  • 需要初始化和定期维护
  • 对中小系统不一定划算

热点 key 击穿:互斥锁与逻辑过期

方案一:互斥锁重建缓存

当 Redis 和本地缓存都失效时,只允许一个线程回源数据库,其它线程等待或快速失败。

伪代码示意:

public Product getProductWithMutex(Long id) {
    String lockKey = "lock:product:" + id;

    Product product = getFromCache(id);
    if (product != null) {
        return product;
    }

    boolean locked = tryLock(lockKey);
    if (!locked) {
        sleep(50);
        return getProductWithMutex(id);
    }

    try {
        product = getFromCache(id);
        if (product != null) {
            return product;
        }

        product = loadFromDb(id);
        putToCache(id, product);
        return product;
    } finally {
        unlock(lockKey);
    }
}

优点:

  • 简单直接
  • 适合热点 key 不多的场景

缺点:

  • 会有线程等待
  • 锁设置不当可能死锁或误删

方案二:逻辑过期

逻辑过期的思路是:

  • 缓存数据不立刻删除
  • 数据中额外存一个“逻辑过期时间”
  • 读请求拿到旧值也先返回
  • 由后台线程异步重建缓存

这适合对实时性要求没那么极致,但对可用性和低延迟要求更高的场景。

状态图可以这样理解:

stateDiagram-v2
    [*] --> Valid
    Valid --> Expired : 到达逻辑过期时间
    Expired --> Rebuilding : 线程抢到重建锁
    Expired --> Expired : 未抢到锁,返回旧值
    Rebuilding --> Valid : 重建完成

如果你做的是商品详情、店铺信息、内容页,这种方案通常很香。
但如果你做的是库存、余额,就要非常谨慎,不能为了“快”牺牲关键正确性。


常见坑与排查

这一部分是我觉得最值钱的。因为多级缓存不是“能跑就行”,很多问题都出在边角处。

坑 1:本地缓存和 Redis 数据不一致

现象:

  • A 节点已经读到新数据
  • B 节点还在返回旧数据

原因:

  • 只删了 Redis,没删其他节点的本地缓存
  • 本地缓存 TTL 比 Redis 长很多

排查方式:

  1. 确认是不是多实例部署
  2. 查看缓存更新链路有没有广播失效消息
  3. 对比本地缓存 TTL 和 Redis TTL
  4. 打印 key 的更新时间和来源层级

建议:

  • 多实例时一定做本地缓存失效通知
  • 本地缓存 TTL 通常要短于 Redis

坑 2:序列化失败或反序列化类型不对

现象:

  • Redis 有值,但取出来报类型转换异常
  • 对象字段为空或类型错乱

常见原因:

  • 用了 JDK 序列化,跨版本兼容差
  • 多态对象没带类型信息
  • 修改了实体字段结构,旧缓存没清

建议:

  • 优先使用 JSON 序列化
  • 对缓存对象做版本管理
  • 发布前评估是否需要清理历史缓存 key

坑 3:@Cacheable 不生效

这个是 Spring Cache 初学者最常见的坑之一。

典型原因:

  1. 方法是同类内部调用,没走代理
  2. 方法不是 public
  3. 没有启用 @EnableCaching
  4. key 表达式写错
  5. 异常导致方法未正常返回

排查方式:

  • 看启动类和配置类是否加了 @EnableCaching
  • 看调用路径是否经过 Spring 代理 Bean
  • 打开 debug 日志观察缓存切面是否执行

坑 4:大对象进入本地缓存导致 Full GC

现象:

  • 接口一开始很快,后来频繁 GC
  • 堆内存上涨明显

原因:

  • 本地缓存塞了过多大对象
  • maximumSize 配置不合理
  • 把列表、分页大结果直接缓存了

建议:

  • 本地缓存只放高频、相对小的热点对象
  • 大分页结果更适合放 Redis,不适合放 JVM 堆
  • 监控堆内存、GC 次数、缓存命中率

坑 5:缓存雪崩

现象:

  • 某个时间点 Redis QPS 暴涨
  • 数据库连接池打满

原因:

  • 大量 key 用了相同 TTL
  • 某批缓存统一重建

解决:

  • TTL 加随机值
  • 热点 key 单独配置更长 TTL
  • 做分批预热
  • 用本地缓存做兜底

示例:

Duration ttl = Duration.ofMinutes(30).plusSeconds(ThreadLocalRandom.current().nextInt(0, 300));

安全/性能最佳实践

这部分我按“能直接拿去用”的角度给建议。

1. 缓存 key 设计要可控

推荐格式:

product:{id}
user:{id}
order:{id}

如果使用 Spring Cache 默认 key,也建议统一 cacheName 和 key 策略。
不要把复杂对象直接拼成 key,否则:

  • 难排查
  • 容易变化
  • key 长度失控

2. TTL 分层设计

一个比较稳妥的经验值:

  • 本地缓存 Caffeine:30 秒 ~ 5 分钟
  • Redis 缓存:5 分钟 ~ 30 分钟
  • 空值缓存:1 分钟 ~ 5 分钟

原则是:

  • 本地缓存更短,确保多实例旧数据窗口更小
  • Redis 更长,减轻数据库压力
  • 空值更短,给后续真实数据插入留空间

3. 热点数据单独配置

不要所有缓存都一刀切。
热点商品、配置字典、地区信息、类目树,这些访问模式完全不一样。

可以按 cacheName 维度配置不同 TTL:

configMap.put("product", defaultConfig.entryTtl(Duration.ofMinutes(30)));
configMap.put("dict", defaultConfig.entryTtl(Duration.ofHours(6)));
configMap.put("userProfile", defaultConfig.entryTtl(Duration.ofMinutes(10)));

4. 加监控,不要盲调

缓存优化最怕“凭感觉”。

至少监控这些指标:

  • 本地缓存命中率
  • Redis 命中率
  • 数据库回源次数
  • 热点 key 排行
  • 缓存重建耗时
  • Redis 网络耗时
  • JVM 堆内存与 GC

没有监控时,很多人会把问题归咎于 Redis 慢、数据库慢,最后发现其实是 key 根本没命中。


5. 防止缓存存入敏感信息

这一点很容易被忽略。

不要随便把以下数据直接缓存:

  • 明文手机号、身份证号
  • token、密钥、凭证
  • 权限敏感数据且无隔离控制

如果必须缓存:

  • 做脱敏
  • 做加密
  • 控制 TTL
  • 严格区分环境

6. 读写比不高时,不要过度设计多级缓存

多级缓存很好,但不是所有系统都值得上。

如果你的场景是:

  • 写多读少
  • 数据变化极频繁
  • 每次读取都要求强一致

那多级缓存可能收益有限,甚至徒增复杂度。
这时候也许:

  • 只用 Redis
  • 只做局部热点缓存
  • 或者直接优化 SQL / 索引

会更合适。


方案边界与取舍

这里我想专门说一句:多级缓存不是默认正确答案。

它适合:

  • 读多写少
  • 热点数据明显
  • 允许短暂最终一致
  • 对 RT 很敏感

它不太适合:

  • 强一致金融账务
  • 高频写入库存扣减
  • 实时状态类业务
  • 数据体积超大且对象结构复杂

工程上最重要的不是“用了多少组件”,而是: 复杂度和收益是否匹配。


总结

这篇文章我们完成了一套 Spring Boot 下基于 Spring Cache + Redis + Caffeine 的多级缓存实践,并重点讲了三个核心问题:

  1. 一致性:推荐“先更新数据库,再删除缓存”,多实例下补上本地缓存失效通知
  2. 穿透防护:参数校验 + 空值缓存,必要时再引入布隆过滤器
  3. 性能调优:本地缓存兜热点、Redis 抗大多数流量、TTL 分层并配合随机过期

如果你准备把它真正用到项目里,我建议按下面顺序落地:

  • 第一步:先做单层 Redis 缓存,保证 key、TTL、序列化规范
  • 第二步:再引入本地 Caffeine,解决热点与延迟问题
  • 第三步:补齐一致性广播、空值缓存、互斥锁、监控指标
  • 第四步:按业务类型拆分 cacheName,单独调参

最后给一个很实用的判断标准:

如果你还不知道系统的热点 key 是谁、命中率多少、数据库回源比例多少,那先别急着“上多级缓存黑科技”,先把监控补齐。

因为缓存优化,从来都不是“配个注解结束了”,而是一个持续观察、持续修正的工程过程。


分享到:

上一篇
《安卓逆向实战:基于 Frida 与 JADX 定位并绕过常见签名校验逻辑》
下一篇
《Web3 中级实战:基于 EIP-4337 的账户抽象钱包接入与 Gas 代付方案落地》