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

《Spring Boot 中基于 Spring Cache + Redis 的多级缓存设计与实战优化》

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

Spring Boot 中基于 Spring Cache + Redis 的多级缓存设计与实战优化

在 Spring Boot 项目里,很多同学第一次做缓存,往往是“先把 Redis 接上再说”。这样做短期能扛住一部分读压力,但系统一旦进入高并发、热点数据、复杂失效、实例横向扩容这些场景,单级 Redis 很快就会暴露出几个问题:

  • 应用每次都要走网络访问 Redis,延迟还是不够低
  • Redis 扛住了数据库,却自己成了新的热点
  • 本地缓存和分布式缓存一混用,数据一致性开始变得“玄学”
  • Spring Cache 用起来很方便,但一到多级缓存,就容易写成“表面优雅,实际难控”

这篇文章我从架构设计的角度,带你把一个基于 Spring Cache + Redis 的多级缓存方案搭起来,并重点讲清楚:

  1. 为什么需要多级缓存
  2. Spring Cache 在其中扮演什么角色
  3. 本地缓存 + Redis 如何协同
  4. 实战中如何处理一致性、穿透、击穿、雪崩
  5. 落地时有哪些常见坑,怎么排查

如果你已经会 Spring Boot、Redis、Spring Cache 的基础注解,这篇内容会比较适合你。


背景与问题

先看一个典型场景:商品详情查询。

请求链路通常是:

  • 用户请求商品详情
  • 应用先查缓存
  • 缓存没有,再查数据库
  • 查到后回填缓存

单机、低并发阶段,这么干没问题。但一旦有以下情况,单级缓存就不够用了:

1. 热点 Key 访问量极高

比如首页爆款商品、活动配置、用户权限数据,这类数据会被反复读取。
如果每次都去 Redis,即便 Redis 很快,也仍然有:

  • 网络开销
  • 序列化/反序列化开销
  • Redis 连接池竞争

当 QPS 上来后,这些成本会被无限放大。

2. 应用实例增多,缓存失效逻辑变复杂

你加了本地缓存后,请求更快了,但问题马上变成:

  • A 实例更新了数据
  • B、C、D 实例的本地缓存怎么同步失效?

如果没有一个可靠的失效通知机制,本地缓存迟早脏。

3. Spring Cache 默认更偏“单层缓存抽象”

Spring Cache 的优势是统一注解、降低样板代码,但它本身并不天然等于“多级缓存架构”。
换句话说:

  • @Cacheable 很方便
  • 但多级缓存的“先查本地,再查 Redis,再查 DB,再回填多层,并广播失效”这些逻辑,需要你自己补齐

所以,多级缓存不是“多配一个依赖”就完成的,而是一个缓存体系设计问题


方案对比与取舍分析

在进入实现前,我先把几种常见方案摆出来。做架构时,先知道为什么选它,比直接贴代码更重要。

方案一:只用 Redis

优点:

  • 实现最简单
  • 数据共享天然支持集群
  • 一致性相对容易控制

缺点:

  • 所有读请求都经过网络
  • 热点访问下 Redis 压力大
  • 延迟不如 JVM 本地缓存稳定

适用:

  • 中小流量
  • 读性能要求没那么极致
  • 数据一致性要求高于极致性能

方案二:只用本地缓存

优点:

  • 访问速度最快
  • 无网络开销
  • 对热点 Key 极其友好

缺点:

  • 多实例之间数据难同步
  • 重启即丢
  • 容量受 JVM 内存限制
  • 不适合作为全局共享缓存

适用:

  • 配置类、字典类、极低变更频率的数据
  • 单实例或一致性要求不高的场景

方案三:本地缓存 + Redis 多级缓存

优点:

  • 热点请求优先命中本地缓存,延迟低
  • Redis 作为分布式共享缓存,弥补本地缓存一致性和容量不足
  • 更适合高并发读多写少业务

缺点:

  • 实现复杂度上升
  • 需要处理多实例本地缓存失效同步
  • 调试和监控要求更高

适用:

  • 读多写少
  • 热点明显
  • 应用多实例部署
  • 对性能与可控性都有要求

推荐取舍

如果你的业务是:

  • 商品详情
  • 店铺信息
  • 用户画像只读视图
  • 配置中心读缓存
  • 权限菜单快照

那多级缓存通常是值得做的。

如果你的业务是:

  • 高频写入
  • 强一致交易状态
  • 库存扣减
  • 资金类数据

那缓存最多做辅助,不能把多级缓存当核心方案。


核心原理

我们先统一一下本文的多级缓存模型:

  • 一级缓存(L1):应用本地缓存,使用 Caffeine
  • 二级缓存(L2):Redis 分布式缓存
  • 数据源:MySQL 或其他持久化存储

整体访问流程如下。

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

这个流程看起来很标准,但真正难点不在查,而在写和失效

缓存更新的关键原则

在业务更新时,常见做法是:

  1. 先更新数据库
  2. 再删除缓存,而不是直接更新缓存

原因很现实:

  • 更新数据库通常是事实来源
  • 直接更新多级缓存容易漏掉某一级
  • 删除缓存可以让后续读请求重新加载最新值

多级缓存下,删除也不是只删 Redis,而是:

  • 删除 Redis 缓存
  • 通知所有应用实例删除对应本地缓存

这就是我们常说的缓存失效广播

一致性策略

这里先说结论:
多级缓存很难做到严格强一致,一般追求最终一致 + 可控延迟。

常见策略:

  • Cache Aside(旁路缓存):最常见
    • 读:查缓存,miss 查库并回填
    • 写:先更新库,再删缓存
  • 消息通知失效
    • 更新后发 MQ / Redis PubSub 通知各实例清理 L1
  • 短 TTL + 主动删除结合
    • 避免极端情况下脏数据长期存在

Spring Cache 在方案中的位置

Spring Cache 最适合作为:

  • 统一缓存编程模型
  • 统一 key 管理
  • 注解化缓存读写
  • 统一接入监控和扩展

但多级缓存落地时,我更建议你把它理解为:

“一个缓存门面,而不是完整的多级缓存治理系统”

也就是说:

  • @Cacheable 负责声明式读缓存
  • 自定义 CacheManager / Cache 负责实现多级逻辑
  • 失效通知、热点保护、TTL 策略、空值缓存等能力,由你在底层补齐

多级缓存架构设计

下面给出一个比较实用的组件划分。

classDiagram
    class ProductController {
      +getById(Long id)
      +update(Product product)
    }

    class ProductService {
      +getProduct(Long id)
      +updateProduct(Product product)
    }

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

    class CaffeineCache {
      +getIfPresent(key)
      +put(key, value)
      +invalidate(key)
    }

    class RedisCache {
      +get(key)
      +set(key, value, ttl)
      +delete(key)
    }

    class CacheInvalidationPublisher {
      +publish(cacheName, key)
    }

    class CacheInvalidationSubscriber {
      +onMessage(message)
    }

    ProductController --> ProductService
    ProductService --> MultiLevelCache
    MultiLevelCache --> CaffeineCache
    MultiLevelCache --> RedisCache
    ProductService --> CacheInvalidationPublisher
    CacheInvalidationSubscriber --> CaffeineCache

这个设计的重点是:

  • 读路径:L1 -> L2 -> DB
  • 写路径:DB -> 删除 L2 -> 广播删除 L1
  • 广播机制:Redis Pub/Sub 或 MQ 都可以

为什么不建议“更新缓存值”而优先“删除缓存”

我自己在业务里踩过一个坑:
更新商品信息时,代码里同步去更新了 Redis,但忘了删部分实例中的本地缓存,结果线上偶发读到旧数据。这个问题非常隐蔽,因为不是所有实例都有问题。

所以对于多级缓存,更稳妥的方式一般是:

  • 更新数据库
  • 删除 Redis
  • 广播本地缓存失效
  • 让下一次读请求重新构建缓存

这套方式虽然看起来“多走一步”,但在复杂系统里可维护性更高。


实战代码(可运行)

下面给出一个可运行的 Spring Boot 示例思路。
技术栈:

  • Spring Boot
  • Spring Cache
  • Redis
  • Caffeine
  • Jackson

为了把重点放在多级缓存逻辑上,示例用内存仓库模拟数据库,你替换成 JPA / MyBatis 都可以。

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. 配置文件

spring:
  redis:
    host: localhost
    port: 6379
    timeout: 3000ms

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;

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

4. 实体类

package com.example.cachedemo.model;

public class Product {
    private Long id;
    private String name;
    private Long price;

    public Product() {
    }

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

    public Long getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public Long getPrice() {
        return price;
    }

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

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

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

5. 模拟 Repository

package com.example.cachedemo.repository;

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

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

@Repository
public class ProductRepository {

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

    @PostConstruct
    public void init() {
        store.put(1L, new Product(1L, "iPhone", 6999L));
        store.put(2L, new Product(2L, "MacBook", 12999L));
    }

    public Product findById(Long id) {
        return store.get(id);
    }

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

6. 多级缓存配置

这里的关键是:实现一个自定义 CacheManager,让 Spring Cache 底层使用“Caffeine + Redis”组合缓存。

6.1 自定义 MultiLevelCache

package com.example.cachedemo.cache;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.Cache;
import org.springframework.cache.support.SimpleValueWrapper;
import org.springframework.data.redis.core.StringRedisTemplate;

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

public class MultiLevelCache implements Cache {

    private final String name;
    private final com.github.benmanes.caffeine.cache.Cache<Object, Object> localCache;
    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;
    private final Duration ttl;

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

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

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

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

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

        String redisValue = redisTemplate.opsForValue().get(buildRedisKey(key));
        if (redisValue != null) {
            try {
                Object value = objectMapper.readValue(redisValue, Object.class);
                localCache.put(key, value);
                return new SimpleValueWrapper(value);
            } catch (JsonProcessingException e) {
                throw new RuntimeException("Redis value deserialize error", e);
            }
        }
        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();
        }
        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);
        try {
            String json = objectMapper.writeValueAsString(value);
            redisTemplate.opsForValue().set(buildRedisKey(key), json, ttl);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("Redis value serialize error", e);
        }
    }

    @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(buildRedisKey(key));
    }

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

6.2 自定义 CacheManager

package com.example.cachedemo.cache;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.data.redis.core.StringRedisTemplate;

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

public class MultiLevelCacheManager implements CacheManager {

    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;
    private final Map<String, Cache> cacheMap = new ConcurrentHashMap<>();

    public MultiLevelCacheManager(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) {
        this.redisTemplate = redisTemplate;
        this.objectMapper = objectMapper;
    }

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

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

6.3 注册配置

package com.example.cachedemo.config;

import com.example.cachedemo.cache.MultiLevelCacheManager;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;

@Configuration
public class CacheConfig {

    @Bean
    public CacheManager cacheManager(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) {
        return new MultiLevelCacheManager(redisTemplate, objectMapper);
    }
}

7. 业务 Service

这里使用 @Cacheable@CacheEvict 演示读写路径。

package com.example.cachedemo.service;

import com.example.cachedemo.model.Product;
import com.example.cachedemo.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 productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
    public Product getProduct(Long id) {
        System.out.println("load from repository, id=" + id);
        return productRepository.findById(id);
    }

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

8. Controller

package com.example.cachedemo.controller;

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

@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.getProduct(id);
    }

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

9. 运行验证

第一次查询

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

控制台会看到:

load from repository, id=1

说明首次查库并回填缓存。

第二次查询

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

控制台没有查库日志,表示命中缓存。
如果是同一实例下的连续请求,先命中本地缓存;本地失效后,还能从 Redis 补回来。

更新后再查询

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

然后再次查询:

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

会重新查 repository,并回填新值。


多实例下的本地缓存失效同步

上面的代码已经能跑,但它还缺最后一块拼图:多实例本地缓存同步失效

因为 @CacheEvict 当前只会清理当前实例里的本地缓存和 Redis,其他实例的 L1 还可能保留旧数据。

实际生产中,一般会增加“失效消息广播”。

一种常见实现:Redis Pub/Sub

流程如下:

sequenceDiagram
    participant Client as 客户端
    participant AppA as 应用A
    participant DB as 数据库
    participant Redis as Redis
    participant AppB as 应用B
    participant AppC as 应用C

    Client->>AppA: 更新商品
    AppA->>DB: 更新数据库
    AppA->>Redis: 删除 L2 缓存
    AppA->>Redis: 发布失效消息
    Redis-->>AppB: 订阅到失效消息
    Redis-->>AppC: 订阅到失效消息
    AppB->>AppB: 删除本地 L1
    AppC->>AppC: 删除本地 L1

失效消息对象

package com.example.cachedemo.cache;

public class CacheMessage {
    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 String getKey() {
        return key;
    }

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

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

发布消息

package com.example.cachedemo.cache;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

@Component
public class CacheInvalidPublisher {

    private static final String CHANNEL = "cache:evict";

    private final StringRedisTemplate redisTemplate;
    private final ObjectMapper objectMapper;

    public CacheInvalidPublisher(StringRedisTemplate redisTemplate, ObjectMapper objectMapper) {
        this.redisTemplate = redisTemplate;
        this.objectMapper = objectMapper;
    }

    public void publish(String cacheName, Object key) {
        try {
            String msg = objectMapper.writeValueAsString(new CacheMessage(cacheName, String.valueOf(key)));
            redisTemplate.convertAndSend(CHANNEL, msg);
        } catch (JsonProcessingException e) {
            throw new RuntimeException(e);
        }
    }
}

订阅消息并清理本地缓存

这里为了简单,我们直接暴露一个本地缓存清理入口。

package com.example.cachedemo.cache;

import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Component;

@Component
public class LocalCacheEvictor {

    private final CacheManager cacheManager;

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

    public void evictLocal(String cacheName, String key) {
        if (cacheManager.getCache(cacheName) instanceof MultiLevelCache cache) {
            cache.evict(key);
        }
    }
}

严格来说,上面的 evict 会同时删 Redis 和本地,实际生产里最好区分 evictLocalOnlyevictAll,避免重复删除和广播回环。这里是为了示意整体思路。

更推荐的生产做法

在生产环境里,我建议你把 MultiLevelCache 再补两个能力:

  • evictLocalOnly(key):只删本地
  • evictRemoteAndLocal(key):删 Redis + 本地

这样可以避免:

  • 收到订阅消息后再次删除 Redis
  • 形成重复广播
  • 排查链路变复杂

容量估算与 TTL 设计

多级缓存要稳定,不能只写代码,还要做一点容量估算。

本地缓存大小怎么定

可以先用这个思路估算:

本地缓存容量 ≈ 热点 Key 数量 × 单对象平均大小 × 安全系数

例如:

  • 热点商品 5000 个
  • 每个对象序列化后约 2KB
  • 安全系数取 1.5

那么大概需要:

5000 × 2KB × 1.5 ≈ 15MB

对单个 JVM 来说,这个量通常可接受。
但别忘了:

  • Java 对象在堆内存中通常比序列化体积更大
  • 如果缓存对象嵌套多、字段多,实际内存占用会明显放大

所以我自己的经验是:

  • 本地缓存尽量缓存读模型/轻量 DTO
  • 不要直接缓存庞大的 ORM 实体和复杂对象图

TTL 怎么配

一般建议:

  • L1 本地缓存 TTL < L2 Redis TTL
  • 比如:
    • L1:30 秒
    • L2:5 分钟

这样做的好处是:

  • 本地缓存更快淘汰,降低脏数据停留时间
  • Redis 继续承担跨实例共享缓存角色
  • 热点数据仍然有较高命中率

常见经验值:

数据类型L1 TTLL2 TTL
商品详情30s ~ 60s5min ~ 15min
配置字典1min ~ 5min10min ~ 1h
用户权限快照10s ~ 30s1min ~ 5min

不要迷信统一 TTL。
更合理的做法是按业务特征分 cacheName 配置不同 TTL


常见坑与排查

这一部分非常重要。很多缓存问题,不是“不会写”,而是“出了问题不知道从哪查”。

1. @Cacheable 不生效

这是 Spring Cache 经典问题。

常见原因:

  • 没加 @EnableCaching
  • 方法是 private
  • 同类内部调用,绕过了代理
  • 方法不是 Spring Bean 管理的对象

例如:

@Service
public class ProductService {

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

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

    private Product load(Long id) {
        return null;
    }
}

排查建议:

  • 先确认方法有没有被 Spring AOP 代理
  • 把缓存方法放到独立 Service 中
  • 打开 debug 日志看缓存拦截器是否执行

2. 序列化类型丢失

如果 Redis 里统一存 JSON,而你在反序列化时直接 Object.class,复杂对象很容易变成 LinkedHashMap

比如本文示例里,为了演示简单用了:

objectMapper.readValue(redisValue, Object.class)

生产环境更好的方式:

  • 按缓存类型保留目标 Class
  • 或者封装统一序列化器
  • 或者直接使用 Spring Data Redis 的 GenericJackson2JsonRedisSerializer

否则你会遇到:

  • 取出来不是目标对象
  • 强转报错
  • 嵌套对象字段丢失类型信息

3. 本地缓存与 Redis Key 类型不一致

有时本地缓存用的是 Long 键,Redis 广播消息里传的是字符串 "1",结果删本地缓存时删不掉。

这类问题很隐蔽,现象通常是:

  • Redis 已删除
  • 某些实例本地仍返回旧值

排查建议:

  • 统一 key 生成规则
  • 统一将 key 序列化为字符串
  • 本地缓存和远程缓存都使用同一种 key 表达形式

4. 空值缓存缺失导致缓存穿透

如果某个 id 根本不存在,而你不缓存 null,那么每次请求都要打数据库。

处理方案:

  • 缓存空对象或特殊标记
  • TTL 设置短一点,比如 30 秒到 1 分钟

Spring Cache 可以通过 unless = "#result == null" 控制是否缓存 null。
但在防穿透场景下,有时你反而需要“显式缓存空值”。

所以这里没有绝对标准,要看业务:

  • 数据新增频率很低:可以缓存空值
  • 数据可能很快创建:空值 TTL 就要更短

5. 缓存雪崩

大量 Key 在同一时间过期,瞬间全部回源数据库。

解决方案:

  • TTL 加随机值
  • 热点数据永不过期 + 后台异步刷新
  • 多级缓存分担 Redis 和 DB 压力
  • 增加限流和降级

例如:

基础 TTL = 300s
随机抖动 = 0 ~ 60s
实际 TTL = 300s + random(60)

6. 热点 Key 击穿

一个超热点 Key 突然过期,大量请求同时回源。

处理方式:

  • 单飞机制(single flight)
  • 分布式锁
  • 逻辑过期 + 异步重建
  • 热点数据预热

如果你的热点很集中,我建议不要只靠 Spring Cache 默认行为。
最好对“缓存 miss 后的加载过程”做并发保护。

7. 监控缺失,出了问题只能猜

很多团队做了缓存,却没有下面这些指标:

  • L1 命中率
  • L2 命中率
  • DB 回源次数
  • Redis 平均耗时
  • 缓存重建次数
  • 缓存删除广播成功率

没有这些指标,线上出现“数据库突然被打高”“某些接口慢了”时,几乎只能靠猜。

我的建议是:
做多级缓存,就顺手把观测能力一起做掉。


安全/性能最佳实践

这一节给你一些我认为真正能落地的建议。

1. 缓存的数据要做分层,不要什么都往里塞

优先缓存这些数据:

  • 读多写少
  • 查询代价高
  • 热点明显
  • 可接受短暂不一致

谨慎缓存这些数据:

  • 强一致交易状态
  • 高并发写入数据
  • 涉及权限边界的敏感动态数据

2. 本地缓存不要缓存过大对象

过大的对象会带来:

  • GC 压力
  • 堆内存抖动
  • 命中虽高但整体吞吐下降

建议:

  • 缓存 DTO,而不是完整聚合对象
  • 去掉无用字段
  • 控制嵌套深度

3. 缓存 Key 要可读、稳定、可约束

推荐格式:

业务前缀:数据类型:业务主键[:扩展维度]

例如:

product:detail:1001
user:permission:20001
shop:config:12

如果是 Spring Cache 的 cacheName::key 模式,也建议 key 本身不要拼得太随意。

4. Redis 必须设置超时与连接池参数

生产里最怕 Redis 抖动把应用线程拖死。
至少要配置:

  • 连接超时
  • 读写超时
  • 连接池大小
  • 最大等待时间

否则在高峰期,应用可能并不是“缓存慢”,而是“线程在等 Redis 连接”。

5. 删除缓存优先于更新缓存值的复杂联动

对于多级缓存:

  • 优先考虑“更新 DB + 删除缓存”
  • 少做“更新 DB + 精确更新多层缓存值”

后者看似更快,实则更容易把系统带进不一致泥潭。

6. 对热点数据做预热

系统刚启动时,本地缓存为空,如果热点请求瞬间涌入,会对 Redis 和 DB 形成冲击。

可以做:

  • 启动后预热热点数据到 Redis
  • 应用实例启动时加载核心热点到 L1
  • 大促前预加载活动商品

7. 做好权限与敏感信息隔离

有些数据不适合直接缓存原始对象,比如:

  • 用户手机号
  • Token
  • 敏感配置
  • 隐私字段

建议:

  • 缓存脱敏后的只读视图
  • Redis 开启访问控制和网络隔离
  • 不在缓存中落敏感明文

8. 为缓存失败设计兜底路径

缓存系统不是绝对可靠的。
你需要明确:

  • Redis 挂了,应用是否允许直接查库?
  • 查库后是否要限流?
  • 是否要返回降级数据?

一个成熟的方案,不是“缓存永远命中”,而是“缓存失效时系统也不会崩”。


一个更稳妥的生产级演进方向

如果你准备把这套方案真正上生产,我建议按下面路径演进:

第一步:先做单级 Redis + Spring Cache

目标:

  • 跑通注解式缓存
  • 统一 key 和 TTL
  • 补齐监控

第二步:增加 Caffeine 本地缓存

目标:

  • 吃掉热点请求
  • 降低 Redis 压力
  • 观测 L1/L2 命中率

第三步:接入失效广播

目标:

  • 多实例数据最终一致
  • 降低本地脏数据风险

第四步:补齐高并发保护

目标:

  • 防击穿
  • 防雪崩
  • 防穿透
  • 加限流、空值缓存、TTL 抖动

第五步:分业务精细化治理

目标:

  • 不同 cacheName 独立配置 TTL、容量、序列化方式
  • 区分热点缓存、短期缓存、空值缓存
  • 接入指标平台做持续优化

这个路径的好处是:
不会一开始就把缓存系统做得过重,而是随着业务复杂度逐步增强。


总结

基于 Spring Cache + Redis 做多级缓存,真正的价值不在“把两个缓存拼在一起”,而在于建立一套兼顾性能、可维护性和可观测性的缓存体系。

你可以把全文压缩成这几条核心原则:

  1. L1 用本地缓存,L2 用 Redis,热点请求优先打本地
  2. 读走 Cache Aside,写优先更新 DB 后删除缓存
  3. 多实例必须有本地缓存失效广播机制
  4. L1 TTL 要短于 L2 TTL,并加抖动防雪崩
  5. 不要迷信注解就能解决所有问题,Spring Cache 只是门面
  6. 把监控、容量、序列化、一致性一起设计进去

最后给几个可执行建议,方便你直接落地:

  • 如果你现在只有 Redis,先别急着全面上多级缓存,先挑一个热点明显、读多写少的接口试点
  • 本地缓存建议优先用 Caffeine
  • 更新路径尽量采用删缓存而不是更新缓存值
  • 业务上要接受“短时间最终一致”,不要把多级缓存用在强一致核心链路
  • 上线前一定做压测,重点看:
    • L1 命中率
    • Redis QPS
    • DB 回源峰值
    • 更新后脏数据窗口时间

如果你把这些点都考虑到了,多级缓存不仅能“跑起来”,还能在高并发场景下真正稳住系统。


分享到:

上一篇
《从 0 到可维护:基于开源项目贡献工作流的 Issue 诊断、PR 提交与代码评审实战》
下一篇
《面向中型业务的集群架构实战:从高可用设计到弹性扩缩容的落地方案》