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

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

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

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

在业务量上来之后,很多项目都会遇到一个很现实的问题:数据库扛不住、接口延迟波动大、热点数据抖一下全链路都跟着抖

这时候,单一 Redis 缓存往往已经不够用了。原因不复杂:

  • Redis 能抗不少流量,但网络往返成本仍然存在
  • 热点 key 非常集中时,Redis 也可能成为瓶颈
  • 本地 JVM 内存其实离业务代码最近,读起来最快
  • 但本地缓存一旦引入,又会带来一致性失效传播脏数据这些新问题

所以这篇文章,我们就从工程实践角度,带你搭一个 Spring Boot + Spring Cache + Redis + 本地缓存(Caffeine) 的多级缓存方案,并重点解决下面几个痛点:

  • 如何把 Spring Cache 用起来,而不是全手写缓存逻辑
  • 多级缓存怎么设计:本地缓存 + Redis 如何配合
  • 缓存一致性怎么处理
  • 如何防止缓存穿透、缓存击穿、缓存雪崩
  • 线上调优时,哪些参数最影响性能
  • 出问题了怎么排查

这不是一篇只讲概念的文章,我会尽量用“能跑、能改、能定位问题”的方式来讲。


背景与问题

先看一个典型查询链路:

Controller -> Service -> DB

刚开始数据量不大,没问题;一旦 QPS 上来,数据库慢查询增多,接口响应时间就开始抖。最直接的优化是加 Redis:

Controller -> Service -> Redis -> DB

这确实能挡掉大部分读取流量,但当热点数据极端集中时,问题又来了:

  1. 所有请求都要过网络访问 Redis
  2. 同一个节点上的相同热点数据被重复获取
  3. 某个热点 key 失效瞬间,大量请求同时打到 DB
  4. 分布式环境下,本地缓存更新不及时会出现数据不一致

所以我们通常会进一步演进为:

Controller -> Service -> 本地缓存(Caffeine) -> Redis -> DB

也就是所谓的多级缓存

  • 一级缓存:本地缓存,极低延迟
  • 二级缓存:Redis,跨节点共享
  • 最终存储:MySQL / PostgreSQL 等数据库

但这套方案要真正可用,不能只停留在“读的时候先查本地,不存在再查 Redis”。真正难的是:

  • 写操作之后如何删除/刷新多级缓存
  • 多节点如何广播本地缓存失效
  • 空值是否缓存,缓存多久
  • 热点 key 如何避免击穿
  • TTL 如何设计,避免同一时间大批量失效

这篇文章就围绕这些问题展开。


前置知识与环境准备

本文示例使用:

  • JDK 17
  • Spring Boot 3.x
  • Spring Cache
  • Redis
  • Caffeine
  • Maven

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>

application.yml

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

server:
  port: 8080

这里我故意把 spring.cache.type 设成 none,因为我们要自己接管 CacheManager,这样可控性更高。


核心原理

先把整体架构画出来。

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

这个过程看起来简单,但真正上线要考虑“写”:

sequenceDiagram
    participant Client as Client
    participant App1 as App-1
    participant Redis as Redis
    participant DB as DB
    participant MQ as Redis Pub/Sub
    participant App2 as App-2

    Client->>App1: 更新商品
    App1->>DB: 更新数据库
    App1->>Redis: 删除二级缓存
    App1->>MQ: 发布本地缓存失效消息
    MQ-->>App1: 清理本地缓存
    MQ-->>App2: 清理本地缓存
    App1-->>Client: 返回成功

也就是说,多级缓存设计里有三个核心原则:

1. 读路径:逐级回源

  • 先查本地缓存
  • 本地没有,再查 Redis
  • Redis 没有,再查数据库
  • 数据查到后逐级回填

这是为了性能。

2. 写路径:先更新数据库,再删缓存

这是经典的 Cache Aside Pattern

一般推荐:

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

而不是先更新缓存再更新数据库。因为缓存不是权威数据源,数据库才是。

3. 一致性目标:追求最终一致,而不是强一致

多级缓存天然不适合追求跨节点强一致。实际工程里通常接受:

  • 短时间内出现旧值
  • 通过 TTL + 主动删除 + 失效广播,将窗口缩到可接受范围

如果你的业务是账户余额、库存扣减这类强一致场景,缓存只能做辅助,甚至不该直接缓存关键值。


多级缓存设计思路

我们这次实现采用下面的策略:

  • Spring Cache 注解驱动
  • 自定义 Cache,内部组合 Caffeine + Redis
  • Redis 作为二级缓存与跨节点共享层
  • 本地缓存只做热点数据短 TTL 存储
  • 更新数据时通过 Redis Pub/Sub 通知所有实例清理本地缓存
  • 对空值进行短时缓存,防止穿透
  • 对热点 key 增加互斥加载,防止击穿
  • TTL 增加随机抖动,避免雪崩

实战代码(可运行)

下面给出一套简化但可运行的实现。

1. 启动类

package com.example.multicache;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

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

2. 模拟实体与仓库

package com.example.multicache.domain;

import java.io.Serializable;

public class Product implements Serializable {
    private Long id;
    private String name;
    private Integer price;

    public Product() {
    }

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

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public Integer getPrice() {
        return price;
    }

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

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

    public void setPrice(Integer price) {
        this.price = price;
    }
}
package com.example.multicache.repository;

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

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, "机械键盘", 399));
        storage.put(2L, new Product(2L, "显示器", 1299));
    }

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

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

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

3. 多级缓存配置

3.1 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.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
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(objectMapper());

        template.setKeySerializer(keySerializer);
        template.setHashKeySerializer(keySerializer);
        template.setValueSerializer(valueSerializer);
        template.setHashValueSerializer(valueSerializer);
        template.afterPropertiesSet();
        return template;
    }

    @Bean
    public ObjectMapper objectMapper() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.activateDefaultTyping(
                LaissezFaireSubTypeValidator.instance,
                ObjectMapper.DefaultTyping.NON_FINAL,
                JsonTypeInfo.As.PROPERTY
        );
        return mapper;
    }

    @Bean
    public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) {
        return new StringRedisTemplate(factory);
    }
}

这里顺手提一句:生产环境里如果对反序列化安全要求高,不建议滥用默认类型信息,后面“安全最佳实践”里会展开说。

3.2 自定义缓存实现

package com.example.multicache.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;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;

public class TwoLevelCache 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 redisTtl;
    private final String keyPrefix;
    private final ConcurrentHashMap<Object, ReentrantLock> keyLocks = new ConcurrentHashMap<>();

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

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

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

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

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

        Object redisValue = redisTemplate.opsForValue().get(buildKey(key));
        if (redisValue != null) {
            localCache.put(key, redisValue);
            return wrapValue(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;
        }
        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();
        }

        ReentrantLock lock = keyLocks.computeIfAbsent(key, k -> new ReentrantLock());
        lock.lock();
        try {
            wrapper = get(key);
            if (wrapper != null) {
                return (T) wrapper.get();
            }
            T value = valueLoader.call();
            put(key, value);
            return value;
        } catch (Exception e) {
            throw new ValueRetrievalException(key, valueLoader, e);
        } finally {
            lock.unlock();
            keyLocks.remove(key, lock);
        }
    }

    @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, redisTtl);
    }

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

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

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

    private ValueWrapper wrapValue(Object value) {
        if (Objects.equals(value, NULL_VALUE)) {
            return new SimpleValueWrapper(null);
        }
        return new SimpleValueWrapper(value);
    }
}

这段代码有几个关键点:

  • 先查本地,再查 Redis
  • 查到 Redis 后回填本地缓存
  • get(key, Callable) 做了单机内互斥加载,减轻热点 key 击穿
  • null 用占位对象缓存,避免穿透

注意:这里的锁是单机粒度,对多实例来说只能防本机并发,跨节点击穿需要 Redis 分布式锁或热点永不过期策略。

3.3 CacheManager 配置

package com.example.multicache.config;

import com.example.multicache.cache.TwoLevelCache;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.support.AbstractCacheManager;
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.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.concurrent.TimeUnit;

@Configuration
public class CacheConfig {

    public static final String PRODUCT_CACHE = "product";

    @Bean
    public CacheManager cacheManager(RedisTemplate<String, Object> redisTemplate) {
        return new AbstractCacheManager() {
            @Override
            protected Collection<? extends Cache> loadCaches() {
                TwoLevelCache productCache = new TwoLevelCache(
                        PRODUCT_CACHE,
                        Caffeine.newBuilder()
                                .initialCapacity(100)
                                .maximumSize(10_000)
                                .expireAfterWrite(30, TimeUnit.SECONDS)
                                .build(),
                        redisTemplate,
                        Duration.ofMinutes(5),
                        "cache:"
                );
                return List.of(productCache);
            }
        };
    }
}

4. 本地缓存失效广播

多节点部署时,只删当前节点本地缓存是不够的,所以我们借助 Redis Pub/Sub 广播失效消息。

4.1 消息发布器

package com.example.multicache.cache;

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

@Component
public class CacheInvalidationPublisher {

    public static final String CHANNEL = "cache:invalidate";

    private final StringRedisTemplate stringRedisTemplate;

    public CacheInvalidationPublisher(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    public void publish(String cacheName, Object key) {
        String message = cacheName + "::" + key;
        stringRedisTemplate.convertAndSend(CHANNEL, message);
    }
}

4.2 消息订阅器

package com.example.multicache.cache;

import com.example.multicache.config.CacheConfig;
import org.springframework.cache.CacheManager;
import org.springframework.data.redis.connection.Message;
import org.springframework.data.redis.connection.MessageListener;
import org.springframework.stereotype.Component;

@Component
public class CacheInvalidationSubscriber implements MessageListener {

    private final CacheManager cacheManager;

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

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String body = new String(message.getBody());
        String[] parts = body.split("::");
        if (parts.length != 2) {
            return;
        }
        String cacheName = parts[0];
        String key = parts[1];

        org.springframework.cache.Cache cache = cacheManager.getCache(cacheName);
        if (cache instanceof TwoLevelCache twoLevelCache) {
            twoLevelCache.clearLocal(key);
        }
    }
}

4.3 订阅配置

package com.example.multicache.config;

import com.example.multicache.cache.CacheInvalidationPublisher;
import com.example.multicache.cache.CacheInvalidationSubscriber;
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.PatternTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;

@Configuration
public class RedisListenerConfig {

    @Bean
    public RedisMessageListenerContainer redisContainer(
            RedisConnectionFactory connectionFactory,
            CacheInvalidationSubscriber subscriber) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(subscriber, new PatternTopic(CacheInvalidationPublisher.CHANNEL));
        return container;
    }
}

5. Service:基于 Spring Cache 的读写

package com.example.multicache.service;

import com.example.multicache.cache.CacheInvalidationPublisher;
import com.example.multicache.config.CacheConfig;
import com.example.multicache.domain.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;
    private final CacheInvalidationPublisher publisher;

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

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

    public Product update(Product product) {
        Product saved = repository.save(product);

        // 删除 Redis + 当前节点本地缓存
        // 注意:Spring AOP 内部调用 @CacheEvict 容易失效,这里直接通过 CacheManager 或拆分 Bean 更稳
        publisher.publish(CacheConfig.PRODUCT_CACHE, product.getId());
        return saved;
    }
}

上面这段有个刻意保留的点:@Cacheable(unless = "#result == null") 和我们底层 TwoLevelCache 的空值缓存策略并不完全一致。

这是我实际项目里经常看到的“配置打架”问题。为了演示穿透防护,后面我们把 Service 改成手动控制写法,更直观。


6. 更稳妥的 Service 写法

如果你既想用 Spring Cache 注解,又想对空值缓存、更新删除做更精细控制,我更建议这样:

package com.example.multicache.service;

import com.example.multicache.cache.CacheInvalidationPublisher;
import com.example.multicache.config.CacheConfig;
import com.example.multicache.domain.Product;
import com.example.multicache.repository.ProductRepository;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Service;

@Service
public class ProductService {

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

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

    public Product getById(Long id) {
        Cache cache = cacheManager.getCache(CacheConfig.PRODUCT_CACHE);
        if (cache == null) {
            return repository.findById(id);
        }
        return cache.get(id, () -> repository.findById(id));
    }

    public Product update(Product product) {
        Product saved = repository.save(product);

        Cache cache = cacheManager.getCache(CacheConfig.PRODUCT_CACHE);
        if (cache != null) {
            cache.evict(product.getId());
        }

        publisher.publish(CacheConfig.PRODUCT_CACHE, product.getId());
        return saved;
    }
}

这个版本的好处是:

  • 读逻辑清晰可控
  • 借用了缓存实现里的防击穿逻辑
  • 更新后立即删 Redis 和当前节点本地缓存
  • 再通过 Pub/Sub 通知其他节点删本地缓存

7. Controller

package com.example.multicache.controller;

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

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

    private final ProductService service;

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

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

    @PutMapping("/{id}")
    public Product update(@PathVariable Long id, @RequestBody @Valid UpdateProductRequest request) {
        Product product = new Product(id, request.getName(), request.getPrice());
        return service.update(product);
    }

    public static class UpdateProductRequest {
        @NotNull
        private String name;
        @NotNull
        private Integer price;

        public String getName() {
            return name;
        }

        public Integer getPrice() {
            return price;
        }

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

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

8. 逐步验证清单

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

第一次查询:走 DB

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

预期:

  • 本地缓存 miss
  • Redis miss
  • DB 查询
  • 回填 Redis 和本地缓存

第二次查询:命中本地缓存

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

预期:

  • 直接命中本地缓存
  • 响应更快

更新数据后再次查询

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

再查:

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

预期:

  • 更新后缓存被删除
  • 查询重新回源
  • 读到最新值

一致性到底怎么做才靠谱?

多级缓存最容易被问到的,就是“怎么保证一致性”。

先说结论:大多数业务里,建议用“更新 DB + 删除缓存 + 广播本地失效 + TTL兜底”的组合拳

推荐时序

flowchart LR
    A[写请求] --> B[更新数据库]
    B --> C[删除 Redis 缓存]
    C --> D[删除当前节点本地缓存]
    D --> E[广播其他节点删除本地缓存]
    E --> F[后续读请求重新加载]

为什么不是更新缓存?

因为更新缓存需要知道:

  • 这个对象有哪些缓存 key
  • 是否有多种查询维度
  • 本地缓存、Redis、聚合缓存是否都要同步改
  • 更新过程中有无并发读写

一旦缓存 key 多、聚合结构复杂,“更新缓存”很容易漏。相比之下,删除缓存让后续请求自然回源重建,风险通常更小。

双删要不要做?

有些场景会用“延迟双删”:

  1. 更新 DB
  2. 删除缓存
  3. 睡眠几十到几百毫秒
  4. 再删一次缓存

它主要用来缓解下面这个窗口:

  • 线程 A 更新数据库
  • 线程 B 恰好在删除前后读到旧数据并回填缓存

这个方案有效,但不优雅,而且睡眠时间不好拍脑袋。我的建议是:

  • 高并发热点写场景:可考虑延迟双删
  • 一般业务:先用删除缓存 + TTL + 失效广播,够用了
  • 极致一致性业务:用消息队列、版本号、订阅 binlog 等更稳方案

缓存穿透、击穿、雪崩怎么防?

这是缓存方案绕不开的三件套。

1. 缓存穿透

问题

请求查询一个根本不存在的数据,比如商品 ID = 999999999,缓存和数据库都没有。攻击者或异常流量不断打这个 key,就会一直访问 DB。

解决

  • 缓存空值,TTL 设短一点,比如 30 秒
  • 参数校验,非法 ID 直接拦截
  • 对恶意请求加限流/黑名单

实践建议

  • 空值缓存 TTL 不要太长
  • 热门不存在 key 也可以缓存
  • 对于明显越界 ID,可以在业务层提前返回

2. 缓存击穿

问题

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

解决

  • 单机互斥加载
  • 分布式锁
  • 热点数据逻辑过期 + 后台刷新
  • 热点 key 永不过期,靠消息主动删

本文示例里的 get(key, Callable) 已经实现了单机内互斥加载。如果你的服务是 10 个实例,那只是把击穿风险从“10 万请求打 DB”降成“最多 10 个实例一起打 DB”,已经有帮助,但还不够彻底。

更进一步:Redis 分布式锁示意

String lockKey = "lock:product:" + id;
Boolean success = stringRedisTemplate.opsForValue()
        .setIfAbsent(lockKey, "1", Duration.ofSeconds(3));
if (Boolean.TRUE.equals(success)) {
    try {
        Product product = repository.findById(id);
        cache.put(id, product);
        return product;
    } finally {
        stringRedisTemplate.delete(lockKey);
    }
}
Thread.sleep(50);
return cache.get(id, () -> repository.findById(id));

这只是演示思路,生产环境里最好封装成统一组件,并处理:

  • 锁续期
  • 锁误删
  • 超时与降级
  • 自旋重试次数

3. 缓存雪崩

问题

大量缓存 key 在同一时间过期,导致回源流量集中压垮数据库。

解决

  • TTL 加随机抖动
  • 不同业务缓存分层设置 TTL
  • 热点数据永不过期 + 主动失效
  • 限流、熔断、降级兜底

TTL 抖动示例

long baseSeconds = 300;
long jitter = java.util.concurrent.ThreadLocalRandom.current().nextLong(0, 60);
redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(baseSeconds + jitter));

这是很小但很有效的优化,我几乎会默认加上。


常见坑与排查

这部分我想讲得实一点,因为真正折腾人的往往不是原理,而是“为什么明明写了缓存,结果根本没生效”。

1. @Cacheable 不生效

常见原因

  • 没加 @EnableCaching
  • 方法是类内部自调用
  • 不是 public 方法
  • 被代理的 Bean 没被 Spring 管理
  • key 表达式写错

正确姿势

package com.example.multicache.config;

import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableCaching
public class CacheEnableConfig {
}

如果是同类内部调用,比如:

public void test() {
    this.getById(1L);
}

@Cacheable 往往不会走代理,这是很多人第一次接触 Spring Cache 时都会踩的坑。


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

现象

  • Redis 已删,但某个实例仍返回旧数据
  • 某个节点长时间命中旧本地缓存

排查方向

  1. Pub/Sub 是否正常订阅
  2. 消息频道名是否一致
  3. 本地缓存 key 类型是否统一
  4. 是否有多个 CacheManager
  5. 更新后是否真的执行了 evict

一个非常隐蔽的问题是:key 类型不一致

比如查询时用的是 Long 1L,广播清理时拿的是 "1" 字符串,那么:

  • Redis key 可能删掉了
  • 本地缓存却删不掉

所以 key 的字符串化规则要统一。


3. 序列化问题

现象

  • Redis 里数据可读性差
  • 类结构变更后反序列化失败
  • 出现 LinkedHashMap 无法转换
  • 跨服务共享缓存时类型不兼容

建议

  • 能约束类型就不要存任意 Object
  • 优先使用 JSON 序列化
  • 跨服务缓存尽量存 DTO,而不是整个领域对象
  • 对缓存对象做版本控制

我在实际项目里一般会避免“通用 Object 缓存一切”的做法,因为一旦类变更、包名变更、字段重命名,老缓存可能直接变成地雷。


4. 热点 key 还是把 DB 打高了

可能原因

  • 本地缓存 TTL 太短
  • Redis TTL 太短
  • 锁粒度不对
  • 分布式环境只有本地锁,没有全局锁
  • 热点 key 被频繁删除

排查建议

看三个指标:

  • Redis 命中率
  • 本地缓存命中率
  • DB 某类 SQL 的瞬时 QPS

如果发现:

  • 本地命中率很低
  • Redis 命中率高
  • DB 也偶发冲高

说明你的本地缓存设计可能没有真正兜住热点。


安全/性能最佳实践

这一部分直接给建议,方便你上线前对照。

1. 本地缓存只放热点、短 TTL 数据

不要把本地缓存当“第二个数据库”。

建议:

  • 只缓存高频读取的小对象
  • 设置 maximumSize
  • TTL 控制在秒级到分钟级
  • 避免缓存超大对象和列表

否则 JVM 堆很容易膨胀,GC 一重,反而把延迟拉高。


2. Redis key 设计要规范

建议格式:

业务前缀:环境:cacheName:业务key

例如:

mall:prod:product:1

好处:

  • 易排查
  • 易统计
  • 易做权限隔离
  • 多环境不容易串数据

3. TTL 分层,不要一刀切

不同数据 TTL 应该不同:

  • 商品详情:5~30 分钟
  • 配置项:1~10 分钟
  • 用户权限:更短,甚至强制主动失效
  • 空值缓存:30 秒左右

一个项目里所有缓存都 30 分钟,通常不是精细化设计,而是懒。


4. 给 TTL 加随机值

这是防雪崩的低成本高收益手段,建议默认开启。

private Duration withJitter(Duration base) {
    long jitter = java.util.concurrent.ThreadLocalRandom.current().nextLong(0, 60);
    return base.plusSeconds(jitter);
}

5. 避免危险的反序列化配置

虽然示例里用了 activateDefaultTyping,但在生产环境里更推荐:

  • 缓存固定类型 DTO
  • 使用 Jackson2JsonRedisSerializer<YourType>
  • 限制反序列化白名单
  • 不要对不可信输入做任意类型反序列化

如果你的 Redis 缓存对象来源复杂,这点尤其重要。


6. 指标监控要先于调优

别等线上慢了再看缓存。

至少监控:

  • 本地缓存命中率
  • Redis 命中率
  • 缓存加载耗时
  • 缓存 evict 次数
  • 热点 key 排行
  • DB 回源 QPS
  • Redis 慢查询与连接池使用情况

没有监控,调优基本就是猜。


7. 为更新操作设计降级与兜底

缓存失效广播不是 100% 不丢消息的银弹,尤其 Pub/Sub 在消费者短暂断开时可能丢消息。

所以你要有兜底:

  • 本地缓存 TTL 不宜过长
  • 关键更新场景可增加二次删除
  • 更高可靠性要求下改用 MQ/Kafka
  • 极端情况下提供手工清缓存入口

一套可执行的落地建议

如果你现在要把这套方案落到一个中型 Spring Boot 项目,我建议按这个顺序做:

  1. 先用 Spring Cache + Redis 跑通单级缓存
  2. 确认 key 设计、TTL 策略、序列化方案
  3. 对热点接口加本地 Caffeine
  4. 实现更新后的本地缓存广播失效
  5. 对不存在数据加短 TTL 空值缓存
  6. 对热点 key 做互斥加载或分布式锁
  7. 补充命中率、回源率、慢查询监控
  8. 根据真实流量再调本地缓存大小和 TTL

不要一上来就把所有花活都加满。缓存这东西,越复杂越要克制。


总结

这篇文章我们完整走了一遍 Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战,核心要点可以浓缩成下面几句:

  • 多级缓存的目标是降低延迟、减少 Redis 与 DB 压力
  • 读走逐级回源,写走更新 DB 后删缓存
  • 一致性通常追求最终一致,不要幻想低成本强一致
  • 穿透靠空值缓存,击穿靠互斥加载/分布式锁,雪崩靠 TTL 抖动
  • 本地缓存一定要配合失效广播和 TTL 兜底
  • 真正的性能调优离不开监控数据

如果你的系统是普通商品详情、配置、字典、内容页这类“读多写少”的场景,这套方案非常适合;但如果你做的是库存、余额、交易状态这类高一致业务,就要谨慎使用缓存,至少不能把缓存当真相。

最后给一个我自己比较常用的判断标准:

能删缓存就尽量别更新缓存;能缩短不一致窗口就别硬追强一致;能监控就别靠感觉。

这三句,基本能帮你避开多级缓存里的大部分坑。


分享到:

上一篇
《微服务架构下分布式事务实战:基于 Saga 模式的订单系统一致性设计与落地》
下一篇
《Java开发踩坑实战:ThreadLocal 在线程池中的内存泄漏与上下文串值排查指南》