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

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

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

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

在做接口优化时,很多人第一反应是“上 Redis”。这当然没错,但如果你的服务本身 QPS 不低、接口读多写少,而且应用实例和数据库之间的延迟已经比较明显,那么只靠单层 Redis 往往还不够。

我自己在项目里踩过一个典型场景:某商品详情接口,数据库查询并不复杂,但流量高峰时,大量请求仍然会穿透到 Redis,导致 Redis CPU 抬升、网络开销增加,接口 RT 波动明显。后来我们把“本地缓存 + Redis”做成两级缓存后,热点数据的访问延迟明显下降,同时结合失效通知处理一致性,整体效果就稳定很多。

这篇文章就带你从 Spring Boot + Spring Cache + Redis 出发,做一个可运行的多级缓存方案,重点解决两个问题:

  1. 性能怎么提起来
  2. 缓存一致性怎么尽量稳住

背景与问题

先看一个典型链路:

  • Controller 接口接收请求
  • Service 查询商品信息
  • 先查 Redis,没有再查数据库
  • 查到数据库后回填 Redis

这已经是常见的缓存旁路(Cache Aside)模式了。但它依然有几个痛点:

1. 单层 Redis 仍有网络开销

即使 Redis 很快,它也是远程调用:

  • 序列化/反序列化
  • 网络 IO
  • Redis 本身的并发压力

对于高频热点 key,每次都打到 Redis,其实还是浪费。

2. 热点数据容易集中打爆下游

例如某个商品详情、配置项、首页聚合数据:

  • 高并发同时访问
  • 本地没有缓存时全去 Redis
  • Redis miss 时又一起压数据库

这就是典型的缓存击穿问题。

3. 多实例下本地缓存天然不一致

如果你加了 JVM 本地缓存,就会遇到:

  • A 实例更新了缓存
  • B 实例本地缓存还是旧值
  • 用户访问不同实例时看到的数据不一样

所以多级缓存并不只是“再加一层缓存”,而是要把一致性传播机制一起考虑进去。


核心原理

本文采用的方案是:

  • 一级缓存(L1):本地缓存,使用 Caffeine
  • 二级缓存(L2):Redis
  • 缓存注解入口:Spring Cache
  • 一致性传播:更新时删除 Redis,并通过 Redis Pub/Sub 广播清理各实例本地缓存

这是一个偏实战、落地成本低的方案。对中级读者来说,比较适合先把它跑起来。

整体流程

flowchart TD
    A[客户端请求] --> B[Controller]
    B --> C[Service @Cacheable]
    C --> D{L1 本地缓存命中?}
    D -- 是 --> E[直接返回]
    D -- 否 --> F{L2 Redis 命中?}
    F -- 是 --> G[写回 L1]
    G --> E
    F -- 否 --> H[查询数据库]
    H --> I[回填 Redis]
    I --> J[回填 L1]
    J --> E

更新流程

sequenceDiagram
    participant Client
    participant AppA as 应用实例A
    participant DB as MySQL
    participant Redis
    participant AppB as 应用实例B

    Client->>AppA: 更新商品信息
    AppA->>DB: update
    AppA->>Redis: 删除 L2 缓存
    AppA->>Redis: 发布失效消息
    Redis-->>AppA: 清理本地 L1
    Redis-->>AppB: 清理本地 L1
    AppA-->>Client: 返回成功

设计要点

1. 为什么不用“只本地缓存”?

只本地缓存的问题是:

  • 多实例数据不一致严重
  • 重启就丢
  • 容量受单机内存限制

所以本地缓存更适合做热点加速层,而 Redis 作为共享层更稳妥。

2. 为什么不用“只 Redis”?

只 Redis 当然简单,但热点请求都得过网络,不够极致。而 L1 本地缓存能把超热点请求直接拦在 JVM 内部,延迟和吞吐都更漂亮。

3. 一致性怎么理解?

先说结论:缓存和数据库很难做到绝对强一致,业务里通常追求最终一致。

在这套方案里,我们做到的是:

  • 更新数据库后,删除 Redis 缓存
  • 再通知各实例删除本地缓存
  • 下一次读请求重新加载新数据

这已经是大多数读多写少接口的合理平衡。


前置知识与环境准备

技术栈

  • JDK 8+
  • Spring Boot 2.x
  • Spring Cache
  • Spring Data Redis
  • Caffeine
  • Maven
  • Redis 5.x+

示例场景

我们以“商品详情接口”为例:

  • GET /products/{id}:查询商品
  • PUT /products/{id}:更新商品

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.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>
</dependencies>

核心原理落地:Spring Cache 如何接入多级缓存

Spring Cache 的关键点在于:它本质上只关心 CacheManagerCache 接口。

也就是说,我们完全可以自己封装一个 Cache,让它内部变成:

  • 先查 Caffeine
  • 再查 Redis
  • 回填 Caffeine
  • 更新/删除时同时操作两层

这就是本文的实现思路。

类关系

classDiagram
    class CacheManager {
        <<interface>>
        +getCache(String name)
    }

    class Cache {
        <<interface>>
        +get(Object key)
        +put(Object key, Object value)
        +evict(Object key)
        +clear()
    }

    class MultiLevelCacheManager {
        +getCache(String name)
    }

    class MultiLevelCache {
        -CaffeineCache localCache
        -RedisTemplate redisTemplate
        +get(Object key)
        +put(Object key, Object value)
        +evict(Object key)
        +clear()
    }

    CacheManager <|.. MultiLevelCacheManager
    Cache <|.. MultiLevelCache

实战代码(可运行)

下面这套代码是一个简化但完整的多级缓存实现,适合作为 tutorial 起点。


1. 启动类开启缓存

package com.example.cache;

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

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

2. 商品实体

package com.example.cache.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 updateTime;

    public Product() {
    }

    public Product(Long id, String name, BigDecimal price, Long updateTime) {
        this.id = id;
        this.name = name;
        this.price = price;
        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 Long getUpdateTime() {
        return updateTime;
    }

    public void setUpdateTime(Long updateTime) {
        this.updateTime = updateTime;
    }
}

3. 模拟 Repository

为了方便运行,这里先不用真实数据库,用 ConcurrentHashMap 模拟。你接入 MyBatis/JPA 时,替换掉这一层即可。

package com.example.cache.repository;

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

import javax.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"), System.currentTimeMillis()));
        storage.put(2L, new Product(2L, "电竞鼠标", new BigDecimal("159.00"), System.currentTimeMillis()));
    }

    public Product findById(Long id) {
        sleep(100); // 模拟数据库耗时
        return storage.get(id);
    }

    public Product update(Product product) {
        product.setUpdateTime(System.currentTimeMillis());
        storage.put(product.getId(), product);
        return product;
    }

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

4. Redis 配置

package com.example.cache.config;

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

        StringRedisSerializer keySerializer = new StringRedisSerializer();
        GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer();

        template.setKeySerializer(keySerializer);
        template.setHashKeySerializer(keySerializer);
        template.setValueSerializer(valueSerializer);
        template.setHashValueSerializer(valueSerializer);

        template.afterPropertiesSet();
        return template;
    }
}

5. 多级缓存实现

package com.example.cache.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 Cache localCache;
    private final RedisTemplate<String, Object> redisTemplate;
    private final Duration ttl;

    public MultiLevelCache(String name, Cache localCache, RedisTemplate<String, Object> redisTemplate, Duration ttl) {
        this.name = name;
        this.localCache = localCache;
        this.redisTemplate = redisTemplate;
        this.ttl = ttl;
    }

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

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

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

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

        Object redisValue = redisTemplate.opsForValue().get(buildKey(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 (value != null && type.isInstance(value)) ? (T) value : null;
    }

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

        synchronized (this.internKey(buildKey(key))) {
            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 ValueRetrievalException(key, valueLoader, e);
            }
        }
    }

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

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

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

    private String internKey(String key) {
        return key.intern();
    }
}

这里我特意加了一个简单的 synchronized + valueLoader,用于减少同一 key 并发 miss 时的击穿。但请注意,大量使用 intern() 在超大 key 空间下并不完美,后文会说更稳妥的做法。


6. 多级 CacheManager

package com.example.cache.cache;

import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.cache.support.AbstractCacheManager;
import org.springframework.data.redis.core.RedisTemplate;

import java.time.Duration;
import java.util.Collection;
import java.util.Collections;
import java.util.concurrent.TimeUnit;

public class MultiLevelCacheManager extends AbstractCacheManager {

    private final RedisTemplate<String, Object> redisTemplate;

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

    @Override
    protected Collection<? extends Cache> loadCaches() {
        return Collections.emptyList();
    }

    @Override
    protected Cache getMissingCache(String name) {
        CaffeineCache localCache = new CaffeineCache(
                name,
                Caffeine.newBuilder()
                        .initialCapacity(100)
                        .maximumSize(1000)
                        .expireAfterWrite(30, TimeUnit.SECONDS)
                        .recordStats()
                        .build()
        );

        return new MultiLevelCache(name, localCache, redisTemplate, Duration.ofMinutes(5));
    }
}

7. 缓存配置注册

package com.example.cache.config;

import com.example.cache.cache.MultiLevelCacheManager;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;

@Configuration
public class CacheConfig {

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

8. 缓存失效消息体

package com.example.cache.message;

import java.io.Serializable;

public class CacheEvictMessage implements Serializable {

    private String cacheName;
    private String key;

    public CacheEvictMessage() {
    }

    public CacheEvictMessage(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;
    }
}

9. Pub/Sub 配置

package com.example.cache.config;

import com.example.cache.listener.CacheMessageSubscriber;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.adapter.MessageListenerAdapter;

@Configuration
public class RedisPubSubConfig {

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

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

    @Bean
    public MessageListenerAdapter listenerAdapter(CacheMessageSubscriber subscriber) {
        return new MessageListenerAdapter(subscriber, "onMessage");
    }

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

10. 监听并清理本地缓存

package com.example.cache.listener;

import com.example.cache.cache.MultiLevelCache;
import com.example.cache.message.CacheEvictMessage;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Component;

@Component
public class CacheMessageSubscriber {

    private final CacheManager cacheManager;
    private final ObjectMapper objectMapper;

    public CacheMessageSubscriber(CacheManager cacheManager, ObjectMapper objectMapper) {
        this.cacheManager = cacheManager;
        this.objectMapper = objectMapper;
    }

    public void onMessage(String message) {
        try {
            CacheEvictMessage evictMessage = objectMapper.readValue(message, CacheEvictMessage.class);
            Cache cache = cacheManager.getCache(evictMessage.getCacheName());
            if (cache != null) {
                cache.evict(evictMessage.getKey());
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

这里有个细节:如果直接调用 cache.evict(key),会同时删本地和 Redis。虽然问题不大,但会重复删除 Redis。更严谨的做法是给 MultiLevelCache 单独暴露一个“只删本地”的方法。为了示例简洁先这么写,后面会讲优化版。


11. Service 层

package com.example.cache.service;

import com.example.cache.config.RedisPubSubConfig;
import com.example.cache.message.CacheEvictMessage;
import com.example.cache.model.Product;
import com.example.cache.repository.ProductRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

@Service
public class ProductService {

    private static final String CACHE_NAME = "product";

    private final ProductRepository productRepository;
    private final StringRedisTemplate stringRedisTemplate;
    private final ObjectMapper objectMapper;

    public ProductService(ProductRepository productRepository,
                          StringRedisTemplate stringRedisTemplate,
                          ObjectMapper objectMapper) {
        this.productRepository = productRepository;
        this.stringRedisTemplate = stringRedisTemplate;
        this.objectMapper = objectMapper;
    }

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

    public Product update(Product product) {
        Product updated = productRepository.update(product);

        // 先删 Redis/L1(通过后续查询重建)
        stringRedisTemplate.delete(CACHE_NAME + "::" + product.getId());

        // 广播所有实例清理本地缓存
        try {
            String msg = objectMapper.writeValueAsString(new CacheEvictMessage(CACHE_NAME, String.valueOf(product.getId())));
            stringRedisTemplate.convertAndSend(RedisPubSubConfig.CACHE_EVICT_TOPIC, msg);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }

        return updated;
    }
}

12. Controller 层

package com.example.cache.controller;

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

import java.math.BigDecimal;

@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,
                          @RequestParam String name,
                          @RequestParam BigDecimal price) {
        Product product = new Product();
        product.setId(id);
        product.setName(name);
        product.setPrice(price);
        return productService.update(product);
    }
}

13. application.yml

spring:
  redis:
    host: localhost
    port: 6379
  cache:
    type: simple

server:
  port: 8080

logging:
  level:
    org.springframework.cache: debug

逐步验证清单

建议你按下面顺序验证,而不是一上来就压测。

1. 启动 Redis

redis-server

或者 Docker:

docker run -d --name redis -p 6379:6379 redis:6.2

2. 启动应用后访问接口

第一次访问:

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

第一次会慢一些,因为会查“数据库”。

第二次访问同一个 key:

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

这时应该明显更快,优先命中本地缓存。

3. 查看 Redis 中缓存

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

4. 更新数据

curl -X PUT "http://localhost:8080/products/1?name=新机械键盘&price=399.00"

然后再次查询:

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

应该能看到新值,并且缓存被重新构建。

5. 多实例验证

你可以启动两个实例:

  • 8080
  • 8081

更新其中一个实例的数据后,另一个实例查询时,本地缓存也应该被清理。


常见坑与排查

多级缓存好用,但坑也不少。下面这些问题,我基本都见过。

坑 1:@Cacheable 方法内部调用不生效

现象

同一个 Service 里:

public Product test(Long id) {
    return this.getById(id);
}

结果发现缓存注解没生效。

原因

Spring Cache 基于 AOP 代理,类内部自调用不会走代理

解决方式

  • 把缓存方法放到独立 Bean
  • 或注入代理对象再调用
  • 或用 AopContext.currentProxy(),但不建议滥用

坑 2:本地缓存和 Redis TTL 不一致

现象

Redis 已过期,但本地缓存还活着,导致短时间返回旧数据。

原因

L1/L2 的过期时间设置不同步。

建议

  • 本地缓存 TTL 通常应 小于等于 Redis TTL
  • 热点 key 可让 L1 更短,减少脏数据窗口
  • 写敏感数据尽量主动失效,而不是只靠 TTL

坑 3:更新后仍然读到旧值

现象

刚更新完商品,立刻查询,有时还是旧值。

可能原因

  1. 本地缓存未及时清理
  2. Redis Pub/Sub 消息丢失或消费者异常
  3. 先更新缓存再更新数据库,顺序不当
  4. 序列化后的 key 类型不一致,如 1"1"

排查路径

  • 看数据库记录是否已更新
  • 看 Redis 对应 key 是否删除
  • 看应用日志中是否收到失效消息
  • 检查缓存 key 生成规则是否完全一致

坑 4:缓存穿透

现象

大量请求访问不存在的 ID,例如 99999999,缓存一直 miss,数据库被打满。

解决思路

  • 缓存空值,设置较短 TTL
  • 接口参数合法性校验
  • 对恶意请求做限流

如果你要缓存空值,unless = "#result == null" 就不能用了,需要改策略。


坑 5:缓存雪崩

现象

大量 key 同一时间过期,请求瞬间压向 Redis/数据库。

解决思路

  • TTL 加随机值
  • 热点数据预热
  • 多级缓存分担流量
  • 限流、降级、熔断一起上

坑 6:序列化兼容问题

现象

对象结构调整后,旧缓存反序列化失败。

建议

  • 尽量缓存 DTO,而不是复杂领域对象
  • 控制缓存对象字段稳定性
  • 重大结构变更时升级 key 前缀,例如 product:v2::1

安全/性能最佳实践

这部分我尽量说一些“能直接落地”的建议。

1. key 设计要规范

建议统一格式:

业务名:实体名:版本:主键

比如:

product:detail:v1:1

本文示例中用了 cacheName::key,够演示,但线上最好再细化,不然跨业务排查会痛苦。


2. 不要缓存超大对象

缓存不是对象仓库。超大 JSON 会带来:

  • Redis 内存膨胀
  • 网络传输变慢
  • 反序列化耗时上升

建议只缓存接口真正需要的数据视图。


3. 给 TTL 加随机抖动

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

  • 300 ~ 360 秒随机

这样能有效降低同一时刻批量过期。

示意代码:

int base = 300;
int random = ThreadLocalRandom.current().nextInt(60);
redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(base + random));

4. 热点 key 做单飞保护

示例里用了 synchronized,但线上更推荐:

  • Caffeine 自带加载能力
  • 分布式锁
  • 基于 key 的细粒度锁容器

因为 intern() 有额外风险,尤其在大量不同 key 的场景下并不优雅。


5. 失效通知不要只依赖 Pub/Sub

Redis Pub/Sub 的问题是:它不是可靠消息

如果实例在短暂重启期间错过消息,就可能保留旧的本地缓存。

更稳妥的方案有两个:

  • 使用 Redis Stream / MQ 做可靠失效消息
  • 给本地缓存设置更短 TTL,当作兜底

我的建议是:

  • 对一致性要求一般的场景:Pub/Sub + 短 TTL
  • 对一致性要求更高的场景:MQ + 版本号校验

6. 更新顺序要谨慎

比较推荐的写路径是:

  1. 更新数据库
  2. 删除 Redis 缓存
  3. 广播删除本地缓存
  4. 后续读请求重建缓存

不要轻易采用“先更新缓存再更新数据库”,失败时更难收拾。


7. 监控指标要补齐

如果没有监控,多级缓存出了问题很难定位。

至少要看这些指标:

  • 本地缓存命中率
  • Redis 命中率
  • 数据库查询 QPS
  • 缓存重建耗时
  • 缓存失效消息发送/消费失败数
  • 热点 key TOP N

8. 敏感数据不要直接缓存

例如:

  • 用户隐私信息
  • 令牌、密钥
  • 高敏感业务状态

即使要缓存,也应:

  • 做字段脱敏
  • 缩短 TTL
  • 严格控制 key 命名和访问边界

一个更稳妥的优化方向

如果你准备在生产环境继续增强,我建议重点补三件事:

1. 本地缓存只删本地

现在的订阅器调用 cache.evict(key),会把 Redis 也删一次。可以在 MultiLevelCache 增加方法:

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

然后在订阅器里判断类型后调用 evictLocal(),避免重复操作 Redis。


2. 缓存空值

对恶意或高频不存在数据,建议缓存空对象占位,例如:

public class NullValue implements Serializable {
}

然后设置短 TTL,比如 30 秒,能显著降低穿透。


3. 加版本号避免旧值覆盖新值

对于更新频繁的数据,可以在 value 中带 version/updateTime

  • 查询时比较版本
  • 本地缓存写入时校验版本单调递增

这样可以减少极端并发下旧数据回填覆盖新数据的问题。


方案边界与适用场景

这套方案最适合:

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

不太适合:

  • 强一致要求极高的资金类核心写场景
  • 更新极其频繁的数据
  • 单条缓存对象特别大的场景
  • 本地内存非常紧张的应用

换句话说,不是所有接口都值得上多级缓存。不要为了“看起来高级”而过度设计。


总结

回到文章标题,多级缓存真正带来的价值,不只是“快”,而是:

  • Caffeine 拦住热点请求,降低接口 RT
  • Redis 做共享缓存,减轻数据库压力
  • Spring Cache 把接入成本压低
  • Pub/Sub 失效通知 尽量控制多实例一致性问题

如果你现在正准备在 Spring Boot 项目里做缓存升级,我建议按这个顺序推进:

  1. 先把单层 Redis 缓存打稳
  2. 再为热点接口加本地缓存
  3. 补上缓存失效广播
  4. 最后完善监控、空值缓存、随机 TTL、热点保护

一句实话:缓存方案没有银弹,只有取舍。
但对于大多数中后台和内容型接口来说,Spring Cache + Caffeine + Redis 这个组合,已经是一个很实用、性价比很高的答案。

如果你要上线生产版,记住两个底线:

  • 一致性靠机制兜底,不要只靠 TTL
  • 性能优化要靠监控验证,不要凭感觉

做到这两点,多级缓存就不只是“能跑”,而是真正“敢用”。


分享到:

上一篇
《从源码到生产:基于开源项目 Nacos 的服务注册与配置中心实战落地指南》
下一篇
《分布式架构中基于消息队列与幂等设计实现高并发订单系统的实战指南》