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

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

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

背景与问题

做接口优化时,很多同学第一反应是“上 Redis”。这当然没错,但项目跑起来后,往往会遇到第二层问题:

  • Redis 已经扛住了数据库,但热点接口还是不够快
  • Redis 网络抖动时,接口 RT 明显升高
  • 本地 JVM 内其实还有大量重复查询
  • 数据更新后,缓存删除了,但部分实例上还是读到旧值
  • 使用 @Cacheable 很方便,却不知道怎么做“本地缓存 + Redis”两级协同

我自己在业务里踩过一个很典型的坑:某个商品详情接口 QPS 不低,数据库压力是下来了,但 Redis 成了新的热点。进一步排查才发现,大量请求在短时间内反复读取同一个 key,明明应用节点内存还很富余,却没有把“最近频繁访问的数据”留在本地。

这篇文章就从这个问题出发,带你做一套Spring Boot + Spring Cache + Redis 的多级缓存方案,重点不是“能跑”,而是:

  • 为什么要做两级缓存
  • Spring Cache 在这里扮演什么角色
  • 如何设计缓存读写链路
  • 如何处理一致性、穿透、击穿、雪崩这些现实问题
  • 遇到缓存不生效、序列化异常、更新不一致时怎么排查

文章示例基于:

  • Spring Boot 2.x/3.x 思路通用
  • Spring Cache
  • Redis
  • Caffeine 作为本地缓存实现

这里我选择 Caffeine + Redis,因为它是 Java 应用里非常常见的一种多级缓存组合:本地一级缓存负责极低延迟,Redis 二级缓存负责跨实例共享。


前置知识与环境准备

你需要知道什么

如果你已经用过下面这些东西,阅读会很顺:

  • Spring Boot 基础开发
  • @Cacheable / @CachePut / @CacheEvict
  • Redis 基础概念
  • Maven 或 Gradle 依赖管理

示例环境

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

我们要实现的目标

以“商品详情查询”为例,访问链路设计如下:

  1. 先查本地缓存(Caffeine)
  2. 本地没有,再查 Redis
  3. Redis 没有,再查数据库
  4. 查到数据库后,回填 Redis 和本地缓存
  5. 更新商品时,清理两级缓存,尽量保证数据一致

核心原理

什么是多级缓存

多级缓存,本质上是把不同层级、不同特性的缓存组合起来:

  • 一级缓存(L1):应用本地内存缓存,速度最快,但不能跨实例共享
  • 二级缓存(L2):Redis 这类分布式缓存,稍慢于本地内存,但能跨服务实例共享

它们的典型特点如下:

层级实现优点缺点
L1Caffeine / ConcurrentMap延迟极低、无网络开销多实例不共享、容量有限
L2Redis共享、容量更大、支持失效策略有网络开销、可成为热点
DBMySQL/PG 等数据最终来源最慢,压力最大

读请求链路

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

这个链路的收益非常直接:

  • 热点数据大部分命中本地缓存,接口 RT 最低
  • Redis 压力进一步下降
  • 数据库只兜底

写请求链路与一致性

多级缓存的难点不在“查”,而在“改”。

常见做法是:

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

注意我这里强调的是删除缓存,不是“更新缓存值”。原因很现实:

  • 更新缓存容易引入复杂逻辑和并发覆盖问题
  • 删缓存让后续查询自动回源重建,通常更稳

但删除缓存也不是银弹,仍然有几个一致性挑战:

  • 多实例本地缓存如何同步失效
  • 删除缓存和读请求并发时,是否会回填旧值
  • Redis 已删,本地仍旧命中的窗口期怎么办

下面这张时序图可以帮助理解:

sequenceDiagram
    participant U as User
    participant S1 as App实例A
    participant S2 as App实例B
    participant R as Redis
    participant DB as Database

    U->>S1: 更新商品
    S1->>DB: update product
    DB-->>S1: success
    S1->>R: delete product:1
    S1->>S1: evict local cache
    S1-->>S2: 发布失效通知
    S2->>S2: evict local cache

    U->>S2: 查询商品
    S2->>S2: miss local cache
    S2->>R: miss
    S2->>DB: select product
    DB-->>S2: latest data
    S2->>R: set cache
    S2->>S2: set local cache

Spring Cache 在方案中的角色

Spring Cache 不是缓存本身,它更像是一层统一抽象。你可以用它:

  • 用注解声明缓存行为
  • 通过 CacheManager 管理缓存
  • 切换不同缓存实现而不大量改业务代码

但是,Spring 默认并不会自动帮你做好“多级缓存一致性治理”
也就是说:

  • @Cacheable 很方便
  • 真正复杂的地方,还是在 缓存管理器设计、key 规范、失效广播、TTL 策略、异常降级

方案设计

这一版我采用一个比较实用的思路:

  • 一级缓存:Caffeine
  • 二级缓存:Redis
  • 自定义 MultiLevelCache,实现 Spring Cache 接口
  • 自定义 CacheManager,让业务侧继续使用 @Cacheable
  • 数据更新后:
    • 清理当前实例本地缓存
    • 删除 Redis
    • 通过 Redis Pub/Sub 广播其他实例清理本地缓存

这套设计兼顾了两点:

  1. 保留 Spring Cache 注解的开发体验
  2. 具备跨实例的本地缓存失效能力

实战代码(可运行)

1. 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>

2. 配置文件

spring:
  data:
    redis:
      host: 127.0.0.1
      port: 6379
  cache:
    type: none

server:
  port: 8080

这里 spring.cache.type 不直接交给默认实现,因为我们要自己接管 CacheManager

3. 启动类开启缓存

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);
    }
}

4. 实体与模拟仓库

Product.java

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;
    }
}

ProductRepository.java

package com.example.multicache.repository;

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

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

@Repository
public class ProductRepository {

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

    @PostConstruct
    public void init() {
        storage.put(1L, new Product(1L, "机械键盘", new BigDecimal("299.00"), 1L));
        storage.put(2L, new Product(2L, "显示器", new BigDecimal("1299.00"), 1L));
    }

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

    public Product save(Product product) {
        sleep(50);
        Product old = storage.get(product.getId());
        long version = old == null ? 1L : old.getVersion() + 1;
        product.setVersion(version);
        storage.put(product.getId(), product);
        return product;
    }

    private void sleep(long ms) {
        try {
            Thread.sleep(ms);
        } catch (InterruptedException ignored) {
        }
    }
}

5. 自定义多级缓存实现

CacheMessage.java

package com.example.multicache.cache;

import java.io.Serializable;

public class CacheMessage implements Serializable {
    private String cacheName;
    private String key;

    public CacheMessage() {
    }

    public CacheMessage(String cacheName, String key) {
        this.cacheName = cacheName;
        this.key = key;
    }

    public String getCacheName() {
        return cacheName;
    }

    public void setCacheName(String cacheName) {
        this.cacheName = cacheName;
    }

    public String getKey() {
        return key;
    }

    public void setKey(String key) {
        this.key = key;
    }
}

MultiLevelCache.java

package com.example.multicache.cache;

import org.springframework.cache.Cache;
import org.springframework.cache.support.SimpleValueWrapper;
import org.springframework.data.redis.core.RedisTemplate;

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

public class MultiLevelCache implements Cache {

    private final String name;
    private final com.github.benmanes.caffeine.cache.Cache<Object, Object> localCache;
    private final RedisTemplate<String, Object> redisTemplate;
    private final Duration redisTtl;

    public MultiLevelCache(String name,
                           com.github.benmanes.caffeine.cache.Cache<Object, Object> localCache,
                           RedisTemplate<String, Object> redisTemplate,
                           Duration redisTtl) {
        this.name = name;
        this.localCache = localCache;
        this.redisTemplate = redisTemplate;
        this.redisTtl = redisTtl;
    }

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

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

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

    @Override
    public ValueWrapper get(Object key) {
        Object localValue = localCache.getIfPresent(key);
        if (localValue != null) {
            return new SimpleValueWrapper(localValue);
        }

        Object redisValue = redisTemplate.opsForValue().get(buildRedisKey(key));
        if (redisValue != null) {
            localCache.put(key, redisValue);
            return new SimpleValueWrapper(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();
        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();
            if (value != null) {
                put(key, value);
            }
            return value;
        } catch (Exception e) {
            throw new RuntimeException("Load value failed", e);
        }
    }

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

    @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) {
        localCache.invalidate(key);
        redisTemplate.delete(buildRedisKey(key));
    }

    @Override
    public boolean evictIfPresent(Object key) {
        evict(key);
        return true;
    }

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

    @Override
    public boolean invalidate() {
        clear();
        return true;
    }

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

MultiLevelCacheManager.java

package com.example.multicache.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<String, Object> redisTemplate;
    private final Duration redisTtl;
    private final Map<String, Cache> cacheMap = new ConcurrentHashMap<>();

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

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

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

6. Redis 配置与消息监听

RedisConfig.java

package com.example.multicache.config;

import com.example.multicache.cache.MultiLevelCacheManager;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.stream.MapRecord;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.serializer.*;

import java.time.Duration;

@Configuration
public class RedisConfig {

    public static final String CACHE_EVICT_TOPIC = "cache:evict:topic";

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

        GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();

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

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

    @Bean
    public ChannelTopic cacheEvictTopic() {
        return new ChannelTopic(CACHE_EVICT_TOPIC);
    }

    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer(
            RedisConnectionFactory connectionFactory,
            MessageListener cacheMessageListener,
            ChannelTopic cacheEvictTopic) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(cacheMessageListener, cacheEvictTopic);
        return container;
    }
}

CacheEvictMessageListener.java

package com.example.multicache.cache;

import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.stereotype.Component;

@Component
public class CacheEvictMessageListener implements MessageListener {

    private final CacheManager cacheManager;
    private final GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();

    public CacheEvictMessageListener(CacheManager cacheManager) {
        this.cacheManager = cacheManager;
    }

    @Override
    public void onMessage(Message message, byte[] pattern) {
        CacheMessage cacheMessage = (CacheMessage) serializer.deserialize(message.getBody());
        if (cacheMessage == null) {
            return;
        }
        Cache cache = cacheManager.getCache(cacheMessage.getCacheName());
        if (cache instanceof MultiLevelCache multiLevelCache) {
            multiLevelCache.evictLocal(cacheMessage.getKey());
        }
    }
}

CacheSyncPublisher.java

package com.example.multicache.cache;

import com.example.multicache.config.RedisConfig;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

@Component
public class CacheSyncPublisher {

    private final RedisTemplate<String, Object> redisTemplate;

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

    public void publishEvict(String cacheName, Object key) {
        redisTemplate.convertAndSend(RedisConfig.CACHE_EVICT_TOPIC, new CacheMessage(cacheName, String.valueOf(key)));
    }
}

7. 业务服务

ProductService.java

package com.example.multicache.service;

import com.example.multicache.cache.CacheSyncPublisher;
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 {

    public static final String CACHE_NAME = "product";

    private final ProductRepository repository;
    private final CacheSyncPublisher cacheSyncPublisher;

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

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

    @CacheEvict(cacheNames = CACHE_NAME, key = "#product.id")
    public Product update(Product product) {
        Product saved = repository.save(product);
        cacheSyncPublisher.publishEvict(CACHE_NAME, product.getId());
        return saved;
    }
}

这里有一个细节:@CacheEvict 会删除当前实例对应 key 的缓存以及 Redis 中对应 key,
其他实例的本地缓存不会自动删除,所以我们通过 Pub/Sub 补上这一步。

8. Controller

ProductController.java

package com.example.multicache.controller;

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

import java.math.BigDecimal;

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

    private final ProductService productService;

    public static class UpdateProductRequest {
        @NotNull
        private Long id;
        @NotNull
        private String name;
        @NotNull
        private BigDecimal price;

        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 ProductController(ProductService productService) {
        this.productService = productService;
    }

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

    @PutMapping
    public Product update(@RequestBody UpdateProductRequest request) {
        Product product = new Product();
        product.setId(request.getId());
        product.setName(request.getName());
        product.setPrice(request.getPrice());
        return productService.update(product);
    }
}

逐步验证清单

验证 1:本地缓存是否命中

第一次请求:

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

第一次一般会比较慢,因为要走“本地 miss -> Redis miss -> DB”。

立刻再请求一次:

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

第二次大概率直接命中本地缓存,响应更快。

验证 2:Redis 是否有缓存数据

redis-cli
keys product::*
get product::1

如果用了 JSON 序列化,能看到缓存对象内容。

验证 3:更新后缓存是否失效

curl -X PUT http://localhost:8080/products \
  -H "Content-Type: application/json" \
  -d '{"id":1,"name":"机械键盘Pro","price":399.00}'

再查询:

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

应该能拿到最新值。

验证 4:多实例本地缓存同步失效

启动两个应用实例,分别访问同一个商品,让两个实例都把数据放进本地缓存。
然后在实例 A 上更新商品,观察实例 B 再次查询是否读取到新值。

如果 Pub/Sub 生效,实例 B 的本地缓存会被清掉,下一次会重新从 Redis 或 DB 加载。


常见坑与排查

多级缓存最好别一上来就“看起来很优雅”。它真正难的是线上行为。下面这些坑,基本都很常见。

1. @Cacheable 不生效

常见原因

  • 没有加 @EnableCaching
  • 方法是 private
  • 同类内部自调用
  • 方法抛异常,缓存逻辑没执行
  • key 表达式写错

排查建议

先确认代理是否生效。最典型的情况是:

@Service
public class ProductService {

    public Product query(Long id) {
        return getById(id); // 自调用,缓存不会生效
    }

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

这类问题很多人第一次都会踩。我当时也是排查半天,最后发现根本没经过 Spring 代理。

2. Redis 有数据,但总是打到数据库

可能原因

  • Redis key 不一致
  • 序列化器不一致
  • unless = "#result == null" 导致空值未缓存
  • L1/L2 key 生成策略不一致

排查建议

重点看三件事:

  1. 代码里构造的 key 是什么
  2. redis-cli 里真实存的 key 是什么
  3. 使用的序列化器是不是统一

例如你在 @Cacheable 里用了复杂对象做 key,却在自定义缓存里直接 toString(),就容易出现逻辑 key 相同、实际 key 不同的情况。

3. 更新后短时间仍读到旧值

这是多级缓存最常见的“看起来像 bug,其实是设计窗口期”的问题。

可能原因

  • 其他实例本地缓存未及时失效
  • Pub/Sub 消息丢失或监听异常
  • 并发下发生“删缓存后旧值回填”
  • 先删缓存再更新数据库,顺序错了

推荐顺序

先更新数据库,再删缓存,再广播本地失效。

不要写成:

删缓存 -> 更新数据库

否则在并发读场景下,很容易把旧值重新写回缓存。

4. 缓存穿透

查询一个根本不存在的 ID,例如 99999999,每次都 miss,最终打到数据库。

解决办法

  • 缓存空对象
  • 加布隆过滤器
  • 对非法参数提前拦截

示例里可以把“空值缓存”扩展一下,例如缓存一个特殊占位对象,并给较短 TTL。

5. 缓存击穿

某个热点 key 失效瞬间,大量请求同时回源数据库。

解决办法

  • 热点 key 不设置过短 TTL
  • 对加载逻辑加互斥控制
  • 使用 sync = true

例如:

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

sync = true 能降低同一实例内并发回源的问题,但不能天然解决多实例并发击穿,这一点要有边界认知。

6. 缓存雪崩

大量 key 在同一时间集中失效,导致 Redis 和数据库被瞬时流量打爆。

解决办法

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

例如 Redis TTL 不是固定 300 秒,而是:

300 ~ 360 秒随机

这样可以明显降低同一时刻批量失效的概率。


安全/性能最佳实践

这一部分我尽量讲“能直接落地的”。

1. key 设计要稳定、可读、低冲突

推荐格式:

业务名:实体名:主键

比如:

product:detail:1
user:profile:1001

虽然本文示例用了 cacheName::key,但在复杂项目里,我更建议明确业务语义,方便排查和运维。

2. 本地缓存容量不要拍脑袋

Caffeine 很快,但它吃的是 JVM 堆内存。容量过大时:

  • Full GC 风险增加
  • 老年代压力变大
  • 可能把应用本身拖慢

建议从这些维度评估:

  • 热点 key 数量
  • 单对象平均大小
  • 实例堆内存大小
  • GC 指标

如果你都没监控,先保守一点,比如 maximumSize=1000~5000 起步,再逐步调优。

3. Redis TTL 与本地 TTL 不要完全相同

这是个很容易忽略的细节。

如果 L1 和 L2 同时过期,大量请求会一起回源。更稳妥的做法是:

  • 本地缓存 TTL 短一点
  • Redis TTL 长一点
  • 都加一点随机抖动

例如:

  • Caffeine:30 秒
  • Redis:5 分钟

这样 L1 失效后,大概率还能命中 Redis,不至于直接打数据库。

4. 广播失效要考虑“最终一致”而不是“绝对一致”

基于 Redis Pub/Sub 的本地缓存失效方案,有几个现实边界:

  • 订阅端临时断连可能丢消息
  • 应用重启期间错过通知
  • 广播不是事务的一部分

所以它更适合:

  • 商品详情
  • 配置类读多写少数据
  • 非强一致要求场景

如果你面对的是库存、余额、优惠券核销这类强一致业务,建议不要让本地缓存参与核心决策链路。

5. 防止缓存数据被污染

缓存中不要直接存放未经校验、体积过大的对象。建议:

  • DTO 化后再缓存
  • 避免缓存敏感字段
  • 对 value 大小做控制
  • 设置合理 TTL

尤其是用户信息、权限信息、令牌类数据,缓存前一定要审查字段。

6. 加监控,不然优化就是盲飞

至少需要监控这些指标:

  • 本地缓存命中率
  • Redis 命中率
  • 数据库回源次数
  • 平均响应时间 / P95 / P99
  • Redis 连接池使用率
  • 缓存失效广播数量与失败数

很多项目缓存“理论上很快”,但没有命中率监控,最后只能靠感觉调参数,这基本不靠谱。


方案边界与取舍

不是所有场景都适合多级缓存。

适合的场景

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

比如:

  • 商品详情
  • 字典配置
  • 页面聚合接口
  • 用户公开资料

不适合的场景

  • 高频更新
  • 强一致要求高
  • 单条数据非常大
  • 缓存键空间极其离散,热点不明显

比如:

  • 库存扣减
  • 账户余额
  • 实时风控决策
  • 高频变更订单状态

如果更新非常频繁,本地缓存失效广播会变得很重,收益可能反而不明显。


再补一张结构图:组件关系

classDiagram
    class ProductController {
        +get(id)
        +update(request)
    }

    class ProductService {
        +getById(id)
        +update(product)
    }

    class MultiLevelCacheManager {
        +getCache(name)
    }

    class MultiLevelCache {
        +get(key)
        +put(key, value)
        +evict(key)
        +evictLocal(key)
    }

    class RedisTemplate
    class Caffeine
    class ProductRepository
    class CacheSyncPublisher
    class CacheEvictMessageListener

    ProductController --> ProductService
    ProductService --> ProductRepository
    ProductService --> CacheSyncPublisher
    MultiLevelCacheManager --> MultiLevelCache
    MultiLevelCache --> RedisTemplate
    MultiLevelCache --> Caffeine
    CacheEvictMessageListener --> MultiLevelCacheManager

常见优化方向

如果你已经把这套方案跑起来了,后面还可以继续演进:

1. 空值缓存

对不存在的数据缓存一个短 TTL 的占位对象,减少穿透。

2. 逻辑过期

对热点数据不直接“物理过期”,而是在 value 中带过期时间,后台线程异步刷新,适合极热点场景。

3. 分布式锁防击穿

对 Redis miss 且热点 key,使用分布式锁控制单线程回源数据库。

4. 缓存预热

系统启动后,提前加载热点数据进入 Redis 或本地缓存。

5. 按业务拆分缓存策略

不要所有 cacheName 都共用同一套 TTL / 最大容量。
更合理的方式是:

  • 商品详情:TTL 较长
  • 用户资料:TTL 中等
  • 活动配置:本地缓存短、Redis 长
  • 列表类接口:谨慎缓存,注意分页和组合条件爆炸

总结

如果你想在 Spring Boot 里把缓存做得既“好用”又“能上线”,一个比较实用的思路就是:

  • Spring Cache 保持业务代码简洁
  • Caffeine 承担本地一级缓存
  • Redis 承担分布式二级缓存
  • 删除缓存 + 广播本地失效 处理多实例一致性
  • TTL 随机化、空值缓存、热点保护 处理高并发问题

可以把这篇文章里的结论浓缩成 5 条执行建议:

  1. 先做 L2 Redis,再考虑 L1 本地缓存,别过早复杂化
  2. 更新流程一定是先改 DB,再删缓存
  3. 本地缓存必须考虑跨实例失效
  4. 缓存注解能省代码,但一致性治理仍要自己设计
  5. 命中率、回源量、延迟监控必须补齐

最后给一个边界提醒:
多级缓存非常适合“读多写少、允许短暂最终一致”的接口优化,但不适合承担强一致核心业务。这个边界守住了,方案就能真正发挥价值,而不是成为新的隐患。


分享到:

上一篇
《从源码到部署:基于开源项目 Superset 搭建企业级数据可视化平台的实战指南》
下一篇
《Kubernetes 集群架构实战:从控制平面高可用到工作节点弹性扩缩容的设计与落地》