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

《Spring Boot 中基于 Spring Cache 与 Redis 实现多级缓存的实战方案与性能调优》

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

Spring Boot 中基于 Spring Cache 与 Redis 实现多级缓存的实战方案与性能调优

在做接口性能优化时,很多同学第一反应是“上 Redis”。这当然没错,但如果你的热点数据访问非常频繁,只靠 Redis 这一层,网络开销、序列化开销、Redis 自身压力都会逐渐显现。

更现实一点的场景是这样的:

  • 本地 JVM 内访问极快
  • Redis 适合跨实例共享缓存
  • 数据库适合做最终数据源,但最慢

所以,多级缓存的典型目标就是:

  1. 优先命中本地缓存
  2. 本地未命中,再查 Redis
  3. Redis 未命中,再查数据库
  4. 回填 Redis 和本地缓存

这篇文章我会带你用 Spring Boot + Spring Cache + Redis + Caffeine 做一个可运行的多级缓存方案,并把常见的坑和调优点一并讲清楚。文章偏实战,我会尽量按“能落地”的方式来写。


背景与问题

先看单级缓存常见的问题。

只有 Redis 的问题

如果系统只有 Redis 缓存,虽然已经比直接查数据库快很多,但仍然会遇到:

  • 高并发下大量请求都要走网络访问 Redis
  • 热点 Key 会集中打到 Redis
  • 序列化/反序列化消耗不小
  • 多服务实例场景下,Redis 压力会越来越高

只有本地缓存的问题

如果只做 JVM 本地缓存,比如 Caffeine:

  • 单机性能很好
  • 但是缓存无法跨实例共享
  • 多个应用节点的数据容易不一致
  • 服务重启后缓存全失效

多级缓存为什么适合 Spring Boot 场景

Spring Boot 项目里,大多数接口的访问特征都很像:

  • 某些配置、字典、商品详情、用户资料是热点数据
  • 更新频率相对低,读取频率高
  • 服务通常是多实例部署

这类数据特别适合:

  • 一级缓存:Caffeine(本地内存)
  • 二级缓存:Redis(分布式共享)

这样做能兼顾:

  • 极致读性能
  • 多实例共享
  • 适当的一致性控制
  • 对数据库的强保护

前置知识与环境准备

本文示例环境:

  • JDK 17
  • Spring Boot 3.x
  • Spring Cache
  • Spring Data Redis
  • Caffeine
  • Redis 7.x

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

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

核心原理

先把整体链路想清楚,再写代码会顺很多。

多级缓存访问流程

flowchart TD
    A[请求进入] --> B{本地缓存 Caffeine 命中?}
    B -- 是 --> C[返回数据]
    B -- 否 --> D{Redis 命中?}
    D -- 是 --> E[回填本地缓存]
    E --> C
    D -- 否 --> F[查询数据库]
    F --> G[写入 Redis]
    G --> H[写入本地缓存]
    H --> C

更新流程的关键点

读链路不难,真正难的是写链路。因为只要数据更新,就涉及缓存一致性问题。

比较常见的策略:

  • 先更新数据库,再删除缓存
  • 删除时同时删本地缓存和 Redis
  • 如果是多实例,本地缓存删除还需要广播通知
sequenceDiagram
    participant Client as 客户端
    participant App as 应用实例A
    participant DB as 数据库
    participant Redis as Redis
    participant App2 as 应用实例B

    Client->>App: 更新商品信息
    App->>DB: update product
    DB-->>App: success
    App->>Redis: delete redis key
    App->>App: clear local cache
    App->>Redis: publish invalidation message
    Redis-->>App2: 订阅到失效通知
    App2->>App2: clear local cache

Spring Cache 在这里扮演什么角色

Spring Cache 负责的是统一缓存编程模型,例如:

  • @Cacheable
  • @CachePut
  • @CacheEvict

但 Spring Cache 默认并不知道“多级缓存”该怎么协同,所以我们通常有两种做法:

  1. 自定义 CacheManager / Cache 实现
  2. 业务里手工编排本地缓存 + Redis

如果你希望继续用 @Cacheable 这套注解,推荐第一种:自定义一个 MultiLevelCache,让 Spring Cache 帮你接管方法缓存。


方案设计:Caffeine + Redis 双层缓存

这里我们采用下面这个设计:

  • 一级缓存:Caffeine
    • 优点:极快、无网络开销
    • 用途:拦截热点请求
  • 二级缓存:Redis
    • 优点:多实例共享
    • 用途:跨节点缓存、容量更大
  • 缓存失效通知:Redis Pub/Sub
    • 用途:某个实例删缓存后,通知其他实例清理本地缓存

适用场景

推荐用于:

  • 商品详情
  • 分类树
  • 地区字典
  • 用户基础资料
  • 配置项
  • 报表维度数据

不太推荐直接用于:

  • 强一致金融余额
  • 高频写入且必须实时一致的数据
  • 超大对象缓存

实战代码(可运行)

下面给一个可运行的简化版本。为了聚焦主题,我用“商品查询”做示例。

1. application.yml

server:
  port: 8080

spring:
  cache:
    type: none
  data:
    redis:
      host: localhost
      port: 6379
      timeout: 3s

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics

这里把 spring.cache.type 设为 none,是因为我们要自己注册 CacheManager


2. 启动类

package com.example.multicache;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;

@SpringBootApplication
@EnableCaching
public class MultiCacheApplication {
    public static void main(String[] args) {
        SpringApplication.run(MultiCacheApplication.class, args);
    }
}

3. 商品实体

package com.example.multicache.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 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 Long getVersion() {
        return version;
    }

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

4. 模拟 Repository

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

package com.example.multicache.repository;

import com.example.multicache.model.Product;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Repository;

import java.math.BigDecimal;
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, "Mechanical Keyboard", new BigDecimal("399.00"), 1L));
        db.put(2L, new Product(2L, "Wireless Mouse", new BigDecimal("129.00"), 1L));
    }

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

    public Product save(Product product) {
        db.put(product.getId(), product);
        return product;
    }

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

5. Redis 序列化与监听配置

package com.example.multicache.config;

import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.*;

@Configuration
public class RedisConfig {

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

        ObjectMapper mapper = new ObjectMapper();
        mapper.activateDefaultTyping(
                LaissezFaireSubTypeValidator.instance,
                ObjectMapper.DefaultTyping.NON_FINAL,
                JsonTypeInfo.As.PROPERTY
        );

        GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(mapper);

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

    @Bean
    public ChannelTopic cacheEvictTopic() {
        return new ChannelTopic("cache:evict:topic");
    }

    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer(
            RedisConnectionFactory factory,
            MessageListenerAdapter listenerAdapter,
            ChannelTopic topic) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(factory);
        container.addMessageListener(listenerAdapter, topic);
        return container;
    }

    @Bean
    public MessageListenerAdapter messageListenerAdapter(CacheInvalidationListener listener) {
        return new MessageListenerAdapter(listener, "onMessage");
    }
}

6. 本地缓存失效监听器

package com.example.multicache.config;

import org.springframework.stereotype.Component;

@Component
public class CacheInvalidationListener {

    private final MultiLevelCacheManager cacheManager;

    public CacheInvalidationListener(MultiLevelCacheManager cacheManager) {
        this.cacheManager = cacheManager;
    }

    public void onMessage(String message) {
        String[] parts = message.split("::", 2);
        if (parts.length != 2) {
            return;
        }
        String cacheName = parts[0];
        String key = parts[1];
        cacheManager.clearLocal(cacheName, key);
    }
}

7. 自定义 MultiLevelCache

package com.example.multicache.config;

import com.github.benmanes.caffeine.cache.Cache;
import org.springframework.cache.support.AbstractValueAdaptingCache;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ChannelTopic;

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

public class MultiLevelCache extends AbstractValueAdaptingCache {

    private final String name;
    private final Cache<Object, Object> localCache;
    private final RedisTemplate<String, Object> redisTemplate;
    private final ChannelTopic topic;
    private final Duration redisTtl;

    public MultiLevelCache(
            String name,
            Cache<Object, Object> localCache,
            RedisTemplate<String, Object> redisTemplate,
            ChannelTopic topic,
            Duration redisTtl) {
        super(true);
        this.name = name;
        this.localCache = localCache;
        this.redisTemplate = redisTemplate;
        this.topic = topic;
        this.redisTtl = redisTtl;
    }

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

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

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

    @Override
    protected Object lookup(Object key) {
        Object localValue = localCache.getIfPresent(key);
        if (localValue != null) {
            return localValue;
        }

        Object redisValue = redisTemplate.opsForValue().get(buildRedisKey(key));
        if (redisValue != null) {
            localCache.put(key, redisValue);
        }
        return redisValue;
    }

    @Override
    public void put(Object key, Object value) {
        Object storeValue = toStoreValue(value);
        localCache.put(key, storeValue);
        redisTemplate.opsForValue().set(buildRedisKey(key), storeValue, redisTtl);
    }

    @Override
    public ValueWrapper putIfAbsent(Object key, Object value) {
        Object existing = lookup(key);
        if (existing != null) {
            return () -> fromStoreValue(existing);
        }
        put(key, value);
        return null;
    }

    @Override
    public void evict(Object key) {
        localCache.invalidate(key);
        redisTemplate.delete(buildRedisKey(key));
        redisTemplate.convertAndSend(topic.getTopic(), name + "::" + key);
    }

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

    @Override
    public <T> T get(Object key, Callable<T> valueLoader) {
        Object value = lookup(key);
        if (value != null) {
            return (T) fromStoreValue(value);
        }

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

    public void clearLocal(Object key) {
        localCache.invalidate(key);
    }
}

8. 自定义 CacheManager

package com.example.multicache.config;

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

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

@Component
public class MultiLevelCacheManager implements CacheManager {

    private final RedisTemplate<String, Object> redisTemplate;
    private final ChannelTopic topic;
    private final Map<String, MultiLevelCache> cacheMap = new ConcurrentHashMap<>();

    public MultiLevelCacheManager(RedisTemplate<String, Object> redisTemplate, ChannelTopic topic) {
        this.redisTemplate = redisTemplate;
        this.topic = topic;
    }

    @Override
    public Cache getCache(String name) {
        return cacheMap.computeIfAbsent(name, this::createCache);
    }

    private MultiLevelCache createCache(String name) {
        return new MultiLevelCache(
                name,
                Caffeine.newBuilder()
                        .initialCapacity(100)
                        .maximumSize(1000)
                        .expireAfterWrite(Duration.ofSeconds(30))
                        .recordStats()
                        .build(),
                redisTemplate,
                topic,
                Duration.ofMinutes(5)
        );
    }

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

    public void clearLocal(String cacheName, Object key) {
        MultiLevelCache cache = cacheMap.get(cacheName);
        if (cache != null) {
            cache.clearLocal(key);
        }
    }
}

9. 业务 Service

package com.example.multicache.service;

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

@Service
public class ProductService {

    private final ProductRepository repository;

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

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

    @CacheEvict(cacheNames = "product", key = "#product.id")
    public Product update(Product product) {
        Product old = repository.findById(product.getId());
        long version = old == null ? 1L : old.getVersion() + 1;
        product.setVersion(version);
        return repository.save(product);
    }
}

这里的 sync = true 很关键。它可以降低同一实例内缓存击穿时的并发加载问题。这个点我后面还会专门说。


10. Controller

package com.example.multicache.controller;

import com.example.multicache.model.Product;
import com.example.multicache.service.ProductService;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;

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

    private final ProductService service;

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

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

    @PutMapping("/{id}")
    public Product update(@PathVariable Long id, @RequestParam String name, @RequestParam BigDecimal price) {
        Product product = new Product();
        product.setId(id);
        product.setName(name);
        product.setPrice(price);
        return service.update(product);
    }
}

逐步验证清单

项目启动后,你可以按这个顺序验证。

第一次查询:走“数据库”

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

预期:

  • 本地缓存未命中
  • Redis 未命中
  • Repository 睡眠 100ms 后返回数据
  • 数据写入 Redis 和 Caffeine

第二次查询:走“本地缓存”

再次请求:

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

预期:

  • 直接命中 Caffeine
  • 响应明显更快

更新数据:触发双层失效

curl -X PUT "http://localhost:8080/products/1?name=Mechanical%20Keyboard%20V2&price=499"

然后再查询:

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

预期:

  • 本地缓存和 Redis 对应 Key 已失效
  • 重新回源加载最新数据

核心原理再拆开一点:为什么这个实现能工作

有同学看完代码会问:@Cacheable 是怎么自动走到你自定义的多级缓存里的?

答案是:

  • Spring 在执行 @Cacheable 方法时,会从容器里找 CacheManager
  • 我们注册了 MultiLevelCacheManager
  • getCache("product") 返回的是 MultiLevelCache
  • 所以 Spring Cache 注解最终操作的是我们的双层缓存逻辑

换句话说,Spring Cache 负责切面拦截,自定义 Cache 负责缓存策略


常见坑与排查

这一段非常重要。很多多级缓存方案不是不会写,而是上线后问题不断。

1. 本地缓存不一致

现象

你在实例 A 更新了数据,但实例 B 仍然返回旧值。

原因

因为 B 的 Caffeine 本地缓存并不知道数据已经被更新了。

解决

  • 删除 Redis 缓存还不够
  • 必须增加本地缓存失效广播
  • 本文示例用的是 Redis Pub/Sub

排查方法

  • 确认更新接口是否真的触发了 @CacheEvict
  • 确认消息频道是否发出通知
  • 确认其他实例是否成功订阅
  • 检查 key 格式是否一致,例如 product::1

2. @Cacheable 不生效

常见原因

  • 方法是 private
  • 同类内部调用,绕过了 Spring AOP 代理
  • 没有加 @EnableCaching
  • CacheManager 没注册成功

典型错误示例

@Service
public class ProductService {

    @Cacheable(cacheNames = "product", key = "#id")
    public Product getById(Long id) {
        return findInner(id);
    }

    public Product test(Long id) {
        return getById(id); // 同类内部调用可能导致缓存失效
    }

    private Product findInner(Long id) {
        return null;
    }
}

建议

  • 被缓存的方法用 public
  • 跨 Bean 调用
  • 启动时打印 CacheManager 类型确认注入正确

3. 缓存穿透

现象

请求大量不存在的 ID,比如 99999999,每次都打到数据库。

解决思路

  • 缓存空值
  • 做布隆过滤器
  • 接口层做参数校验

本文代码继承 AbstractValueAdaptingCache(true),允许缓存 null,这就是在为“空值缓存”打基础。

不过要注意:空值 TTL 通常要更短,否则业务刚新增数据时,旧的空缓存会影响可见性。


4. 缓存击穿

现象

某个热点 Key 刚过期,大量并发同时回源数据库。

解决思路

  • @Cacheable(sync = true):单实例内有效
  • Redis 分布式锁:跨实例保护
  • 热点数据永不过期 + 异步刷新
  • TTL 加随机值,避免集中过期

边界条件

sync = true 只能解决同一个应用实例上的并发回源问题,解决不了多实例同时击穿。这一点特别容易被误解。


5. 缓存雪崩

现象

大量缓存同一时间过期,瞬间把数据库打崩。

解决

  • TTL 加随机抖动
  • 不同缓存分类设置不同 TTL
  • 热点缓存预热
  • 做限流和降级

比如 Redis TTL 可以从固定 5 分钟改成:

Duration base = Duration.ofMinutes(5);
long randomSeconds = ThreadLocalRandom.current().nextLong(30, 120);
Duration ttl = base.plusSeconds(randomSeconds);

6. 序列化问题

现象

  • Redis 里 value 结构奇怪
  • 反序列化报错
  • 升级类结构后旧缓存读不出来

建议

  • 优先使用 JSON 序列化,避免 JDK 原生序列化
  • 缓存对象保持简单稳定
  • DTO 和缓存对象尽量解耦
  • 大版本升级时考虑缓存统一清理

我自己踩过的一个坑是:缓存里直接塞复杂领域对象,后来对象层次一变,线上旧缓存全反序列化失败。稳妥做法是:缓存专用对象尽量扁平化


安全/性能最佳实践

这一部分我会把“好用”和“能上线”分开讲。

1. TTL 要分层,不要一刀切

不同数据的更新频率不同:

  • 商品详情:5~15 分钟
  • 字典配置:30~60 分钟
  • 用户资料:1~10 分钟
  • 空值缓存:30 秒 ~ 2 分钟

不要所有缓存统一 5 分钟,这通常只是“看起来简单”。


2. 本地缓存容量要有边界

Caffeine 很快,但不是无限的。容量配置要结合 JVM 堆大小。

建议至少关注:

  • initialCapacity
  • maximumSize
  • expireAfterWrite
  • recordStats

如果机器内存紧张,还要结合:

  • -Xms
  • -Xmx
  • Full GC 次数
  • 对象大小估算

3. Redis Key 设计要规范

推荐统一格式:

业务前缀:缓存名:业务主键

例如:

mall:product:1

如果直接用 product::1 也不是不能用,但在大型系统里,带业务前缀更利于排查、隔离和迁移。


4. 不要缓存超大对象

如果一个对象动辄几百 KB,甚至几 MB:

  • Redis 网络传输成本高
  • 本地缓存占内存快
  • 反序列化慢
  • GC 压力大

这种场景更适合:

  • 拆分字段
  • 只缓存摘要/热点字段
  • 缓存分页结果时加限制

5. 监控命中率,而不是“感觉很快”

多级缓存如果没有监控,后面基本靠猜。

建议至少观察:

  • Caffeine 命中率
  • Redis 命中率
  • 数据库 QPS
  • 缓存加载耗时
  • 缓存失效次数
  • Redis 网络延迟

你可以结合 Micrometer 暴露指标,或者定期打印 Caffeine stats。


6. 热点 Key 建议做逻辑隔离

对于极热点数据,比如首页爆款商品、系统配置:

  • 可以单独放一个 cacheName
  • 设置更长 TTL
  • 做主动预热
  • 必要时异步刷新

不要把热点数据和普通长尾数据全部塞进同一个缓存策略里。


7. 更新策略优先“删缓存”,少做“强覆盖”

缓存更新有两种常见思路:

  • 更新数据库后,删除缓存
  • 更新数据库后,直接覆盖缓存

在实际业务里,我更推荐优先删除缓存,因为:

  • 简单
  • 出错面小
  • 避免把脏数据写回缓存

除非你对更新链路控制很强,否则“更新 DB + 删缓存”通常更稳。


8. 给 Redis 做最小权限与网络隔离

安全上容易被忽视的点:

  • Redis 不暴露公网
  • 开启认证
  • 做 VPC/安全组隔离
  • 限制危险命令
  • 配置合理超时
  • 生产环境启用 TLS 时评估额外延迟

缓存也是数据资产,不能因为“只是缓存”就放松防护。


一个更完整的生产级增强方向

如果你准备把本文方案继续往生产级推进,可以按这个路线逐步升级:

flowchart LR
    A[基础版<br/>Caffeine + Redis + Spring Cache] --> B[增强一致性<br/>Redis Pub/Sub 广播失效]
    B --> C[抗击穿<br/>分布式锁 + 热点永不过期]
    C --> D[可观测性<br/>命中率 负载 延迟 指标]
    D --> E[生产增强<br/>随机TTL 预热 限流 降级]

进一步增强点

  1. 空值缓存单独 TTL
  2. 热点 Key 单独锁
  3. 异步刷新缓存
  4. 本地缓存按业务分组
  5. 统一缓存 KeyBuilder
  6. 缓存异常兜底,绝不影响主流程
  7. 引入布隆过滤器防穿透

一个现实的取舍:一致性不是免费的

多级缓存方案的价值很大,但也别神化它。

你要接受几个事实:

  • 本地缓存越 aggressive,一致性越弱
  • TTL 越长,命中率越高,但旧数据窗口越长
  • 更新通知链路越复杂,运维成本越高
  • 强一致场景通常不适合多级缓存

所以,我的建议是:

适合多级缓存的边界

  • 读多写少
  • 可容忍秒级以内短暂不一致
  • 热点明显
  • 查询链路相对昂贵

不适合的边界

  • 每次读取都必须拿最新值
  • 更新极其频繁
  • 对账、余额、库存强一致核心链路

总结

我们这篇文章完成了一个 Spring Boot 下的多级缓存实战方案,核心点可以归纳成 6 句话:

  1. 一级用 Caffeine,二级用 Redis,是很实用的组合
  2. Spring Cache 负责注解与切面,自定义 Cache 负责多级策略
  3. 读链路是本地 -> Redis -> DB -> 回填
  4. 写链路建议更新 DB 后删除缓存,并广播本地失效
  5. sync = true 能缓解单实例击穿,但不是分布式银弹
  6. 上线前一定要补齐 TTL、监控、失效通知、容量边界和异常兜底

如果你现在要把这个方案真正用到项目里,我建议优先做这三件事:

  • 先跑通本文示例,确认注解 + 双层缓存链路没有问题
  • 再补 Pub/Sub 广播和命中率监控
  • 最后按业务热点程度拆分 TTL 与缓存策略

这样推进,风险最小,也最容易看见效果。

如果只想记住一句话,那就是:

多级缓存的重点不在“加两层”,而在“如何失效、如何兜底、如何观测”。

只要这三个问题想明白了,这套方案就不只是“能跑”,而是真能扛线上流量。


分享到:

上一篇
《从提示工程到 RAG 落地:中级开发者构建企业级 AI 知识问答系统实战指南》
下一篇
《Web逆向实战:基于浏览器开发者工具定位并还原前端加密签名生成流程》