Redis 缓存并发问题深度解析:击穿、雪崩与穿透防治指南
Redis-缓存并发
- 引言:缓存,高性能架构的基石与并发挑战
- 一、 缓存击穿:热点 Key 失效引发的“单点风暴”
- 1.1 什么是缓存击穿?
- 1.2 缓存击穿的风险
- 1.3 缓存击穿的解决方案
- 1.3.1 互斥锁(Mutex Lock)/ 分布式锁 (Distributed Lock) - 推荐
- 1.3.2 逻辑过期(Logical Expiration)/ 热点数据永不过期
- 1.3.3 对比与选择
- 二、 缓存雪崩:大面积失效引发的“系统性灾难”
- 2.1 什么是缓存雪崩?
- 2.2 缓存雪崩的风险
- 2.3 缓存雪崩的解决方案
- 2.3.1 预防 Key 同时过期
- 2.3.2 保证缓存服务高可用
- 2.3.3 容灾措施:限流与降级
- 2.3.4 多级缓存
- 2.3.5 对比与选择
- 三、 缓存穿透:查询不存在数据的“持续骚扰”
- 3.1 什么是缓存穿透?
- 3.2 缓存穿透的风险
- 3.3 缓存穿透的解决方案
- 3.3.1 缓存空值 (Cache Null Values) - 常用
- 3.3.2 布隆过滤器 (Bloom Filter) - 推荐
- 3.3.3 接口层校验 (Parameter Validation)
- 3.3.4 对比与选择
- 四、 总结与最佳实践
引言:缓存,高性能架构的基石与并发挑战
在现代分布式系统中,缓存是提升系统性能、降低后端负载不可或缺的关键组件。
通过将热点数据存储在访问速度更快的介质(如内存)中,缓存能够显著减少对后端数据库或其他慢速服务的访问,从而提高应用的响应速度和吞吐量。
Redis 以其高性能、丰富的数据结构和良好的生态,成为了目前最主流的缓存解决方案之一。
然而,引入缓存并非一劳永逸。在高并发场景下,缓存系统自身也可能面临严峻的挑战,其中最典型的就是缓存击穿、缓存雪崩和缓存穿透这三大并发问题。这些问题一旦发生,轻则导致系统性能下降、响应变慢,重则可能引发后端数据库过载甚至整个系统崩溃。
文章旨在介绍分析这三种常见的 Redis 缓存并发问题:
- 缓存击穿 (Cache Breakdown/Penetration):单个热点 Key 过期,高并发请求直击数据库。
- 缓存雪崩 (Cache Avalanche):大量 Key 同时过期或 Redis 服务宕机,海量请求涌向数据库。
- 缓存穿透 (Cache Penetration):查询不存在的数据,请求绕过缓存,频繁访问数据库。
一、 缓存击穿:热点 Key 失效引发的“单点风暴”
1.1 什么是缓存击穿?
缓存击穿,简单来说,是指某个访问极其频繁的热点 Key,在它失效的瞬间,恰好有大量的并发请求访问这个 Key。由于缓存已过期(或被剔除),这些并发请求无法命中缓存,便会“击穿”缓存层,同时涌向后端的数据库或其他数据源,导致数据库压力瞬间剧增,甚至可能被打垮。
想象一下某个电商平台的爆款商品详情页,这个商品 ID 就是一个典型的热点 Key。平时成千上万的用户请求都由 Redis 缓存扛着,毫秒级响应。但如果这个商品 Key 的缓存在某个精确的时间点过期了,而此时恰好有大量用户(比如秒杀活动开始时)同时刷新页面请求该商品信息,那么这些请求就会在极短的时间内全部打到数据库上,形成一次猛烈的“单点冲击”。
关键特征:
- 单一热点 Key: 问题集中在某个特定的、访问量远超其他 Key 的数据上。
- 高并发访问: 在 Key 失效的瞬间,有大量的线程/请求同时访问该 Key。
- 缓存瞬间失效: Key 恰好在此时过期或因其他原因(如 LRU 淘汰)被删除。
1.2 缓存击穿的风险
缓存击穿虽然看似只影响一个 Key,但其带来的风险不容小觑:
- 数据库瞬时超载: 这是最直接也是最严重的风险。热点 Key 的访问量通常非常大,失效瞬间涌入数据库的请求量可能是平时的数十倍甚至数百倍,远超数据库的处理能力上限,导致数据库 CPU、IO、连接数等资源迅速耗尽。
- 接口响应时间剧增: 请求从访问高速缓存(毫秒级)转为访问数据库(可能数十或数百毫秒,甚至更长),用户能明显感知到卡顿或加载缓慢,影响用户体验。
- 系统雪崩风险(连锁反应): 数据库作为许多服务的核心依赖,其压力过大或响应缓慢,会拖慢依赖它的所有服务。这可能导致请求超时、线程阻塞、资源耗尽等问题在系统中蔓延,最终引发更大范围的服务不可用,甚至整个系统雪崩。
- 数据不一致(若处理不当): 如果没有合适的并发控制,多个请求同时查询数据库并回写缓存,可能由于读取和写入的时间差导致缓存中存储了旧的数据。
举例说明:
- 微博热搜榜首: 某个明星八卦突然登上热搜第一,对应的资讯 Key 成为热点。若缓存失效,大量用户的点击和刷新请求会同时打到数据库。
- 电商秒杀活动: 秒杀商品的库存信息 Key。活动开始瞬间,大量用户请求查询库存,若缓存失效,数据库压力陡增。
- 首页推荐内容: 门户网站或 App 首页某个固定推荐位的内容 Key。
1.3 缓存击穿的解决方案
解决缓存击穿的核心思路是:避免大量请求在同一时间点直接请求数据库加载同一个数据。 主要有以下几种常用方法:
1.3.1 互斥锁(Mutex Lock)/ 分布式锁 (Distributed Lock) - 推荐
这是最经典也是最常用的解决方案。其核心思想是:当缓存失效时,只允许一个请求去查询数据库并重建缓存,其他请求则等待该请求完成或直接返回(取决于业务策略)。
基本流程:
- 请求线程访问缓存。
- 如果缓存命中,直接返回数据。
- 如果缓存未命中:
a. 尝试获取该 Key 对应的互斥锁。
b. 获取锁成功的线程:
i. 再次检查缓存(Double Check Locking,防止在等待锁期间已有其他线程重建了缓存)。
ii. 如果缓存仍然不存在,则查询数据库。
iii. 将查询结果写入缓存(设置合理的过期时间)。
iv. 释放锁。
v. 返回数据。
c. 获取锁失败的线程:
i. 可以选择短暂休眠后重试(自旋等待),或者直接返回空值/默认值/提示信息(取决于业务容忍度),避免所有线程都阻塞在锁上。
实现方式:
- 单机环境: 可以使用 JVM 提供的锁机制,如
synchronized
关键字或java.util.concurrent.locks.Lock
(例如ReentrantLock
)。 - 分布式环境: 必须使用分布式锁,因为应用通常是集群部署,JVM 锁无法跨进程生效。常用的分布式锁实现有:
- 基于 Redis 实现:
SETNX
+ Lua 脚本(保证原子性),或使用 Redisson 等成熟的客户端库。 - 基于 ZooKeeper 实现: 利用其临时有序节点。
- 基于数据库实现: 利用数据库的唯一约束或行锁(性能相对较低)。
- 基于 Redis 实现:
推荐使用 Redisson 实现分布式锁:
Redisson 提供了易于使用的分布式锁接口,并处理了锁的可重入、自动续期(看门狗机制)、释放等复杂问题。
Java + Redisson 示例代码:
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;import java.util.concurrent.TimeUnit;@Service
public class ProductServiceWithMutex {@Autowiredprivate StringRedisTemplate stringRedisTemplate; // 用于操作 Redis 缓存@Autowiredprivate RedissonClient redissonClient; // Redisson 客户端,需要配置 Beanprivate static final String CACHE_KEY_PREFIX = "product:";private static final String LOCK_KEY_PREFIX = "lock:product:";private static final long CACHE_TTL = 30; // 缓存过期时间,单位:分钟private static final long LOCK_WAIT_TIME = 1; // 获取锁的等待时间,单位:秒private static final long LOCK_LEASE_TIME = 10; // 锁的持有时间(Redisson 默认有看门狗机制自动续期)/*** 查询商品信息,使用互斥锁解决缓存击穿* @param productId 商品 ID* @return 商品信息,如果不存在则返回 null*/public String getProductInfo(Long productId) {String cacheKey = CACHE_KEY_PREFIX + productId;// 1. 从缓存获取数据String productInfo = stringRedisTemplate.opsForValue().get(cacheKey);// 2. 缓存命中,直接返回if (productInfo != null) {// 可以考虑在这里重置一下缓存有效期(如果需要的话,即缓存续期)// stringRedisTemplate.expire(cacheKey, CACHE_TTL, TimeUnit.MINUTES);System.out.println("缓存命中,直接返回: " + productInfo);return productInfo;}// --- 缓存未命中 ---// 3. 准备获取分布式锁String lockKey = LOCK_KEY_PREFIX + productId;RLock lock = redissonClient.getLock(lockKey);try {// 4. 尝试获取锁// tryLock(waitTime, leaseTime, unit)// waitTime: 获取锁的最大等待时间。如果在等待时间内获取到锁,则返回 true;否则返回 false。// leaseTime: 锁的持有时间。超过这个时间锁会自动释放。如果设置为 -1,则使用 Redisson 的看门狗机制,默认 30 秒,并且会自动续期。// 为避免死锁和保证锁最终释放,通常建议设置一个合理的 leaseTime,或者依赖看门狗。// 这里我们设置一个较短的等待时间,如果获取不到锁就放弃,避免过多线程阻塞。// 同时设置一个 leaseTime,即使看门狗失效(例如服务宕机),锁最终也会释放。boolean isLocked = lock.tryLock(LOCK_WAIT_TIME, LOCK_LEASE_TIME, TimeUnit.SECONDS);// 5. 判断是否获取锁成功if (isLocked) {System.out.println("线程 " + Thread.currentThread().getId() + " 获取锁成功,准备查询数据库...");// 6. 获取锁成功 - Double Check Locking (再次检查缓存)// 防止在等待锁的过程中,已有其他线程重建了缓存productInfo = stringRedisTemplate.opsForValue().get(cacheKey);if (productInfo != null) {System.out.println("获取锁后发现缓存已存在,直接返回: " + productInfo);return productInfo;}// 7. 缓存确实不存在,查询数据库System.out.println("线程 " + Thread.currentThread().getId() + " 查询数据库获取商品信息...");productInfo = queryProductFromDB(productId); // 模拟数据库查询// 8. 数据库查询结果处理if (productInfo != null) {// 数据库中有数据,写入缓存System.out.println("线程 " + Thread.currentThread().getId() + " 将数据写入缓存: " + productInfo);stringRedisTemplate.opsForValue().set(cacheKey, productInfo, CACHE_TTL, TimeUnit.MINUTES);} else {// 数据库中也没有数据(防止缓存穿透,后面会讲),可以缓存一个特殊空值// 注意:缓存空值的时间不宜过长System.out.println("线程 " + Thread.currentThread().getId() + " 数据库无此商品,缓存空值");stringRedisTemplate.opsForValue().set(cacheKey, "", 5, TimeUnit.MINUTES); // 缓存空字符串,过期时间短一些}// 9. 返回查询结果(可能是真实数据或空值标记)return productInfo; // 注意:如果缓存了空值,这里返回的可能是空字符串""} else {// 10. 获取锁失败 - 其他线程正在重建缓存System.out.println("线程 " + Thread.currentThread().getId() + " 获取锁失败,休眠后重试...");// 可以选择短暂休眠后重试,再次调用 getProductInfo 方法// 或者直接返回提示信息或默认值,避免长时间等待TimeUnit.MILLISECONDS.sleep(100); // 休眠 100 毫秒return getProductInfo(productId); // 递归调用重试(注意控制重试次数,防止死循环)// 或者 return "系统繁忙,请稍后重试";}} catch (InterruptedException e) {Thread.currentThread().interrupt(); // 恢复中断状态System.err.println("线程 " + Thread.currentThread().getId() + " 在等待锁或休眠时被中断");return "系统错误,请稍后重试";} finally {// 11. 释放锁 - 必须在 finally 块中执行,确保锁一定会被释放// 需要判断当前线程是否持有锁if (lock.isLocked() && lock.isHeldByCurrentThread()) {lock.unlock();System.out.println("线程 " + Thread.currentThread().getId() + " 释放锁");}}}/*** 模拟从数据库查询商品信息* @param productId 商品 ID* @return 商品信息字符串,如果不存在则返回 null*/private String queryProductFromDB(Long productId) {// 实际应用中,这里会调用 DAO 层或 Mapper 层访问数据库System.out.println("--- 模拟数据库查询 productId: " + productId + " ---");try {// 模拟数据库查询耗时TimeUnit.MILLISECONDS.sleep(200);} catch (InterruptedException e) {Thread.currentThread().interrupt();}// 模拟数据库中存在该商品if (productId != null && productId > 0 && productId < 1000) {return "{\"id\":" + productId + ", \"name\":\"模拟商品" + productId + "\", \"price\":99.9}";} else {// 模拟数据库中不存在该商品return null;}}
}
代码解释与注意事项:
- RedissonClient 配置: 需要在 Spring Boot 配置中正确初始化
RedissonClient
Bean,连接到你的 Redis 服务器。 - 锁 Key 设计: 锁 Key (
LOCK_KEY_PREFIX + productId
) 应该与缓存 Key 相关联,确保对同一个资源的访问使用同一个锁。 tryLock()
参数:waitTime
:设置一个较短的等待时间,避免大量线程因等待锁而阻塞。如果获取失败,可以选择快速失败或短暂休眠后重试。leaseTime
:锁的持有时间。Redisson 默认的看门狗机制(leaseTime
为 -1 或不设置时)会在锁未释放前自动续期(默认每lockWatchdogTimeout / 3
时间续期一次,lockWatchdogTimeout
默认 30 秒)。这可以防止业务逻辑执行时间过长导致锁提前释放。但如果服务宕机,看门狗也会失效,所以设置一个合理的leaseTime
作为兜底是推荐的,确保锁最终能被释放。
- Double Check Locking (DCL): 在获取锁成功后,必须再次检查缓存。因为在线程等待锁的期间,可能已经有其他线程获取了锁、查询了数据库、重建了缓存并释放了锁。DCL 可以避免不必要的数据库查询。
- 释放锁: 必须在
finally
块中释放锁,确保即使发生异常,锁也能被正确释放,防止死锁。同时,需要使用isLocked()
和isHeldByCurrentThread()
判断锁的状态和归属,避免释放不属于自己的锁或未加锁成功的锁。 - 获取锁失败的处理: 可以选择:
- 自旋重试: 休眠一小段时间后再次尝试获取数据(如示例中的递归调用)。需要设置最大重试次数或超时时间,防止无限重试。
- 快速失败: 直接返回错误信息或默认值,将压力快速反馈给调用方。
- 缓存空值: 如果数据库查询结果为空,建议缓存一个特殊的空值(如空字符串 “” 或特定标记),并设置一个较短的过期时间。这可以有效防止缓存穿透(后续会详细讲解)。
优点:
- 强一致性: 能有效保证只有一个线程更新缓存,避免并发更新导致的数据不一致。
- 简单有效: 思路清晰,实现相对直接(尤其使用 Redisson)。
缺点:
- 性能开销: 引入了锁机制,获取和释放锁会带来一定的性能开销。在高并发下,锁的争抢可能成为瓶颈。
- 线程阻塞: 获取锁失败的线程需要等待或重试,增加了请求的响应时间。
- 死锁风险: 如果锁使用不当(如忘记释放锁),可能导致死锁。
1.3.2 逻辑过期(Logical Expiration)/ 热点数据永不过期
另一种思路是不给热点 Key 设置物理过期时间 (TTL),或者设置一个非常长的过期时间,而是在缓存值中包含一个逻辑过期时间字段。
基本流程:
- 请求线程访问缓存。
- 如果缓存命中:
a. 检查缓存值中的逻辑过期时间是否已到。
b. 未过期: 直接返回数据。
c. 已过期:
i. 尝试获取互斥锁(同样需要锁来保证只有一个线程执行异步重建)。
ii. 获取锁成功: 开启一个新的线程或使用线程池,异步去查询数据库并更新缓存(更新数据和新的逻辑过期时间)。
iii. 无论是否获取到锁: 立即返回当前缓存中的旧数据。
iv. 获取锁成功的线程在异步更新完缓存后释放锁。 - 如果缓存未命中(例如首次访问或缓存被意外删除):
a. 走类似互斥锁方案的逻辑:获取锁 -> 查询数据库 -> 写入缓存(包含逻辑过期时间)-> 释放锁 -> 返回数据。或者,可以先写入一个临时的、表示正在加载的标记值,然后异步加载,后续请求根据标记值等待或返回旧数据(如果适用)。
实现要点:
-
缓存结构: 缓存的值不再是简单的业务数据,而是一个包含业务数据和逻辑过期时间戳的对象或 JSON 字符串。
{"data": { ... }, // 真实的业务数据"expireTime": 1678886400000 // 逻辑过期时间戳 (e.g., System.currentTimeMillis() + TimeUnit.MINUTES.toMillis(30)) }
-
异步重建: 当逻辑过期时,需要启动异步任务来更新缓存,而不是阻塞当前请求。可以使用
@Async
注解、CompletableFuture
、线程池等。 -
并发控制: 异步重建的过程仍然需要互斥锁,防止多个请求发现逻辑过期后同时去执行重建任务。
-
数据预热: 对于核心热点数据,可以在系统启动时或低峰期提前加载到缓存中,并设置好逻辑过期时间。
Java 伪代码示例 (结合逻辑过期与互斥锁):
import com.fasterxml.jackson.databind.ObjectMapper; // Jackson for JSON
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;@Service
public class ProductServiceWithLogicalExpire {@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Autowiredprivate RedissonClient redissonClient;private ObjectMapper objectMapper = new ObjectMapper(); // 用于序列化/反序列化private static final String CACHE_KEY_PREFIX = "product:logical:";private static final String LOCK_KEY_PREFIX = "lock:product:logical:";private static final long LOGICAL_TTL_SECONDS = 30 * 60; // 逻辑过期时间:30分钟private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10); // 用于异步重建缓存的线程池// 内部类,用于封装缓存数据和逻辑过期时间private static class RedisData<T> {private T data;private LocalDateTime expireTime; // 逻辑过期时间// 构造函数、Getter、Setter 省略...public RedisData(T data, LocalDateTime expireTime) {this.data = data;this.expireTime = expireTime;}public T getData() { return data; }public LocalDateTime getExpireTime() { return expireTime; }// Jackson 需要无参构造函数public RedisData() {}}/*** 查询商品信息,使用逻辑过期解决缓存击穿* @param productId 商品 ID* @return 商品信息,可能返回旧数据*/public String getProductInfoLogical(Long productId) {String cacheKey = CACHE_KEY_PREFIX + productId;// 1. 从缓存获取数据 (JSON 字符串)String json = stringRedisTemplate.opsForValue().get(cacheKey);// 2. 缓存未命中 (可能是首次访问,或缓存被意外删除)if (json == null) {// 这里可以返回 null,或者触发一次同步加载 (类似互斥锁方案)// 为简化,我们假设数据会通过预热或其他方式写入,这里直接返回 null// 实际应用中可能需要处理这种情况,例如,尝试获取锁并同步加载System.out.println("缓存未命中 (逻辑过期场景,可能需要预热或特殊处理)");// 可以尝试获取锁并同步加载一次// return loadAndCacheProduct(productId); // 类似互斥锁方案的加载逻辑return null;}// 3. 缓存命中,反序列化 JSONRedisData<String> redisData;try {redisData = objectMapper.readValue(json, objectMapper.getTypeFactory().constructParametricType(RedisData.class, String.class));} catch (Exception e) {System.err.println("反序列化缓存数据失败: " + e.getMessage());// 可以选择删除错误格式的缓存,然后让后续请求重新加载stringRedisTemplate.delete(cacheKey);return null; // 或者抛出异常}String productInfo = redisData.getData();LocalDateTime expireTime = redisData.getExpireTime();// 4. 判断逻辑时间是否过期if (expireTime.isAfter(LocalDateTime.now())) {// 4.1 逻辑时间未过期,直接返回缓存数据System.out.println("逻辑时间未过期,直接返回缓存数据: " + productInfo);return productInfo;}// --- 5. 逻辑时间已过期,需要重建缓存 ---System.out.println("逻辑时间已过期,尝试异步重建缓存...");String lockKey = LOCK_KEY_PREFIX + productId;RLock lock = redissonClient.getLock(lockKey);try {// 6. 尝试获取锁 (waitTime=0,不等待,获取不到就算了,让其他线程去重建)boolean isLocked = lock.tryLock(0, 10, TimeUnit.SECONDS); // leaseTime 保证任务执行完前锁不释放if (isLocked) {System.out.println("线程 " + Thread.currentThread().getId() + " 获取锁成功,开启异步任务重建缓存...");// 7. 获取锁成功,开启异步线程重建缓存CACHE_REBUILD_EXECUTOR.submit(() -> {try {// 查询数据库String freshProductInfo = queryProductFromDB(productId);// 计算新的逻辑过期时间LocalDateTime newExpireTime = LocalDateTime.now().plusSeconds(LOGICAL_TTL_SECONDS);// 创建新的 RedisDataRedisData<String> newRedisData = new RedisData<>(freshProductInfo, newExpireTime);// 写入缓存 (没有设置 TTL,永不过期)stringRedisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(newRedisData));System.out.println("异步任务:缓存重建完成");} catch (Exception e) {System.err.println("异步重建缓存失败: " + e.getMessage());// 可以加入重试机制或日志记录} finally {// 确保异步任务结束后释放锁if (lock.isLocked() && lock.isHeldByCurrentThread()) {lock.unlock();System.out.println("异步任务:释放锁");}}});}// 8. 无论是否获取到锁,都直接返回旧的缓存数据System.out.println("返回旧的缓存数据: " + productInfo);return productInfo;} catch (Exception e) {System.err.println("处理逻辑过期时发生错误: " + e.getMessage());// 发生异常时,仍然可以尝试返回旧数据,保证可用性return productInfo;}// 注意:这里的 finally 不需要释放锁,因为锁要么被异步任务持有,要么没获取到。// 如果 tryLock 失败,锁根本没被当前线程持有。// 如果 tryLock 成功,锁的释放逻辑在异步任务中。}// 预热数据:在系统启动或低峰期调用,将数据加载到缓存public void warmUpProductCache(Long productId) {String cacheKey = CACHE_KEY_PREFIX + productId;String lockKey = LOCK_KEY_PREFIX + productId;RLock lock = redissonClient.getLock(lockKey);try {// 加锁防止并发预热boolean isLocked = lock.tryLock(1, 10, TimeUnit.SECONDS);if(isLocked){System.out.println("预热数据: 开始加载 productId=" + productId);String productInfo = queryProductFromDB(productId);if(productInfo != null){LocalDateTime expireTime = LocalDateTime.now().plusSeconds(LOGICAL_TTL_SECONDS);RedisData<String> redisData = new RedisData<>(productInfo, expireTime);stringRedisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(redisData));System.out.println("预热数据: 加载完成 productId=" + productId);}}} catch (Exception e) {System.err.println("预热数据失败: " + e.getMessage());} finally {if (lock.isLocked() && lock.isHeldByCurrentThread()) {lock.unlock();}}}// 模拟数据库查询的方法 (同上一个例子)private String queryProductFromDB(Long productId) {System.out.println("--- 模拟数据库查询 productId: " + productId + " ---");try {TimeUnit.MILLISECONDS.sleep(200);} catch (InterruptedException e) {Thread.currentThread().interrupt();}if (productId != null && productId > 0 && productId < 1000) {return "{\"id\":" + productId + ", \"name\":\"模拟商品" + productId + "\", \"price\":99.9}";} else {return null;}}
}
代码解释与注意事项:
RedisData
类: 用于封装实际数据和逻辑过期时间。你需要根据实际业务数据的类型调整泛型T
。- 序列化: 需要使用 Jackson 或 Gson 等库将
RedisData
对象序列化为 JSON 字符串存入 Redis,取出时再反序列化。 - 异步执行器: 使用
ExecutorService
(线程池) 来执行缓存重建任务,避免阻塞当前请求线程。线程池的大小需要根据系统负载合理配置。 - 获取锁 (
tryLock(0, ...)
): 当发现逻辑过期时,尝试获取锁设置为不等待 (waitTime=0
)。如果获取失败,说明有其他线程正在重建,当前线程直接返回旧数据即可,无需等待。 - 返回旧数据: 即使逻辑过期,也立即返回缓存中的旧数据。这保证了接口的低延迟,但牺牲了一定的数据实时性。是否接受旧数据取决于业务需求。
- 缓存未命中处理: 示例中简化了缓存未命中的情况。实际应用中,如果缓存为空(如首次加载),可能需要一个同步加载的逻辑(类似互斥锁方案),或者在预热阶段确保数据已加载。
- 锁的释放: 异步重建任务完成后,必须在异步任务内部释放锁。
优点:
- 高可用性: 通过返回旧数据,即使在缓存重建期间,服务也能持续提供响应,避免了互斥锁方案中线程等待导致的部分请求延迟增加。
- 低延迟: 大部分请求(逻辑未过期或获取锁失败)都能直接从缓存获取数据(即使是旧数据),响应速度快。
缺点:
- 数据不一致: 在缓存重建完成之前,返回的是旧数据,存在一定时间窗口的数据不一致。业务需要能容忍这种短暂的不一致。
- 实现复杂度高: 需要引入逻辑过期时间字段、异步处理、额外的锁机制,代码复杂度相对较高。
- 额外内存开销: 缓存值需要额外存储逻辑过期时间,占用更多内存。
- 依赖预热: 对于必须有数据才能提供服务的场景,依赖于数据的预热。
1.3.3 对比与选择
特性 | 互斥锁/分布式锁 | 逻辑过期/永不过期 |
---|---|---|
核心思想 | 加锁排队,只允许一个线程加载数据 | 返回旧数据,异步后台更新 |
数据一致性 | 强一致性(理论上) | 最终一致性(存在短暂不一致) |
系统可用性 | 稍低(部分线程需等待) | 高(优先保证服务可用) |
响应延迟 | 可能增加(等待锁) | 低(大部分请求直接返回) |
实现复杂度 | 中等 (使用 Redisson 较简单) | 较高 (涉及异步、时间戳、额外锁) |
内存开销 | 正常 | 略高 (存储逻辑时间) |
适用场景 | 对数据一致性要求高的场景 | 对可用性和性能要求极高,能容忍短暂数据不一致的场景 |
选择建议:
- 如果业务对数据一致性要求非常高,不能容忍返回旧数据,互斥锁/分布式锁是更合适的选择。
- 如果业务对接口性能和可用性要求极高,能够接受短时间的数据不一致(例如,商品详情页展示旧几分钟的价格通常可以接受),逻辑过期方案是更好的选择。
- 在实践中,可以结合使用。例如,对极少数核心热点数据使用逻辑过期,对其他普通热点数据使用互斥锁。
二、 缓存雪崩:大面积失效引发的“系统性灾难”
2.1 什么是缓存雪崩?
缓存雪崩 是指在短时间内,大量缓存 Key 同时失效(例如,设置了相同的固定过期时间),或者 Redis 缓存服务本身发生宕机或不可用,导致海量的请求在无法命中缓存的情况下,直接冲击到后端的数据库或其他数据源,如同雪崩一般,瞬间压垮后端服务。
与缓存击穿针对“单个”热点 Key 不同,缓存雪崩影响的是“大面积”的缓存 Key。
主要诱因:
- 同一时间大面积 Key 过期:
- 固定 TTL: 给大量的 Key 设置了完全相同的过期时间(例如,
expire key 3600
),导致它们在未来的某个时间点同时失效。这在批量导入数据、定时任务刷新缓存等场景下容易发生。 - 应用重启: 应用重启可能导致内存中的缓存(如 Guava Cache, Caffeine)全部丢失,如果此时有大量请求涌入,也会冲击后端。
- 固定 TTL: 给大量的 Key 设置了完全相同的过期时间(例如,
- Redis 服务宕机或故障:
- 单点故障: 如果 Redis 是单节点部署,一旦该节点宕机,所有缓存访问都会失败。
- 集群故障: Redis 集群(如 Sentinel 或 Cluster 模式)发生主从切换、网络分区或其他故障,导致部分或全部缓存节点在短时间内不可用。
2.2 缓存雪崩的风险
缓存雪崩的后果通常比缓存击穿更严重,因为它影响范围更广,可能导致系统性问题:
- 数据库彻底崩溃: 雪崩带来的请求量可能是平时的几倍甚至几十倍,数据库往往难以承受如此巨大的瞬时压力,导致连接耗尽、CPU 飙升、响应超时,最终宕机。
- 系统性瘫痪: 数据库作为核心依赖,其崩溃会迅速传导到上游服务,引发连锁反应,导致整个应用集群或相关微服务大面积不可用。
- 资源耗尽: 不仅仅是数据库,应用服务器的线程池、连接池等资源也可能被大量等待数据库响应的请求耗尽。
- 恢复时间长: 由于影响范围广,涉及多个服务和数据库,从雪崩中恢复通常需要较长时间,需要重启服务、预热数据等。
- 数据丢失风险(极端情况): 如果系统设计不当,数据库崩溃可能导致事务未提交、消息丢失等问题。
举例说明:
- 定时任务刷新全量配置: 每天凌晨 4 点定时任务刷新系统中所有配置项的缓存,并设置了 24 小时过期。那么第二天凌晨 4 点,所有配置缓存将同时失效。
- Redis 主节点宕机: 部署了 Redis 主从模式,但没有哨兵自动切换或切换失败,主节点宕机导致所有写操作失败,读操作也可能失败(取决于配置)。
- 云服务商 Redis 故障: 使用的云 Redis 服务发生区域性故障,导致大量应用的缓存不可用。
2.3 缓存雪崩的解决方案
解决缓存雪崩需要从预防和容灾两个层面入手。核心思路是:避免 Key 同时过期、保证缓存服务高可用、在缓存失效时进行限流和降级。
2.3.1 预防 Key 同时过期
-
过期时间加随机值(推荐): 这是最简单有效的防止因 TTL 相同导致雪崩的方法。在设置缓存过期时间时,不再使用固定的 TTL,而是在一个基础 TTL 上增加一个随机的时间范围。
公式:
expireTime = baseTTL + random(range)
例如,基础过期时间是 1 小时,可以增加一个 0 到 10 分钟的随机值。这样,即使是同一批写入的缓存,它们的过期时间也会分散开,避免在同一时刻集中失效。
Java 示例 (使用
StringRedisTemplate
)import org.springframework.beans.factory.annotation.Autowired; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component;import java.util.Random; import java.util.concurrent.TimeUnit;@Component public class CacheUtils {@Autowiredprivate StringRedisTemplate stringRedisTemplate;private static final long BASE_TTL_MINUTES = 60; // 基础过期时间:60分钟private static final int RANDOM_RANGE_MINUTES = 10; // 随机范围:0-10分钟private static final Random random = new Random();/*** 设置缓存,并添加随机过期时间* @param key 缓存 Key* @param value 缓存 Value*/public void setCacheWithRandomTtl(String key, String value) {// 计算随机增加的秒数long randomSeconds = random.nextInt(RANDOM_RANGE_MINUTES * 60);// 计算最终的过期时间(秒)long finalTtlSeconds = TimeUnit.MINUTES.toSeconds(BASE_TTL_MINUTES) + randomSeconds;System.out.println("设置缓存 Key: " + key + ", 基础 TTL: " + BASE_TTL_MINUTES + " 分钟, 随机增加: "+ TimeUnit.SECONDS.toMinutes(randomSeconds) + " 分钟 "+ (randomSeconds % 60) + " 秒, 最终 TTL: " + finalTtlSeconds + " 秒");stringRedisTemplate.opsForValue().set(key, value, finalTtlSeconds, TimeUnit.SECONDS);}/*** 设置缓存,使用基础 TTL 和固定的随机因子(适合按 Key 哈希分散)* @param key 缓存 Key* @param value 缓存 Value*/public void setCacheWithFixedRandomFactor(String key, String value) {// 使用 key 的哈希值来确定一个固定的随机偏移量,确保同一个 key 的偏移量是稳定的int hashFactor = Math.abs(key.hashCode()) % (RANDOM_RANGE_MINUTES * 60); // 0 到 range-1 的秒数long finalTtlSeconds = TimeUnit.MINUTES.toSeconds(BASE_TTL_MINUTES) + hashFactor;System.out.println("设置缓存 Key: " + key + ", 基础 TTL: " + BASE_TTL_MINUTES + " 分钟, 固定偏移: "+ TimeUnit.SECONDS.toMinutes(hashFactor) + " 分钟 "+ (hashFactor % 60) + " 秒, 最终 TTL: " + finalTtlSeconds + " 秒");stringRedisTemplate.opsForValue().set(key, value, finalTtlSeconds, TimeUnit.SECONDS);} }
注意:
random.nextInt(upperBound)
生成的是[0, upperBound)
范围内的随机整数。- 随机范围
range
需要根据业务场景和基础 TTL 合理设置。范围太小效果不明显,范围太大可能导致缓存命中率略微下降。 - 第二种方法
setCacheWithFixedRandomFactor
使用 Key 的哈希值计算偏移,可以保证同一个 Key 每次写入时的过期时间点相对固定(只要基础 TTL 不变),有助于缓存预热和管理,但也可能因为哈希碰撞导致少量 Key 依然集中过期,是一种折中方案。
-
永不过期(用于逻辑过期方案): 对于核心数据,可以采用上一节提到的逻辑过期方案,不设置物理 TTL,从根本上避免因过期导致的雪崩。但这需要业务能接受返回旧数据。
2.3.2 保证缓存服务高可用
预防 Redis 服务宕机或故障导致的雪崩,关键在于构建高可用的 Redis 集群。
- Redis Sentinel (哨兵模式):
- 原理: 通过引入一个或多个 Sentinel 进程来监控 Redis 主从节点的状态。当主节点故障时,Sentinel 会自动进行故障转移(Failover),选举一个新的从节点提升为新的主节点,并通知客户端切换连接。
- 优点: 实现了主从切换自动化,提高了 Redis 的可用性。
- 缺点: 每个 Sentinel 节点都需要维护所有主从节点的状态信息,配置相对复杂。写操作仍然只能在主节点进行,写性能受限于单机。故障切换过程中可能有短暂的服务中断。
- Redis Cluster (集群模式):
- 原理: 采用去中心化的分片架构。数据被分散存储在多个节点上(通过哈希槽 Slot),每个节点负责一部分 Slot。节点间通过 Gossip 协议进行通信和状态同步。每个主节点可以有自己的从节点用于故障转移。
- 优点: 提供了水平扩展能力(增加节点可以提升容量和吞吐量),天然支持高可用(部分节点故障不影响整个集群),去中心化设计。
- 缺点: 实现更复杂,对客户端有要求(需要支持 Cluster 协议),不支持部分 Redis 命令(如涉及多个 Key 的原子操作可能受限)。
- 多副本与跨机架/跨可用区部署: 无论是 Sentinel 还是 Cluster,都应该配置多个副本(主从),并将这些副本部署在不同的物理机架(IDC 环境)或不同的可用区(云环境),以防止单点物理故障(如机架掉电、可用区网络故障)导致整个缓存服务不可用。
选择建议:
- 对于需要高可用且数据量和并发量不是特别巨大的场景,Redis Sentinel 是一个成熟且相对简单的选择。
- 对于需要高可用、高并发、并且需要水平扩展能力的大规模缓存场景,Redis Cluster 是更优的选择。
- 无论哪种模式,多副本和跨区域部署都是必不可少的。
2.3.3 容灾措施:限流与降级
即使做了上述预防措施,也不能完全保证缓存雪崩绝对不会发生(例如,极端网络故障、程序 Bug 导致缓存被意外清空)。因此,还需要有事后的容灾手段,即在缓存失效、大量请求涌向后端时,能够限制流量并牺牲部分非核心功能,保护核心服务和数据库不被压垮。
-
后端服务限流 (Rate Limiting):
- 目的: 限制单位时间内能够访问数据库或其他后端服务的请求数量,超过阈值的请求直接拒绝或排队等待,防止后端过载。
- 实现方式:
- Guava RateLimiter: Java 单机限流库,简单易用,提供令牌桶和平滑突发限流算法。
- Sentinel: 分布式流量控制、熔断降级框架(阿里巴巴开源)。功能强大,支持多种限流策略(QPS、线程数)、熔断降级、热点参数限流等,提供可视化控制台。
- Hystrix: Netflix 开源的容错库,提供线程隔离/信号量隔离、熔断、降级等功能(目前已进入维护状态,推荐使用 Sentinel 或 Resilience4j)。
- Nginx/Gateway 层限流: 在网关层面对接口进行统一限流。
- 关键: 限流阈值需要根据后端服务的实际处理能力进行压测和设定。
Java + Sentinel 示例 (简单 QPS 限流):
- 需要引入 Sentinel 依赖,并进行配置。
- 在需要保护的方法上添加
@SentinelResource
注解,并配置流控规则。
import com.alibaba.csp.sentinel.annotation.SentinelResource; import com.alibaba.csp.sentinel.slots.block.BlockException; import org.springframework.stereotype.Service;@Service public class DatabaseService {// 定义资源名,用于 Sentinel 控制台配置规则private static final String DB_QUERY_RESOURCE = "queryDatabaseResource";// 模拟数据库查询方法,使用 Sentinel 进行限流保护// value: 资源名// blockHandler: 指定流控降级(被阻止)时调用的方法 (方法签名需匹配)// fallback: 指定发生异常时调用的方法 (方法签名需匹配)@SentinelResource(value = DB_QUERY_RESOURCE,blockHandler = "handleBlock",fallback = "handleFallback")public String queryFromDBWithSentinel(Long id) {System.out.println("--- 尝试查询数据库 ID: " + id + " ---");// 模拟数据库操作可能抛出异常if (id != null && id < 0) {throw new IllegalArgumentException("ID 不能为负数");}// 模拟数据库查询耗时try {Thread.sleep(50); // 模拟耗时} catch (InterruptedException e) {Thread.currentThread().interrupt();}return "DB_Result_For_" + id;}// 流控降级处理方法 (BlockException)// 注意:方法必须是 public,返回值和参数列表要与原方法一致,// 并且额外多一个 BlockException 参数。可以是静态方法。public String handleBlock(Long id, BlockException ex) {System.err.println("触发限流!资源名: " + ex.getRule().getResource()+ ", 规则: " + ex.getRuleLimitApp()+ ", 请求 ID: " + id);// 可以返回默认值、友好提示或 nullreturn "系统繁忙,请稍后重试 (限流)";}// 异常降级处理方法 (Throwable)// 注意:方法必须是 public,返回值和参数列表要与原方法一致,// 并且额外多一个 Throwable 参数。可以是静态方法。public String handleFallback(Long id, Throwable ex) {System.err.println("查询数据库时发生异常!请求 ID: " + id + ", 异常: " + ex.getMessage());// 可以返回默认值、友好提示或 nullreturn "系统错误,请稍后重试 (异常降级)";}// --- Sentinel 规则配置 (实际应通过配置中心或 Dashboard 配置) ---// 这里仅作演示,在应用启动时配置规则 (需要引入 sentinel-datasource-extension)// 或者通过 Sentinel Dashboard 动态配置static {// initFlowRules(); // 在实际项目中通过配置加载}/*private static void initFlowRules(){List<FlowRule> rules = new ArrayList<>();FlowRule rule = new FlowRule();rule.setResource(DB_QUERY_RESOURCE); // 针对哪个资源rule.setGrade(RuleConstant.FLOW_GRADE_QPS); // 限流阈值类型:QPSrule.setCount(10); // 设置 QPS 阈值为 10rules.add(rule);FlowRuleManager.loadRules(rules);System.out.println("Sentinel 流控规则加载完成: " + DB_QUERY_RESOURCE + " QPS=10");}*/ }
-
服务降级 (Degradation):
- 目的: 当系统负载过高或依赖的服务出现问题时,暂时屏蔽或简化非核心功能,释放资源,保证核心功能的稳定运行。
- 实现方式:
- 开关降级: 通过配置中心(如 Nacos, Apollo)设置开关,手动或自动触发降级。
- 熔断降级 (Circuit Breaking): 当某个依赖服务的错误率、慢调用比例超过阈值时,熔断器会打开,后续一段时间内所有对该服务的调用都会直接失败(执行降级逻辑),不再请求该服务,避免资源浪费和雪崩效应。一段时间后,熔断器会进入半开状态,尝试放行少量请求,如果成功则关闭熔断器恢复正常,如果失败则继续保持打开状态。Sentinel 和 Hystrix 都提供了熔断降级功能。
- 降级策略:
- 返回默认值/Mock 数据: 例如,商品推荐服务降级时,返回固定的默认推荐列表。
- 返回空值/错误提示: 例如,用户积分查询服务降级时,返回空或提示“积分服务暂不可用”。
- 执行简化逻辑: 例如,复杂的计算服务降级时,执行一个简化的、资源消耗较低的计算逻辑。
- 关键: 需要提前梳理业务的核心和非核心功能,并为非核心功能设计好降级预案。
-
请求队列/异步化: 对于非实时性要求高的操作,可以考虑将请求放入消息队列(如 Kafka, RabbitMQ),由后端服务异步消费处理,削峰填谷,避免瞬时流量直接冲击数据库。
2.3.4 多级缓存
构建多级缓存体系也是应对缓存雪崩和提升性能的有效手段。
- 客户端缓存 (Local Cache): 在应用服务器内存中缓存数据(如 Guava Cache, Caffeine)。访问速度最快,但容量有限,且存在数据一致性问题(需要合适的失效策略)。可以缓存一些变化频率低、体积小的数据。
- 分布式缓存 (Remote Cache): Redis 等。容量和并发能力远超本地缓存,是主要的缓存层。
- Nginx + Lua 缓存: 在网关层使用 OpenResty (Nginx + Lua) 实现缓存,可以拦截部分请求,减轻后端压力。
当 Redis 雪崩时,如果本地缓存仍然有效,可以顶住一部分流量。多级缓存可以层层过滤请求,降低最终到达数据库的压力。
2.3.5 对比与选择
方案 | 核心作用 | 优点 | 缺点 | 适用阶段 |
---|---|---|---|---|
过期时间加随机值 | 预防 Key 同时过期 | 简单有效,易实现 | 可能略微降低缓存命中率 | 预防 |
高可用缓存集群 | 预防 Redis 服务宕机 | 提高缓存服务自身健壮性 | 配置部署相对复杂,有成本 | 预防 |
服务限流 | 事后保护后端 | 防止后端过载,强制限制流量 | 可能拒绝部分正常请求,需合理设置阈值 | 容灾 |
服务降级/熔断 | 事后保护核心功能 | 牺牲非核心保核心,提高系统韧性 | 需要梳理业务,设计降级预案 | 容灾 |
多级缓存 | 提升性能,分摊压力 | 提高命中率,减轻后端压力,增加一层防护 | 增加了系统复杂度,数据一致性更难保证 | 预防 & 容灾 |
请求队列/异步化 | 削峰填谷,解耦 | 提高系统吞吐,平滑流量 | 增加了延迟,改变了交互模式,引入MQ复杂性 | 架构优化 |
选择建议:
缓存雪崩的防治是一个体系化的工程,通常需要组合使用多种策略:
- 必须做:
- 过期时间加随机值: 成本最低,效果最直接的预防措施。
- 高可用缓存集群 (Sentinel/Cluster): 保障缓存服务自身稳定性的基石。
- 强烈推荐:
- 服务限流: 对访问数据库或其他核心依赖的操作进行限流,是最后的保护屏障。
- 服务降级/熔断: 提前规划,确保极端情况下核心业务可用。
- 可选优化:
- 多级缓存: 根据业务场景和性能需求决定是否引入本地缓存或其他层级缓存。
- 请求队列/异步化: 适用于可以接受异步处理的场景。
三、 缓存穿透:查询不存在数据的“持续骚扰”
3.1 什么是缓存穿透?
缓存穿透 是指客户端持续发起对一个缓存和数据库中都不存在的数据的查询请求。由于缓存中没有命中(因为数据根本不存在),请求每次都会“穿透”缓存层,直接打到后端的数据库。如果这类请求量很大,也会给数据库带来巨大的压力,甚至影响正常服务。
这就像有人故意或无意地,不停地按一个不存在的门铃,每次都得让房主(数据库)亲自去开门确认,徒劳无功。
关键特征:
- 查询不存在的数据: 请求的 Key 在缓存和数据库中都找不到对应的值。
- 绕过缓存: 每次请求都无法命中缓存。
- 直击数据库: 每次请求都落到数据库或其他后端存储上。
常见场景:
- 恶意攻击: 攻击者利用漏洞或猜测,构造大量不存在的 ID 或参数,持续发起查询请求,意图拖垮数据库。例如,不断请求
product_id=-1
,user_id=random_string
等无效 ID。 - 程序 Bug: 代码逻辑错误,导致生成或传入了非法的参数去查询数据。
- 业务规则变化: 之前存在的数据被删除了,但前端或其他系统仍然在请求这些已删除的数据。
3.2 缓存穿透的风险
缓存穿透的风险与雪崩和击穿有所不同,它通常不是瞬时的爆发,而是持续性的压力:
- 数据库持续承压: 大量无效查询不断消耗数据库的连接、CPU 和 IO 资源,导致正常查询性能下降。
- 资源浪费: 系统花费大量资源处理这些无效请求。
- 难以察觉: 单个穿透请求看起来可能并不异常,只有当总量达到一定规模时,才会显现出对数据库的压力,因此可能在造成影响前难以被发现。
- 安全风险: 如果是恶意攻击,可能被用作一种低成本的 DoS (Denial of Service) 或 DDoS (Distributed Denial of Service) 攻击手段。
3.3 缓存穿透的解决方案
解决缓存穿透的核心思路是:识别并拦截这些对不存在数据的无效查询,阻止它们到达数据库。
3.3.1 缓存空值 (Cache Null Values) - 常用
这是最简单直接的方法。当数据库查询一个 Key 返回为空(即数据不存在)时,仍然将这个“空结果”或一个特殊的占位符缓存起来,但设置一个较短的过期时间。
基本流程:
- 请求线程访问缓存。
- 如果缓存命中:
a. 检查命中的值是否是预定义的“空值标记”。
b. 如果是空值标记,直接返回null
或告知调用方数据不存在。
c. 如果是正常数据,直接返回。 - 如果缓存未命中:
a. 查询数据库。
b. 如果数据库查询有结果,将结果写入缓存(设置正常过期时间)。
c. 如果数据库查询无结果 (null),将一个空值标记(如空字符串""
、特定 JSON{"isNull":true}
或null
本身,取决于 Redis 客户端和序列化方式)写入缓存,并设置一个较短的 TTL(例如 1-5 分钟)。 - 返回查询结果(可能是真实数据或
null
)。
Java + Redis 示例 (使用 StringRedisTemplate
缓存空字符串):
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; // Spring Framework StringUtilsimport java.util.concurrent.TimeUnit;@Service
public class ProductServiceWithNullCache {@Autowiredprivate StringRedisTemplate stringRedisTemplate;private static final String CACHE_KEY_PREFIX = "product:nullcache:";private static final long CACHE_TTL_MINUTES = 30; // 正常数据缓存时间private static final long NULL_CACHE_TTL_MINUTES = 5; // 空值缓存时间private static final String NULL_VALUE_MARKER = ""; // 使用空字符串作为空值标记/*** 查询商品信息,使用缓存空值解决缓存穿透* @param productId 商品 ID* @return 商品信息,如果不存在则返回 null*/public String getProductInfoWithNullCache(Long productId) {String cacheKey = CACHE_KEY_PREFIX + productId;// 1. 从缓存获取数据String productInfo = stringRedisTemplate.opsForValue().get(cacheKey);// 2. 缓存命中if (productInfo != null) {// 2.1 判断是否是空值标记if (NULL_VALUE_MARKER.equals(productInfo)) {System.out.println("缓存命中空值标记,返回 null");return null; // 数据不存在}// 2.2 是正常数据,直接返回System.out.println("缓存命中,直接返回: " + productInfo);return productInfo;}// --- 3. 缓存未命中 ---System.out.println("缓存未命中,查询数据库...");// 4. 查询数据库String dbResult = queryProductFromDB(productId); // 模拟数据库查询// 5. 处理数据库结果并写入缓存if (dbResult != null) {// 5.1 数据库有数据,写入缓存 (正常 TTL)System.out.println("数据库查询到数据,写入缓存: " + dbResult);stringRedisTemplate.opsForValue().set(cacheKey, dbResult, CACHE_TTL_MINUTES, TimeUnit.MINUTES);return dbResult;} else {// 5.2 数据库无数据,写入空值标记 (短 TTL)System.out.println("数据库无此数据,写入空值标记到缓存");stringRedisTemplate.opsForValue().set(cacheKey, NULL_VALUE_MARKER, NULL_CACHE_TTL_MINUTES, TimeUnit.MINUTES);return null; // 数据不存在}}// 模拟数据库查询的方法 (同上一个例子)private String queryProductFromDB(Long productId) {System.out.println("--- 模拟数据库查询 productId: " + productId + " ---");try {TimeUnit.MILLISECONDS.sleep(50); // 模拟DB查询耗时} catch (InterruptedException e) {Thread.currentThread().interrupt();}if (productId != null && productId > 0 && productId < 1000) {return "{\"id\":" + productId + ", \"name\":\"真实商品" + productId + "\", \"price\":199.9}";} else {return null; // 模拟数据库不存在}}
}
代码解释与注意事项:
- 空值标记: 选择一个不会与正常业务数据冲突的值作为空值标记。空字符串
""
是常见的选择,但如果业务数据本身可能就是空字符串,则需要选择其他标记,如一个特定的 JSON 串{"isNull": true}
或一个特殊的字符串"$NULL$"
。 - 短 TTL: 缓存空值的过期时间必须设置得比较短(如几分钟)。原因:
- 防止存储过多的无效 Key 占用 Redis 内存。
- 如果之后数据库中真的插入了这个 Key 对应的数据,较短的 TTL 可以让缓存尽快失效,以便后续请求能获取到最新的真实数据。
- 一致性问题: 在空值缓存的 TTL 时间内,如果数据库中新增了对应的数据,客户端仍然会获取到
null
。这是一个短暂的数据不一致,通常可以接受。如果对一致性要求极高,可能需要配合其他机制(如数据库变更时主动删除缓存)。
优点:
- 简单易懂: 实现逻辑清晰,容易理解和部署。
- 效果显著: 能有效阻止对同一个不存在 Key 的重复数据库查询。
缺点:
- 额外的缓存开销: 需要存储空值 Key,占用了 Redis 的内存空间。如果恶意攻击者持续请求大量不同的不存在 Key,可能会消耗大量缓存空间。
- 短暂的数据不一致: 如上所述,在空值 TTL 内无法感知到数据库的新增数据。
3.3.2 布隆过滤器 (Bloom Filter) - 推荐
布隆过滤器是一种空间效率极高的概率型数据结构,用于判断一个元素是否可能存在于一个集合中。它可以在使用极少内存的情况下,快速判断一个 Key 是否一定不存在。
核心特点:
- 空间效率高: 比哈希表等传统结构节省大量空间。
- 查询速度快: 判断时间复杂度为 O(k),k 为哈希函数个数,通常是常数。
- 存在误判率 (False Positive): 它可能将一个不存在的元素误判为存在(但概率可控)。
- 绝不漏判 (No False Negative): 它绝不会将一个存在的元素误判为不存在。
工作原理简述:
- 初始化: 一个长度为 m 的位数组(所有位初始化为 0)和 k 个独立的哈希函数。
- 添加元素: 当要添加一个元素时,用 k 个哈希函数分别计算该元素的哈希值,得到 k 个在位数组中的下标位置,并将这些位置的位都置为 1。
- 查询元素: 当要查询一个元素是否存在时,同样用 k 个哈希函数计算出 k 个下标位置。检查这 k 个位置:
- 如果任意一个位置的位是 0,则该元素一定不存在。
- 如果所有位置的位都是 1,则该元素可能存在(有可能是之前添加的其他元素恰好把这些位都置为 1 了,这就是误判)。
如何用于防止缓存穿透:
- 预加载: 将数据库中所有可能被查询的 Key (例如,所有商品 ID、用户 ID) 提前加载到布隆过滤器中。
- 请求过滤: 当一个查询请求到来时:
a. 先用布隆过滤器判断该 Key 是否存在。
b. 如果布隆过滤器判断一定不存在,则直接返回null
或错误信息,不再查询缓存和数据库。
c. 如果布隆过滤器判断可能存在,则继续执行后续的缓存查询和数据库查询逻辑(允许少量误判的请求穿透到缓存层)。
实现方式:
- Guava BloomFilter: Google Guava 库提供了 Java 实现的布隆过滤器,适用于单机内存。
- Redis (配合 Redisson 或 Lua 脚本):
- Redisson
RBloomFilter
: Redisson 客户端提供了开箱即用的分布式布隆过滤器实现RBloomFilter
,底层利用 Redis 的 Bitmap 数据结构。这是在分布式环境中最推荐的方式。 - 自定义 Lua + Bitmap: 可以自己编写 Lua 脚本,利用 Redis 的
SETBIT
和GETBIT
命令操作 Bitmap 来实现布隆过滤器逻辑。
- Redisson
Java + Redisson RBloomFilter
示例:
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;import javax.annotation.PostConstruct; // 用于初始化布隆过滤器
import java.util.concurrent.TimeUnit;
import java.util.stream.LongStream; // 用于生成模拟数据@Service
public class ProductServiceWithBloomFilter {@Autowiredprivate StringRedisTemplate stringRedisTemplate;@Autowiredprivate RedissonClient redissonClient;private RBloomFilter<Long> productBloomFilter; // Redisson 布隆过滤器实例private static final String CACHE_KEY_PREFIX = "product:bloom:";private static final String BLOOM_FILTER_NAME = "product_ids_bloom_filter";private static final long EXPECTED_INSERTIONS = 10000; // 预期插入的元素数量 (例如,预计的商品总数)private static final double FALSE_POSITIVE_PROBABILITY = 0.01; // 期望的误判率 (例如 1%)/*** 初始化布隆过滤器,加载全量数据* 实际应用中,应该在系统启动时或通过定时任务加载*/@PostConstruct // 在 Bean 初始化后执行public void initBloomFilter() {System.out.println("开始初始化商品 ID 布隆过滤器...");productBloomFilter = redissonClient.getBloomFilter(BLOOM_FILTER_NAME);// tryInit(expectedInsertions, falseProbability)// expectedInsertions: 预期放入的元素数量// falseProbability: 可接受的最大误判率// Redisson 会根据这两个参数自动计算最优的位数组长度和哈希函数个数productBloomFilter.tryInit(EXPECTED_INSERTIONS, FALSE_POSITIVE_PROBABILITY);// --- 模拟从数据库加载全量商品 ID 并添加到布隆过滤器 ---// 实际应用中,这里应该是查询数据库获取所有有效的商品 IDSystem.out.println("模拟加载商品 ID 到布隆过滤器...");LongStream.rangeClosed(1, 1000) // 假设数据库有 1000 个商品 ID.forEach(id -> {productBloomFilter.add(id);if (id % 100 == 0) {System.out.print("."); // 打印进度}});System.out.println("\n布隆过滤器初始化完成。");System.out.println("布隆过滤器大小 (估计): " + productBloomFilter.getSize());System.out.println("布隆过滤器哈希函数数量 (估计): " + productBloomFilter.getHashIterations());}/*** 查询商品信息,使用布隆过滤器解决缓存穿透* @param productId 商品 ID* @return 商品信息,如果不存在则返回 null*/public String getProductInfoWithBloomFilter(Long productId) {String cacheKey = CACHE_KEY_PREFIX + productId;// --- 1. 使用布隆过滤器进行前置判断 ---if (!productBloomFilter.contains(productId)) {// 如果布隆过滤器判断该 ID 一定不存在System.out.println("布隆过滤器拦截:商品 ID " + productId + " 不存在,直接返回 null");return null; // 直接返回,不查询缓存和数据库}// --- 布隆过滤器认为 ID 可能存在,继续后续流程 ---System.out.println("布隆过滤器认为商品 ID " + productId + " 可能存在,继续查询缓存...");// 2. 从缓存获取数据String productInfo = stringRedisTemplate.opsForValue().get(cacheKey);// 3. 缓存命中if (productInfo != null) {// 注意:即使布隆过滤器通过了,缓存中也可能存的是空值标记(如果结合了缓存空值策略)// 这里我们假设没有结合缓存空值,或者之前的例子已经处理了空值标记的判断System.out.println("缓存命中,直接返回: " + productInfo);return productInfo;}// --- 4. 缓存未命中 ---System.out.println("缓存未命中,查询数据库...");// 5. 查询数据库String dbResult = queryProductFromDB(productId); // 模拟数据库查询// 6. 处理数据库结果并写入缓存if (dbResult != null) {// 数据库有数据,写入缓存 (正常 TTL)System.out.println("数据库查询到数据,写入缓存: " + dbResult);stringRedisTemplate.opsForValue().set(cacheKey, dbResult, 30, TimeUnit.MINUTES); // 正常缓存时间return dbResult;} else {// 数据库无数据// 走到这里,说明发生了布隆过滤器的误判 (False Positive)// 或者是在布隆过滤器初始化之后,数据库删除了该 IDSystem.err.println("布隆过滤器误判或数据已删除:数据库未找到商品 ID " + productId);// 可以选择缓存空值来防止后续对该误判 Key 的数据库查询// stringRedisTemplate.opsForValue().set(cacheKey, "", 5, TimeUnit.MINUTES);return null; // 数据不存在}}// 模拟数据库查询的方法 (同上)private String queryProductFromDB(Long productId) {System.out.println("--- 模拟数据库查询 productId: " + productId + " ---");try {TimeUnit.MILLISECONDS.sleep(50);} catch (InterruptedException e) {Thread.currentThread().interrupt();}// 注意:为了测试布隆过滤器效果,这里的判断条件要和初始化时一致if (productId != null && productId >= 1 && productId <= 1000) { // 假设只有 1 到 1000 是有效 IDreturn "{\"id\":" + productId + ", \"name\":\"真实商品" + productId + "\", \"price\":199.9}";} else {return null;}}
}
代码解释与注意事项:
RBloomFilter<Long>
: Redisson 提供了泛型接口,这里指定元素类型为Long
(商品 ID)。tryInit()
: 初始化布隆过滤器。expectedInsertions
(预期元素数量) 和falseProbability
(误判率) 是最重要的参数。Redisson 会根据它们计算出最优的位数组大小 (m) 和哈希函数个数 (k)。expectedInsertions
预估要准: 如果实际插入数量远超预期,误判率会急剧上升。通常需要预留一些余量。falseProbability
选择要合理: 误判率越低,需要的内存空间越大,计算开销也可能略高。通常设置在 0.01 (1%) 到 0.001 (0.1%) 之间。
add()
: 将元素添加到布隆过滤器。contains()
: 判断元素是否存在。返回false
表示一定不存在,返回true
表示可能存在。- 初始化时机: 布隆过滤器的初始化(加载全量 Key)通常在系统启动时完成。如果 Key 集合会变化(新增商品),需要有机制(如定时任务、MQ 监听数据库变更)来定期重建或增量更新布隆过滤器。标准布隆过滤器不支持删除元素,如果需要删除,可以考虑使用计数布隆过滤器 (Counting Bloom Filter) 或定期完全重建。
- 分布式环境: Redisson 的
RBloomFilter
是分布式的,多个应用实例共享同一个位于 Redis 中的布隆过滤器。 - 误判处理: 即使布隆过滤器判断 Key 可能存在,数据库查询后仍然可能返回
null
(因为误判或数据被删除)。此时可以选择缓存空值(短 TTL)来进一步防止对该误判 Key 的重复数据库查询。
优点:
- 内存效率极高: 相比缓存空值,占用内存极少,可以处理海量 Key。
- 效率高: 查询速度快,能拦截掉绝大部分无效查询,显著降低数据库压力。
- 实现相对简单 (使用 Redisson): Redisson 封装了底层细节。
缺点:
- 存在误判率: 无法 100% 拦截所有穿透请求,总有一小部分(概率由
falseProbability
控制)会漏过布隆过滤器到达缓存层甚至数据库层。 - 不支持删除(标准实现): 标准布隆过滤器无法安全地删除元素。删除操作可能影响其他元素的判断。需要定期重建或使用变种(如 Counting Bloom Filter,但空间效率会降低)。
- 需要预加载/更新: 需要将全量或变化的 Key 同步到过滤器中,增加了维护成本。
3.3.3 接口层校验 (Parameter Validation)
在缓存和数据库查询之前,对请求参数进行合法性校验,也是一种有效的辅助手段。
- 基本类型校验: 例如,用户 ID 必须是正整数,商品 ID 必须符合某种格式。
- 取值范围校验: 例如,订单号长度必须是 N 位,状态值只能是几个枚举值之一。
- 业务规则校验: 例如,根据用户权限判断其是否能查询某些数据。
通过严格的参数校验,可以在入口处就拦截掉大量明显不合法的请求,减轻后续处理逻辑的压力。这通常在 Controller 层或 Service 层入口完成,可以使用 Java 的 Bean Validation (JSR 303/380) 注解(如 @NotNull
, @Min
, @Pattern
等)或手动编写校验逻辑。
优点:
- 提前拦截: 在请求处理早期就过滤掉非法请求。
- 逻辑清晰: 校验规则明确。
缺点:
- 无法覆盖所有场景: 只能校验参数本身的格式和范围,无法判断一个格式合法但实际不存在的 ID(例如,一个符合 ID 格式但数据库里没有的
productId=999999
)。 - 不能完全替代其他方案: 通常作为第一道防线,需要结合缓存空值或布隆过滤器使用。
3.3.4 对比与选择
方案 | 核心作用 | 优点 | 缺点 | 适用阶段 |
---|---|---|---|---|
缓存空值 | 缓存不存在的结果 | 实现简单,效果直接 | 占用额外缓存空间,存在短暂数据不一致,对大量不同 Key 攻击效果差 | 缓存层 |
布隆过滤器 | 概率性判断 Key 是否存在 | 空间效率极高,速度快,拦截大部分无效请求 | 存在误判率,标准实现不支持删除,需要维护 Key 集合 | 前置过滤 |
接口层校验 | 校验参数合法性 | 提前拦截明显非法请求,逻辑清晰 | 无法判断逻辑上存在但实际不存在的 Key | 入口层 |
选择建议:
缓存穿透的解决方案也常常是组合拳:
- 接口层校验: 作为基础防线,拦截明显无效的请求参数。
- 布隆过滤器: 强烈推荐作为核心解决方案,尤其是当 Key 集合相对稳定且数量较大时。它可以高效地过滤掉绝大多数不存在的 Key 的查询。
- 缓存空值: 可以作为布隆过滤器的补充。对于通过了布隆过滤器(可能是误判)但数据库查询确实为空的 Key,缓存一个短暂的空值,可以防止后续对该误判 Key 的重复数据库访问。也可以在不方便使用布隆过滤器(如 Key 集合变化频繁且难以维护)的场景下单独使用,但要注意内存占用和一致性问题。
四、 总结与最佳实践
核心区别回顾:
- 缓存击穿: 单个热点 Key 过期,大量并发请求打到 DB。
- 缓存雪崩: 大量 Key 同时过期 或 缓存服务宕机,海量请求打到 DB。
- 缓存穿透: 查询不存在的数据,请求绕过缓存,持续打到 DB。
解决方案思维导图(简化):
缓存并发问题
├── 缓存击穿 (单个热点 Key)
│ ├── 互斥锁/分布式锁 (推荐,强一致)
│ └── 逻辑过期 (高可用,最终一致)
├── 缓存雪崩 (大量 Key / 服务宕机)
│ ├── 预防 Key 同时过期
│ │ └── 过期时间加随机值 (推荐)
│ ├── 保证缓存高可用
│ │ └── Redis Sentinel / Cluster (推荐)
│ └── 容灾措施
│ ├── 服务限流 (推荐)
│ ├── 服务降级/熔断 (推荐)
│ └── 多级缓存 / 异步化 (可选)
└── 缓存穿透 (查询不存在数据)├── 布隆过滤器 (推荐,高效过滤)├── 缓存空值 (常用,简单直接)└── 接口层校验 (基础防线)