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

《Spring Boot 中基于 Spring Cache 与 Redis 的多级缓存实战:设计、穿透防护与一致性治理》

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

背景与问题

很多团队一开始上缓存,往往只有一句话:“把热点数据放 Redis 就行。”
但系统一跑起来,问题就来了:

  • Redis 有网络开销,热点接口还是扛不住
  • 本地 JVM 堆里明明有大量重复查询,却没利用起来
  • 缓存穿透、击穿、雪崩轮番出现
  • 数据更新后,多个节点的本地缓存不一致
  • Spring Cache 用起来很方便,但一旦涉及多级缓存和一致性,默认能力不够

我自己第一次做这类方案时,最大的误区就是:把“能缓存”当成“缓存设计已经完成”。实际上,多级缓存真正难的不是 @Cacheable 注解本身,而是下面这三个问题:

  1. 怎么分层:本地缓存 + Redis 怎么配合?
  2. 怎么防护:穿透、击穿、雪崩怎么压住?
  3. 怎么治理一致性:更新后,多个实例上的本地缓存怎么一起失效?

这篇文章就按一个中型 Spring Boot 应用的视角,带你从架构设计、代码实现到问题排查走一遍。我们会基于:

  • Spring Boot
  • Spring Cache
  • Redis
  • Caffeine(本地缓存)
  • Redis Pub/Sub 做本地缓存失效广播

目标是落地一个可运行、可扩展、能线上使用的多级缓存方案。


方案总览与取舍分析

先给出本文采用的方案:

  • 一级缓存(L1):Caffeine,本地 JVM 内存,超低延迟
  • 二级缓存(L2):Redis,跨实例共享
  • 统一入口:Spring Cache
  • 一致性治理:更新数据库后,删除 Redis,并通过 Redis Pub/Sub 通知各节点清理本地缓存
  • 穿透防护:缓存空值 + 参数校验 + 布隆过滤器可选
  • 击穿防护:热点 Key 加互斥锁 / 逻辑过期
  • 雪崩防护:TTL 加随机值,避免同一时间大量失效

为什么不直接只用 Redis?

只用 Redis 的优点很明显:

  • 简单
  • 跨实例天然共享
  • 容量相对大

但缺点也很现实:

  • 每次访问都有网络 IO
  • 超高并发热点下,Redis 依然可能成为瓶颈
  • 对一些“读多写少且热点集中的数据”,本地缓存能明显降低 RT

为什么不只用本地缓存?

只用本地缓存的问题更大:

  • 多实例之间数据不共享
  • 更新后容易不一致
  • 容量受单机内存限制
  • 发布新版本、扩缩容时命中率波动大

所以比较稳妥的架构通常是:

L1 本地缓存负责极致性能,L2 Redis 负责共享与兜底。


flowchart LR
    A[客户端请求] --> B[Spring Cache]
    B --> C{L1 Caffeine命中?}
    C -- 是 --> D[返回数据]
    C -- 否 --> E{L2 Redis命中?}
    E -- 是 --> F[写回L1]
    F --> D
    E -- 否 --> G[查询数据库]
    G --> H[写入Redis]
    H --> I[写入L1]
    I --> D

核心原理

1. Spring Cache 在这里扮演什么角色?

Spring Cache 本质上是一个缓存抽象层
它把业务代码和底层缓存实现解耦了。你在业务里写:

  • @Cacheable:查缓存,没有就执行方法并放入缓存
  • @CachePut:执行方法,并把结果更新缓存
  • @CacheEvict:执行方法后删除缓存

但默认的 CacheManager 通常只管理一种缓存实现。
要做多级缓存,就需要我们自己扩展一个 Cache,实现下面这套读取顺序:

  1. 先查本地缓存 Caffeine
  2. 本地没有,再查 Redis
  3. Redis 有,就回填本地
  4. Redis 没有,再走数据库加载
  5. 加载结果同时写入 Redis 和本地缓存

2. 为什么一致性难点主要在“删除”?

缓存和数据库之间,最怕的是“写后读脏数据”。

行业里最常见的思路不是“先更新缓存”,而是:

  • 更新数据库
  • 删除缓存

原因很简单:
缓存不是数据源,数据库才是。更新缓存容易引入覆盖、并发写乱序等问题,而删除缓存相对简单、鲁棒。

但在多级缓存中,要删的不只 Redis,还有每个节点自己的本地缓存。
所以我们需要一个广播机制:

  • 某节点更新成功后
  • 删除 Redis Key
  • 再向 Redis Pub/Sub 发布失效消息
  • 所有应用节点收到消息后,删除各自的 Caffeine Key

sequenceDiagram
    participant Client as 客户端
    participant App1 as 应用节点A
    participant DB as 数据库
    participant Redis as Redis
    participant App2 as 应用节点B

    Client->>App1: 更新商品信息
    App1->>DB: UPDATE product
    DB-->>App1: success
    App1->>Redis: DEL product::1001
    App1->>Redis: PUBLISH cache:evict product::1001
    Redis-->>App1: 消息通知
    Redis-->>App2: 消息通知
    App1->>App1: 删除本地L1缓存
    App2->>App2: 删除本地L1缓存

3. 穿透、击穿、雪崩分别对应什么问题?

这是缓存设计里最容易混淆的三件事。

缓存穿透

请求的 Key 在缓存和数据库里都不存在,导致请求次次打到数据库。

典型例子:

  • 查一个不存在的商品 ID
  • 恶意构造大量随机参数攻击

常用手段:

  • 缓存空值
  • 参数合法性校验
  • 布隆过滤器

缓存击穿

某个热点 Key过期瞬间,大量请求同时访问数据库。

典型例子:

  • 秒杀商品详情
  • 首页核心配置

常用手段:

  • 互斥锁重建缓存
  • 逻辑过期
  • 热点永不过期,异步刷新

缓存雪崩

大量 Key 在同一时间集中过期,导致数据库流量暴涨。

常用手段:

  • TTL 加随机值
  • 多级缓存削峰
  • 限流降级
  • Redis 高可用

架构设计

模块划分

本文示例会分为几个关键组件:

  • ProductService:业务服务
  • MultiLevelCache:自定义 Spring Cache 实现
  • MultiLevelCacheManager:管理缓存实例
  • CacheInvalidationPublisher:发布缓存失效消息
  • CacheInvalidationSubscriber:订阅失效消息并清理本地缓存

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

    class MultiLevelCache {
        -String name
        -Cache caffeineCache
        -RedisTemplate redisTemplate
        +get(key)
        +put(key, value)
        +evict(key)
    }

    class MultiLevelCacheManager {
        -Map~String, Cache~ cacheMap
        +getCache(name)
        +getCacheNames()
    }

    class ProductService {
        +getProductById(id)
        +updateProduct(product)
    }

    class CacheInvalidationPublisher {
        +publish(cacheName, key)
    }

    Cache <|.. MultiLevelCache
    ProductService --> MultiLevelCacheManager
    ProductService --> CacheInvalidationPublisher

容量估算思路

多级缓存不是越大越好。一个常见误区是“本地缓存全量放”。
正确做法是按热点集做估算。

以商品详情为例:

  • 单对象序列化后平均 2 KB
  • 热点商品 5 万个
  • 如果全部进本地缓存,大约需要 100 MB
  • 但 JVM 堆还要给业务对象、线程、其他缓存留空间

所以实际建议:

  • 本地缓存只放最热数据集
  • Redis 放更大范围的数据集
  • 通过 maximumSizeexpireAfterWrite 控制本地缓存边界

实战代码(可运行)

下面给一套可以在 Spring Boot 项目里直接落地的核心代码。
为简化篇幅,省略数据库连接细节,Repository 用内存 Map 模拟,但缓存链路是完整的。

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

2. application.yml

spring:
  redis:
    host: localhost
    port: 6379

server:
  port: 8080

3. 启用缓存

package com.example.cachedemo;

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

4. 实体与仓储

package com.example.cachedemo.domain;

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

public class Product implements Serializable {
    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 Product setId(Long id) {
        this.id = id;
        return this;
    }

    public String getName() {
        return name;
    }

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

    public BigDecimal getPrice() {
        return price;
    }

    public Product setPrice(BigDecimal price) {
        this.price = price;
        return this;
    }
}
package com.example.cachedemo.repository;

import com.example.cachedemo.domain.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, "iPhone", new BigDecimal("6999")));
        storage.put(2L, new Product(2L, "MacBook", new BigDecimal("12999")));
    }

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

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

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

5. Redis 序列化配置

这里我建议统一使用 JSON 序列化,便于排查。线上排障时,能直接在 Redis 里看到值,比 JDK 序列化舒服太多。

package com.example.cachedemo.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
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);

        ObjectMapper mapper = new ObjectMapper();
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);

        GenericJackson2JsonRedisSerializer serializer =
                new GenericJackson2JsonRedisSerializer(mapper);

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

6. 自定义多级缓存实现

package com.example.cachedemo.cache;

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

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

public class MultiLevelCache implements org.springframework.cache.Cache {

    private static final Object NULL_VALUE = new Object();

    private final String name;
    private final Cache<Object, Object> localCache;
    private final RedisTemplate<String, Object> redisTemplate;
    private final Duration ttl;

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

    private String buildKey(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 == NULL_VALUE ? null : localValue);
        }

        Object redisValue = redisTemplate.opsForValue().get(buildKey(key));
        if (redisValue != null) {
            localCache.put(key, redisValue);
            return new SimpleValueWrapper(redisValue == NULL_VALUE ? null : 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 (value == null) {
            return null;
        }
        if (type != null && !type.isInstance(value)) {
            throw new IllegalStateException("Cache value is not of required type");
        }
        return (T) value;
    }

    @Override
    @SuppressWarnings("unchecked")
    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) {
        Object storeValue = value == null ? NULL_VALUE : value;
        localCache.put(key, storeValue);
        redisTemplate.opsForValue().set(buildKey(key), storeValue, ttl);
    }

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

    @Override
    public void evict(Object key) {
        localCache.invalidate(key);
        redisTemplate.delete(buildKey(key));
    }

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

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

7. CacheManager 配置

package com.example.cachedemo.config;

import com.example.cachedemo.cache.MultiLevelCache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;

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

@Configuration
public class CacheConfig {

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

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

            @Override
            public Cache getCache(String name) {
                return cacheMap.computeIfAbsent(name, key ->
                        new MultiLevelCache(
                                key,
                                Caffeine.newBuilder()
                                        .maximumSize(10_000)
                                        .expireAfterWrite(Duration.ofMinutes(5))
                                        .build(),
                                redisTemplate,
                                Duration.ofMinutes(10)
                        )
                );
            }

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

8. 缓存失效消息发布与订阅

失效消息对象

package com.example.cachedemo.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;
    }
}

发布器

package com.example.cachedemo.cache;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

@Component
public class CacheInvalidationPublisher {

    public static final String CHANNEL = "cache:evict:channel";

    private final RedisTemplate<String, Object> redisTemplate;

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

    public void publish(String cacheName, String key) {
        redisTemplate.convertAndSend(CHANNEL, new CacheMessage(cacheName, key));
    }
}

订阅器配置

package com.example.cachedemo.config;

import com.example.cachedemo.cache.CacheInvalidationPublisher;
import com.example.cachedemo.cache.CacheMessage;
import com.example.cachedemo.cache.MultiLevelCache;
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.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;

@Configuration
public class RedisListenerConfig {

    @Bean
    public RedisMessageListenerContainer redisContainer(
            org.springframework.data.redis.connection.RedisConnectionFactory factory,
            CacheManager cacheManager) {

        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(factory);

        GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();

        MessageListener listener = (message, pattern) -> {
            CacheMessage msg = (CacheMessage) serializer.deserialize(message.getBody());
            if (msg == null) {
                return;
            }
            org.springframework.cache.Cache cache = cacheManager.getCache(msg.getCacheName());
            if (cache instanceof MultiLevelCache multiLevelCache) {
                multiLevelCache.evictLocal(msg.getKey());
            }
        };

        container.addMessageListener(listener, new ChannelTopic(CacheInvalidationPublisher.CHANNEL));
        return container;
    }
}

9. 业务服务

这里我们用 @Cacheable 负责读缓存,用“更新 DB + 删缓存 + 广播本地失效”处理一致性。

package com.example.cachedemo.service;

import com.example.cachedemo.cache.CacheInvalidationPublisher;
import com.example.cachedemo.domain.Product;
import com.example.cachedemo.repository.ProductRepository;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
public class ProductService {

    private final ProductRepository productRepository;
    private final CacheManager cacheManager;
    private final CacheInvalidationPublisher publisher;

    public ProductService(ProductRepository productRepository,
                          CacheManager cacheManager,
                          CacheInvalidationPublisher publisher) {
        this.productRepository = productRepository;
        this.cacheManager = cacheManager;
        this.publisher = publisher;
    }

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

    public Product updateProduct(Product product) {
        Product saved = productRepository.save(product);

        org.springframework.cache.Cache cache = cacheManager.getCache("product");
        if (cache != null) {
            cache.evict(product.getId());
        }
        publisher.publish("product", String.valueOf(product.getId()));
        return saved;
    }

    public Product getNullableProduct(Long id) {
        return productRepository.findById(id);
    }
}

这里有个细节:@Cacheable(unless = "#result == null") 默认不会缓存空值。
如果你要防缓存穿透,建议改为支持空值缓存,而不是直接排除掉 null。本文下面会专门讲这个坑。

10. Controller

package com.example.cachedemo.controller;

import com.example.cachedemo.domain.Product;
import com.example.cachedemo.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 get(@PathVariable Long id) {
        return productService.getProductById(id);
    }

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

11. 如何验证

启动 Redis 后运行项目:

第一次查询,走 DB

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

第二次查询,命中缓存

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

更新数据后,缓存失效

curl -X PUT "http://localhost:8080/products/1?name=iPhone15&price=7999"
curl http://localhost:8080/products/1

如果你开两个应用实例,就能验证 Pub/Sub 对本地缓存失效广播是否生效。


穿透防护与热点治理

上面的代码能跑,但离“线上可用”还差一步:缓存防护策略

1. 缓存空值,防止穿透

很多人习惯写:

@Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")

这在“保持缓存整洁”上没问题,但对不存在的数据会产生穿透。
比如一直查 id=999999,每次都得打 DB。

更实用的做法是:

  • 允许缓存空值
  • 给空值较短 TTL,比如 1~3 分钟

在本文的 MultiLevelCache 中,已经用 NULL_VALUE 做了空值占位。
所以如果你要开启空值缓存,可以把 unless 去掉:

@Cacheable(cacheNames = "product", key = "#id")
public Product getProductById(Long id) {
    return productRepository.findById(id);
}

什么时候不适合缓存空值?

有边界条件:

  • 数据“先不存在,后很快创建”的场景很多
  • 你对新数据实时可见性要求极高
  • 空值请求本来就很少

这时可以只对高风险接口开启空值缓存,TTL 设短一点。

2. 参数校验是第一道门

缓存穿透不一定都要靠缓存层扛。
很多无效请求,在 Controller 层就该拦住。

@GetMapping("/{id}")
public Product get(@PathVariable Long id) {
    if (id == null || id <= 0) {
        throw new IllegalArgumentException("非法ID");
    }
    return productService.getProductById(id);
}

别小看这一步,线上经常能挡掉一批脏请求。

3. 布隆过滤器适合超大 Key 空间

如果你的系统是这种特点:

  • 商品 / 用户 / 内容 ID 非常大
  • 恶意随机查询很多
  • 不存在 ID 请求比例高

可以在 Redis 前加布隆过滤器:

  • 先判断 ID 是否“可能存在”
  • 不存在就直接返回
  • 存在再进入缓存 / 数据库查询流程

不过布隆过滤器有误判率,不适合拿来当绝对真相,只适合当前置筛子

4. 热点击穿:加锁重建

对于热点 Key,最简单的办法是加互斥锁,确保只有一个线程去查库重建缓存。

伪代码如下:

public Product queryWithMutex(Long id) {
    String key = "product::" + id;
    Object val = redisTemplate.opsForValue().get(key);
    if (val != null) {
        return (Product) val;
    }

    String lockKey = "lock:product:" + id;
    Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
    if (Boolean.TRUE.equals(locked)) {
        try {
            Product dbVal = repository.findById(id);
            redisTemplate.opsForValue().set(key, dbVal, Duration.ofMinutes(10));
            return dbVal;
        } finally {
            redisTemplate.delete(lockKey);
        }
    }

    try {
        Thread.sleep(50);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
    return queryWithMutex(id);
}

这个方案好理解,但要注意:

  • 递归重试不能无限制
  • 锁超时时间要合理
  • 失败时要有降级方案

如果是超热点业务,建议进一步演进为:

  • 逻辑过期
  • 后台异步刷新
  • 单飞(single flight)合并请求

常见坑与排查

这部分我尽量讲得“像现场排障”,因为这些坑真的太常见了。

1. @Cacheable 不生效

常见原因

  • 方法不是 public
  • 同类内部自调用
  • 没有加 @EnableCaching
  • 代理没生效

典型错误

@Service
public class ProductService {

    public Product outer(Long id) {
        return inner(id); // 同类内部调用,缓存不会生效
    }

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

解决办法

  • 把缓存方法拆到另一个 Bean
  • 或通过代理对象调用
  • 优先选择结构清晰的拆分方式

2. 本地缓存和 Redis 数据不一致

现象

  • 某台机器查到旧数据
  • Redis 里已经是新值,但接口还是返回旧值

排查路径

  1. 看更新后是否删除了 Redis Key
  2. 看是否发送了 Pub/Sub 消息
  3. 看所有实例是否都订阅成功
  4. 看本地缓存 TTL 是否过长
  5. 看消息反序列化是否失败

我踩过的一个坑

消息体发的是 JSON,但监听端用了另一套序列化器,导致订阅逻辑“看起来没报错,实际上根本没正确解析”。
所以建议:

  • 发布和订阅统一序列化方式
  • 失效消息打日志
  • 监控消息消费成功率

3. Redis 明明有值,却总查数据库

可能原因

  • Key 拼接不一致
  • 序列化后的类型转换失败
  • cacheNames 和实际 CacheManager 中的名字不一致
  • Redis TTL 过短导致频繁失效

排查命令

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

如果你用的是 JSON 序列化,get 的结果就很容易看懂。

4. 空值缓存引起“新数据延迟可见”

现象

  • 某个 ID 之前不存在,被缓存为 null
  • 后来数据刚插入
  • 查询还是返回空

原因

空值缓存 TTL 还没过。

解决办法

  • 空值 TTL 单独设短
  • 新增数据时主动删除对应缓存
  • 对“先查后建”的业务特别留意

5. Redis Pub/Sub 不可靠的问题

这个问题必须说清楚:Redis Pub/Sub 不是可靠消息队列

也就是说:

  • 如果实例订阅断开
  • 失效消息可能丢
  • 某些节点本地缓存可能来不及删

所以 Pub/Sub 更适合:

  • 本地缓存一致性的“高性价比方案”
  • 最终一致、短时脏读可接受的业务

如果你的业务对一致性要求更高,可以考虑:

  • Redis Stream
  • Kafka
  • Canal + MQ
  • 统一配置中心 / 变更总线

安全/性能最佳实践

1. TTL 一定要加随机值

如果所有商品缓存 TTL 都是 10 分钟,那第 10 分钟会发生什么?
一批缓存同时失效,数据库瞬间承压。

建议做法:

  • 基础 TTL:10 分钟
  • 随机抖动:0~120 秒

示例思路:

int randomSeconds = ThreadLocalRandom.current().nextInt(0, 120);
Duration ttl = Duration.ofMinutes(10).plusSeconds(randomSeconds);

2. 区分热点数据和普通数据

不是所有缓存都应该一视同仁。

建议按访问特征分层:

  • 热点详情:本地 + Redis,较长 TTL,主动失效
  • 普通列表:只放 Redis,TTL 短
  • 强一致配置:谨慎使用本地缓存,必要时不走缓存

3. 控制本地缓存大小,避免挤爆堆内存

Caffeine 很快,但不是白送的。

建议关注:

  • maximumSize
  • 对象平均大小
  • Full GC 次数
  • 命中率与内存占用的平衡

经验上,本地缓存命中率到了一个平台后,再继续扩大容量,收益会迅速下降。

4. 给缓存链路打监控

至少要有这些指标:

  • L1 命中率
  • L2 命中率
  • DB 回源次数
  • 缓存重建耗时
  • 缓存失效消息量
  • Redis 慢查询 / 延迟
  • 热点 Key 排名

没有监控的缓存系统,出问题时几乎等于“盲修”。

5. Redis 不要裸奔

安全上至少要做到:

  • 开启访问认证
  • 限制内网访问
  • 禁止危险命令暴露
  • 生产环境使用高可用方案
  • 关键缓存实例独立部署,避免和别的高 IO 业务抢资源

6. 谨慎缓存大对象

大对象会带来三个问题:

  • 网络传输慢
  • 序列化反序列化慢
  • Redis 内存碎片和淘汰风险更高

如果对象过大,建议:

  • 拆字段缓存
  • 只缓存渲染所需字段
  • 分页/分片缓存

适用边界与演进建议

多级缓存不是银弹,它最适合这类场景:

  • 读多写少
  • 热点明显
  • 允许短暂最终一致
  • 有多实例部署
  • 对响应时间敏感

不太适合的场景:

  • 强一致金融核心账务
  • 写入频繁且读热点不明显
  • 数据极易变化,TTL 很难设置
  • 数据量极大但单条访问价值不高

如果你的系统继续演进,可以按这个路径升级:

  1. 当前阶段:Caffeine + Redis + Pub/Sub
  2. 热点更强:引入逻辑过期和异步刷新
  3. 一致性要求更高:消息队列做可靠失效广播
  4. 规模更大:按业务域拆缓存集群,建立统一缓存治理平台

总结

基于 Spring Boot、Spring Cache 和 Redis 做多级缓存,真正有价值的不是“会不会加注解”,而是能不能把这几个问题设计清楚:

  • 分层:L1 本地缓存提速,L2 Redis 共享兜底
  • 防护:空值缓存、参数校验、布隆过滤器、防击穿锁、TTL 抖动
  • 一致性:更新数据库后删除缓存,并广播各节点清理本地缓存
  • 治理:监控命中率、控制容量、明确业务边界

如果你准备在项目里落地,我给一个比较实用的执行建议:

  1. 先挑一个读多写少、热点明显的接口试点
  2. 优先实现 Caffeine + Redis + Spring Cache 的统一接入
  3. 再补 空值缓存、随机 TTL、更新后失效广播
  4. 最后通过监控决定是否继续升级为逻辑过期、可靠消息同步

一句话收尾:

多级缓存的上限,不取决于缓存框架多炫,而取决于你是否把“失效”和“一致性”当成一等公民来设计。

如果你现在的系统已经有 Redis,但接口 RT 还是高、数据库回源还是多,那很可能不是 Redis 不够快,而是你该认真做一次多级缓存设计了。


分享到:

上一篇
《Spring Boot + MyBatis 实战:在 Java Web 项目中设计高可用的用户权限与接口鉴权体系》
下一篇
《Node.js 中级实战:基于 Worker Threads 与队列机制构建高并发任务处理服务-298》