目录
引言
1. 缓存穿透
1.1 什么是缓存穿透?
示例:
1.2 缓存穿透的原因
1.3 缓存穿透的解决方案
1.3.1 缓存空对象
1.3.2 布隆过滤器(Bloom Filter)
1.3.3 参数校验
2. 缓存击穿
2.1 什么是缓存击穿?
示例:
2.2 缓存击穿的原因
2.3 缓存击穿的解决方案
2.3.1 互斥锁(Mutex Lock)
2.3.2 永不过期 + 后台更新
2.3.3 缓存预热
3. 缓存雪崩
3.1 什么是缓存雪崩?
示例:
3.2 缓存雪崩的原因
3.3 缓存雪崩的解决方案
3.3.1 设置随机过期时间
3.3.2 多级缓存
3.3.3 限流与降级
4. 缓存穿透、缓存击穿与缓存雪崩的区别
5. 最佳实践
6. 总结
引言
在使用 Redis 作为缓存系统时,缓存穿透、缓存击穿和缓存雪崩是三个常见的问题。它们不仅会影响系统的性能,还可能导致数据库压力过大甚至系统崩溃。本文将深入探讨这三种问题的定义、原因、解决方案以及最佳实践,并通过 Java 代码示例 帮助读者全面理解并有效应对这些问题。
1. 缓存穿透
1.1 什么是缓存穿透?
缓存穿透是指查询一个 不存在的数据,导致请求直接穿透缓存层,直接访问数据库。由于数据库中也不存在该数据,因此每次请求都会绕过缓存,直接访问数据库,从而导致数据库压力过大。
示例:
-
用户请求一个不存在的商品 ID,缓存中没有该数据,请求直接打到数据库。
-
恶意攻击者故意请求大量不存在的数据,导致数据库压力激增。
1.2 缓存穿透的原因
-
恶意攻击:攻击者故意请求大量不存在的数据。
-
业务逻辑问题:业务代码未对请求参数进行校验,导致非法请求直接访问数据库。
1.3 缓存穿透的解决方案
1.3.1 缓存空对象
当查询数据库发现数据不存在时,将空结果(如 null
)缓存到 Redis 中,并设置一个较短的过期时间。这样,后续相同的请求可以直接从缓存中获取空结果,避免直接访问数据库。
import redis.clients.jedis.Jedis;
public class CachePenetration {
private Jedis redis;
private Database db;
public CachePenetration(Jedis redis, Database db) {
this.redis = redis;
this.db = db;
}
public String getData(String key) {
// 从缓存中获取数据
String data = redis.get(key);
if (data != null) {
return "NULL".equals(data) ? null : data; // 返回空结果
}
// 从数据库中查询数据
data = db.query(key);
if (data == null) {
redis.setex(key, 300, "NULL"); // 缓存空对象,过期时间 300 秒
return null;
}
redis.setex(key, 3600, data); // 缓存真实数据,过期时间 1 小时
return data;
}
}
1.3.2 布隆过滤器(Bloom Filter)
布隆过滤器是一种概率型数据结构,用于快速判断一个元素是否存在于集合中。它可以有效过滤掉不存在的数据请求,避免缓存穿透。
-
优点:内存占用少,查询效率高。
-
缺点:存在一定的误判率(False Positive),但可以通过调整参数降低误判率。
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import redis.clients.jedis.Jedis;
public class CachePenetration {
private Jedis redis;
private Database db;
private BloomFilter<String> bloomFilter;
public CachePenetration(Jedis redis, Database db) {
this.redis = redis;
this.db = db;
this.bloomFilter = BloomFilter.create(Funnels.stringFunnel(), 1000000, 0.001);
}
public String getData(String key) {
// 使用布隆过滤器判断 key 是否存在
if (!bloomFilter.mightContain(key)) {
return null; // 如果 key 不在布隆过滤器中,直接返回
}
// 从缓存中获取数据
String data = redis.get(key);
if (data != null) {
return data;
}
// 从数据库中查询数据
data = db.query(key);
if (data == null) {
redis.setex(key, 300, "NULL"); // 缓存空对象
return null;
}
redis.setex(key, 3600, data); // 缓存真实数据
return data;
}
}
1.3.3 参数校验
在业务逻辑中对请求参数进行校验,过滤掉非法请求。例如,检查商品 ID 是否为正整数,或者是否符合某种格式。
public class CachePenetration {
private Jedis redis;
private Database db;
public CachePenetration(Jedis redis, Database db) {
this.redis = redis;
this.db = db;
}
private boolean validateKey(String key) {
try {
int id = Integer.parseInt(key);
return id > 0; // 检查 key 是否为正整数
} catch (NumberFormatException e) {
return false;
}
}
public String getData(String key) {
if (!validateKey(key)) {
return null; // 非法请求直接返回
}
// 其他逻辑...
return null;
}
}
2. 缓存击穿
2.1 什么是缓存击穿?
缓存击穿是指 某个热点数据在缓存中过期,同时有大量并发请求访问该数据,导致所有请求直接访问数据库,从而导致数据库压力激增。
示例:
-
某个热门商品的缓存过期,同时有大量用户请求该商品,导致数据库压力激增。
2.2 缓存击穿的原因
-
热点数据过期:某个热点数据的缓存过期。
-
高并发请求:大量并发请求同时访问该热点数据。
2.3 缓存击穿的解决方案
2.3.1 互斥锁(Mutex Lock)
在缓存失效时,使用互斥锁确保只有一个线程去加载数据,其他线程等待加载完成后再从缓存中获取数据。
import redis.clients.jedis.Jedis;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class CacheBreakdown {
private Jedis redis;
private Database db;
private Lock lock = new ReentrantLock();
public CacheBreakdown(Jedis redis, Database db) {
this.redis = redis;
this.db = db;
}
public String getData(String key) {
// 从缓存中获取数据
String data = redis.get(key);
if (data != null) {
return data;
}
// 尝试获取锁
if (lock.tryLock()) {
try {
// 从数据库中查询数据
data = db.query(key);
if (data != null) {
redis.setex(key, 3600, data); // 更新缓存
}
} finally {
lock.unlock(); // 释放锁
}
return data;
} else {
try {
Thread.sleep(100); // 等待其他线程加载数据
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return getData(key); // 重试
}
}
}
2.3.2 永不过期 + 后台更新
对于热点数据,可以设置缓存永不过期,并通过后台任务定期更新缓存。
import redis.clients.jedis.Jedis;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
public class CacheBreakdown {
private Jedis redis;
private Database db;
public CacheBreakdown(Jedis redis, Database db) {
this.redis = redis;
this.db = db;
// 启动后台任务定期更新缓存
ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(this::updateCache, 0, 1, TimeUnit.HOURS);
}
public String getData(String key) {
// 从缓存中获取数据
String data = redis.get(key);
if (data != null) {
return data;
}
// 从数据库中查询数据
data = db.query(key);
if (data != null) {
redis.set(key, data); // 缓存永不过期
}
return data;
}
private void updateCache() {
String hotData = db.queryHotData();
redis.set("hot_data", hotData); // 更新缓存
}
}
2.3.3 缓存预热
在系统启动或低峰期,提前加载热点数据到缓存中,避免缓存击穿。
import redis.clients.jedis.Jedis;
public class CacheBreakdown {
private Jedis redis;
private Database db;
public CacheBreakdown(Jedis redis, Database db) {
this.redis = redis;
this.db = db;
preheatCache();
}
private void preheatCache() {
String hotData = db.queryHotData();
redis.set("hot_data", hotData); // 缓存预热
}
}
3. 缓存雪崩
3.1 什么是缓存雪崩?
缓存雪崩是指 大量缓存数据在同一时间失效,导致大量请求直接访问数据库,从而导致数据库压力激增甚至崩溃。
示例:
-
缓存中的数据设置了相同的过期时间,导致大量数据在同一时间失效。
-
Redis 实例宕机,导致所有缓存失效。
3.2 缓存雪崩的原因
-
缓存集中失效:缓存中的数据设置了相同的过期时间。
-
Redis 实例宕机:Redis 服务不可用,导致所有缓存失效。
-
热点数据失效:某些热点数据的缓存失效,导致大量请求直接访问数据库。
3.3 缓存雪崩的解决方案
3.3.1 设置随机过期时间
为缓存数据设置随机的过期时间,避免大量缓存数据在同一时间失效。
import redis.clients.jedis.Jedis;
import java.util.Random;
public class CacheAvalanche {
private Jedis redis;
private Database db;
private Random random = new Random();
public CacheAvalanche(Jedis redis, Database db) {
this.redis = redis;
this.db = db;
}
public void setCache(String key, String value) {
int expireTime = 3600 + random.nextInt(600); // 过期时间在 1 小时到 1 小时 10 分钟之间
redis.setex(key, expireTime, value);
}
}
3.3.2 多级缓存
使用多级缓存架构(如本地缓存 + Redis 缓存),即使 Redis 缓存失效,本地缓存仍然可以提供服务。
import redis.clients.jedis.Jedis;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class CacheAvalanche {
private Jedis redis;
private Database db;
private Map<String, String> localCache = new ConcurrentHashMap<>();
public CacheAvalanche(Jedis redis, Database db) {
this.redis = redis;
this.db = db;
}
public String getData(String key) {
// 先从本地缓存获取
String data = localCache.get(key);
if (data != null) {
return data;
}
// 再从 Redis 缓存获取
data = redis.get(key);
if (data != null) {
localCache.put(key, data); // 更新本地缓存
return data;
}
// 最后从数据库获取
data = db.query(key);
if (data != null) {
redis.setex(key, 3600, data); // 更新 Redis 缓存
localCache.put(key, data); // 更新本地缓存
}
return data;
}
}
3.3.3 限流与降级
在缓存雪崩发生时,通过限流和降级机制保护数据库。例如,使用限流工具(如 Redis 的 INCR
命令)限制请求速率,或者返回默认值或错误页面。
import redis.clients.jedis.Jedis;
public class CacheAvalanche {
private Jedis redis;
private Database db;
public CacheAvalanche(Jedis redis, Database db) {
this.redis = redis;
this.db = db;
}
public String getData(String key) {
// 限流:每秒最多处理 100 个请求
if (redis.incr("request_rate") > 100) {
return "Too many requests, please try again later.";
}
// 其他逻辑...
return null;
}
}
4. 缓存穿透、缓存击穿与缓存雪崩的区别
特性 | 缓存穿透 | 缓存击穿 | 缓存雪崩 |
---|---|---|---|
定义 | 查询不存在的数据,导致请求直接访问数据库 | 热点数据缓存失效,导致大量请求直接访问数据库 | 大量缓存数据在同一时间失效,导致请求直接访问数据库 |
原因 | 恶意攻击或业务逻辑问题 | 热点数据过期或高并发请求 | 缓存集中失效或 Redis 实例宕机 |
影响 | 数据库压力过大 | 数据库压力激增 | 数据库压力激增甚至崩溃 |
解决方案 | 缓存空对象、布隆过滤器、参数校验 | 互斥锁、永不过期 + 后台更新、缓存预热 | 设置随机过期时间、多级缓存、限流与降级 |
5. 最佳实践
-
合理设置缓存过期时间:避免缓存集中失效。
-
使用布隆过滤器:有效防止缓存穿透。
-
多级缓存架构:提高系统的容错能力。
-
限流与降级机制:保护数据库不被压垮。
-
监控与报警:实时监控缓存命中率和数据库负载,及时发现并解决问题。
6. 总结
缓存穿透、缓存击穿和缓存雪崩是 Redis 使用过程中常见的问题,它们会导致数据库压力过大甚至系统崩溃。通过合理的设计和优化,可以有效避免这些问题:
-
缓存穿透:通过缓存空对象、布隆过滤器和参数校验来解决。
-
缓存击穿:通过互斥锁、永不过期 + 后台更新和缓存预热来解决。
-
缓存雪崩:通过设置随机过期时间、多级缓存和限流降级来解决。