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

《Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:一致性、穿透与热点 Key 优化》

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

Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:一致性、穿透与热点 Key 优化

在业务量还不大时,很多项目的缓存方案都很“朴素”:
查数据库慢,就在 Redis 前面加一层缓存。

但项目一旦进入真实流量环境,问题很快就冒出来:

  • Redis 明明有缓存,为什么接口还是偶发抖动?
  • 热门商品详情、配置项、用户画像这类数据,为啥一到高峰期就把 Redis 打得很忙?
  • 缓存更新后,为什么总有人读到旧值?
  • 某些不存在的 ID 被恶意刷请求时,数据库为什么还能被打穿?
  • 本地缓存、Redis、数据库三层数据,怎么尽量做到“够一致”?

这篇文章我会带你从 Spring Boot + Spring Cache + Redis 出发,做一个真正能跑的多级缓存方案,并重点解决三类问题:

  1. 一致性:更新数据库后,缓存怎么删、怎么更新更稳妥?
  2. 穿透:请求不存在的数据,怎么避免把数据库打爆?
  3. 热点 Key:超高频访问的 key,怎么降低 Redis 压力并规避击穿?

文章会以教程方式展开,代码可运行,适合已经会用 Spring Boot 和 Redis,但想把缓存方案从“能用”提升到“可上线”的同学。


一、背景与问题

1.1 为什么单级 Redis 缓存不够了

很多系统最初的写法大概是这样:

请求 -> 应用 -> Redis -> MySQL

看起来已经比直查数据库快很多了,但在中高并发场景会遇到几个典型瓶颈:

  • 网络开销:即便 Redis 很快,也比 JVM 本地内存慢一个量级。
  • 热点集中:少数热点 key 会被频繁访问,Redis QPS 很高。
  • 失效风暴:某个热点 key 同时过期,大量请求穿透到数据库。
  • 不一致问题:数据库更新了,Redis 和本地缓存未及时清理。

这时,多级缓存的思路就比较自然了:

请求 -> 本地缓存(Caffeine) -> Redis -> MySQL

也就是:

  • 一级缓存(L1):应用本地内存缓存,速度最快
  • 二级缓存(L2):Redis,跨实例共享
  • 最终存储:MySQL/其他数据库

1.2 多级缓存解决了什么

它主要解决两件事:

  1. 降低延迟:热点数据优先走本地缓存
  2. 减轻 Redis 压力:很多读请求不再打到 Redis

但它又引入了一个新问题:

层级越多,一致性越难。

所以多级缓存不是“把 Caffeine 加上就完了”,而是要把下面这些细节想清楚:

  • 本地缓存失效如何同步?
  • Redis 删除失败怎么办?
  • 空值要不要缓存?
  • TTL 怎么配?
  • 热点 key 同时过期怎么办?
  • Spring Cache 的注解默认行为,是否真的符合你的业务?

二、前置知识与环境准备

2.1 技术栈

本文示例基于:

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

2.2 依赖配置

<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-data-jpa</artifactId>
    </dependency>

    <dependency>
        <groupId>com.h2database</groupId>
        <artifactId>h2</artifactId>
        <scope>runtime</scope>
    </dependency>

    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

2.3 application.yml

spring:
  datasource:
    url: jdbc:h2:mem:testdb;MODE=MYSQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
    username: sa
    password:
  h2:
    console:
      enabled: true

  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true

  data:
    redis:
      host: localhost
      port: 6379

  cache:
    type: none

server:
  port: 8080

这里把 spring.cache.type 设为 none,是因为我们要手动装配一个多级 CacheManager


三、核心原理

先把整体思路画出来。

flowchart LR
    A[客户端请求] --> B{L1 本地缓存 Caffeine}
    B -- 命中 --> C[直接返回]
    B -- 未命中 --> D{L2 Redis}
    D -- 命中 --> E[回填 L1 并返回]
    D -- 未命中 --> F[查询数据库]
    F --> G[写入 Redis]
    G --> H[回填 L1]
    H --> I[返回结果]

这个流程看着简单,但真正的难点在于写路径

sequenceDiagram
    participant Client as 客户端
    participant App as 应用服务
    participant DB as MySQL
    participant Redis as Redis
    participant L1 as 本地缓存

    Client->>App: 更新商品信息
    App->>DB: update
    DB-->>App: success
    App->>Redis: delete(key)
    App->>L1: evict(key)
    App-->>Client: 返回成功

3.1 为什么推荐“更新数据库 + 删除缓存”

在缓存一致性里,最常见的几种策略是:

  • 先更新缓存,再更新数据库
  • 先删缓存,再更新数据库
  • 先更新数据库,再删缓存
  • 更新数据库后异步通知删除缓存

实际项目里,最稳妥的基础方案通常是:先更新数据库,再删除缓存

原因很简单:

  • 如果先删缓存,再更新数据库,更新数据库还没完成时,别的线程可能把旧数据重新写回缓存。
  • 如果更新缓存和数据库都做写入,双写一致性很难保证。
  • 更新数据库后删缓存,即使删除失败,也可以借助 TTL、重试、消息补偿来兜底。

3.2 多级缓存的一致性本质

多级缓存的一致性不是强一致,而是:

在可接受的时间窗口内做到最终一致。

具体落地时,一般遵循:

  • 读多写少场景最适合多级缓存
  • 本地缓存 TTL 应该短于 Redis TTL
  • 更新时至少要:
    • 删除 Redis
    • 删除本地缓存
  • 多实例部署下,最好通过消息通知广播本地缓存失效

3.3 Spring Cache 在这里扮演什么角色

Spring Cache 的价值不在于“性能魔法”,而在于:

  • 统一缓存注解
  • 屏蔽底层缓存实现差异
  • 把缓存读写逻辑收敛到框架层

常用注解:

  • @Cacheable:读缓存,未命中则加载
  • @CachePut:更新缓存
  • @CacheEvict:删除缓存
  • @Caching:组合多个操作

但需要注意:

Spring Cache 默认更适合单级缓存;做多级缓存时,通常要自定义 CacheCacheManager


四、实战代码(可运行)

这一节我们做一个“商品详情缓存”的例子。

目标:

  • L1 用 Caffeine
  • L2 用 Redis
  • 通过 Spring Cache 注解访问
  • 支持空值缓存,防止穿透
  • 支持更新后双层删除

4.1 实体与 Repository

Product.java

package com.example.cachedemo.model;

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Entity
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Product {
    @Id
    private Long id;

    private String name;

    private Integer price;
}

ProductRepository.java

package com.example.cachedemo.repository;

import com.example.cachedemo.model.Product;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ProductRepository extends JpaRepository<Product, Long> {
}

4.2 启动初始化数据

package com.example.cachedemo.config;

import com.example.cachedemo.model.Product;
import com.example.cachedemo.repository.ProductRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DataInitializer {

    @Bean
    public CommandLineRunner init(ProductRepository repository) {
        return args -> {
            repository.save(new Product(1L, "iPhone", 6999));
            repository.save(new Product(2L, "ThinkPad", 9999));
        };
    }
}

4.3 自定义多级缓存实现

MultiLevelCache.java

package com.example.cachedemo.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 redisTtl;
    private final boolean cacheNullValues;

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

    @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 local = localCache.get(key);
        if (local != null) {
            return local;
        }

        Object value = redisTemplate.opsForValue().get(buildKey(key));
        if (value != null) {
            localCache.put(key, value);
            return new SimpleValueWrapper(value);
        }

        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
    @SuppressWarnings("unchecked")
    public <T> T get(Object key, Callable<T> valueLoader) {
        ValueWrapper wrapper = get(key);
        if (wrapper != null) {
            return (T) wrapper.get();
        }

        synchronized ((name + ":" + key).intern()) {
            wrapper = get(key);
            if (wrapper != null) {
                return (T) wrapper.get();
            }

            try {
                T value = valueLoader.call();
                if (value != null || cacheNullValues) {
                    put(key, value);
                }
                return value;
            } catch (Exception e) {
                throw new RuntimeException("Cache value loading failed", e);
            }
        }
    }

    @Override
    public void put(Object key, Object value) {
        if (value == null && !cacheNullValues) {
            return;
        }
        localCache.put(key, value);
        redisTemplate.opsForValue().set(buildKey(key), value, 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.evict(key);
        redisTemplate.delete(buildKey(key));
    }

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

这个实现有几个点值得注意:

  • 优先查本地缓存
  • 本地没命中再查 Redis
  • Redis 命中后回填本地缓存
  • 加了简单的 synchronized 防止单机内同 key 并发回源
  • 支持缓存空值

这里的 intern() 适合演示,不建议直接原样用于超大规模生产环境。生产上更建议用细粒度锁对象池或分布式锁。

MultiLevelCacheManager.java

package com.example.cachedemo.cache;

import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.data.redis.core.RedisTemplate;

import com.github.benmanes.caffeine.cache.Caffeine;

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 Map<String, Cache> cacheMap = new ConcurrentHashMap<>();

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

    @Override
    public Cache getCache(String name) {
        return cacheMap.computeIfAbsent(name, cacheName -> {
            CaffeineCache localCache = new CaffeineCache(
                    cacheName,
                    Caffeine.newBuilder()
                            .maximumSize(10_000)
                            .expireAfterWrite(Duration.ofSeconds(30))
                            .build()
            );

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

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

4.4 缓存配置

CacheConfig.java

package com.example.cachedemo.config;

import com.example.cachedemo.cache.MultiLevelCacheManager;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
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
@EnableCaching
public class CacheConfig {

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

        StringRedisSerializer keySerializer = new StringRedisSerializer();

        ObjectMapper mapper = new ObjectMapper();
        mapper.activateDefaultTyping(
                BasicPolymorphicTypeValidator.builder().allowIfSubType(Object.class).build(),
                ObjectMapper.DefaultTyping.NON_FINAL,
                JsonTypeInfo.As.PROPERTY
        );

        GenericJackson2JsonRedisSerializer valueSerializer =
                new GenericJackson2JsonRedisSerializer(mapper);

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

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

4.5 Service 层:读写缓存

ProductService.java

package com.example.cachedemo.service;

import com.example.cachedemo.model.Product;
import com.example.cachedemo.repository.ProductRepository;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;

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

    @Transactional
    @CacheEvict(cacheNames = "product", key = "#product.id")
    public Product update(Product product) {
        Product saved = productRepository.save(product);
        return saved;
    }

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

这里我特意保留了 sleep(100),方便你在本地压测时直观看出缓存命中的效果。

注意:unless = "#result == null" 会阻止 Spring Cache 存 null。
但我们的自定义缓存支持空值缓存,所以如果你真的要防穿透,生产上应根据业务调整这一策略。这里先用最直观的版本,后面会讲如何优化。

4.6 Controller

ProductController.java

package com.example.cachedemo.controller;

import com.example.cachedemo.model.Product;
import com.example.cachedemo.service.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;

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

    private final ProductService productService;

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

    @PutMapping("/{id}")
    public Product update(@PathVariable Long id, @RequestBody Product product) {
        product.setId(id);
        return productService.update(product);
    }
}

4.7 启动验证

先启动 Redis,再启动应用。

第一次读取

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

预期:

  • 查数据库
  • 写入 Redis
  • 写入本地缓存

第二次读取

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

预期:

  • 直接命中本地缓存,速度明显更快

更新后再次读取

curl -X PUT http://localhost:8080/products/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"iPhone 16","price":7999}'

然后再读:

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

预期:

  • 更新时删除双层缓存
  • 再次读取时回源数据库
  • 拿到最新值并重新写入缓存

五、穿透、击穿、雪崩:别混了

这三个词经常一起出现,但处理方式不完全一样。

flowchart TD
    A[缓存异常访问] --> B[缓存穿透]
    A --> C[缓存击穿]
    A --> D[缓存雪崩]

    B --> B1[请求不存在的数据]
    B --> B2[解决: 空值缓存/布隆过滤器]

    C --> C1[热点 key 失效瞬间被并发访问]
    C --> C2[解决: 单飞锁/互斥重建/逻辑过期]

    D --> D1[大量 key 同时过期]
    D --> D2[解决: TTL 随机化/限流/降级]

5.1 缓存穿透

定义:请求的数据根本不存在,缓存里没有,数据库里也没有。
如果有人一直拿不存在的 ID 来打接口,每次都会穿过缓存访问数据库。

解决方案 1:缓存空值

例如查不到 id=99999,也缓存一个空对象,TTL 设短一点,比如 1~5 分钟。

思路:

  • 命中空值就直接返回不存在
  • 避免同一个无效 key 反复查库

可以改造 @Cacheable 的写法,不再排除 null,而是显式包装返回值。更实用的方式是定义一个包装对象。

5.2 缓存击穿

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

比如首页热门商品详情缓存刚过期,1 秒几千请求一起进来,就很容易打爆数据库。

常用手段

  • 本地锁 / 分布式锁
  • singleflight 风格并发合并
  • 逻辑过期 + 后台异步刷新
  • 热点数据永不过期,靠消息主动更新

5.3 缓存雪崩

定义:大量 key 在同一时间过期,整体回源数据库。

优化手段

  • 给 TTL 加随机值
  • 对核心缓存做预热
  • 限流、熔断、降级
  • 将热点与普通数据分组配置

六、一致性设计:怎么做到“够稳”

这部分是多级缓存真正的灵魂。

6.1 基础版:更新 DB 后删除缓存

最常见流程:

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

但在我们示例里,evict() 同时删了本地和 Redis,所以注解上一行就够。

边界问题:删除缓存失败怎么办?

这是线上非常常见、而且经常被忽略的问题。

建议至少做下面几件事:

  • 删除失败要打日志并监控
  • 关键业务可做重试
  • 再高级一点,投递 MQ 做异步重删

例如:

update DB -> delete cache fail -> send MQ -> consumer retry delete

6.2 多实例场景:本地缓存怎么同步失效

单机没问题,多实例就不一样了:

  • A 实例更新了商品并删缓存
  • B 实例本地缓存里可能还保留旧值

这是多级缓存最容易出问题的地方。

方案:Redis Pub/Sub 或 MQ 广播失效消息

sequenceDiagram
    participant A as 应用实例A
    participant Redis as Redis/MQ
    participant B as 应用实例B
    participant C as 应用实例C

    A->>Redis: 发布缓存失效消息 product:1
    Redis-->>B: 订阅并删除本地缓存
    Redis-->>C: 订阅并删除本地缓存

也就是说:

  • Redis 仍然删除
  • 另外广播一条“key 已失效”的消息
  • 所有应用实例收到后删除各自 L1 本地缓存

这一步如果不做,你的多级缓存只能算“单机优化”,还称不上完整方案。


七、热点 Key 优化实战思路

如果你们系统里有:

  • 秒杀商品
  • 首页推荐位
  • 热榜内容
  • 配置中心高频项
  • 用户会话信息

那热点 key 优化就一定要做。

7.1 方案一:热点 key 放大本地缓存收益

适合:

  • 数据变化不频繁
  • 读远大于写
  • 单 key QPS 很高

建议:

  • L1 TTL 设短一些,如 10~30 秒
  • Redis TTL 设长一些,如 5~30 分钟
  • 配合失效通知做主动剔除

这样能把大量请求挡在 JVM 内存里。

7.2 方案二:逻辑过期

物理过期容易击穿,逻辑过期更稳。

思路:

  • 缓存数据结构里带一个 expireAt
  • 读到过期数据时,先返回旧值
  • 只让一个线程异步刷新缓存

伪代码如下:

public Product getHotProduct(Long id) {
    CacheData<Product> cacheData = redisGet(id);
    if (cacheData == null) {
        return rebuild(id);
    }

    if (cacheData.getExpireAt().isAfter(LocalDateTime.now())) {
        return cacheData.getData();
    }

    if (tryLock(id)) {
        asyncRefresh(id);
    }

    return cacheData.getData();
}

这种方案非常适合:

  • 可以接受短暂旧数据
  • 但不能接受接口抖动的场景

比如首页榜单、推荐结果、活动配置。

7.3 方案三:TTL 随机化

这是我很推荐的一个“小成本高收益”技巧:

long baseSeconds = 300;
long randomSeconds = ThreadLocalRandom.current().nextLong(0, 120);
long ttl = baseSeconds + randomSeconds;

这样可以避免很多 key 在同一时刻集中过期。


八、常见坑与排查

这一段我尽量讲一些实际会踩到的坑,而不是“理论正确”的那种。

8.1 @Cacheable 不生效

常见原因:

  • 方法是 private
  • 同类内部直接调用,绕过代理
  • 没加 @EnableCaching
  • CacheManager 没配好

排查方式

看启动日志是否有缓存相关 Bean;
确认调用链是不是通过 Spring 代理对象进入。

错误示例:

@Service
public class ProductService {

    public Product a(Long id) {
        return b(id); // 同类内部调用,@Cacheable 失效
    }

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

8.2 序列化问题

Redis 里如果序列化配置不一致,经常出现:

  • 反序列化失败
  • 类型丢失
  • key 看起来正常,value 读不出来

建议:

  • key 使用字符串序列化
  • value 使用 JSON 序列化
  • 明确版本演进策略,避免对象字段变更导致兼容问题

8.3 空值缓存策略和注解冲突

上面示例里用了:

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

这会导致 null 根本不进缓存。
如果你的目标是防缓存穿透,这个写法就和设计目标冲突了。

该怎么做?

两种思路:

  1. 去掉 unless,并允许缓存 null
  2. 使用包装对象,比如 CacheValue<Product>

我更推荐第二种,语义更清晰,也更方便扩展逻辑过期字段。

8.4 本地缓存过大导致 GC 压力

Caffeine 很快,但不是“无限快”。
如果你把本地缓存最大容量设得太大,JVM 内存压力会上来,GC 抖动反而影响接口。

建议根据业务压测后定:

  • 容量上限
  • 访问后过期 or 写入后过期
  • 权重淘汰策略

8.5 多实例数据不一致

现象通常是:

  • A 机器读到新值
  • B 机器一段时间内还读到旧值

几乎可以直接怀疑:

  • 本地缓存没做广播失效
  • 消息丢了
  • 消费失败未重试

排查路径

  1. 先看 Redis 中的值是不是新的
  2. 再看某实例本地缓存是否还保留旧值
  3. 再查失效消息是否发出、是否消费成功

九、安全/性能最佳实践

这一节给的是我认为比较“能落地”的建议。

9.1 Key 设计规范化

推荐 key 格式:

业务前缀:实体:主键[:版本]

例如:

product:detail:1
user:profile:1001
config:homepage:banner:v2

好处:

  • 易排查
  • 易批量治理
  • 便于区分环境和业务域

如果是多环境,还建议加环境前缀:

prod:product:detail:1

9.2 TTL 分层配置

不同数据不要共用一个 TTL。

建议分层:

  • 热点详情:30 秒本地 + 5 分钟 Redis
  • 用户信息:10 秒本地 + 10 分钟 Redis
  • 配置数据:1 分钟本地 + 30 分钟 Redis
  • 空值缓存:30 秒 ~ 2 分钟

核心原则:

  • L1 TTL < L2 TTL
  • 空值 TTL < 正常值 TTL
  • 热点 key TTL 加随机抖动

9.3 不要缓存所有查询

适合缓存的通常是:

  • 读多写少
  • 按主键查询
  • 结果相对稳定
  • 体积可控

不太适合直接缓存的包括:

  • 强一致要求极高的数据
  • 复杂组合查询且条件离散度极高
  • 大对象、超大列表
  • 高频写入数据

9.4 防止缓存成为攻击面

缓存也有安全面:

  • 恶意构造海量不同 key,撑爆缓存
  • 穿透不存在 ID 打数据库
  • 热点 key 恶意打击穿

应对建议:

  • 接口层参数校验
  • ID 合法性校验
  • 对不存在对象做空值缓存或布隆过滤器
  • 限流与熔断
  • 对关键接口加鉴权和访问频率限制

9.5 监控一定要补齐

上线缓存最怕“看不见”。

建议至少监控这些指标:

  • 缓存命中率(L1 / L2 分开看)
  • Redis QPS、慢查询、连接数
  • 回源数据库 QPS
  • 热点 key 排名
  • 缓存删除失败次数
  • 本地缓存大小与淘汰数
  • 接口 P95 / P99 延迟

如果只看接口平均耗时,很容易把问题看丢。


十、逐步验证清单

如果你准备把这套方案用到项目里,我建议按下面顺序验证,而不是一把梭全上。

第一步:验证基本命中链路

  • 第一次查数据库
  • 第二次命中本地缓存
  • 重启实例后命中 Redis

第二步:验证更新一致性

  • 更新数据库后,缓存是否删除
  • 再次读取是否得到新值
  • 删除失败有没有日志和告警

第三步:验证穿透保护

  • 连续请求不存在 ID
  • 看数据库是否被重复访问
  • 确认空值缓存是否生效

第四步:验证热点 key 抗压

  • 用压测工具持续打同一 key
  • 观察本地缓存命中率
  • 观察 Redis QPS 是否明显下降

第五步:验证多实例同步

  • 两个实例同时部署
  • 在 A 更新数据
  • 在 B 读取,确认本地缓存是否及时失效

十一、一个更贴近生产的改造建议

如果你准备把本文示例继续往生产推进,我建议优先补这三项:

1. 本地缓存失效广播

用 Redis Pub/Sub、Kafka、RabbitMQ 都可以。
这是多实例多级缓存的关键。

2. 空值包装与逻辑过期对象

不要直接缓存裸对象,建议统一封装:

public class CacheValue<T> {
    private T data;
    private boolean empty;
    private long expireAt;
}

这样可以同时支持:

  • 空值缓存
  • 逻辑过期
  • 元信息扩展

3. 删除缓存失败补偿

至少做成:

  • 同步删除失败 -> 记录日志
  • 投递消息重试
  • 定时任务兜底扫描

很多“偶发脏读”问题,最后都是补偿机制没做。


十二、总结

Spring Boot 里做 Spring Cache + Redis 的多级缓存,真正的重点不在“怎么加注解”,而在这三件事:

  1. 读路径足够快:L1 本地缓存挡住热点流量,L2 Redis 承担共享缓存
  2. 写路径尽量稳:更新数据库后删除缓存,并考虑失败补偿
  3. 异常流量扛得住:防穿透、抗击穿、避免雪崩

如果你让我给一个实用落地版本,我会建议你这样起步:

  • 先上 Caffeine + Redis 两级缓存
  • 更新采用 先更新 DB,再删缓存
  • 给热点 key 加 TTL 随机化
  • 对不存在数据做 空值缓存
  • 多实例一定补上 本地缓存失效广播
  • 用监控数据持续校准 TTL、容量和命中率

最后说个经验判断:

多级缓存不是银弹,它适合“读多写少、允许短暂最终一致”的场景。
如果你的业务对强一致要求极高,就别硬套缓存,或者至少只缓存非关键读模型。

把边界想清楚,缓存才会帮你,而不是在高峰期反噬你。


分享到:

上一篇
《前端性能实战:基于 Vite 与 Chrome DevTools 的首屏加载优化方案》
下一篇
《前端开发中的微前端落地实践:基于 Module Federation 的应用拆分、共享依赖与部署策略》