
在构建高性能应用系统时,Redis 缓存凭借其出色的读写速度,已成为减少数据库压力、提升系统响应性能的标配组件。但在实际生产环境中,我们可能会遭遇缓存击穿、穿透、雪崩这三大棘手问题,它们如同隐藏在暗处的 “性能杀手”,稍有不慎就会让系统性能大打折扣,甚至引发服务崩溃。本文将深入剖析这三个问题的本质,通过丰富的案例和直观的图示,为你呈现全面且实用的解决方案。
一、缓存击穿(Cache Breakdown)
1.1 定义
缓存击穿指的是在高并发场景下,某个热点 Key(被大量频繁访问的数据)在缓存中的数据过期瞬间,大量并发请求同时穿透缓存,直接涌向数据库,导致数据库瞬间承受巨大压力,可能出现性能瓶颈甚至崩溃的情况。
1.2 案例分析
以电商平台为例,某款热门手机在促销期间成为热点商品,其商品详情数据缓存在 Redis 中。假设该缓存 Key 设置了 1 小时的过期时间,当这 1 小时到期,缓存中的数据失效。此时,恰好处于促销活动的流量高峰,大量用户同时刷新商品详情页,这些请求发现缓存未命中,便一股脑地去数据库查询该商品信息。由于数据库处理能力有限,面对突如其来的高并发查询,很可能因资源耗尽而响应缓慢,甚至无法响应,导致用户体验极差,页面加载长时间转圈或直接报错。
1.4 解决方案
- 互斥锁(Mutex):
- 原理:当缓存未命中时,使用分布式锁(如 Redis 的 SETNX 命令),只有获取到锁的线程能够去数据库查询数据并更新缓存,其他线程处于等待状态。待获取锁的线程完成数据库查询和缓存更新后,释放锁,其他等待线程再从缓存获取数据。
- 示例代码(Java + Redis):
TypeScript复制
// 尝试获取锁
Boolean success = jedis.set(key, value, "NX", "EX", 10);
if (success) {
try {
// 获取锁成功,查询数据库
Object data = queryFromDB(key);
// 更新缓存
jedis.set(key, data);
} finally {
// 释放锁
jedis.del(key);
}
} else {
// 未获取到锁,等待一段时间后重试
Thread.sleep(100);
return getFromCache(key);
}
- 热点数据永不过期:
- 原理:对于一些访问频率极高、数据更新频率较低的热点数据,如商品分类信息、系统配置参数等,设置其缓存永不过期。但需要注意,虽然缓存数据不过期,我们仍需通过其他机制(如数据库变更监听、定时任务等)来保证缓存数据与数据库数据的一致性。
- 示例代码(Redis 命令):
TypeScript复制
# 设置热点数据,不设置过期时间
SET hot_key "hot_value"
- 提前续约缓存:
- 原理:在缓存 Key 快要过期时,提前开启一个异步线程或定时任务,重新查询数据库获取最新数据并更新缓存,让缓存 Key 的有效期得到延续,避免在高并发期间出现缓存过期的情况。
- 示例代码(Java + Quartz 定时任务):
TypeScript复制
// 配置Quartz定时任务,在缓存过期前30秒续约
JobDetail jobDetail = JobBuilder.newJob(RenewCacheJob.class)
.usingJobData("cacheKey", "hot_key")
.build();
SimpleTrigger trigger = TriggerBuilder.newTrigger()
.startAt(DateBuilder.futureDate(30, DateBuilder.IntervalUnit.SECOND))
.build();
scheduler.scheduleJob(jobDetail, trigger);
在上述代码中,RenewCacheJob 类负责从数据库查询最新数据并更新 Redis 缓存。
二、缓存穿透(Cache Penetration)
2.1 定义
缓存穿透是指查询的数据在缓存和数据库中均不存在,导致每次请求都绕过缓存直接访问数据库。若此类请求频繁发生,会给数据库带来极大压力,甚至可能导致数据库因不堪重负而崩溃。这种情况常见于恶意攻击场景,攻击者故意构造大量不存在的 Key 进行请求,也可能是由于业务代码逻辑漏洞,导致应用程序频繁查询不存在的数据。
2.2 案例分析
还是以电商平台为例,假设攻击者通过编写脚本,大量发送查询商品 ID 为负数(如 - 1、-2 等)的请求。由于正常业务中商品 ID 都是正整数,这些负数 ID 在缓存和数据库中都不存在。每次请求到达系统后,缓存未命中,接着去数据库查询,同样无法找到对应数据。随着这类无效请求数量不断增加,数据库的资源被大量消耗,最终可能导致数据库服务不可用,影响正常用户的业务操作。
图中可以看出,由于请求的数据在缓存和数据库中都不存在,请求不断地穿透缓存,持续对数据库发起无效查询。
2.4 解决方案
- 缓存空值:
- 原理:当数据库查询结果为空时,将空值(如 null 或特定占位符)存入 Redis 缓存,并设置一个较短的过期时间(如 5 分钟)。后续针对相同 Key 的请求,直接从缓存中获取到空值,避免再次查询数据库。
- 示例代码(Java + Redis):
-
Object data = queryFromDB(key); if (data == null) { // 缓存空值,设置过期时间5分钟 jedis.setex(key, 300, "null"); return null; } else { // 缓存有效数据 jedis.setex(key, 3600, data); return data; } - 布隆过滤器(Bloom Filter):
- 原理:布隆过滤器是一种基于哈希的数据结构,它可以高效地判断一个元素是否存在于一个集合中。在系统初始化时,将数据库中已有的数据 Key 添加到布隆过滤器中。当有请求到达时,先通过布隆过滤器判断该 Key 是否可能存在于数据库中。如果布隆过滤器判断 Key 不存在,那么可以直接返回,无需查询缓存和数据库;如果判断 Key 可能存在,再进行常规的缓存和数据库查询流程。需要注意的是,布隆过滤器存在一定的误判率,但在合理设置参数的情况下,误判率可以控制在很低的水平。
- 示例代码(Java + Guava 布隆过滤器):
-
// 创建布隆过滤器,预计元素数量1000000,误判率0.01 BloomFilter<CharSequence> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.forName("UTF-8")), 1000000, 0.01); // 假设从数据库中获取所有商品ID并添加到布隆过滤器 List<String> productIds = queryAllProductIdsFromDB(); for (String id : productIds) { bloomFilter.put(id); } // 处理请求时先通过布隆过滤器判断 if (!bloomFilter.mightContain(requestKey)) { return null; } - 参数校验:
- 原理:在请求进入业务逻辑层之前,对请求参数进行严格校验。例如,对于商品 ID,确保其为合法的正整数;对于用户名,检查其是否符合特定的格式要求等。通过这种方式,在源头拦截非法请求,避免无效请求穿透到缓存和数据库。
- 示例代码(Spring Boot + Hibernate Validator):
-
@RestController @RequestMapping("/products") public class ProductController { @GetMapping("/{id}") public ResponseEntity<Product> getProduct(@PathVariable @Valid @Min(1) Long id) { // 业务逻辑,查询商品 } }在上述代码中,@Min (1) 注解确保传入的商品 ID 是大于等于 1 的正整数,若不符合要求,Spring Boot 会自动返回错误信息,不会进入后续的缓存和数据库查询流程。
三、缓存雪崩(Cache Avalanche)
3.1 定义
缓存雪崩是指在某一时刻,大量缓存数据同时过期失效,导致大量请求瞬间涌向数据库,使数据库承受巨大压力,可能引发数据库性能急剧下降甚至宕机,进而影响整个系统的正常运行。这种情况通常是由于缓存数据的过期时间设置不合理,或者 Redis 服务器出现故障导致缓存数据全部丢失所引起的。
3.2 案例分析
比如电商平台举办限时抢购活动,为了提升性能,将参与活动的商品信息全部缓存到 Redis 中,并设置了相同的过期时间(假设为活动结束时间)。当活动结束的瞬间,所有这些商品的缓存同时过期,大量用户在活动结束后仍在查询商品信息,这些请求纷纷绕过缓存,直接查询数据库。由于请求量远远超出数据库的正常承载能力,数据库可能会因为资源耗尽而无法正常响应,导致整个平台出现卡顿、报错等问题,严重影响用户体验。
-
3.4 解决方案
- 设置随机过期时间:
- 原理:在设置缓存过期时间时,不使用固定的过期时长,而是在一个基础过期时间上加上一个随机值。例如,原本设置缓存过期时间为 1 小时,现在设置为 50 分钟到 70 分钟之间的随机值。这样可以避免大量缓存数据在同一时刻集中过期,使缓存过期时间分散开来,降低数据库瞬间承受高并发请求的风险。
- 示例代码(Redis 命令):
-
# 设置缓存,过期时间为60分钟左右的随机值(50 - 70分钟)
SET key value EX $(($RANDOM % 1200 + 3000))
- 缓存预热:
- 原理:在系统上线或重大活动开始前,提前将热点数据加载到 Redis 缓存中,并设置较长的过期时间。这样在业务高峰期来临时,大部分请求可以直接从缓存获取数据,避免因缓存未命中而大量查询数据库。缓存预热可以通过定时任务、数据初始化脚本等方式实现。
- 示例代码(Java 定时任务预热缓存):
-
// 配置定时任务,在系统启动后10分钟开始预热缓存 JobDetail jobDetail = JobBuilder.newJob(PreheatCacheJob.class) .startAt(DateBuilder.futureDate(10, DateBuilder.IntervalUnit.MINUTE)) .build(); SimpleTrigger trigger = TriggerBuilder.newTrigger() .build(); scheduler.scheduleJob(jobDetail, trigger);在 PreheatCacheJob 类中,实现从数据库查询热点数据并缓存到 Redis 的逻辑。
- 多级缓存架构:
- 原理:采用本地缓存(如 Guava Cache、EhCache)与 Redis 分布式缓存相结合的多级缓存架构。当请求到达时,先查询本地缓存,如果本地缓存命中则直接返回数据;若本地缓存未命中,再查询 Redis 缓存。若 Redis 缓存也未命中,才查询数据库,并将查询结果依次存入 Redis 缓存和本地缓存。通过这种方式,即使 Redis 缓存出现大量数据过期或故障,本地缓存仍能在一定程度上拦截部分请求,减轻数据库的压力。
- 示例代码(Java + Guava Cache + Redis):
-
// 创建Guava本地缓存 LoadingCache<String, Object> localCache = CacheBuilder.newBuilder() .maximumSize(1000) .expireAfterWrite(10, TimeUnit.MINUTES) .build(new CacheLoader<String, Object>() { @Override public Object load(String key) throws Exception { // 本地缓存未命中,查询Redis缓存 Object data = jedis.get(key); if (data!= null) { return data; } // Redis缓存也未命中,查询数据库 data = queryFromDB(key); if (data!= null) { // 将数据存入Redis缓存 jedis.setex(key, 3600, data); } return data; } }); // 获取数据,先从本地缓存查询 Object data = localCache.get(key); return data;
1272

被折叠的 条评论
为什么被折叠?



