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

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

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

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

在实际业务里,单纯“上 Redis”通常只能解决一部分性能问题。真正到高并发场景,尤其是详情页、配置查询、用户画像、商品信息这类“读多写少”的接口,往往还会遇到几个老问题:

  • Redis 有了,但 RT 还是不够低
  • 数据库扛不住缓存穿透
  • 热点 Key 被打爆
  • 更新后数据不一致,用户看到旧值
  • Spring Cache 用起来很爽,但一上多实例就开始出问题

这篇文章我会带你从一个可运行的 Spring Boot 实战出发,搭建一个“本地缓存 + Redis 二级缓存 + Spring Cache 注解”的方案,并且把几个最容易踩坑的点讲透:

  • 多级缓存怎么分工
  • Spring Cache 怎么和 Redis 配合
  • 更新时如何尽量保证一致性
  • 怎么处理缓存穿透、击穿、热点 Key
  • 怎么排查“明明加了缓存但就是不生效”

整篇文章偏实战,我会尽量按“能直接上手”的方式写。


背景与问题

先看一个典型接口:

根据商品 ID 查询商品详情

这个接口有几个特点:

  1. 读流量远大于写流量
  2. 商品详情变更频率不高
  3. 同一个商品可能会被频繁访问
  4. 热门商品会形成明显热点

如果只查数据库,会出现:

  • 数据库连接池吃紧
  • SQL RT 抖动大
  • 峰值流量压垮主库

如果只加 Redis,又可能出现:

  • 每次请求都走网络,延迟仍高于本地内存
  • 热点 Key 集中打 Redis
  • Redis 故障时,流量回源数据库,产生雪崩

所以比较稳妥的思路通常是:

  • 一级缓存:本地缓存(Caffeine)
  • 二级缓存:Redis
  • 最终数据源:MySQL / DB

这样分层的目标很明确:

  • 本地缓存:追求极致低延迟,减 Redis 压力
  • Redis:跨实例共享缓存,减少数据库访问
  • 数据库:兜底数据源

前置知识与环境准备

技术栈

本文示例使用:

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

示例依赖

<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>

核心原理

先把整体流程看清楚,不然后面代码会显得像“东一块西一块”。

多级缓存访问流程

flowchart TD
    A[请求进入] --> B{本地缓存命中?}
    B -- 是 --> C[直接返回]
    B -- 否 --> D{Redis命中?}
    D -- 是 --> E[回填本地缓存]
    E --> C
    D -- 否 --> F{布隆/空值校验}
    F -- 不存在 --> G[返回空结果/防穿透]
    F -- 可能存在 --> H[查询数据库]
    H --> I{查到数据?}
    I -- 是 --> J[写入Redis]
    J --> K[写入本地缓存]
    K --> C
    I -- 否 --> L[写入空值缓存]
    L --> G

为什么要用 Spring Cache

Spring Cache 的优点是:

  • 注解式开发,上手快
  • 统一缓存抽象,不直接耦合具体实现
  • 可以扩展 CacheManager / CacheResolver
  • @Cacheable@CachePut@CacheEvict 覆盖常见场景

但它也有边界:

  • 它主要是声明式缓存工具,不是“一致性框架”
  • 本地多级缓存、热点 Key、互斥锁等问题,通常还要自己补方案
  • 注解基于 AOP,自调用失效是经典坑

多级缓存一致性思路

缓存和数据库之间,一致性通常追求的是最终一致性,而不是绝对强一致。

常见更新流程有两种:

  1. 先更新数据库,再删除缓存
  2. 先删除缓存,再更新数据库

实践里更常用的是:

更新数据库成功后,删除 Redis 和本地缓存

为什么不是“更新缓存”?

  • 删除通常更简单,减少脏数据覆盖
  • 下次读请求自然会重建缓存
  • 多实例下“更新多个缓存副本”成本更高

不过这还不够。多实例环境下,本地缓存需要同步失效,不然 A 节点删了,B 节点还在读旧值。这个问题后面会讲解决方案。

更新流程时序图

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

    Client->>App: 更新商品
    App->>DB: update product
    DB-->>App: success
    App->>Redis: delete cache:key
    App->>Local: evict local key
    App-->>Client: 返回成功

    Client->>App: 查询商品
    App->>Local: get key
    Local-->>App: miss
    App->>Redis: get key
    Redis-->>App: miss
    App->>DB: select by id
    DB-->>App: product data
    App->>Redis: set key
    App->>Local: put key
    App-->>Client: 返回最新数据

项目结构设计

这次示例我们用下面的结构:

src/main/java/com/example/cache
├── CacheDemoApplication.java
├── config
│   ├── CacheConfig.java
│   └── RedisConfig.java
├── controller
│   └── ProductController.java
├── domain
│   └── Product.java
├── repository
│   └── ProductRepository.java
├── service
│   ├── ProductService.java
│   └── impl/ProductServiceImpl.java
└── cache
    ├── MultiLevelCache.java
    └── MultiLevelCacheManager.java

核心思路是:

  • Caffeine 做一级缓存
  • RedisCache 做二级缓存
  • 自定义一个 MultiLevelCache,先查本地,再查 Redis
  • 用 Spring Cache 注解操作统一缓存入口

实战代码(可运行)

1. application.yml

server:
  port: 8080

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

logging:
  level:
    org.springframework.cache: debug
    com.example.cache: debug

这里把 spring.cache.type 设成 none,是因为我们要自定义 CacheManager


2. 启动类

package com.example.cache;

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

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

3. 实体类

package com.example.cache.domain;

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

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

    public Product() {
    }

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

    public Long getId() {
        return id;
    }

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

    public String getName() {
        return name;
    }

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

    public BigDecimal getPrice() {
        return price;
    }

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

    public Long getVersion() {
        return version;
    }

    public void setVersion(Long version) {
        this.version = version;
    }
}

4. 模拟仓储层

为了让示例可运行,我们先不用数据库,直接用 ConcurrentHashMap 模拟。

package com.example.cache.repository;

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

import java.math.BigDecimal;
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, "机械键盘", new BigDecimal("399.00"), 1L));
        store.put(2L, new Product(2L, "无线鼠标", new BigDecimal("129.00"), 1L));
        store.put(3L, new Product(3L, "显示器", new BigDecimal("1499.00"), 1L));
    }

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

    public Product updatePrice(Long id, BigDecimal price) {
        Product old = store.get(id);
        if (old == null) {
            return null;
        }
        Product updated = new Product(old.getId(), old.getName(), price, old.getVersion() + 1);
        store.put(id, updated);
        return updated;
    }

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

这里故意加了 100ms 延迟,方便观察缓存效果。


5. Redis 序列化配置

如果你不配序列化,默认结果往往不够友好,线上排查也痛苦。

package com.example.cache.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.cache.RedisCacheConfiguration;

import java.time.Duration;

@Configuration
public class RedisConfig {

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration() {
        GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();

        return RedisCacheConfiguration.defaultCacheConfig()
                .serializeValuesWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(serializer)
                )
                .entryTtl(Duration.ofMinutes(10))
                .disableCachingNullValues();
    }

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

6. 自定义多级缓存实现

6.1 MultiLevelCache

package com.example.cache.cache;

import org.springframework.cache.Cache;
import org.springframework.cache.support.SimpleValueWrapper;

import java.util.concurrent.Callable;

public class MultiLevelCache implements Cache {

    private final String name;
    private final Cache localCache;
    private final Cache redisCache;

    public MultiLevelCache(String name, Cache localCache, Cache redisCache) {
        this.name = name;
        this.localCache = localCache;
        this.redisCache = redisCache;
    }

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

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

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

        ValueWrapper redisValue = redisCache.get(key);
        if (redisValue != null) {
            localCache.put(key, redisValue.get());
            return redisValue;
        }

        return null;
    }

    @Override
    @SuppressWarnings("unchecked")
    public <T> T get(Object key, Class<T> type) {
        ValueWrapper wrapper = get(key);
        if (wrapper == null) {
            return null;
        }
        Object value = wrapper.get();
        return value == null ? null : (T) value;
    }

    @Override
    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 RuntimeException(e);
        }
    }

    @Override
    public void put(Object key, Object value) {
        redisCache.put(key, value);
        localCache.put(key, value);
    }

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

    @Override
    public void evict(Object key) {
        redisCache.evict(key);
        localCache.evict(key);
    }

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

6.2 MultiLevelCacheManager

package com.example.cache.cache;

import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.data.redis.cache.RedisCacheManager;

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

public class MultiLevelCacheManager implements CacheManager {

    private final RedisCacheManager redisCacheManager;

    public MultiLevelCacheManager(RedisCacheManager redisCacheManager) {
        this.redisCacheManager = redisCacheManager;
    }

    @Override
    public Cache getCache(String name) {
        Cache redisCache = redisCacheManager.getCache(name);
        Cache localCache = new CaffeineCache(name,
                Caffeine.newBuilder()
                        .initialCapacity(100)
                        .maximumSize(1000)
                        .expireAfterWrite(30, TimeUnit.SECONDS)
                        .build());

        return new MultiLevelCache(name, localCache, redisCache);
    }

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

这里先实现最小可运行版本。
严格来说,getCache 每次 new 一个本地缓存实例并不理想,后面“常见坑”里我会专门说这个问题以及修复方式。


7. CacheManager 配置

package com.example.cache.config;

import com.example.cache.cache.MultiLevelCacheManager;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.connection.RedisConnectionFactory;

@Configuration
public class CacheConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory factory,
                                     RedisCacheConfiguration redisCacheConfiguration) {
        RedisCacheManager redisCacheManager = RedisCacheManager.builder(factory)
                .cacheDefaults(redisCacheConfiguration)
                .build();

        return new MultiLevelCacheManager(redisCacheManager);
    }
}

8. Service 层

这里是 Spring Cache 注解真正发挥作用的地方。

package com.example.cache.service;

import com.example.cache.domain.Product;

import java.math.BigDecimal;

public interface ProductService {
    Product getById(Long id);

    Product updatePrice(Long id, BigDecimal price);

    void deleteCache(Long id);
}
package com.example.cache.service.impl;

import com.example.cache.domain.Product;
import com.example.cache.repository.ProductRepository;
import com.example.cache.service.ProductService;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

import java.math.BigDecimal;

@Service
public class ProductServiceImpl implements ProductService {

    private final ProductRepository productRepository;

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

    @Override
    @Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
    public Product getById(Long id) {
        System.out.println(">>> 查询数据库: " + id);
        return productRepository.findById(id);
    }

    @Override
    public Product updatePrice(Long id, BigDecimal price) {
        Product updated = productRepository.updatePrice(id, price);
        deleteCache(id);
        return updated;
    }

    @Override
    @CacheEvict(cacheNames = "product", key = "#id")
    public void deleteCache(Long id) {
        System.out.println(">>> 删除缓存: " + id);
    }
}

这里故意使用:

  • @Cacheable:查数据时自动缓存
  • @CacheEvict:更新后删除缓存

这是比较符合实际的模式:更新数据库后淘汰缓存


9. Controller 层

package com.example.cache.controller;

import com.example.cache.domain.Product;
import com.example.cache.service.ProductService;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotNull;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;

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

    private final ProductService productService;

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

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

    @PutMapping("/{id}/price")
    public Product updatePrice(@PathVariable Long id,
                               @RequestParam @NotNull @DecimalMin("0.01") BigDecimal price) {
        return productService.updatePrice(id, price);
    }

    @DeleteMapping("/{id}/cache")
    public String deleteCache(@PathVariable Long id) {
        productService.deleteCache(id);
        return "ok";
    }
}

10. 运行与验证

启动 Redis

如果本机没有 Redis,可以直接用 Docker:

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

启动应用

mvn spring-boot:run

验证缓存命中

第一次查询:

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

你会看到控制台打印:

>>> 查询数据库: 1

第二次查询:

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

正常情况下不会再查数据库,而是从缓存命中。

验证更新后失效

curl -X PUT "http://localhost:8080/products/1/price?price=499.00"

再查一次:

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

会重新查数据库并写回缓存。


逐步验证清单

如果你想确认“多级缓存真的在工作”,建议按这个顺序验证:

  1. 第一次查询:一定走 DB
  2. 第二次查询:命中本地缓存或 Redis
  3. 重启应用后再次查询:本地缓存丢失,但 Redis 仍可命中
  4. 更新数据后再查询:应重新回源加载新值
  5. 查不存在 ID:观察是否发生重复穿透

一致性、穿透与热点 Key 的落地处理

上面的代码能跑,但要进生产,还差几步关键增强。

1. 缓存一致性:更新数据库后删除缓存

最常用的基本原则:

  • 先更新 DB
  • 再删 Redis
  • 再删本地缓存
  • 多实例下通过消息通知清除其他节点本地缓存

多实例本地缓存失效方案

如果系统有多个应用实例,仅删除当前节点的本地缓存是不够的。可以通过 Redis Pub/Sub 或 MQ 广播失效事件。

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

    A->>DB: 更新商品
    DB-->>A: success
    A->>Redis: 删除二级缓存
    A->>Redis: 发布缓存失效消息(product:1)
    Redis-->>A: 通知失效
    Redis-->>B: 通知失效
    A->>A: 删除本地缓存
    B->>B: 删除本地缓存

这个思路很常见,也比较实用:

  • Redis 保证共享缓存失效
  • 消息广播保证各节点一级缓存失效

一个经验提醒

我自己踩过的坑是:只删 Redis,不删本地缓存。单机自测没问题,一上多实例就开始出现“有些用户看到新值,有些用户看到旧值”的诡异现象。本质上就是一级缓存没同步失效。


2. 缓存穿透:空值缓存 + 参数校验

缓存穿透通常是:

  • 查询一个根本不存在的数据
  • Redis 没有
  • DB 也没有
  • 每次请求都打到数据库

比如恶意刷:

curl http://localhost:8080/products/99999999

方案一:缓存空值

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

不过要注意:本文前面配置了 disableCachingNullValues(),如果你要缓存空值,需要自己定义“空对象标记”,而不是直接缓存 null

例如定义一个占位对象:

public class NullValueMarker implements java.io.Serializable {
    public static final NullValueMarker INSTANCE = new NullValueMarker();
    private NullValueMarker() {}
}

然后在自定义缓存里识别它。
如果项目不复杂,也可以绕开 Spring Cache 注解,直接手写读写逻辑,这样控制更细。

方案二:入口参数校验

像 ID 这种参数,先做基础校验:

  • 不能为空
  • 必须大于 0
  • 非法格式直接拒绝

方案三:布隆过滤器

当数据集合相对稳定时,可以把合法 ID 放进布隆过滤器。请求进来先判断:

  • 不存在:直接返回
  • 可能存在:继续查 Redis/DB

这个方案特别适合“商品 ID / 用户 ID / 内容 ID”这类主键查询。


3. 热点 Key:本地缓存 + 互斥重建 + 逻辑过期

热点 Key 的本质问题不是“命中率低”,而是:

某一个 Key 太热,失效瞬间大量请求同时回源

这就是典型的缓存击穿

处理套路

方案一:本地缓存顶住瞬时流量

一级缓存本身就能吸收大量热点读请求,这也是多级缓存的第一价值。

方案二:互斥锁防止并发重建

当 Redis 和本地缓存都失效时,只允许一个线程去查数据库,其他线程等待或快速失败。

伪代码如下:

public Product queryWithMutex(Long id) {
    String key = "product:" + id;
    Product cache = getCache(key);
    if (cache != null) {
        return cache;
    }

    String lockKey = "lock:" + key;
    boolean locked = tryLock(lockKey);
    if (!locked) {
        sleep(50);
        return queryWithMutex(id);
    }

    try {
        cache = getCache(key);
        if (cache != null) {
            return cache;
        }

        Product dbData = repository.findById(id);
        if (dbData == null) {
            setEmptyCache(key);
            return null;
        }

        setCache(key, dbData);
        return dbData;
    } finally {
        unlock(lockKey);
    }
}

方案三:逻辑过期

逻辑过期不是立即删除缓存,而是让旧值先继续服务,同时异步刷新。

适合:

  • 热点数据
  • 可接受短时间旧值
  • 读流量特别大

不适合:

  • 强一致敏感数据
  • 库存、余额、权限这类场景

常见坑与排查

这一节很重要。很多 Spring Cache 问题,不是“不会写”,而是“写了却不生效”。

1. 同类内部方法调用,注解失效

比如:

public Product updatePrice(Long id, BigDecimal price) {
    Product updated = productRepository.updatePrice(id, price);
    deleteCache(id); // 自调用
    return updated;
}

在同一个类里直接调用 deleteCache(id)可能绕过代理,导致 @CacheEvict 不生效。

解决方式

  • 把删除缓存的方法拆到另一个 Bean
  • 或通过代理对象调用
  • 或干脆直接使用 CacheManager 手动删除

这是 Spring AOP 的经典问题,不只是缓存会踩。


2. 自定义 CacheManager 每次都创建本地缓存实例

前面的示例代码里:

Cache localCache = new CaffeineCache(name,
        Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(1000)
                .expireAfterWrite(30, TimeUnit.SECONDS)
                .build());

如果 getCache(name) 每次都 new,一个缓存名可能对应多个本地缓存实例,效果就不对了。

修正版

package com.example.cache.cache;

import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.data.redis.cache.RedisCacheManager;

import java.util.Collection;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;

public class MultiLevelCacheManager implements CacheManager {

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

    public MultiLevelCacheManager(RedisCacheManager redisCacheManager) {
        this.redisCacheManager = redisCacheManager;
    }

    @Override
    public Cache getCache(String name) {
        return cacheMap.computeIfAbsent(name, cacheName -> {
            Cache redisCache = redisCacheManager.getCache(cacheName);
            Cache localCache = new CaffeineCache(cacheName,
                    Caffeine.newBuilder()
                            .initialCapacity(100)
                            .maximumSize(1000)
                            .expireAfterWrite(30, TimeUnit.SECONDS)
                            .build());
            return new MultiLevelCache(cacheName, localCache, redisCache);
        });
    }

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

这个版本更适合真实使用。


3. Redis 序列化不一致导致反序列化异常

常见表现:

  • 能写进去,读不出来
  • 类一改字段就报错
  • Redis 里全是看不懂的二进制

建议

  • 统一使用 JSON 序列化
  • 缓存对象尽量保持稳定结构
  • 大对象、深层嵌套对象谨慎缓存
  • 涉及版本变更时做好兼容

4. TTL 配置不合理

如果一级缓存和二级缓存 TTL 完全一致,可能会出现:

  • 同一时刻大面积失效
  • 某批 Key 同时回源

建议

给 TTL 加随机抖动,例如:

  • Redis: 10 分钟 ± 60 秒
  • 本地缓存: 30 秒 ± 5 秒

这样可以降低雪崩风险。


5. 本地缓存过大导致内存压力

本地缓存不是越大越好。

要关注:

  • 堆内存占用
  • Full GC 次数
  • 热点数据是否真有价值
  • 是否缓存了大对象列表

有一次我见过有人把分页结果、超大 JSON、用户会话都塞本地缓存,最后不是 Redis 先扛不住,而是应用 JVM 先开始抖。


安全/性能最佳实践

1. Key 设计规范化

建议统一 Key 前缀,例如:

product:detail:1
product:stock:1
user:profile:1001

好处:

  • 便于排查
  • 便于分业务清理
  • 避免 Key 冲突

不要直接把复杂对象 toString() 当 key。


2. 区分数据类型设置 TTL

不同数据应该分开设计:

  • 商品详情:10~30 分钟
  • 首页配置:1~5 分钟
  • 用户权限:短 TTL + 主动失效
  • 热点榜单:逻辑过期 + 异步刷新

不要一个全局 TTL 打天下。


3. 缓存与数据库双写时优先删缓存

对于读多写少业务,建议:

  • 更新数据库
  • 删除缓存
  • 让下次读取自动回填

除非你能非常确定“更新缓存”不会覆盖新值,否则不要轻易做复杂双写。


4. 对不存在数据做保护

至少做两层:

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

这样才能真正挡住穿透。


5. 热点 Key 做专项治理

如果你已经知道某些商品、活动页、配置项特别热,不要等出问题再处理。建议提前准备:

  • 本地缓存
  • 互斥锁重建
  • 限流降级
  • 逻辑过期
  • 异步预热

6. 监控必须补齐

缓存方案上线前,至少加这些指标:

  • 本地缓存命中率
  • Redis 命中率
  • 缓存加载耗时
  • DB 回源次数
  • 热点 Key 排名
  • 缓存删除失败次数
  • Redis 超时/异常次数

没有监控,缓存问题基本靠猜。


7. 给缓存链路设置边界条件

当 Redis 故障时,系统不应该无限制回源数据库。建议加:

  • 接口限流
  • 熔断降级
  • 热点接口兜底值
  • 关键缓存预热

这点特别重要,很多“缓存事故”最后其实是“数据库被缓存故障拖下水”。


一个更贴近生产的优化建议

如果你的业务已经进入中高并发阶段,我更建议把职责拆开:

  • Spring Cache 注解:处理大多数标准读写缓存
  • 手写缓存逻辑:处理热点 Key、逻辑过期、互斥锁
  • 消息通知机制:处理多实例本地缓存失效
  • 布隆过滤器:处理穿透
  • 监控告警:观察命中率和回源情况

也就是说:

Spring Cache 很适合做“基础层”,但不要指望它一个注解解决所有缓存问题。


总结

这篇文章我们完成了一个基于 Spring Boot + Spring Cache + Redis + Caffeine 的多级缓存实战,并重点处理了三个核心问题:

  1. 一致性

    • 推荐“先更新数据库,再删除缓存”
    • 多实例下要同步清理本地缓存
    • 接受最终一致性,不追求不必要的强一致
  2. 缓存穿透

    • 参数校验
    • 空值缓存
    • 布隆过滤器
  3. 热点 Key / 击穿

    • 本地缓存分流
    • 互斥锁重建
    • 逻辑过期

如果你现在要把它落到项目里,我建议按这个顺序推进:

  1. 先上 Redis + Spring Cache
  2. 再补 本地缓存
  3. 接着解决 多实例本地缓存失效
  4. 最后针对热点 Key 加 互斥锁 / 逻辑过期 / 预热

这样复杂度是逐步增加的,不会一上来把系统做得太重。

最后给一个很实用的判断边界:

  • 读多写少、可接受秒级旧数据:多级缓存非常适合
  • 余额、库存、强事务一致性:不要迷信缓存,优先保证正确性
  • 超热点业务:一定要专项治理,不能只靠默认注解

如果你已经在用 Spring Cache,但总觉得“够用又不完全够用”,那多半就是该把多级缓存、一致性和热点治理这些“后半段能力”补上了。


分享到:

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