欢迎光临
我们一直在努力

第04篇:Redis 缓存设计精讲:穿透、击穿、雪崩与 Java SaaS 实战

本文速览:本文是 Redis 系列第四篇,深入解析缓存使用中最常见的三大问题——穿透、击穿、雪崩的成因与解法,结合 Java Spring Boot 给出代码级解决方案,并讲解 SaaS 多租户场景下的缓存隔离策略与缓存一致性保证。

关键词:Redis缓存穿透、缓存击穿、缓存雪崩、布隆过滤器Redis、Redis缓存三大问题、Redisson


一、三大缓存问题全景图

如果你在生产环境中用过 Redis 缓存,大概率已经遇到或即将遇到这三个问题。先建立整体认知:

三大缓存问题示意

【缓存穿透】查询一个根本不存在的 Key
请求 ──→ Redis(未命中)──→ 数据库(也没有)──→ 每次都打到数据库
成因:恶意攻击或 Bug 导致大量查询不存在的数据

【缓存击穿】一个热点 Key 在某一刻过期
T=0: 10000 个请求同时访问 Key "hot_product"
Redis:该 Key 刚好过期,全部未命中
结果:10000 个请求同时打到数据库,数据库直接被打挂

【缓存雪崩】大量 Key 在同一时间集体过期
T=0: 1000 个商品的缓存同时过期(因为上线时同时预热,TTL 一致)
结果:数据库瞬间承受 1000 倍正常流量,雪崩式崩溃

三者乍看相似,其实成因和解法都不同。我们逐一击破。


二、缓存穿透

2.1 问题复现

想象这样的业务代码:

// 这段看似正常的代码,在面对"查询不存在数据"时会有大麻烦

public Product getProduct(Long productId) {
String key = "product:" + productId;
Product cached = (Product) redisTemplate.opsForValue().get(key);

if (cached != null) {
return cached; // 缓存命中
}

// 缓存未命中,查数据库
Product product = productRepository.findById(productId).orElse(null);

if (product != null) {
redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(30));
}
// 问题在这里:product 为 null 时,什么都没缓存!
// 下次同样的请求进来,缓存还是没有,还是要查数据库

return product;
}

如果攻击者用一个循环,不断查询 productId=-1、productId=-2……这些在数据库中根本不存在的 ID,每次请求都会穿透缓存直接打到数据库,轻松打垮系统。

2.2 方案一:缓存空值

最简单的解法:即使数据库查不到,也把"空结果"缓存起来。

// 修复后的代码:缓存空值

private static final String NULL_VALUE = "NULL_PLACEHOLDER"; // 空值占位符
private static final Duration NULL_VALUE_TTL = Duration.ofMinutes(5); // 空值 TTL 设短一些

public Product getProduct(Long productId) {
String key = "product:" + productId;
Object cached = redisTemplate.opsForValue().get(key);

if (cached != null) {
// 命中了空值占位符,说明数据库里真的没有这条数据
if (NULL_VALUE.equals(cached)) {
return null;
}
return (Product) cached;
}

Product product = productRepository.findById(productId).orElse(null);

if (product != null) {
redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(30));
} else {
// 关键改动:数据库也没查到,缓存空值,TTL 设短
// 短 TTL 的原因:如果这条数据过一会儿被创建了,
// 我们不希望"空值缓存"持续太久导致新数据无法被查到
redisTemplate.opsForValue().set(key, NULL_VALUE, NULL_VALUE_TTL);
}

return product;
}

缓存空值的局限:如果攻击者每次用不同的不存在的 ID,每次都会在 Redis 里创建一个新的空值 Key,最终撑爆 Redis 内存。这时候需要更有力的方案——布隆过滤器。

2.3 方案二:布隆过滤器(Bloom Filter)

布隆过滤器是一种概率型数据结构,它的特性是:如果它说"不存在",那一定不存在;如果它说"存在",有很小的概率是误判(哈希碰撞导致)。

布隆过滤器工作原理

初始化:将所有合法的 productId 通过多个哈希函数映射到 Bitmap 上

查询时:
productId=1001 → Hash1(1001)=5, Hash2(1001)=13, Hash3(1001)=27
检查 Bitmap 第 5、13、27 位是否都为 1
都为 1 → 可能存在(放行,去查缓存/数据库)
有任意一位为 0 → 一定不存在(直接返回 null)

优点:内存极省,10亿个元素只需约 1.2GB 内存
缺点:有误判率(可配置,通常 0.01%~1%),且不支持删除元素

用 Redisson 实现布隆过滤器:

// BloomFilterConfig.java
@Configuration
public class BloomFilterConfig {

private final RedissonClient redissonClient;

/**
* 初始化商品 ID 布隆过滤器。
* 实际业务中,这个初始化操作应该在系统启动时(或定时任务中)
* 从数据库加载所有合法 ID 填充进去。
*/

@Bean
public RBloomFilter<Long> productBloomFilter() {
RBloomFilter<Long> bloomFilter =
redissonClient.getBloomFilter("bloom:product:ids");

// tryInit:如果已经初始化过就跳过
// 预期元素数量:100万;误判率:0.1%(1000次查询平均1次误判)
bloomFilter.tryInit(1_000_000L, 0.001);

return bloomFilter;
}
}

// ProductCacheService.java — 集成布隆过滤器
@Service
public class ProductCacheService {

private final RedisTemplate<String, Object> redisTemplate;
private final RBloomFilter<Long> productBloomFilter;
private final ProductRepository productRepository;

/**
* 布隆过滤器 + 缓存的完整防穿透方案。
*
* 流程:
* 1. 布隆过滤器判断 ID 是否可能存在(不存在直接返回,拦截无效请求)
* 2. 查 Redis 缓存
* 3. 缓存未命中,查数据库
* 4. 结果回填缓存
*/

public Product getProduct(Long productId) {
// 第一关:布隆过滤器拦截
// 误判率 0.1% 意味着:1000 个真实不存在的 ID 里,有 1 个会漏过去
// 但 999 个会在这里被拦截,数据库完全不会被触碰
if (!productBloomFilter.contains(productId)) {
log.debug("布隆过滤器拦截,productId={} 不存在", productId);
return null;
}

String key = "product:info:" + productId;
Product cached = (Product) redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}

Product product = productRepository.findById(productId).orElse(null);

if (product != null) {
redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(30));
}

return product;
}

/**
* 新增商品时,同步更新布隆过滤器。
* 注意:布隆过滤器不支持删除,商品下架后布隆过滤器里还有这个 ID,
* 但没关系——缓存层或数据库层会返回 null,影响很小。
*/

public Product createProduct(Product product) {
Product saved = productRepository.save(product);
productBloomFilter.add(saved.getId());
return saved;
}
}

生产注意:布隆过滤器需要在系统启动时预热(从数据库加载所有合法 ID)。这个预热过程在数据量大时(百万级)可能需要几十秒,建议在应用启动后异步完成,或者使用持久化的布隆过滤器(Redisson 的布隆过滤器数据存在 Redis 中,重启不需要重新预热)。


三、缓存击穿

3.1 问题场景

缓存击穿发生在热点数据的 Key 过期瞬间。比如双十一的爆款商品,平时每秒 5000 次查询,某一刻 TTL 到期,5000 个请求同时发现缓存 Miss,全部穿透到数据库。数据库承受不了瞬间 5000 倍的压力,直接崩溃。

3.2 方案一:互斥锁(Mutex Lock)

让多个并发请求中,只有一个拿到锁去重建缓存,其他请求等待或返回旧数据:

/**
* 基于分布式锁的缓存击穿保护。
*
* 为什么要用分布式锁而不是 synchronized?
* 因为你的应用通常是多实例部署的,JVM 级别的锁只在单个实例内有效,
* 其他实例上的线程同样会穿透到数据库。
*/

public Product getProductWithMutexLock(Long productId) {
String cacheKey = "product:info:" + productId;
String lockKey = "product:rebuild:lock:" + productId;

// 第一次查缓存
Product product = (Product) redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product;
}

// 缓存未命中,尝试获取互斥锁
// SET lock NX EX 10 :原子操作,防止死锁(10秒后自动释放)
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(10));

if (Boolean.TRUE.equals(locked)) {
// 获取锁成功,去数据库重建缓存
try {
// 重要:再查一次缓存(Double Check)
// 原因:在你等待锁的期间,可能已经有另一个线程重建了缓存
product = (Product) redisTemplate.opsForValue().get(cacheKey);
if (product != null) {
return product; // 已被其他线程重建,直接返回
}

// 真正去数据库查
product = productRepository.findById(productId).orElse(null);
if (product != null) {
redisTemplate.opsForValue().set(cacheKey, product, Duration.ofMinutes(30));
}
} finally {
// 用完立刻释放锁,不要等 TTL 自然过期
redisTemplate.delete(lockKey);
}
} else {
// 没拿到锁,说明其他线程正在重建缓存
// 短暂等待后重试(简单实现;生产中可以用指数退避)
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getProductWithMutexLock(productId); // 递归重试
}

return product;
}

互斥锁方案的权衡:在锁争抢期间,其他请求会有 50ms 左右的延迟(等待重建完成)。这对大多数业务是可以接受的。但如果重建缓存本身很慢(如需要聚合查询),这个等待时间会更长。

3.3 方案二:逻辑过期(更优雅的热点数据方案)

对于永远都是热点的数据(如首页 Banner、热门商品),可以不设置物理 TTL,而是在 Value 里存一个逻辑过期时间,到期后异步更新:

// LogicalExpireCache.java — 逻辑过期方案

@Data
@AllArgsConstructor
public class CacheEntry<T> {
private T data;
private Instant expireAt; // 逻辑过期时间(存在 Value 里)
}

@Service
public class HotProductCacheService {

private final RedisTemplate<String, Object> redisTemplate;
private final ProductRepository productRepository;
// 用于异步重建缓存的线程池(与业务线程隔离,防止影响主流程)
private final ExecutorService rebuildExecutor =
Executors.newFixedThreadPool(4);
// 正在重建中的 key(防止多个线程同时重建同一个 key)
private final Set<String> rebuildingKeys = ConcurrentHashMap.newKeySet();

/**
* 逻辑过期方案的核心逻辑:
* – 缓存里永远有数据(不设物理过期),所以不会有"击穿"
* – 发现逻辑过期后,立刻返回旧数据(不阻塞请求),同时异步更新缓存
* – 代价:在异步更新完成前,请求会拿到"稍旧"的数据(最终一致性)
*/

public Product getHotProduct(Long productId) {
String key = "product:hot:" + productId;

@SuppressWarnings("unchecked")
CacheEntry<Product> entry = (CacheEntry<Product>)
redisTemplate.opsForValue().get(key);

if (entry == null) {
// 缓存里完全没有,说明还没预热,同步加载一次
return syncLoadAndCache(productId);
}

// 检查逻辑过期时间
if (Instant.now().isBefore(entry.getExpireAt())) {
// 未过期,直接返回
return entry.getData();
}

// 逻辑已过期,立刻返回旧数据(保证请求不阻塞)
// 同时触发异步重建(如果还没有其他线程在重建)
if (rebuildingKeys.add(key)) { // add 返回 true 说明之前没有这个 key,即没在重建
rebuildExecutor.submit(() -> {
try {
asyncRebuildCache(productId, key);
} finally {
rebuildingKeys.remove(key);
}
});
}

// 返回旧数据(即使已过期,也比让用户等待好)
return entry.getData();
}

private void asyncRebuildCache(Long productId, String key) {
Product product = productRepository.findById(productId).orElse(null);
if (product != null) {
// 逻辑过期时间设为 30 分钟后,不设物理 TTL(或设很长的物理 TTL)
CacheEntry<Product> newEntry = new CacheEntry<>(
product, Instant.now().plus(Duration.ofMinutes(30)));
redisTemplate.opsForValue().set(key, newEntry);
}
}

private Product syncLoadAndCache(Long productId) {
String key = "product:hot:" + productId;
Product product = productRepository.findById(productId).orElse(null);
if (product != null) {
CacheEntry<Product> entry = new CacheEntry<>(
product, Instant.now().plus(Duration.ofMinutes(30)));
redisTemplate.opsForValue().set(key, entry);
}
return product;
}
}

两种方案对比:

方案一致性性能适用场景
互斥锁 强(更新完才返回新值) 锁等待期间有延迟 一般热点数据
逻辑过期 弱(可能返回稍旧的数据) 无等待,始终快速响应 超高热点,不要求强一致

四、缓存雪崩

4.1 场景一:大量 Key 同时过期

// 错误示范:系统上线时批量预热缓存,所有 Key TTL 相同

for (Product product : allProducts) {
redisTemplate.opsForValue().set(
"product:" + product.getId(),
product,
Duration.ofHours(1) // ← 所有 Key 都是 1 小时后过期!
);
}
// 1 小时后:1000 个 Key 同时过期 → 1000 倍流量打到数据库 → 雪崩

解法:TTL 随机化

// 正确做法:在基础 TTL 上加随机偏移,打散过期时间

private static final Random RANDOM = new Random();

public void cacheProduct(Product product) {
// 基础 TTL = 1小时,随机偏移 = 0~600秒(10分钟内随机)
Duration baseTtl = Duration.ofHours(1);
Duration randomOffset = Duration.ofSeconds(RANDOM.nextInt(600));
Duration finalTtl = baseTtl.plus(randomOffset);

redisTemplate.opsForValue().set(
"product:" + product.getId(), product, finalTtl);
}

// 批量预热时同样需要随机化
public void batchWarmUp(List<Product> products) {
// 使用 Pipeline 批量写入,减少网络往返
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (Product product : products) {
long ttlSeconds = 3600 + RANDOM.nextInt(600); // 3600~4200 秒
connection.stringCommands().setEx(
("product:" + product.getId()).getBytes(),
ttlSeconds,
serialize(product)
);
}
return null;
});
}

4.2 场景二:Redis 实例宕机

这是更严重的雪崩:整个 Redis 不可用,所有请求直接打到数据库。

解法:多级缓存 + 熔断降级

// 多级缓存:本地 Caffeine 缓存 + Redis 远程缓存

@Service
public class MultiLevelCacheService {

private final RedisTemplate<String, Object> redisTemplate;
private final ProductRepository productRepository;

// 本地 L1 缓存(Caffeine),容量小、TTL 短,但访问速度极快(纳秒级)
// 即使 Redis 宕机,L1 缓存也能挡住大部分请求
private final Cache<Long, Product> localCache = Caffeine.newBuilder()
.maximumSize(1000) // 最多缓存 1000 个商品
.expireAfterWrite(Duration.ofMinutes(5)) // 5 分钟过期(比 Redis 短)
.build();

public Product getProduct(Long productId) {
// L1:查本地缓存
Product product = localCache.getIfPresent(productId);
if (product != null) {
return product;
}

// L2:查 Redis(用 try-catch 防止 Redis 异常影响主流程)
try {
product = (Product) redisTemplate.opsForValue()
.get("product:" + productId);
if (product != null) {
localCache.put(productId, product); // 回填 L1
return product;
}
} catch (Exception e) {
// Redis 不可用时,降级到数据库,同时记录告警
log.error("Redis 查询失败,降级到数据库: {}", e.getMessage());
// 生产中应该触发告警通知(钉钉/飞书/PagerDuty)
}

// L3:查数据库(最后兜底)
product = productRepository.findById(productId).orElse(null);
if (product != null) {
localCache.put(productId, product);
// Redis 恢复后再写入(这里可以加一个异步重试机制)
tryWriteRedis("product:" + productId, product);
}

return product;
}

private void tryWriteRedis(String key, Object value) {
try {
redisTemplate.opsForValue().set(key, value,
Duration.ofHours(1).plus(Duration.ofSeconds(RANDOM.nextInt(600))));
} catch (Exception e) {
log.warn("回填 Redis 失败(忽略): {}", e.getMessage());
}
}
}


五、SaaS 多租户缓存隔离

在 SaaS 场景下,缓存设计还有一个额外维度:租户数据隔离。租户 A 的数据绝对不能污染租户 B 的缓存。

5.1 三种隔离方案对比

方案实现方式隔离强度成本适合场景
Key 前缀隔离 Key 中加租户 ID 逻辑隔离 大多数 SaaS(≤1000 租户)
Database 隔离 不同租户用不同 db(0-15) 逻辑隔离 不推荐(Cluster 不支持)
独立实例隔离 每个大客户独立 Redis 物理隔离 高价值大客户、合规要求

5.2 Key 前缀隔离的完整实现

// TenantAwareCacheService.java — 感知租户的缓存服务基类

@Service
public abstract class TenantAwareCacheService {

private final RedisTemplate<String, Object> redisTemplate;

/**
* 从当前请求上下文中获取租户 ID。
* 通常通过 ThreadLocal 或 Spring Security 上下文传递。
*/

protected String getCurrentTenantId() {
return TenantContext.getCurrentTenantId(); // 你的租户上下文工具类
}

/**
* 构造带租户隔离的 Key。
* 格式:saas:{tenantId}:{业务模块}:{具体Key}
*/

protected String tenantKey(String module, String key) {
String tenantId = getCurrentTenantId();
if (tenantId == null || tenantId.isEmpty()) {
throw new IllegalStateException("无法获取当前租户 ID,缓存操作被拒绝");
}
return String.format("saas:%s:%s:%s", tenantId, module, key);
}

/**
* 清除某租户的所有缓存(如租户数据迁移或退订时)。
* 注意:使用 SCAN 而非 KEYS,避免阻塞 Redis。
*/

public void evictTenantCache(String tenantId) {
String pattern = "saas:" + tenantId + ":*";
ScanOptions options = ScanOptions.scanOptions().match(pattern).count(100).build();

redisTemplate.execute((RedisCallback<Void>) connection -> {
Cursor<byte[]> cursor = connection.keyCommands().scan(options);
List<byte[]> keysToDelete = new ArrayList<>();

while (cursor.hasNext()) {
keysToDelete.add(cursor.next());
// 批量删除,避免单次删除太多 key 阻塞
if (keysToDelete.size() >= 500) {
connection.keyCommands().del(keysToDelete.toArray(new byte[0][]));
keysToDelete.clear();
}
}
if (!keysToDelete.isEmpty()) {
connection.keyCommands().del(keysToDelete.toArray(new byte[0][]));
}
return null;
});
}
}

// 具体业务服务继承基类
@Service
public class TenantProductCacheService extends TenantAwareCacheService {

public Product getProduct(Long productId) {
String key = tenantKey("product", productId.toString());
// … 正常缓存逻辑,key 自动包含租户隔离
}
}


六、缓存与数据库一致性

最后讨论一个在 SaaS 生产中非常重要但容易被忽视的问题:更新数据库后,如何保证缓存的一致性?

四种缓存更新策略对比

策略 一致性 复杂度 推荐程度
─────────────────────────────────────────
先更新缓存后更新DB 低 低 ❌ 有丢更新风险
先更新DB后更新缓存 中 低 ❌ 并发下仍有不一致
先删缓存后更新DB 低 低 ❌ 删除窗口期不一致
先更新DB后删缓存 ★ 高 低 ✅ 推荐(Cache-Aside)

为什么推荐"先更新 DB,后删缓存"?

// 推荐的 Cache-Aside 更新模式

public void updateProduct(Product product) {
// 1. 先更新数据库(数据库是权威来源)
productRepository.save(product);

// 2. 再删缓存(不是更新缓存!)
// 为什么删而不是更新?
// 因为"更新缓存"在并发下会有竞态条件:
// T1: 更新DB为 price=100,准备更新缓存
// T2: 更新DB为 price=200,准备更新缓存
// T2: 缓存写入 price=200
// T1: 缓存写入 price=100(覆盖了 T2)
// 结果:DB 是 200,缓存是 100,不一致!
//
// 而"删缓存"不会有这个问题:下次读请求会从 DB 重新加载最新值
String cacheKey = "product:" + product.getId();
redisTemplate.delete(cacheKey);

// 进阶:如果你用了多级缓存,本地缓存也要清理
// 分布式环境下,需要通过消息(如 Redis Pub/Sub)通知所有实例清理本地缓存
}

"删缓存"也有极小的不一致窗口(删除缓存和更新 DB 之间有极短时间,并发读请求可能读到旧数据)。想要接近零不一致,需要引入延迟双删或基于 binlog 的缓存失效(Canal + MQ),这属于高阶话题,我们在第八篇生产实战中会提及。


七、本篇总结

问题成因解法
缓存穿透 查询不存在的 Key 缓存空值 / 布隆过滤器
缓存击穿 热点 Key 到期瞬间 互斥锁 / 逻辑过期
缓存雪崩 大量 Key 同时过期 / Redis 宕机 TTL 随机化 / 多级缓存 + 熔断
多租户隔离 Key 混用 Key 前缀含租户 ID
缓存一致性 更新策略不当 Cache-Aside:先更新 DB,后删缓存

下一篇预告:缓存问题搞定了,但分布式系统中"同一时刻只允许一个线程执行某个操作"怎么保证?Redis 分布式锁是面试高频、生产高危地带,下篇我们从 SETNX 一路讲到 Redisson,彻底吃透。


📌 觉得有帮助?点赞收藏支持一下,系列持续更新中!

赞(0)
未经允许不得转载:171主机测评 » 第04篇:Redis 缓存设计精讲:穿透、击穿、雪崩与 Java SaaS 实战
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址