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

《Spring Boot 中基于 Spring Cache + Redis 的多级缓存实战:缓存穿透、击穿与雪崩治理方案》

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

背景与问题

在 Spring Boot 项目里,缓存几乎是绕不过去的一层:商品详情、用户资料、配置字典、首页推荐……只要读多写少,就天然适合缓存。

但很多团队一开始做缓存时,往往只有一句配置:

spring:
  cache:
    type: redis

然后上线后就发现问题开始“成套出现”:

  • 缓存穿透:请求的 key 根本不存在,每次都打到数据库
  • 缓存击穿:某个热点 key 恰好过期,大量并发同时回源
  • 缓存雪崩:大量 key 在同一时间失效,数据库瞬间被冲垮
  • 单级 Redis 缓存不够快:应用进程内访问和远程 Redis 访问仍有延迟差距
  • 缓存一致性难处理:更新数据库后,缓存到底先删还是后删?

这篇文章不只讲“概念”,而是从一个可运行的 Spring Boot 示例出发,带你做一套更实用的方案:

  • 一级缓存:Caffeine(本地缓存)
  • 二级缓存:Redis(分布式缓存)
  • 缓存框架:Spring Cache
  • 治理策略:空值缓存、防击穿锁、过期时间随机化、主动预热

如果你之前只用过单 Redis 缓存,这篇会帮你把方案补完整。


前置知识与环境准备

适合谁看

这篇文章默认你已经了解:

  • Spring Boot 基础开发
  • Redis 基本读写
  • Maven 依赖管理
  • Java 8+ 语法

环境版本

本文示例环境:

  • JDK 8+
  • Spring Boot 2.7.x
  • Redis 6.x
  • Maven 3.6+

目标效果

我们要实现这样一条读取链路:

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

这个结构的核心收益是:

  • 本地缓存快
  • Redis 共享数据
  • 数据库作为最终数据源
  • Spring Cache 统一编码入口

核心原理

为什么要做多级缓存

单 Redis 缓存当然能用,但它仍然是一次网络调用。对于高频热点数据,本地缓存能进一步降低 RT 和 Redis 压力。

多级缓存通常是这样分层:

  1. L1 本地缓存(Caffeine)
    • 速度最快
    • 适合热点数据
    • 每个实例各自维护
  2. L2 分布式缓存(Redis)
    • 多实例共享
    • 容量大
    • 可持久化、可观测
  3. DB
    • 最终数据源
    • 成本最高,必须兜底

Spring Cache 在这里扮演什么角色

Spring Cache 不是缓存中间件,而是一个缓存抽象层。它让我们用注解把缓存逻辑挂在方法上,例如:

  • @Cacheable:先查缓存,没有再执行方法
  • @CachePut:执行方法并更新缓存
  • @CacheEvict:删除缓存

这样做的好处是:业务代码更干净,缓存逻辑更集中。

三大问题怎么治理

1. 缓存穿透

缓存穿透的典型场景是:查询一个根本不存在的商品 ID,比如 id=99999999。Redis 没有,数据库也没有,每次请求都去查 DB。

治理思路:

  • 缓存空值
  • 布隆过滤器(本文不展开实现)
  • 参数校验,拦截非法请求

本文用最容易落地的方式:空值缓存

2. 缓存击穿

击穿通常发生在热点 key 过期瞬间。例如某个爆款商品详情缓存失效,几千个请求一起回源数据库。

治理思路:

  • 互斥锁 / 分布式锁
  • 热点数据永不过期 + 异步刷新
  • 本地缓存兜底

本文会演示一个简化版的 Redis 分布式锁方案。

3. 缓存雪崩

雪崩是大量 key 同时过期,导致数据库承压。

治理思路:

  • 过期时间加随机值
  • 缓存预热
  • 多级缓存
  • 限流降级

这个我在生产里踩过坑:如果一批 key 是通过定时任务统一刷进去的,而且 TTL 都是 30 分钟,那 30 分钟后很可能就“集体下线”。


项目结构设计

示例结构如下:

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

实战代码(可运行)

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>

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

2. application.yml

server:
  port: 8080

spring:
  redis:
    host: localhost
    port: 6379
    timeout: 3000
  cache:
    type: redis

logging:
  level:
    com.example.cache: info

3. 启动类

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

4. 实体类

package com.example.cache.entity;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

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

@Data
@NoArgsConstructor
@AllArgsConstructor
public class Product implements Serializable {
    private Long id;
    private String name;
    private BigDecimal price;
}

5. 模拟数据库层

为了示例可运行,我们先用内存 Map 代替数据库。

package com.example.cache.repository;

import com.example.cache.entity.Product;
import org.springframework.stereotype.Repository;

import javax.annotation.PostConstruct;
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")));
        store.put(2L, new Product(2L, "4K显示器", new BigDecimal("1999.00")));
        store.put(3L, new Product(3L, "人体工学椅", new BigDecimal("1299.00")));
    }

    public Product findById(Long id) {
        try {
            Thread.sleep(200);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
        return store.get(id);
    }

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

    public void deleteById(Long id) {
        store.remove(id);
    }
}

6. Redis 序列化配置

默认 JDK 序列化不太友好,线上排查也难读,建议直接改为 JSON。

package com.example.cache.config;

import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
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> redisSerializer() {
        ObjectMapper mapper = new ObjectMapper();
        mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
        mapper.activateDefaultTyping(
                mapper.getPolymorphicTypeValidator(),
                ObjectMapper.DefaultTyping.NON_FINAL
        );
        return new GenericJackson2JsonRedisSerializer(mapper);
    }
}

7. 多级缓存核心实现

这里我们自己实现一个 CacheManager,优先读 Caffeine,再读 Redis,回填本地缓存。

CacheConfig.java

package com.example.cache.config;

import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.*;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.RedisSerializer;

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

@Configuration
public class CacheConfig extends CachingConfigurerSupport {

    @Bean
    public com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache() {
        return Caffeine.newBuilder()
                .initialCapacity(100)
                .maximumSize(1000)
                .expireAfterWrite(60, TimeUnit.SECONDS)
                .build();
    }

    @Bean
    public CacheManager redisCacheManager(
            RedisConnectionFactory redisConnectionFactory,
            RedisSerializer<Object> redisSerializer) {

        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(redisSerializer))
                .entryTtl(Duration.ofMinutes(5))
                .disableCachingNullValues();

        return RedisCacheManager.builder(redisConnectionFactory)
                .cacheDefaults(config)
                .build();
    }

    @Bean
    @Override
    public KeyGenerator keyGenerator() {
        return (target, method, params) -> {
            StringBuilder sb = new StringBuilder();
            sb.append(target.getClass().getSimpleName())
              .append(":")
              .append(method.getName());
            for (Object param : params) {
                sb.append(":").append(param);
            }
            return sb.toString();
        };
    }

    @Bean
    public CacheManager cacheManager(
            com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache,
            @Qualifier("redisCacheManager") CacheManager redisCacheManager) {
        return new MultiLevelCacheManager(caffeineCache, redisCacheManager);
    }
}

MultiLevelCacheManager.java

package com.example.cache.config;

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

import java.util.Collection;
import java.util.Collections;

public class MultiLevelCacheManager implements CacheManager {

    private final com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache;
    private final CacheManager redisCacheManager;

    public MultiLevelCacheManager(
            com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache,
            CacheManager redisCacheManager) {
        this.caffeineCache = caffeineCache;
        this.redisCacheManager = redisCacheManager;
    }

    @Override
    public Cache getCache(String name) {
        Cache redisCache = redisCacheManager.getCache(name);
        return new MultiLevelSpringCache(name, caffeineCache, redisCache);
    }

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

MultiLevelSpringCache.java

package com.example.cache.config;

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

import java.util.concurrent.Callable;

public class MultiLevelSpringCache implements Cache {

    private final String name;
    private final com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache;
    private final Cache redisCache;

    public MultiLevelSpringCache(
            String name,
            com.github.benmanes.caffeine.cache.Cache<Object, Object> caffeineCache,
            Cache redisCache) {
        this.name = name;
        this.caffeineCache = caffeineCache;
        this.redisCache = redisCache;
    }

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

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

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

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

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

    @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();
            put(key, value);
            return value;
        } catch (Exception e) {
            throw new ValueRetrievalException(key, valueLoader, e);
        }
    }

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

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

    @Override
    public void evict(Object key) {
        caffeineCache.invalidate(buildKey(key));
        redisCache.evict(key);
    }

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

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

8. 业务服务:缓存、空值、击穿锁

这里是文章重点。我们不只依赖 @Cacheable,还会在服务方法中加入更细粒度控制。

package com.example.cache.service;

import com.example.cache.entity.Product;
import com.example.cache.repository.ProductRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
public class ProductService {

    private final ProductRepository productRepository;
    private final StringRedisTemplate stringRedisTemplate;

    private static final String LOCK_PREFIX = "lock:product:";
    private static final String NULL_CACHE_PREFIX = "null:product:";
    private static final Duration LOCK_TTL = Duration.ofSeconds(10);
    private static final Duration NULL_TTL = Duration.ofMinutes(2);

    @Cacheable(cacheNames = "product", key = "#id", unless = "#result == null")
    public Product getProductById(Long id) {
        String nullKey = NULL_CACHE_PREFIX + id;
        Boolean hasNullMarker = stringRedisTemplate.hasKey(nullKey);
        if (Boolean.TRUE.equals(hasNullMarker)) {
            log.info("命中空值缓存, id={}", id);
            return null;
        }

        String lockKey = LOCK_PREFIX + id;
        String lockValue = UUID.randomUUID().toString();

        try {
            Boolean locked = stringRedisTemplate.opsForValue()
                    .setIfAbsent(lockKey, lockValue, LOCK_TTL);

            if (Boolean.TRUE.equals(locked)) {
                log.info("获取分布式锁成功, id={}", id);
                Product product = productRepository.findById(id);

                if (product == null) {
                    stringRedisTemplate.opsForValue().set(nullKey, "1", NULL_TTL);
                    log.info("写入空值缓存, id={}", id);
                    return null;
                }

                return product;
            } else {
                log.info("未获取到锁, 短暂休眠后重试, id={}", id);
                Thread.sleep(100);
                return productRepository.findById(id);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return null;
        } finally {
            String current = stringRedisTemplate.opsForValue().get(lockKey);
            if (lockValue.equals(current)) {
                stringRedisTemplate.delete(lockKey);
            }
        }
    }

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

    @CacheEvict(cacheNames = "product", key = "#id")
    public void deleteProduct(Long id) {
        productRepository.deleteById(id);
        stringRedisTemplate.delete(NULL_CACHE_PREFIX + id);
    }
}

说明:
这里的 @Cacheable(unless = "#result == null") 不缓存空对象,所以我们额外用了 null:product:id 这种标记 key 来做空值缓存
如果你希望直接缓存空值,也可以自定义 RedisCacheConfiguration 允许 null,但通常要更谨慎。


9. Controller

package com.example.cache.controller;

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

import java.math.BigDecimal;

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

    private final ProductService productService;

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

    @PutMapping("/{id}")
    public Product update(@PathVariable Long id,
                          @RequestParam String name,
                          @RequestParam BigDecimal price) {
        Product product = new Product(id, name, price);
        return productService.updateProduct(product);
    }

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

逐步验证清单

到这里项目就能跑起来了。下面我们按场景验证。

1. 验证多级缓存

第一次请求:

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

预期:

  • 本地缓存没有
  • Redis 没有
  • 查“数据库”
  • 写入 Redis
  • 回填本地缓存

第二次立刻请求同一个接口:

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

预期:

  • 优先命中本地缓存
  • 响应更快

2. 验证缓存穿透治理

请求一个不存在的 ID:

curl http://localhost:8080/products/999

再请求一次:

curl http://localhost:8080/products/999

预期:

  • 第一次会查库,发现为空,写入空值标记
  • 第二次不会继续穿透到数据库

3. 验证更新缓存

curl -X PUT "http://localhost:8080/products/1?name=机械键盘Pro&price=499.00"
curl http://localhost:8080/products/1

预期:

  • 更新后缓存同步刷新
  • 读请求能拿到最新值

缓存击穿与雪崩的执行流程

击穿处理时序

sequenceDiagram
    participant C as Client
    participant A as App
    participant R as Redis
    participant D as DB

    C->>A: 查询热点商品
    A->>R: 查缓存
    R-->>A: 未命中
    A->>R: 尝试加锁
    R-->>A: 加锁成功
    A->>D: 查询数据库
    D-->>A: 返回数据
    A->>R: 写缓存
    A->>R: 释放锁
    A-->>C: 返回结果

    Note over A,R: 其他并发请求未拿到锁时短暂等待/重试

雪崩治理思路图

flowchart TD
A[大量缓存同一时刻过期] --> B[瞬时回源数据库]
B --> C[数据库连接打满]
C --> D[接口超时]
D --> E[服务雪崩]

F[随机 TTL] --> G[错峰失效]
H[缓存预热] --> I[减少冷启动]
J[本地缓存] --> K[减少 Redis 压力]
L[限流降级] --> M[保护数据库]

常见坑与排查

1. @Cacheable 不生效

这是最常见的问题之一,排查顺序建议直接按下面走:

原因一:没有开启缓存

检查是否有:

@EnableCaching

原因二:方法是同类内部调用

例如:

public void test() {
    this.getProductById(1L);
}

这种调用绕过了 Spring AOP,缓存注解不会生效。

解决办法:

  • 把缓存方法放到另一个 Bean
  • 或通过代理对象调用

原因三:方法不是 public

Spring Cache 默认基于代理,private/protected 方法通常不会生效。


2. 本地缓存与 Redis 数据不一致

多级缓存最大的现实问题不是“能不能用”,而是一致性

比如:

  1. 实例 A 更新了商品并刷新 Redis
  2. 实例 B 的 Caffeine 里还是旧值
  3. 用户打到实例 B,就读到旧数据

解决思路:

  • 写操作时主动删除本地缓存
  • 通过 Redis Pub/Sub 通知各实例清理 L1
  • 缩短本地缓存 TTL
  • 热点读多写少时接受短暂最终一致性

如果你的业务是库存、余额这类强一致敏感数据,我的建议是:不要上本地缓存,至少不要把它作为直接返回依据。


3. 分布式锁释放不安全

本文用了一个简化版方案:

String current = stringRedisTemplate.opsForValue().get(lockKey);
if (lockValue.equals(current)) {
    stringRedisTemplate.delete(lockKey);
}

这在大多数演示里够用,但严格来说不是原子操作。生产建议:

  • 使用 Lua 脚本 保证“比较并删除”原子性
  • 或直接上 Redisson

4. 序列化失败 / 反序列化异常

常见现象:

  • Redis 里数据乱码
  • 类结构变更后旧缓存读不出来
  • 泛型对象转换异常

建议:

  • 使用 JSON 序列化
  • 缓存对象尽量简单、稳定
  • 对升级兼容性要求高时,加版本前缀

5. TTL 设置不合理

TTL 太短:

  • 命中率低
  • 回源频繁

TTL 太长:

  • 数据陈旧
  • 热点脏数据停留时间长

我的经验是:

  • 本地缓存 TTL 更短
  • Redis TTL 稍长
  • 热点数据单独配置 TTL
  • 所有 TTL 加随机抖动

例如:

基础 TTL 300 秒 + 随机 0~120 秒

安全/性能最佳实践

1. 对空值缓存设置较短 TTL

空值缓存能挡住穿透,但不要缓存太久。否则真实数据后来入库了,缓存还在误伤。

建议:

  • 空值 TTL:1~5 分钟
  • 正常数据 TTL:5~30 分钟
  • 热点数据按业务单独设计

2. TTL 一定要加随机值

如果一批 key 是批量写入的,请务必错峰过期。示意代码:

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

public Duration randomTtl(int baseSeconds, int boundSeconds) {
    int random = ThreadLocalRandom.current().nextInt(boundSeconds);
    return Duration.ofSeconds(baseSeconds + random);
}

3. 本地缓存容量要设上限

Caffeine 很快,但不是无限大。没有 maximumSize,最后会把 JVM 内存吃掉。

建议至少配置:

Caffeine.newBuilder()
    .initialCapacity(100)
    .maximumSize(1000)
    .expireAfterWrite(60, TimeUnit.SECONDS)
    .build();

4. 热点 key 做预热

上线后不要等第一波请求自己把缓存打热,可以在应用启动时主动加载热点数据。

package com.example.cache.config;

import com.example.cache.service.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class CacheWarmUpRunner implements CommandLineRunner {

    private final ProductService productService;

    @Override
    public void run(String... args) {
        productService.getProductById(1L);
        productService.getProductById(2L);
    }
}

5. 为缓存建立监控指标

至少监控这些指标:

  • 缓存命中率
  • Redis QPS
  • DB 回源次数
  • 热点 key 访问量
  • 锁竞争次数
  • 接口 RT/P99

如果没有监控,你很难知道自己是不是“缓存配了但没赚到”。


6. 敏感数据不要裸放缓存

像手机号、身份证、令牌、权限数据,不要简单地原样塞到缓存里。至少要做到:

  • key 设计避免暴露业务含义
  • value 脱敏或最小化存储
  • Redis 开启访问控制
  • 内网隔离与密码认证
  • 重要场景设置合理过期与删除机制

方案边界与取舍

这套方案不是银弹,适用边界要说清楚。

适合场景

  • 商品详情、文章详情、字典配置
  • 读多写少
  • 能接受秒级内最终一致性
  • 需要降低 Redis 和 DB 压力

不太适合的场景

  • 库存扣减
  • 账户余额
  • 强一致权限判断
  • 高频写入、低命中率数据

一句话总结就是:

多级缓存适合“高频读、低频写、可接受短暂不一致”的业务。


总结

这篇我们从 Spring Boot 实战出发,完成了一套基于 Spring Cache + Redis + Caffeine 的多级缓存方案,并针对三类典型问题给出了落地治理手段:

  • 缓存穿透:空值缓存、参数校验
  • 缓存击穿:分布式锁、热点保护、本地缓存兜底
  • 缓存雪崩:随机 TTL、预热、限流降级、多级缓存

如果你准备在项目里真正落地,我建议按这个顺序推进:

  1. 先把 Redis 单级缓存 跑通
  2. 再加 本地缓存 Caffeine
  3. 然后补上 空值缓存、随机 TTL、热点锁
  4. 最后做 监控、预热、实例间本地缓存失效通知

不要一开始就把系统搞得很复杂。缓存这件事,先解决 80% 的性能问题,再逐步补一致性和治理细节,往往是最稳的路径。

如果你让我给一个最实用的生产建议,那就是:

先明确业务是否允许短暂不一致,再决定要不要上多级缓存。

因为真正难的,从来不是“把缓存加上”,而是“出问题时你能不能解释清楚它为什么这样工作”。


分享到:

上一篇
《微服务架构中的分布式事务实战:基于 Saga 模式的设计、实现与故障补偿》
下一篇
《分布式架构中基于 Saga 模式的跨服务事务设计与落地实践-113》