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

《Spring Boot 中基于 Spring Cache + Redis 实现多级缓存与缓存一致性的实战指南》

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

Spring Boot 中基于 Spring Cache + Redis 实现多级缓存与缓存一致性的实战指南

在很多业务系统里,缓存不是“要不要上”的问题,而是“什么时候会不够用”。

我自己做 Spring Boot 项目时,最常见的演进路径是这样的:

  • 一开始直接查数据库,简单直给
  • 后来接口变慢,先接 Redis
  • 再后来发现 Redis 也不是万能的,热点数据频繁走网络,延迟还是不够低
  • 最终会走到多级缓存:应用本地缓存 + Redis 分布式缓存 + 数据库

但多级缓存真正难的地方,从来不是“把缓存加上”,而是缓存一致性怎么做,故障时怎么兜底,更新时怎么避免脏数据

这篇文章不讲空泛概念,我会带你从一个可以运行的 Spring Boot 示例出发,搭建:

  • Spring Cache 统一缓存入口
  • Caffeine 作为一级本地缓存
  • Redis 作为二级分布式缓存
  • 基于“旁路缓存(Cache Aside)”的读写策略
  • 利用消息通知实现多节点本地缓存失效
  • 常见坑位与排查方式

这套方案不一定适合所有系统,但对于读多写少、对延迟敏感、允许短暂最终一致性的中大型业务,非常实用。


背景与问题

先明确一个现实问题:为什么单用 Redis 还不够?

典型场景

比如商品详情、用户画像、配置中心读取、首页推荐元数据等场景,有几个特点:

  1. 读多写少
  2. 热点明显
  3. 请求量大
  4. 接口对 RT 比较敏感

如果只有 Redis:

  • 每次请求都要走网络
  • Redis 自身会有连接池、序列化、反序列化开销
  • 热点 key 会集中打到 Redis
  • 应用实例扩容后,Redis 压力并不会线性下降

这时候引入本地缓存(如 Caffeine)就很自然了:

  • 一级缓存:JVM 本地,速度极快
  • 二级缓存:Redis,共享数据
  • 三级存储:数据库,最终来源

但新问题也随之而来

多级缓存一上,大家最容易踩的坑就是一致性:

  • 数据库更新了,本地缓存还没删
  • 本地缓存删了,Redis 还在
  • 一个节点更新了,其他节点本地缓存没失效
  • 并发更新导致旧值回写
  • 缓存击穿、穿透、雪崩同时出现时,系统直接抖动

所以,问题的本质不是“怎么缓存”,而是:

如何在 Spring Boot 中优雅地实现多级缓存,并把一致性控制在业务可接受范围内。


前置知识与环境准备

本文示例环境:

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

Maven 依赖

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

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

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

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

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>

    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
    </dependency>
</dependencies>

核心原理

我们先把整体设计想清楚,再写代码。

多级缓存结构

flowchart TD
    A[客户端请求] --> B[Spring Cache]
    B --> C{一级缓存 Caffeine}
    C -- 命中 --> D[返回数据]
    C -- 未命中 --> E{二级缓存 Redis}
    E -- 命中 --> F[写回一级缓存]
    F --> D
    E -- 未命中 --> G[查询数据库]
    G --> H[写入 Redis]
    H --> I[写入 Caffeine]
    I --> D

推荐读写策略:Cache Aside

这个策略是实际项目里最常见、也最容易掌控的。

读流程

  1. 先查本地缓存
  2. 本地未命中,查 Redis
  3. Redis 未命中,查数据库
  4. 查到后回填 Redis 和本地缓存

写流程

  1. 先更新数据库
  2. 再删除缓存,而不是更新缓存
  3. 删除 Redis 后,通知所有节点删除本地缓存

为什么更推荐删除缓存而不是更新缓存

因为更新缓存的逻辑容易复杂化:

  • 更新顺序难保证
  • 序列化对象容易出现局部字段不一致
  • 多节点情况下更新广播更难做
  • 删除通常更简单,下一次读会自动回源重建

一致性边界

这里要讲一句实话:缓存一致性往往不是绝对一致,而是“业务可接受的一致性”

常见级别:

  • 强一致:非常难、成本高,缓存通常不适合强一致核心链路
  • 最终一致:大多数业务可接受,尤其是读多写少场景
  • 短暂不一致可接受:比如商品标题、配置、画像标签等

如果你的场景是:

  • 扣库存
  • 余额
  • 订单状态强校验

那缓存只能辅助,不能当真相来源。


实现方案设计

为了兼顾 Spring Cache 易用性和多级缓存能力,我们做一个组合方案:

  1. 自定义 CacheManager
  2. 每个缓存操作先走 Caffeine,再走 Redis
  3. 数据写入/删除时同步处理两级缓存
  4. Redis Pub/Sub 通知其他节点清理本地缓存

方案架构图

sequenceDiagram
    participant Client as Client
    participant App1 as App Node A
    participant L1 as Caffeine
    participant L2 as Redis
    participant DB as Database
    participant MQ as Redis PubSub
    participant App2 as App Node B

    Client->>App1: 查询商品
    App1->>L1: get(key)
    alt L1命中
        L1-->>App1: value
        App1-->>Client: 返回
    else L1未命中
        App1->>L2: get(key)
        alt L2命中
            L2-->>App1: value
            App1->>L1: put(key, value)
            App1-->>Client: 返回
        else L2未命中
            App1->>DB: select by id
            DB-->>App1: data
            App1->>L2: put
            App1->>L1: put
            App1-->>Client: 返回
        end
    end

    Client->>App1: 更新商品
    App1->>DB: update
    App1->>L2: evict(key)
    App1->>L1: evict(key)
    App1->>MQ: publish evict event
    MQ-->>App2: evict(key)
    App2->>App2: 清理本地L1

实战代码(可运行)

下面给出一套简化但完整的实现。

1. 配置文件

application.yml

server:
  port: 8080

spring:
  cache:
    cache-names:
      - product
  data:
    redis:
      host: localhost
      port: 6379
      timeout: 3000ms

logging:
  level:
    root: info
    com.example.cache: debug

2. 实体与模拟仓库

Product.java

package com.example.cache.entity;

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

ProductRepository.java

这里为了让示例能跑,我用 ConcurrentHashMap 模拟数据库。

package com.example.cache.repository;

import com.example.cache.entity.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> storage = new ConcurrentHashMap<>();

    @PostConstruct
    public void init() {
        storage.put(1L, new Product(1L, "机械键盘", new BigDecimal("399.00"), 1L));
        storage.put(2L, new Product(2L, "无线鼠标", new BigDecimal("129.00"), 1L));
    }

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

    public Product update(Product product) {
        sleep(100);
        storage.put(product.getId(), product);
        return product;
    }

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

3. Redis 序列化配置

默认 JDK 序列化可读性差、兼容性也一般,我更建议直接用 JSON。

RedisConfig.java

package com.example.cache.config;

import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
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
public class RedisConfig {

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

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

        GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(mapper);

        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer);
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(serializer);
        template.afterPropertiesSet();
        return template;
    }
}

提醒一下:activateDefaultTyping 在安全上要谨慎,生产环境最好结合明确类型边界或使用更收敛的序列化方式,后面我会单独讲。


4. 自定义多级缓存实现

Spring Cache 的关键扩展点是 CacheCacheManager
我们定义一个 TwoLevelCache,把 Caffeine 和 Redis 组合起来。

TwoLevelCache.java

package com.example.cache.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 TwoLevelCache implements Cache {

    private final String name;
    private final com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache;
    private final RedisTemplate<String, Object> redisTemplate;
    private final Duration ttl;

    public TwoLevelCache(String name,
                         com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache,
                         RedisTemplate<String, Object> redisTemplate,
                         Duration ttl) {
        this.name = name;
        this.caffeineCache = caffeineCache;
        this.redisTemplate = redisTemplate;
        this.ttl = ttl;
    }

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

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

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

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

        value = redisTemplate.opsForValue().get(buildKey(key));
        if (value != null) {
            caffeineCache.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();
        if (type != null && !type.isInstance(value)) {
            throw new IllegalStateException("缓存值类型不匹配,期望: " + type + ", 实际: " + value.getClass());
        }
        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();
            put(key, value);
            return value;
        } catch (Exception e) {
            throw new ValueRetrievalException(key, valueLoader, e);
        }
    }

    @Override
    public void put(Object key, Object value) {
        caffeineCache.put(key, value);
        redisTemplate.opsForValue().set(buildKey(key), value, ttl);
    }

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

    @Override
    public boolean evictIfPresent(Object key) {
        evict(key);
        return true;
    }

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

    @Override
    public boolean invalidate() {
        clear();
        return true;
    }
}

5. 自定义 CacheManager

TwoLevelCacheManager.java

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.data.redis.core.RedisTemplate;

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

public class TwoLevelCacheManager implements CacheManager {

    private final RedisTemplate<String, Object> redisTemplate;
    private final Map<String, Cache> cacheMap = new ConcurrentHashMap<>();
    private final Duration ttl;

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

    @Override
    public Cache getCache(String name) {
        return cacheMap.computeIfAbsent(name, cacheName -> new TwoLevelCache(
                cacheName,
                Caffeine.newBuilder()
                        .initialCapacity(100)
                        .maximumSize(1000)
                        .expireAfterWrite(Duration.ofSeconds(30))
                        .build(),
                redisTemplate,
                ttl
        ));
    }

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

CacheConfig.java

package com.example.cache.config;

import com.example.cache.cache.TwoLevelCacheManager;
import org.springframework.cache.CacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.cache.annotation.EnableCaching;

import java.time.Duration;

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager(org.springframework.data.redis.core.RedisTemplate<String, Object> redisTemplate) {
        return new TwoLevelCacheManager(redisTemplate, Duration.ofMinutes(5));
    }
}

6. 业务服务:读写缓存

这里使用 Spring Cache 注解,让业务代码保持清爽。

ProductService.java

package com.example.cache.service;

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

@Service
public class ProductService {

    private final ProductRepository repository;
    private final CacheInvalidationPublisher invalidationPublisher;

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

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

    @CacheEvict(cacheNames = "product", key = "#product.id")
    public Product update(Product product) {
        Product updated = new Product(
                product.getId(),
                product.getName(),
                product.getPrice(),
                product.getVersion() == null ? 1L : product.getVersion() + 1
        );
        Product result = repository.update(updated);
        invalidationPublisher.publish("product", product.getId().toString());
        return result;
    }
}

这里我故意用了“更新数据库后删缓存”的思路,而不是直接 @CachePut
因为在多节点环境里,删除通常比更新更稳。


7. 多节点本地缓存失效通知

如果系统只有一个实例,那删本地缓存就结束了。
但线上通常是多个实例,所以某个节点更新后,其他节点的 Caffeine 也得删。

我们用 Redis Pub/Sub 做一个轻量广播。

CacheMessage.java

package com.example.cache.message;

import java.io.Serializable;

public class CacheMessage implements Serializable {

    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 void setCacheName(String cacheName) {
        this.cacheName = cacheName;
    }

    public String getKey() {
        return key;
    }

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

CacheInvalidationPublisher.java

package com.example.cache.service;

import com.example.cache.message.CacheMessage;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

@Component
public class CacheInvalidationPublisher {

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

    private final RedisTemplate<String, Object> redisTemplate;

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

    public void publish(String cacheName, String key) {
        redisTemplate.convertAndSend(CHANNEL, new CacheMessage(cacheName, key));
    }
}

CacheInvalidationSubscriber.java

package com.example.cache.service;

import com.example.cache.cache.TwoLevelCache;
import com.example.cache.message.CacheMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.cache.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 static final Logger log = LoggerFactory.getLogger(CacheInvalidationSubscriber.class);

    private final CacheManager cacheManager;

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

    @Override
    public void onMessage(Message message, byte[] pattern) {
        String body = new String(message.getBody());
        log.info("收到缓存失效消息: {}", body);
    }
}

上面这个 subscriber 只是打日志,还不能真正反序列化消息并清本地缓存。
我们再补上监听容器和实际处理。

RedisListenerConfig.java

package com.example.cache.config;

import com.example.cache.service.CacheInvalidationPublisher;
import com.example.cache.service.LocalCacheInvalidationListener;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.listener.ChannelTopic;
import org.springframework.data.redis.listener.RedisMessageListenerContainer;

@Configuration
public class RedisListenerConfig {

    @Bean
    public RedisMessageListenerContainer redisMessageListenerContainer(
            RedisConnectionFactory connectionFactory,
            LocalCacheInvalidationListener listener) {

        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(listener, new ChannelTopic(CacheInvalidationPublisher.CHANNEL));
        return container;
    }
}

LocalCacheInvalidationListener.java

package com.example.cache.service;

import com.example.cache.message.CacheMessage;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.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 LocalCacheInvalidationListener implements MessageListener {

    private final CacheManager cacheManager;
    private final ObjectMapper objectMapper = new ObjectMapper();

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

    @Override
    public void onMessage(Message message, byte[] pattern) {
        try {
            CacheMessage cacheMessage = objectMapper.readValue(message.getBody(), CacheMessage.class);
            Cache cache = cacheManager.getCache(cacheMessage.getCacheName());
            if (cache != null) {
                cache.evict(cacheMessage.getKey());
            }
        } catch (Exception e) {
            throw new RuntimeException("处理缓存失效消息失败", e);
        }
    }
}

修正发布消息格式

因为监听端用 ObjectMapper 读 JSON,所以发布端最好显式发 JSON。

package com.example.cache.service;

import com.example.cache.message.CacheMessage;
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 CacheInvalidationPublisher {

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

    private final StringRedisTemplate stringRedisTemplate;
    private final ObjectMapper objectMapper = new ObjectMapper();

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

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

这里你会发现:消息发布建议单独用 StringRedisTemplate,不要和业务缓存序列化混在一起。
这是我很推荐的一个小习惯,后面排查问题时省很多事。


8. 控制器

ProductController.java

package com.example.cache.controller;

import com.example.cache.entity.Product;
import com.example.cache.service.ProductService;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;

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

    private final ProductService service;

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

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

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

9. 启动类

CacheDemoApplication.java

package com.example.cache;

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

@SpringBootApplication
public class CacheDemoApplication {

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

逐步验证清单

到这里,项目已经具备最小可运行能力。你可以按下面顺序验证。

1)首次查询,走数据库

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

第一次会慢一点,因为模拟数据库有 sleep(100)

2)再次查询,命中缓存

再请求一次:

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

理论上会更快,优先命中本地缓存。

3)更新数据,触发缓存删除

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

4)再次查询,回源并重建缓存

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

如果你起多个应用实例,就可以观察 Redis Pub/Sub 广播失效消息的行为。


一致性实现的关键细节

上面的代码能跑,但真实项目里还要把一些细节想透。

1. 为什么更新后删除缓存,而不是先删再更新?

很多人会问:到底是“先更新库再删缓存”,还是“先删缓存再更新库”?

通常推荐:

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

原因是如果你先删缓存,再更新数据库,可能出现这个窗口:

  • 线程 A 删除缓存
  • 线程 B 读缓存未命中,查数据库读到旧值,又写回缓存
  • 线程 A 更新数据库成功
  • 缓存里反而是旧值

这就是经典并发脏写问题。

2. 删除两级缓存时,要注意什么?

本地缓存和 Redis 都要删。
但多个节点之间,本地缓存还需要广播。

可以理解成三步:

  1. 当前节点删本地缓存
  2. 删除 Redis 缓存
  3. 发布失效事件,让其他节点删本地缓存

3. 广播时为什么不要直接调用 cache.evict() 删除全部?

因为我们只想删某一个 key。
如果粗暴清空整个缓存,会带来:

  • 短时间大量回源
  • Redis/QPS 抖动
  • 热点数据集中重建

常见坑与排查

这部分我建议你认真看。很多问题平时没感觉,一上生产就很真实。

坑 1:@Cacheable 不生效

常见原因

  • 没加 @EnableCaching
  • 方法不是 public
  • 同类内部自调用
  • Spring 管理的 Bean 没有被代理到

例子

@Service
public class ProductService {

    @Cacheable(cacheNames = "product", key = "#id")
    public Product getById(Long id) {
        return repository.findById(id);
    }

    public Product test(Long id) {
        return this.getById(id); // 自调用,缓存不会生效
    }
}

排查建议

  • 看启动日志里是否启用了缓存代理
  • getById() 方法里打日志,确认是否每次都进方法体
  • 遇到自调用,可拆分到另一个 Service,或从代理对象调用

坑 2:本地缓存失效消息导致 Redis 也被删了

这是一个很隐蔽但很常见的设计错误。

我们前面的 cache.evict(key) 会同时删除:

  • 本地缓存
  • Redis 缓存

但其他节点收到“本地失效通知”时,如果也调用这个方法,就把 Redis 重复删了一遍。

虽然多数情况下问题不大,但会有副作用:

  • 无意义的 Redis 删除
  • 并发重建窗口被放大
  • 排查时行为不清晰

更合理的做法

TwoLevelCache 增加一个只删本地缓存的方法。

改进版 TwoLevelCache.java

public void evictLocal(Object key) {
    caffeineCache.invalidate(key);
}

然后监听器里判断类型后,只删本地:

package com.example.cache.service;

import com.example.cache.cache.TwoLevelCache;
import com.example.cache.message.CacheMessage;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.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 LocalCacheInvalidationListener implements MessageListener {

    private final CacheManager cacheManager;
    private final ObjectMapper objectMapper = new ObjectMapper();

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

    @Override
    public void onMessage(Message message, byte[] pattern) {
        try {
            CacheMessage cacheMessage = objectMapper.readValue(message.getBody(), CacheMessage.class);
            Cache cache = cacheManager.getCache(cacheMessage.getCacheName());
            if (cache instanceof TwoLevelCache twoLevelCache) {
                twoLevelCache.evictLocal(parseKey(cacheMessage.getKey()));
            }
        } catch (Exception e) {
            throw new RuntimeException("处理缓存失效消息失败", e);
        }
    }

    private Object parseKey(String key) {
        try {
            return Long.valueOf(key);
        } catch (Exception e) {
            return key;
        }
    }
}

这个坑我真踩过。看着只是“多删一次”,但高并发下会让回源曲线变得很难看。


坑 3:缓存穿透

当请求的 key 根本不存在时,每次都会打到数据库。

解决方案

  • 缓存空值
  • 布隆过滤器
  • 参数校验,拦截非法 ID

比如空值缓存:

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

这个写法其实没有缓存 null
如果你要防穿透,需要手动缓存一个空对象标记,或者使用包装结果。


坑 4:缓存击穿

某个热点 key 恰好过期,大量并发同时回源数据库。

解决方案

  • 本地互斥锁 / 分布式锁
  • 热点数据逻辑不过期
  • 提前刷新
  • 单飞(single flight)机制

简单示例:对热点 key 加锁回源。

private final ConcurrentHashMap<Object, Object> locks = new ConcurrentHashMap<>();

public <T> T getWithLock(Object key, Callable<T> loader) {
    Object lock = locks.computeIfAbsent(key, k -> new Object());
    synchronized (lock) {
        ValueWrapper wrapper = get(key);
        if (wrapper != null) {
            return (T) wrapper.get();
        }
        try {
            T value = loader.call();
            put(key, value);
            return value;
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

坑 5:缓存雪崩

大量 key 在同一时刻过期,瞬间把数据库打穿。

解决方案

  • TTL 加随机值
  • 分批预热
  • 热点数据永不过期 + 异步刷新
  • Redis 限流降级

比如 TTL 增加抖动:

Duration ttl = Duration.ofMinutes(5).plusSeconds(ThreadLocalRandom.current().nextInt(30));
redisTemplate.opsForValue().set(buildKey(key), value, ttl);

坑 6:序列化不兼容

代码升级后,类结构变了,旧缓存反序列化失败。

排查点

  • Redis 中值格式是否统一
  • 是否混用了 JDK 序列化和 JSON 序列化
  • 是否存在历史版本字段不兼容

建议

  • 缓存对象尽量使用稳定 DTO
  • 升级时允许“删缓存重建”
  • 不要把复杂领域对象直接长期缓存

安全/性能最佳实践

这部分非常关键,尤其是准备上生产时。

安全实践

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

比如:

  • 身份证号
  • 手机号
  • token
  • 支付信息

即使必须缓存,也要考虑:

  • 脱敏
  • 短 TTL
  • 独立命名空间
  • 访问控制

2. 谨慎使用默认多态反序列化

前面示例里为了方便演示用了:

mapper.activateDefaultTyping(...)

这在生产环境里需要谨慎,因为多态反序列化如果边界没控好,会有安全风险。

更推荐:

  • 按缓存 value 类型分别定义 RedisTemplate
  • 使用明确 DTO 类型
  • 避免对任意 Object 做宽泛反序列化

3. 缓存 key 不要直接拼接用户原始输入

比如搜索词、动态表达式、超长字符串,容易带来:

  • key 爆炸
  • 内存异常增长
  • 热点分散失控

建议统一规范:

业务名:对象类型:主键[:字段]

例如:

mall:product:1
mall:user:1001:profile

性能实践

1. 一级缓存容量要克制

本地缓存不是越大越好。
太大可能带来:

  • Old 区压力增加
  • Full GC 变频繁
  • 热点不明显时收益有限

建议从以下维度调参:

  • maximumSize
  • expireAfterWrite
  • 命中率
  • GC 情况

2. Redis TTL 和本地 TTL 分层设置

一般我会让:

  • 本地缓存 TTL 更短
  • Redis TTL 更长

这样做的好处是:

  • 本地缓存更灵活,减轻脏数据风险
  • Redis 保持共享缓存能力
  • 节点重启后仍可从 Redis 快速恢复

例如:

  • Caffeine:30 秒
  • Redis:5 分钟

3. 热点 key 做特别治理

如果某几个 key QPS 特别高,不要完全依赖通用缓存框架,最好单独处理:

  • 永不过期 + 异步刷新
  • 预热
  • 单独指标监控
  • 限流保护

4. 监控指标必须补齐

最少监控这些:

  • 本地缓存命中率
  • Redis 命中率
  • DB 回源次数
  • 缓存删除次数
  • Pub/Sub 消息堆积/丢失情况
  • 热点 key 排名

没有指标,缓存问题几乎全靠猜。


一个更稳妥的落地建议

如果你准备真正用于生产,我建议按下面的优先级落地,而不是一步到位全上:

第一步:先做好单级 Redis 缓存

确保你已经有:

  • 清晰的 key 规范
  • TTL 策略
  • 读写时序
  • 基础监控

第二步:再引入本地缓存

优先加在这些场景:

  • 超热点读请求
  • 对 RT 极敏感接口
  • 数据更新频率低

第三步:补齐多节点失效机制

至少做到:

  • 更新 DB 后删 Redis
  • 广播通知其他节点删本地缓存
  • 失败有重试或兜底日志

第四步:治理并发与异常场景

包括:

  • 空值缓存
  • 热点锁
  • TTL 抖动
  • 降级开关

这是比较现实的演进方式,不容易把系统一下搞复杂。


再看一遍完整时序

stateDiagram-v2
    [*] --> ReadRequest
    ReadRequest --> CheckL1
    CheckL1 --> HitL1: 命中
    HitL1 --> ReturnData

    CheckL1 --> CheckL2: 未命中
    CheckL2 --> HitL2: 命中
    HitL2 --> RebuildL1
    RebuildL1 --> ReturnData

    CheckL2 --> QueryDB: 未命中
    QueryDB --> RebuildL2
    RebuildL2 --> RebuildL1FromDB
    RebuildL1FromDB --> ReturnData

    ReturnData --> [*]

    [*] --> WriteRequest
    WriteRequest --> UpdateDB
    UpdateDB --> DeleteRedis
    DeleteRedis --> DeleteLocal
    DeleteLocal --> PublishInvalidation
    PublishInvalidation --> OtherNodesDeleteLocal
    OtherNodesDeleteLocal --> [*]

总结

这篇文章我们完整走了一遍 Spring Boot 中多级缓存的典型落地方案:

  • Spring Cache 统一业务入口
  • Caffeine 做一级本地缓存
  • Redis 做二级共享缓存
  • Cache Aside 实现读写分离
  • Redis Pub/Sub 做多节点本地缓存失效通知
  • 结合常见坑位处理一致性、击穿、穿透、雪崩问题

最后给你几个可以直接执行的建议:

  1. 先接受“最终一致性”这个前提
    多级缓存很难做到绝对强一致,别把它用到余额、库存扣减这种核心强一致链路。

  2. 更新时优先“更新 DB + 删除缓存”
    不要急着做缓存更新,删除通常更稳,也更容易排查问题。

  3. 本地缓存失效要做广播,但广播只删本地
    不要让其他节点收到消息后再去重复删除 Redis。

  4. TTL 分层设置,避免一起过期
    本地短一点,Redis 长一点,并加随机抖动。

  5. 先监控再调优
    命中率、回源次数、热点 key、删除次数,这些指标一定要有。

如果你的系统特点是读多写少、允许短暂不一致、追求低延迟,那这套方案非常值得上手。
如果你的业务要求的是强一致、严格事务语义,那缓存只能当加速器,别当真相源。

多级缓存真正的价值,不是“更快”这两个字,而是:在复杂业务下,用可控的方式换取性能收益。这件事做对了,系统会很舒服;做错了,线上会很热闹。


分享到:

上一篇
《集群架构中的服务发现与负载均衡实战:从节点注册、健康检查到流量切换设计》
下一篇
《安卓逆向实战:基于 Frida 与 JADX 定位并绕过常见签名校验逻辑》