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

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

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

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

在业务规模刚起来的时候,很多系统的缓存方案都很“朴素”:Spring Cache + Redis,一把梭。
一开始确实够用,但等到接口 QPS 上来、热点数据集中、数据库偶发抖动时,就会发现只靠单级 Redis 缓存并不总是最优。

这篇文章我想带你从实战角度做一遍多级缓存:

  • 一级缓存:应用内本地缓存(Caffeine)
  • 二级缓存:Redis
  • 统一接入:Spring Cache
  • 重点解决:
    • 缓存一致性
    • 缓存穿透
    • 热点 Key 与击穿
    • 序列化与性能调优
    • 常见坑排查

如果你已经会用 @Cacheable,那这篇文章会帮你把它从“能用”提升到“敢在线上用”。


一、背景与问题

先说一个典型场景。

比如商品详情接口:

  • 读多写少
  • 同一个商品会被高频访问
  • 接口对 RT 比较敏感
  • 底层数据库是 MySQL

很多项目的第一版实现是:

  1. Controller 调 Service
  2. Service 用 @Cacheable
  3. 缓存落 Redis
  4. Redis miss 时查库并回填

这个方案没错,但到了中高并发场景,往往会暴露几个问题:

1. 单级 Redis 仍然有网络开销

Redis 再快,也有网络 IO、序列化、反序列化成本。
同一个热点数据如果每次都要跨网络取,延迟还是比本地内存高不少。

2. 热点 Key 击穿

某个热 Key 恰好过期,大量请求同时打到数据库。
如果数据库抗不住,链路就会抖。

3. 缓存穿透

查询一个根本不存在的数据,比如恶意传入不存在的商品 ID。
如果不做处理,缓存永远 miss,请求次次打数据库。

4. 缓存与数据库不一致

数据更新了,但缓存没有及时失效;
或者先删缓存再写库,期间并发读把旧值重新写回缓存。

5. 序列化问题

  • JDK 序列化体积大
  • JSON 序列化可能有类型丢失
  • LocalDateTime、泛型对象常出坑

所以,多级缓存不是“为了炫技”,而是为了更稳、更快、更省数据库。


二、前置知识与环境准备

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

  • JDK 17
  • Spring Boot 3.x
  • Spring Cache
  • Redis
  • 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-validation</artifactId>
    </dependency>

    <dependency>
        <groupId>com.fasterxml.jackson.datatype</groupId>
        <artifactId>jackson-datatype-jsr310</artifactId>
    </dependency>
</dependencies>

application.yml

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

server:
  port: 8080

logging:
  level:
    org.springframework.cache: debug

这里我把 spring.cache.type 设成 none,原因很简单:
我们自己接管 CacheManager,避免 Spring Boot 自动配置干扰。


三、核心原理

多级缓存的基本思路是:

  • L1 本地缓存(Caffeine):极低延迟,适合热点数据
  • L2 Redis 缓存:多实例共享,容量更大
  • DB:最终数据源

请求链路如下:

flowchart LR
    A[请求进入] --> B{L1 Caffeine命中?}
    B -- 是 --> C[直接返回]
    B -- 否 --> D{L2 Redis命中?}
    D -- 是 --> E[写入L1并返回]
    D -- 否 --> F[查询DB]
    F --> G[写入Redis]
    G --> H[写入Caffeine]
    H --> I[返回结果]

这个链路解决了两个关键问题:

  1. 热点数据尽量在 JVM 内存命中
  2. 多实例之间通过 Redis 保持一定的数据共享能力

但它也引入了新的挑战:一致性


四、多级缓存一致性怎么理解

先讲结论:
多级缓存做不到绝对强一致,通常追求“最终一致 + 可接受窗口”

最常见的更新策略有三种:

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

这是线上最常用的策略。

流程:

sequenceDiagram
    participant C as Client
    participant S as Service
    participant DB as MySQL
    participant R as Redis
    participant L1 as Caffeine

    C->>S: 更新商品信息
    S->>DB: update
    DB-->>S: success
    S->>R: delete cache
    S->>L1: invalidate cache
    S-->>C: 返回成功

优点:

  • 简单
  • 比“先删缓存再写库”更安全

问题:

  • 在极端并发下,仍可能出现旧值短暂回填

2. 延迟双删

更新数据库后先删缓存,过一小段时间再删一次。

适用于:

  • 写操作较少
  • 对短暂不一致比较敏感

但它不是银弹,更多像“补丁式增强”。

3. 基于消息通知清理本地缓存

如果服务是集群部署,一个实例删了自己 JVM 内的 L1,其他实例的 L1 还在。
所以常见做法是:

  • 先删 Redis
  • 再通过 MQ / Redis PubSub 广播
  • 各实例收到消息后清理本地缓存
flowchart TD
    A[更新DB成功] --> B[删除Redis缓存]
    B --> C[发送缓存失效消息]
    C --> D[实例1清理L1]
    C --> E[实例2清理L1]
    C --> F[实例3清理L1]

这篇文章先聚焦单服务内可运行方案,集群广播我会在“最佳实践”里说明扩展方式。


五、实战代码:基于 Spring Cache + Caffeine + Redis 的多级缓存

下面我们实现一个可运行的商品查询示例


5.1 定义实体与模拟数据库

package com.example.cache.model;

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

public class Product implements Serializable {
    private Long id;
    private String name;
    private BigDecimal price;
    private Integer stock;
    private LocalDateTime updateTime;

    public Product() {
    }

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

    public Long getId() {
        return id;
    }

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

    public String getName() {
        return name;
    }

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

    public BigDecimal getPrice() {
        return price;
    }

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

    public Integer getStock() {
        return stock;
    }

    public void setStock(Integer stock) {
        this.stock = stock;
    }

    public LocalDateTime getUpdateTime() {
        return updateTime;
    }

    public void setUpdateTime(LocalDateTime updateTime) {
        this.updateTime = updateTime;
    }
}
package com.example.cache.repository;

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

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

@Repository
public class ProductRepository {

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

    @PostConstruct
    public void init() {
        db.put(1L, new Product(1L, "机械键盘", new BigDecimal("299.00"), 100, LocalDateTime.now()));
        db.put(2L, new Product(2L, "电竞鼠标", new BigDecimal("159.00"), 80, LocalDateTime.now()));
    }

    public Product findById(Long id) {
        slowQuery();
        return db.get(id);
    }

    public void update(Product product) {
        product.setUpdateTime(LocalDateTime.now());
        db.put(product.getId(), product);
    }

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

这里我故意加了 Thread.sleep(200),方便你直观看到缓存命中后的性能差异。


5.2 自定义二级缓存管理器

Spring Cache 默认不会帮你做“多级缓存串联”,所以我们自己实现一个 Cache

MultiLevelCache

package com.example.cache.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 localValue = caffeineCache.get(key);
        if (localValue != null) {
            return localValue;
        }

        ValueWrapper redisValue = redisCache.get(key);
        if (redisValue != null) {
            caffeineCache.put(key, redisValue.get());
            return redisValue;
        }
        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("缓存值类型不匹配, key=" + key);
        }
        return (T) value;
    }

    @Override
    public <T> T get(Object key, Callable<T> valueLoader) {
        ValueWrapper wrapper = get(key);
        if (wrapper != null) {
            return (T) wrapper.get();
        }

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

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

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

    @Override
    public void evict(Object key) {
        caffeineCache.evict(key);
        redisCache.evict(key);
    }

    @Override
    public boolean evictIfPresent(Object key) {
        boolean local = caffeineCache.evictIfPresent(key);
        boolean remote = redisCache.evictIfPresent(key);
        return local || remote;
    }

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

    @Override
    public boolean invalidate() {
        boolean local = caffeineCache.invalidate();
        boolean remote = redisCache.invalidate();
        return local || remote;
    }
}

这个实现的核心逻辑很直接:

  • 读:先 Caffeine,后 Redis
  • 写:同时写两级
  • 删:同时删两级

对 tutorial 来说,这个版本够用,也好理解。
但我要先提醒一句:生产环境里,更新策略最好偏向“更新库后删缓存”,而不是业务写入时直接强依赖 cache put”。 这一点后面会讲。


5.3 自定义 CacheManager

package com.example.cache.cache;

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

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

public class MultiLevelCacheManager implements CacheManager {

    private final CacheManager caffeineCacheManager;
    private final CacheManager redisCacheManager;
    private final Map<String, Cache> cacheMap = new ConcurrentHashMap<>();

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

    @Override
    public Cache getCache(String name) {
        return cacheMap.computeIfAbsent(name, key -> {
            Cache caffeineCache = caffeineCacheManager.getCache(key);
            Cache redisCache = redisCacheManager.getCache(key);
            if (caffeineCache == null || redisCache == null) {
                return null;
            }
            return new MultiLevelCache(key, caffeineCache, redisCache);
        });
    }

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

5.4 缓存配置

package com.example.cache.config;

import com.example.cache.cache.MultiLevelCacheManager;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
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.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;

@Configuration
@EnableCaching
public class CacheConfig {

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

    @Bean
    public CacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.registerModule(new JavaTimeModule());

        BasicPolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
                .allowIfSubType(Object.class)
                .build();
        objectMapper.activateDefaultTyping(ptv, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY);

        RedisSerializationContext.SerializationPair<Object> valueSerializationPair =
                RedisSerializationContext.SerializationPair.fromSerializer(
                        new GenericJackson2JsonRedisSerializer(objectMapper)
                );

        RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(5))
                .disableCachingNullValues()
                .serializeValuesWith(valueSerializationPair)
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));

        return RedisCacheManager.builder(redisConnectionFactory)
                .cacheDefaults(configuration)
                .initialCacheNames(java.util.Set.of("product"))
                .build();
    }

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

这里有几个点值得注意:

  • 本地缓存 TTL 30 秒
  • Redis TTL 5 分钟
  • L1 TTL 一般比 L2 更短,避免本地脏数据停留过久
  • Redis 使用 GenericJackson2JsonRedisSerializer,比 JDK 序列化更直观

5.5 Service:查询、更新、穿透防护

package com.example.cache.service;

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

import java.math.BigDecimal;
import java.time.LocalDateTime;

@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) {
        return productRepository.findById(id);
    }

    @CacheEvict(cacheNames = "product", key = "#id")
    public Product updatePrice(Long id, BigDecimal newPrice) {
        Product product = productRepository.findById(id);
        if (product == null) {
            return null;
        }
        product.setPrice(newPrice);
        product.setUpdateTime(LocalDateTime.now());
        productRepository.update(product);
        return product;
    }
}

这里我用了:

  • @Cacheable:读缓存
  • @CacheEvict:更新后删缓存

为什么不是 @CachePut
因为在大多数业务中,删缓存比更新缓存更稳妥

原因是:

  • 更新逻辑通常复杂
  • 可能涉及多个缓存 Key
  • 删缓存可以让后续读走“查库再回填”的标准路径

这是一个我自己比较偏爱的经验法则:
能删就别硬改,能统一回填就别多处写缓存。


5.6 Controller

package com.example.cache.controller;

import com.example.cache.model.Product;
import com.example.cache.service.ProductService;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;

@RestController
@RequestMapping("/products")
@Validated
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}/price")
    public Product updatePrice(@PathVariable Long id,
                               @RequestParam @NotNull @DecimalMin("0.01") BigDecimal price) {
        return productService.updatePrice(id, price);
    }
}

六、逐步验证清单

你可以按这个顺序自己跑一遍。

1. 第一次查询,走数据库

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

预期:

  • 接口响应较慢,约 200ms+
  • Redis 出现缓存
  • 本地缓存也已建立

2. 再次查询,同实例命中本地缓存

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

预期:

  • 响应明显变快
  • 不再触发 repository 的慢查询

3. 更新数据后,缓存被清理

curl -X PUT "http://localhost:8080/products/1/price?price=399.00"

然后再查:

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

预期:

  • 第一次查询重新回源数据库
  • 后续再次查询重新命中缓存

4. 查询不存在数据,观察穿透问题

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

因为我们当前配置里 unless = "#result == null",空值不会缓存。
这在“正常业务”里可能没问题,但在恶意扫描场景里就会形成缓存穿透。
下面我们专门处理它。


七、缓存穿透防护:空对象缓存 + 参数校验

缓存穿透最常见的解决方式:

  1. 参数校验
  2. 缓存空值
  3. 布隆过滤器(适用于大规模 ID 查询)

7.1 参数校验先挡一层

比如商品 ID 不允许小于等于 0。

@GetMapping("/{id}")
public Product getById(@PathVariable @jakarta.validation.constraints.Min(1) Long id) {
    return productService.getById(id);
}

这个简单,但很有效。
我见过不少系统被“无效 ID 扫描”打到 DB 飙升,结果加上参数边界校验后,噪音请求直接少一大截。


7.2 空对象缓存

如果你的业务允许,可以缓存“空结果”,TTL 设短一点,比如 30 秒。

思路有两种:

  • Spring Cache 直接允许缓存 null
  • 返回一个特殊空对象占位

Spring Cache 对 null 的处理不总是直观,我更建议用特殊对象占位

定义空对象标记

package com.example.cache.model;

import java.math.BigDecimal;
import java.time.LocalDateTime;

public class ProductNullValue extends Product {
    public ProductNullValue() {
        super(-1L, "NULL", BigDecimal.ZERO, 0, LocalDateTime.MIN);
    }
}

Service 中处理

package com.example.cache.service;

import com.example.cache.model.Product;
import com.example.cache.model.ProductNullValue;
import com.example.cache.repository.ProductRepository;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
public class ProductQueryService {

    private final ProductRepository productRepository;

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

    @Cacheable(cacheNames = "product", key = "#id")
    public Product getByIdWithNullCache(Long id) {
        Product product = productRepository.findById(id);
        return product == null ? new ProductNullValue() : product;
    }

    public Product unwrap(Product product) {
        return product instanceof ProductNullValue ? null : product;
    }
}

这种方式的优点是:

  • 能挡住不存在数据的重复查询
  • 逻辑明确,便于调试

缺点是:

  • 业务层要处理占位对象
  • 需要防止占位对象误传给前端

八、缓存击穿与热点 Key:怎么扛住瞬时并发

当某个热点 Key 失效时,多个线程同时回源 DB,就是典型的缓存击穿。

方案 1:使用 @Cacheable(sync = true)

对于单机内并发,这是最省事的办法。

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

sync = true 的作用是:

  • 同一 JVM 内,同一个 key 只让一个线程执行 valueLoader
  • 其他线程等待结果

这招我挺推荐,尤其是热点数据明显、且项目先要快速落地时。

但它也有边界:

  • 只对当前实例有效
  • 多实例部署下,仍然可能多个节点同时回源

方案 2:Redis 分布式锁

如果是多实例热点 Key,可以引入分布式锁。
不过这个方案复杂度会明显提升,包括:

  • 锁续期
  • 锁超时
  • 异常释放
  • 高并发下锁竞争

对大部分中级项目来说,我建议优先顺序是:

  1. 本地缓存
  2. sync = true
  3. TTL 加随机值
  4. 真有必要再引入分布式锁

九、常见坑与排查

这一节很重要,因为多级缓存“写起来不难,出问题很隐蔽”。

1. @Cacheable 不生效

常见原因:

  • 没加 @EnableCaching
  • 方法不是 public
  • 同类内部调用,绕过了代理
  • 异常被吃掉,导致看起来像缓存没生效

比如下面这种,缓存不会生效:

@Service
public class ProductService {

    public Product outer(Long id) {
        return inner(id);
    }

    @Cacheable(cacheNames = "product", key = "#id")
    public Product inner(Long id) {
        return ...
    }
}

因为 outer()inner() 是对象内部调用,没有经过 Spring AOP 代理。

排查建议:

  • 开启 org.springframework.cache debug 日志
  • 打断点看是否进入 CacheInterceptor
  • 把缓存方法拆到独立 Bean 中

2. 序列化失败或反序列化类型错乱

典型报错:

  • Cannot deserialize value
  • LinkedHashMap cannot be cast to xxx
  • LocalDateTime 反序列化异常

排查重点:

  • Redis 序列化器是否统一
  • ObjectMapper 是否注册 JavaTimeModule
  • 是否启用了多态类型信息

如果你项目里多个服务共用同一批 Redis Key,还要特别注意:

  • 不同服务用不同序列化格式会出问题
  • Key 命名空间最好隔离,比如 app1:product:1

3. 本地缓存与 Redis 数据不一致

这是多级缓存最常见的问题之一。

场景:

  1. 实例 A 更新了数据并删 Redis
  2. 实例 A 删了自己的本地缓存
  3. 实例 B 的本地缓存还没删
  4. 实例 B 继续返回旧值

单机开发时感觉一切正常,一上集群就翻车,这个坑我见得很多。

解决思路:

  • 用 MQ / Redis PubSub 通知各节点清理 L1
  • 或让 L1 TTL 更短,控制脏数据窗口
  • 对一致性极敏感的数据,不要放本地缓存

4. Key 设计混乱

错误示例:

@Cacheable(cacheNames = "product", key = "#root.args")

这样出来的 key 不可读、不可控、跨版本也容易变。

建议 key 设计:

  • 简单、稳定、可观测
  • 包含业务前缀和主键
  • 避免直接使用对象整体序列化作为 key

例如:

@Cacheable(cacheNames = "product", key = "'product:' + #id")

如果你已经用了 cacheNames,再叠一层业务前缀会更清晰。


5. TTL 设置不合理

两个极端都不行:

  • TTL 太短:频繁回源,缓存价值低
  • TTL 太长:脏数据时间窗大

我的经验建议:

  • L1 本地缓存:10~60 秒
  • L2 Redis:1~10 分钟
  • 空值缓存:30~120 秒
  • 热点 Key:TTL 增加随机值,避免同时失效

十、安全/性能最佳实践

这一节给你一些更贴近线上环境的建议。

1. 不要缓存敏感数据明文

Redis 常被当成“内网组件”,但这不代表可以随便放敏感字段。
比如:

  • 用户手机号
  • 身份证号
  • token 明文
  • 支付相关信息

建议:

  • 非必要不缓存
  • 必要时脱敏或加密
  • Redis 开启访问控制与网络隔离

2. 缓存对象尽量轻量

缓存不是对象仓库。
对象越大:

  • 网络传输越慢
  • 序列化越耗时
  • Redis 内存占用越高

建议只缓存查询真正需要的字段,而不是整个聚合对象全塞进去。

比如商品详情页只需要:

  • id
  • name
  • price
  • stock
  • updateTime

那就不要把一堆无关扩展字段一起缓存。


3. TTL 加随机抖动

防止大量 key 同时过期。

示例思路:

Duration base = Duration.ofMinutes(5);
long randomSeconds = java.util.concurrent.ThreadLocalRandom.current().nextLong(30, 120);
Duration finalTtl = base.plusSeconds(randomSeconds);

如果你的缓存是批量预热进去的,这一步特别重要。


4. 热点数据优先放 L1,本地缓存大小要可控

Caffeine 很快,但 JVM 堆不是无限的。
如果本地缓存无限长、无限大,最终会把 GC 压力拉起来。

建议至少配置:

  • maximumSize
  • expireAfterWriteexpireAfterAccess
  • 指标监控 hit rate / eviction count

5. 对强一致数据设置“不过本地缓存”

并不是所有数据都适合多级缓存。
比如:

  • 库存强一致
  • 支付状态
  • 秒杀资格

这些数据如果允许几秒旧值,就可能出事故。
建议:

  • 只走 Redis
  • 或干脆不走缓存
  • 或走专门的一致性方案

多级缓存适合的是高频读、可容忍短暂旧值的数据。


6. 加监控,不要“盲用缓存”

至少监控这些指标:

  • 缓存命中率
  • Redis QPS
  • Redis 网络耗时
  • DB 回源次数
  • 热 Key 分布
  • 缓存大小与淘汰次数
  • 接口 TP99 / TP999

没有监控的缓存优化,很容易变成“自我感觉优化”。


十一、一个更稳妥的生产落地建议

如果你准备真正上线,我建议按这个优先级实施,而不是一步到位堆复杂度。

第一阶段:先把单级缓存用稳

  • @Cacheable
  • @CacheEvict
  • 合理 TTL
  • 参数校验
  • 空值缓存
  • 命中率监控

第二阶段:引入 L1 本地缓存

适用于:

  • 读非常频繁
  • 同一个实例上的热点明显
  • 接口 RT 敏感

第三阶段:解决集群一致性

  • Redis PubSub 或 MQ 广播失效
  • 对热点 key 做防击穿
  • 对关键数据分类治理

这比一开始就上分布式锁、布隆过滤器、复杂一致性协议更现实。


十二、总结

我们这篇文章做了几件事:

  • 用 Spring Cache 统一缓存访问入口
  • 用 Caffeine + Redis 搭建多级缓存
  • 解释了多级缓存的读写链路
  • 落地了可运行代码
  • 处理了缓存一致性、穿透、击穿与序列化问题
  • 给出了线上可执行的调优建议

最后我给你一个实用结论,方便落地时做判断:

什么时候适合多级缓存?

适合:

  • 读多写少
  • 热点明显
  • 可接受短暂最终一致
  • 对接口 RT 敏感

不太适合:

  • 强一致要求极高
  • 数据更新非常频繁
  • 本地缓存失效广播成本高
  • 缓存对象特别大

我个人建议的默认组合

如果你问我一个中型 Spring Boot 项目,应该怎么起步,我会建议:

  1. @Cacheable + Redis
  2. 更新时优先“先更新库,再删缓存”
  3. 参数校验 + 空值缓存防穿透
  4. 热点查询加 sync = true
  5. 高频热点再加 Caffeine
  6. 集群场景用消息机制清理 L1

这套方案不算花哨,但很实用,也比较容易维护。

如果你照着本文的代码先跑通,再逐步补上监控、广播失效和 TTL 抖动,基本就已经具备线上可用的多级缓存骨架了。


分享到:

上一篇
《自动化测试中的测试数据治理实战:从数据构造、隔离到回放的落地方案》
下一篇
《分布式架构中基于消息队列实现最终一致性的实战设计与排障指南》