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

《Spring Boot 中基于 Spring Cache 与 Redis 的多级缓存实战:热点数据更新一致性与性能优化》

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

Spring Boot 中基于 Spring Cache 与 Redis 的多级缓存实战:热点数据更新一致性与性能优化

在大多数业务系统里,缓存不是“要不要上”的问题,而是“什么时候不够用”。
尤其是商品详情、用户画像、配置中心这类热点数据,一旦流量上来,只靠数据库硬扛,基本就是在等报警。

很多同学第一次做缓存,通常是这样的路径:

  • 先加 Redis,挡住一部分读流量
  • 再发现应用实例一多,Redis 也开始吃紧
  • 然后想加本地缓存,结果更新时出现脏数据
  • 最后线上出现“有些机器显示新值,有些机器还是旧值”的诡异问题

这篇文章我想带你完整做一遍:在 Spring Boot 里,基于 Spring Cache + Caffeine(本地缓存)+ Redis(分布式缓存)搭一个多级缓存方案,并重点解决两个实战里最容易翻车的问题:

  1. 热点数据更新时的一致性
  2. 高并发下的性能优化与缓存保护

整篇文章偏 tutorial,我会尽量用“能直接跑、能直接改”的方式来讲。


背景与问题

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

假设你的接口是:

GET /products/{id}

它有这些特点:

  • 读多写少
  • 热点明显,少量商品会被频繁访问
  • 详情数据查询可能涉及多表或远程调用
  • 用户对“刚更新完就要看到新值”比较敏感

如果只查数据库,会遇到:

  • 数据库 QPS 高
  • 慢 SQL 被热点放大
  • 高峰期线程堆积

如果只用 Redis,会遇到:

  • Redis 成为所有应用实例的共享瓶颈
  • 网络 IO 和序列化反序列化有成本
  • 极热点 Key 仍然集中打到 Redis

所以自然会想到:

  • 一级缓存:应用内本地缓存(Caffeine)
  • 二级缓存:Redis
  • 最终数据源:MySQL / PostgreSQL 等数据库

这就是常说的多级缓存

但这里最难的并不是“把缓存加上”,而是下面这几个问题:

1. 本地缓存和 Redis 怎么协同?

理想路径通常是:

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

2. 数据更新时怎么避免脏读?

比如商品价格更新了:

  • 数据库已经是 99
  • Redis 还是 88
  • 某台机器的本地缓存还是 77

这时候用户到底看到哪个值?如果更新策略不对,答案可能是“随机”。

3. 高并发下如何防止缓存击穿、穿透、雪崩?

这是上线后经常碰到的三连问题:

  • 击穿:热点 Key 失效,大量请求同时打 DB
  • 穿透:查不存在的数据,每次都穿透到 DB
  • 雪崩:一批 Key 同时过期,瞬时把后端打垮

所以,多级缓存不是简单“堆两个缓存层”这么轻松,它本质上是一个一致性 + 性能 + 可观测性的组合题。


前置知识与环境准备

本文示例基于以下环境:

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

依赖

<dependencies>
    <!-- Web -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    <!-- Spring Cache -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>

    <!-- Redis -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>

    <!-- Caffeine -->
    <dependency>
        <groupId>com.github.ben-manes.caffeine</groupId>
        <artifactId>caffeine</artifactId>
    </dependency>

    <!-- 可选:Lombok -->
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>

    <!-- 测试 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

启用缓存

package com.example.multicache;

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

@SpringBootApplication
@EnableCaching
public class MultiCacheApplication {

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

核心原理

先别急着写代码,先把流程图理顺。多级缓存最关键的是:读路径写路径

读路径

flowchart TD
    A[请求查询商品] --> B{本地缓存命中?}
    B -- 是 --> C[直接返回]
    B -- 否 --> D{Redis 命中?}
    D -- 是 --> E[回填本地缓存]
    E --> C
    D -- 否 --> F[查询数据库]
    F --> G{查到数据?}
    G -- 是 --> H[写入 Redis]
    H --> I[写入本地缓存]
    I --> C
    G -- 否 --> J[缓存空值/短TTL]
    J --> K[返回空结果]

这条路径的目标是:

  • 让最热的数据尽量停留在本地缓存
  • Redis 作为跨实例共享缓存
  • 数据库只处理真正 miss 的请求

写路径

更新时的原则,不是“让所有缓存同步写新值”,而是:

先更新数据库,再删除缓存,而不是先更新缓存。

这是缓存一致性里非常经典的策略:Cache Aside Pattern(旁路缓存模式)

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

    Client->>App: 更新商品信息
    App->>DB: 更新数据库
    DB-->>App: 更新成功
    App->>Redis: 删除二级缓存Key
    App->>Local: 删除一级缓存Key
    App-->>Client: 返回成功

为什么不推荐“更新数据库后顺手把缓存更新成新值”?

因为缓存更新会面临更多问题:

  • 并发覆盖:旧请求可能把新值覆盖掉
  • 多实例同步复杂
  • 更新失败与部分成功更难处理

删缓存通常比改缓存更稳。

多级缓存的一致性边界

这里要很现实地说一句:
只要你不是走强一致分布式事务,多级缓存基本上是“最终一致性”,不是“绝对实时一致”。

你真正能做到的是:

  • 绝大多数情况下快速收敛
  • 在极短窗口内接受旧值
  • 用消息通知或版本控制缩小不一致时间

这个边界一定要和业务方说清楚。
比如库存扣减、账户余额这类强一致场景,不应该靠普通缓存来“赌运气”。


方案设计:Spring Cache + Caffeine + Redis

本文采用下面这个组合:

  • Caffeine:一级缓存,速度快,减少 Redis 压力
  • Redis:二级缓存,跨实例共享
  • Spring Cache:统一注解与缓存抽象
  • 手动失效通知:更新后删除 Redis + 清本地缓存
  • 可选增强:Redis Pub/Sub 广播本地缓存失效事件

为什么不直接只用 Spring Cache 默认实现?

因为 Spring Cache 的抽象很好用,但它本身不提供完整的“多级缓存一致性协调”。
也就是说:

  • 单独配一个 RedisCacheManager 很方便
  • 单独配一个 CaffeineCacheManager 也方便
  • 但你想要“先本地、再 Redis、再 DB”的联动,就需要自己扩展一层

这也是很多项目里真正落地时会写一个 Composite CacheMultiLevelCache 的原因。


实战代码(可运行)

下面我们做一个最小可运行版本。

目录结构建议

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

1. 配置文件

spring:
  data:
    redis:
      host: localhost
      port: 6379
      timeout: 3s

server:
  port: 8080

logging:
  level:
    com.example.multicache: info

2. 实体与模拟仓储

为了让示例可跑,我们先用内存 Map 模拟数据库。

Product.java

package com.example.multicache.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 String getName() {
        return name;
    }

    public BigDecimal getPrice() {
        return price;
    }

    public Long getVersion() {
        return version;
    }

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

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

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

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

ProductRepository.java

package com.example.multicache.repository;

import com.example.multicache.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> database = new ConcurrentHashMap<>();

    @PostConstruct
    public void init() {
        database.put(1L, new Product(1L, "机械键盘", new BigDecimal("299.00"), 1L));
        database.put(2L, new Product(2L, "显示器", new BigDecimal("1499.00"), 1L));
    }

    public Product findById(Long id) {
        simulateSlowQuery();
        Product p = database.get(id);
        if (p == null) {
            return null;
        }
        return new Product(p.getId(), p.getName(), p.getPrice(), p.getVersion());
    }

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

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

3. Redis 序列化配置

我建议显式配置 JSON 序列化,别偷懒用 JDK 默认序列化。
默认序列化体积大、可读性差、跨语言也不友好。

RedisConfig.java

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.serializer.*;

@Configuration
public class RedisConfig {

    @Bean
    public RedisSerializer<Object> redisValueSerializer() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.activateDefaultTyping(
                LaissezFaireSubTypeValidator.instance,
                ObjectMapper.DefaultTyping.NON_FINAL,
                JsonTypeInfo.As.PROPERTY
        );
        return new GenericJackson2JsonRedisSerializer(objectMapper);
    }

    @Bean
    public RedisSerializationContext.SerializationPair<Object> redisValueSerializationPair(
            RedisSerializer<Object> redisValueSerializer) {
        return RedisSerializationContext.SerializationPair.fromSerializer(redisValueSerializer);
    }
}

4. 多级缓存实现

这是本文最核心的部分。

MultiLevelCache.java

package com.example.multicache.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 remoteCache;

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

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

        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 (type != null && !type.isInstance(value)) {
            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();
        }

        synchronized (this.internKey(key)) {
            wrapper = get(key);
            if (wrapper != null) {
                return (T) wrapper.get();
            }

            try {
                T value = valueLoader.call();
                put(key, value);
                return value;
            } catch (Exception e) {
                throw new ValueRetrievalException(key, valueLoader, e);
            }
        }
    }

    @Override
    public void put(Object key, Object value) {
        remoteCache.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) {
        remoteCache.evict(key);
        localCache.evict(key);
    }

    @Override
    public boolean evictIfPresent(Object key) {
        boolean remote = remoteCache.evictIfPresent(key);
        boolean local = localCache.evictIfPresent(key);
        return remote || local;
    }

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

    @Override
    public boolean invalidate() {
        boolean remote = remoteCache.invalidate();
        boolean local = localCache.invalidate();
        return remote || local;
    }

    private String internKey(Object key) {
        return (name + "::" + key).intern();
    }
}

这个实现做了几件事:

  • 优先读本地缓存
  • 本地 miss 时读 Redis
  • Redis 命中后回填本地缓存
  • 最终 miss 时通过 valueLoader 查库再双写缓存
  • evict 时同时删除本地和 Redis

注意:这里的 synchronized(intern()) 是教学示例,适合单机演示。
生产环境对热点 key 更推荐细粒度锁、Caffeine Cache#get(key, mappingFunction)、或分布式锁做保护,避免字符串常量池风险。

MultiLevelCacheManager.java

package com.example.multicache.cache;

import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;

import java.util.Collection;
import java.util.LinkedHashSet;
import java.util.Set;

public class MultiLevelCacheManager implements CacheManager {

    private final CacheManager localCacheManager;
    private final CacheManager remoteCacheManager;

    public MultiLevelCacheManager(CacheManager localCacheManager, CacheManager remoteCacheManager) {
        this.localCacheManager = localCacheManager;
        this.remoteCacheManager = remoteCacheManager;
    }

    @Override
    public Cache getCache(String name) {
        Cache local = localCacheManager.getCache(name);
        Cache remote = remoteCacheManager.getCache(name);

        if (local == null || remote == null) {
            return null;
        }
        return new MultiLevelCache(name, local, remote);
    }

    @Override
    public Collection<String> getCacheNames() {
        Set<String> names = new LinkedHashSet<>();
        names.addAll(localCacheManager.getCacheNames());
        names.addAll(remoteCacheManager.getCacheNames());
        return names;
    }
}

5. 缓存管理器配置

CacheConfig.java

package com.example.multicache.config;

import com.example.multicache.cache.MultiLevelCacheManager;
import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.Primary;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.*;

import java.time.Duration;
import java.util.List;

@Configuration
public class CacheConfig {

    public static final String PRODUCT_CACHE = "productCache";

    @Bean("localCacheManager")
    public CacheManager localCacheManager() {
        CaffeineCacheManager cacheManager = new CaffeineCacheManager(PRODUCT_CACHE);
        cacheManager.setCaffeine(Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(10_000)
                .expireAfterWrite(Duration.ofSeconds(30))
                .recordStats());
        return cacheManager;
    }

    @Bean("remoteCacheManager")
    public CacheManager remoteCacheManager(
            RedisCacheConfiguration redisCacheConfiguration) {
        RedisCacheManager.RedisCacheManagerBuilder builder = RedisCacheManager
                .builder()
                .cacheDefaults(redisCacheConfiguration)
                .initialCacheNames(List.of(PRODUCT_CACHE));
        return builder.build();
    }

    @Bean
    public RedisCacheConfiguration redisCacheConfiguration(
            RedisSerializationContext.SerializationPair<Object> redisValueSerializationPair) {
        return RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(5))
                .disableCachingNullValues()
                .serializeValuesWith(redisValueSerializationPair);
    }

    @Bean
    @Primary
    public CacheManager cacheManager(
            @Qualifier("localCacheManager") CacheManager localCacheManager,
            @Qualifier("remoteCacheManager") CacheManager remoteCacheManager) {
        return new MultiLevelCacheManager(localCacheManager, remoteCacheManager);
    }
}

这里的 TTL 我故意设置成:

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

这是一种常见策略:

  • 本地缓存更短,降低脏数据停留时间
  • Redis 更长,保证整体命中率

6. 业务服务

ProductService.java

package com.example.multicache.service;

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;

import java.math.BigDecimal;

@Service
public class ProductService {

    private final ProductRepository productRepository;

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

    @Cacheable(cacheNames = CacheConfig.PRODUCT_CACHE, key = "#id", sync = true)
    public Product getById(Long id) {
        return productRepository.findById(id);
    }

    @CacheEvict(cacheNames = CacheConfig.PRODUCT_CACHE, key = "#id")
    public Product updatePrice(Long id, BigDecimal newPrice) {
        return productRepository.updatePrice(id, newPrice);
    }
}

这里为什么用 @CacheEvict 而不是 @CachePut

这是个重点。

  • @CachePut:更新 DB 后把新值写回缓存
  • @CacheEvict:更新 DB 后删缓存,让下一次读重新加载

对于热点数据、多实例部署场景,我更推荐先用 @CacheEvict。原因前面提过:删缓存比改缓存更稳


7. 控制器

ProductController.java

package com.example.multicache.controller;

import com.example.multicache.domain.Product;
import com.example.multicache.service.ProductService;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;

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

    private final ProductService productService;

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

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

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

逐步验证清单

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

1. 第一次查询,应该走“数据库”

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

第一次会比较慢,因为仓储里有 200ms 的模拟慢查询。

2. 第二次查询,应该命中本地缓存

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

这次会明显快很多。

3. 重启一个新实例,查询应优先命中 Redis

如果你多开一个应用实例,本地缓存为空,但 Redis 已经有值。
这时第二个实例查询时,会走:

  • 本地 miss
  • Redis hit
  • 回填本地缓存

4. 更新价格,缓存应被清理

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

然后再查询:

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

应该能读到新价格。


一致性增强:多实例下如何清理本地缓存

上面的示例在单实例下已经够用了。
但如果是多实例部署,会有一个很现实的问题:

  • A 实例更新了商品,删了 Redis 和自己本地缓存
  • B 实例的本地缓存还在
  • 用户打到 B 实例时,仍然读到旧值

这也是多级缓存最常见的一类线上问题。

解决思路:Redis Pub/Sub 广播失效事件

更新成功后:

  1. 删除 Redis key
  2. 发布一个“缓存失效消息”
  3. 所有实例收到消息后,删除各自本地缓存
sequenceDiagram
    participant A as 实例A
    participant Redis as Redis
    participant B as 实例B
    participant C as 实例C

    A->>Redis: 删除 productCache::1
    A->>Redis: 发布失效消息 productCache::1
    Redis-->>B: 收到失效消息
    Redis-->>C: 收到失效消息
    B->>B: 删除本地缓存 productCache::1
    C->>C: 删除本地缓存 productCache::1

简化实现思路

这里给一个关键代码骨架,方便你继续扩展。

CacheInvalidationMessage.java

package com.example.multicache.cache;

import java.io.Serializable;

public class CacheInvalidationMessage implements Serializable {

    private String cacheName;
    private String key;

    public CacheInvalidationMessage() {
    }

    public CacheInvalidationMessage(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.multicache.cache;

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

@Component
public class CacheInvalidationPublisher {

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

    private final StringRedisTemplate stringRedisTemplate;

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

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

订阅失效消息

package com.example.multicache.cache;

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

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

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String payload = new String(message.getBody());
        String[] parts = payload.split(":");
        if (parts.length < 2) {
            return;
        }

        String cacheName = parts[0];
        String key = parts[1];

        var cache = localCacheManager.getCache(cacheName);
        if (cache != null) {
            cache.evictIfPresent(key);
        }
    }
}

提醒一下:这里为了演示直接用了字符串拼接。生产环境建议用 JSON,并处理 key 中可能带分隔符的问题。

更进一步的做法

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

  • 延迟双删
  • 基于 MQ 的失效通知
  • 数据版本号校验
  • 逻辑过期 + 异步刷新

这些策略不一定都要上,但至少要知道它们各自解决什么问题。


常见坑与排查

这部分我尽量写得像线上排障手册,因为这些坑真的很常见。

坑 1:@Cacheable 不生效

常见原因

  • 没加 @EnableCaching
  • 方法不是 public
  • 同类内部自调用,绕过了 Spring AOP 代理
  • Bean 没被 Spring 管理

排查方式

先打日志确认方法是否真的执行了数据库查询。
如果每次都查库,十有八九是代理没走到。

解决建议

把缓存方法放到独立的 Service Bean 中,通过 Spring 注入调用,而不是类内 this.xxx()


坑 2:更新后仍然读到旧值

可能原因

  • 本地缓存没清
  • 多实例下只有当前实例删了本地缓存
  • Redis 删除失败
  • 查询和更新并发交错,旧值被重新回填

典型并发时序

sequenceDiagram
    participant T1 as 查询线程
    participant T2 as 更新线程
    participant DB as 数据库
    participant Cache as 缓存

    T1->>DB: 读取旧值
    T2->>DB: 更新新值
    T2->>Cache: 删除缓存
    T1->>Cache: 回填旧值

这就是经典的“旧值回填”问题。

解决建议

  • 删除缓存后增加延迟双删
  • 对热点 key 采用互斥加载
  • 给数据带上 version 字段,回填时做版本校验
  • 如果是超高一致性要求,考虑不要走本地缓存

坑 3:缓存击穿

热点 key 一过期,几千个请求同时查 DB。

解决建议

  • @Cacheable(sync = true):同一实例内合并加载
  • 本地缓存短 TTL + Redis 长 TTL
  • 对极热点数据设置逻辑过期并后台刷新
  • 必要时对单 key 加分布式锁

sync = true 只保证 Spring Cache 在当前实例内减少并发加载,不是全局分布式锁,这个边界要清楚。


坑 4:缓存穿透

大量请求查不存在的商品 ID,比如 /products/99999999

解决建议

  • 缓存空值,TTL 短一些
  • 参数合法性校验
  • 布隆过滤器拦截明显非法 ID

不过要注意,本文示例里 Redis 配置用了 disableCachingNullValues(),这是为了简单。
如果你的业务穿透问题明显,可以改成支持空值缓存,并约定好短 TTL。


坑 5:缓存雪崩

一批 key 同时过期,瞬间流量打到 DB。

解决建议

  • TTL 加随机抖动
  • 热点数据永不过期 + 逻辑过期
  • 做服务降级和限流
  • 将缓存预热纳入发布流程

例如 Redis TTL 可以做成:

Duration base = Duration.ofMinutes(5);
long randomSeconds = ThreadLocalRandom.current().nextLong(30, 90);
Duration ttl = base.plusSeconds(randomSeconds);

坑 6:序列化报错或 class 变更后反序列化失败

表现

  • Redis 里有历史数据
  • 应用升级后读取时报类型转换异常
  • 多模块/多服务读同一个缓存 key 时结构不一致

解决建议

  • 尽量用 JSON 序列化
  • 缓存 value 使用稳定 DTO,不直接暴露复杂领域对象
  • 升级时考虑 cache namespace 版本化,比如 product:v2:1

安全/性能最佳实践

这部分是我比较建议真正带到项目里的。

1. 缓存 Key 要规范化

建议统一格式:

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

例如:

mall:product:1
mall:product:1:v2

好处:

  • 便于排查
  • 便于批量治理
  • 降低 key 冲突风险

2. 本地缓存不要无脑开太大

Caffeine 很快,但不是不要钱。

如果你把本地缓存设得过大,会遇到:

  • JVM 堆膨胀
  • Full GC 变频繁
  • 热点数据和冷数据混在一起,命中收益不明显

建议按业务热点规模来配:

  • 明确 key 数量级
  • maximumSize
  • 打开 recordStats() 观察命中率

3. TTL 不要拍脑袋设

可以按数据类型分层:

  • 用户基础信息:5~30 分钟
  • 商品详情:1~10 分钟
  • 配置类数据:30 秒~5 分钟
  • 超热点数据:逻辑过期 + 异步刷新

一个实用原则:

更新越频繁,TTL 越短;重算成本越高,TTL 可以适当拉长。


4. 热点 key 要重点保护

如果某个 key 占了 30% 以上流量,不要把它当普通缓存处理。

建议:

  • 单独设置本地缓存策略
  • 不和普通 key 使用同一 TTL
  • 必要时预热
  • 必要时增加请求合并与异步刷新

5. 不要缓存敏感数据明文

如果缓存里有这些内容,就要格外小心:

  • 手机号
  • 身份证号
  • token / session
  • 支付相关信息

建议:

  • 敏感字段脱敏后再缓存
  • Redis 做访问控制和网络隔离
  • 禁止把调试用 key 打进公共日志
  • 设置合理 TTL,避免敏感数据长期驻留

6. 为缓存建立可观测性

如果你上线后只能看到“接口变慢了”,那排查会非常痛苦。
至少建议监控这些指标:

  • 本地缓存命中率
  • Redis 命中率
  • Redis 慢查询 / 网络耗时
  • DB 回源次数
  • 热点 key TOP N
  • 缓存删除失败次数
  • 缓存重建耗时

我自己的经验是:
没有命中率和回源率监控,优化缓存基本靠猜。


7. 更新链路要保证“先 DB 后删缓存”

这是底线。

错误示范:

先删缓存 -> 再更新DB

如果删完缓存、DB 更新还没完成,这段窗口内新请求会把旧值重新查出来并写回缓存。

正确顺序:

先更新DB -> 再删缓存

如果你担心删缓存失败,可以进一步:

  • 重试删除
  • 记录失败日志并告警
  • 用消息队列补偿
  • 做延迟双删

8. 延迟双删适合哪些场景?

延迟双删的思路是:

  1. 更新 DB
  2. 删除缓存
  3. sleep 一小段时间
  4. 再删一次缓存

它主要用来缓解“旧值并发回填”的问题。

但也别神化它,它不是银弹。
如果你系统延迟波动很大、链路复杂,单纯 sleep 50ms/100ms 未必能完全兜住。
更稳的方案通常是:

  • MQ 异步失效
  • 版本号机制
  • 热点 key 互斥加载

方案取舍分析

多级缓存不是所有系统都必须上,这里简单做个取舍。

只用 Redis 的适用场景

  • 应用实例不多
  • 热点不极端
  • 读流量中等
  • 一致性要求比性能更重要

优点:

  • 结构简单
  • 容易维护
  • 一致性问题相对少一点

缺点:

  • Redis 压力会集中
  • 网络开销不可避免

本地缓存 + Redis 的适用场景

  • 多实例部署
  • 热点 key 明显
  • 追求极低延迟
  • Redis 成本或压力需要进一步控制

优点:

  • 性能更高
  • Redis 压力更低
  • 热点抗压更强

缺点:

  • 一致性更复杂
  • 排障难度更高

不建议上多级缓存的场景

  • 数据强一致要求极高
  • 更新频繁、读取收益不明显
  • 团队缺少缓存治理经验
  • 缺少监控、告警、压测能力

一句话总结就是:

缓存不是越多级越高级,而是越适合业务越高级。


一个更稳的落地建议

如果你准备在真实项目落地,我建议分三步走,而不是一口气把所有高级策略都堆上:

阶段 1:先用 Redis 单层缓存

先解决数据库压力问题,完成:

  • key 规范
  • TTL 设计
  • 空值缓存
  • 基本监控

阶段 2:给热点接口加本地缓存

仅对少量热点接口启用 Caffeine + Redis 多级缓存,重点观察:

  • 命中率变化
  • Redis QPS 下降幅度
  • 更新后一致性问题

阶段 3:做失效广播与热点治理

当多实例下开始出现旧值问题,再上:

  • Redis Pub/Sub 或 MQ 广播失效
  • 延迟双删
  • 逻辑过期
  • 版本控制

这样演进成本最低,也最不容易把系统搞复杂。


总结

这篇文章我们做了几件关键的事:

  • Spring Cache 统一缓存入口
  • Caffeine 做一级本地缓存
  • Redis 做二级共享缓存
  • 通过自定义 MultiLevelCache 实现“先本地、再 Redis、最后 DB”
  • 先更新 DB,再删除缓存 的方式处理更新一致性
  • 分析了多实例下本地缓存脏数据问题,并给出 Redis Pub/Sub 广播失效 的思路
  • 总结了击穿、穿透、雪崩、旧值回填等常见坑及排查方法

如果你只记住三条,我建议是这三条:

  1. 多级缓存的核心不是“快”,而是“快的同时可控”。
  2. 更新链路优先用删缓存,不要迷信直接改缓存。
  3. 多实例本地缓存一定要考虑失效通知,否则早晚会踩一致性坑。

最后给几个可执行建议,方便你直接带回项目:

  • 读多写少的热点接口,优先考虑多级缓存
  • 先从单层 Redis 开始,再逐步演进到本地 + Redis
  • 本地缓存 TTL 设短一点,Redis TTL 设长一点
  • 给热点 key 做单独策略,不要一刀切
  • 上线前一定压测“缓存失效瞬间”的流量峰值
  • 没有监控就不要轻易做复杂缓存

如果你的业务是商品详情、首页聚合、活动配置、字典数据这类场景,这套方案通常非常实用。
但如果是余额、库存、订单状态这种强一致核心链路,请谨慎评估,必要时宁可少缓存,也不要把一致性赌在线上事故上。


分享到:

上一篇
《从源码到部署:基于开源项目 MinIO 搭建高可用对象存储服务的实战指南-172》
下一篇
《Node.js 中基于 Worker Threads 与任务队列的 CPU 密集型服务优化实战》