缓存雪崩、穿透与击穿:原因与应对策略
缓存雪崩
缓存雪崩的定义与现象
在后端开发中,缓存雪崩是一种较为严重的缓存相关问题。当大量的缓存数据在同一时间过期失效,或者缓存服务器发生不可恢复的故障(如宕机)时,原本由缓存承担的大量请求会瞬间直接涌向数据库,导致数据库压力急剧增大,甚至可能使数据库因不堪重负而崩溃,最终造成整个系统的不可用。
想象一个电商系统,首页展示了热门商品的信息,这些商品信息都缓存在缓存中。假设缓存设置的过期时间都是 1 小时,当 1 小时后,所有这些商品的缓存同时过期,而此时又恰逢用户访问高峰期,大量用户同时请求访问首页,这些请求就会直接打到数据库,这就是典型的缓存雪崩现象。
缓存雪崩产生的原因
- 缓存过期时间集中:开发人员在设置缓存过期时间时,如果没有进行合理的分散,使得大量缓存数据在同一时间过期,就容易引发缓存雪崩。例如,在上述电商系统中,如果开发人员为了方便,将所有热门商品缓存的过期时间都设置为相同的 1 小时,就埋下了缓存雪崩的隐患。
- 缓存服务器故障:缓存服务器作为缓存数据的存储和管理核心,如果发生故障,如硬件故障、网络问题或者软件错误等,导致缓存服务不可用,那么所有依赖该缓存的请求都会转而请求数据库,同样会造成缓存雪崩。以 Redis 缓存服务器为例,如果 Redis 服务器所在的物理机器突然断电,重启后 Redis 服务无法正常启动,就会引发这种情况。
应对缓存雪崩的策略
- 分散缓存过期时间:在设置缓存过期时间时,避免将大量缓存数据的过期时间设置为相同值。可以采用在固定过期时间基础上,增加一个随机的时间偏移量的方式。例如,原本设置商品缓存过期时间为 1 小时,可以改为 55 分钟到 65 分钟之间的随机值。在 Java 代码中使用 Redis 缓存时,可以这样实现:
import redis.clients.jedis.Jedis;
import java.util.Random;
public class CacheUtil {
private static final Jedis jedis = new Jedis("localhost", 6379);
private static final Random random = new Random();
public static void setWithRandomExpiry(String key, String value, int baseExpiry) {
int randomExpiry = baseExpiry + random.nextInt(10) * 60; // 增加 0 到 10 分钟的随机偏移
jedis.setex(key, randomExpiry, value);
}
public static String get(String key) {
return jedis.get(key);
}
}
在上述代码中,setWithRandomExpiry
方法用于设置带有随机过期时间的缓存数据,baseExpiry
为基础过期时间(单位为秒),通过 random.nextInt(10) * 60
生成一个 0 到 600 秒(即 0 到 10 分钟)的随机偏移量,与基础过期时间相加作为最终的过期时间。
- 使用多级缓存:构建多级缓存架构,如本地缓存(如 Guava Cache)和分布式缓存(如 Redis)相结合。当请求到达时,首先查询本地缓存,如果本地缓存中没有命中,则查询分布式缓存。如果分布式缓存也没有命中,再查询数据库,并将结果依次更新到分布式缓存和本地缓存中。这样,即使分布式缓存发生故障,本地缓存仍然可以承担一部分请求,减轻数据库的压力。以下是一个简单的使用 Guava Cache 和 Redis 实现多级缓存的 Java 示例:
import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import redis.clients.jedis.Jedis;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
public class MultiLevelCache {
private static final Jedis jedis = new Jedis("localhost", 6379);
private static final LoadingCache<String, String> localCache = CacheBuilder.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(new CacheLoader<String, String>() {
@Override
public String load(String key) throws Exception {
return jedis.get(key);
}
});
public static String get(String key) {
try {
return localCache.get(key);
} catch (ExecutionException e) {
String value = jedis.get(key);
if (value != null) {
localCache.put(key, value);
}
return value;
}
}
public static void set(String key, String value, int expiry) {
jedis.setex(key, expiry, value);
localCache.put(key, value);
}
}
在这个示例中,localCache
是 Guava Cache 构建的本地缓存,设置了最大容量为 1000 个元素,并且在写入 10 分钟后过期。get
方法首先尝试从本地缓存获取数据,如果获取失败则从 Redis 中获取,并将结果更新到本地缓存。set
方法则同时更新 Redis 和本地缓存。
- 缓存预热:在系统上线前或者在缓存数据大量过期前,提前将部分热点数据加载到缓存中,避免在系统运行过程中由于大量缓存失效而导致请求直接打到数据库。可以通过定时任务或者启动脚本的方式来实现缓存预热。例如,在 Spring Boot 应用中,可以使用
@Scheduled
注解来实现定时缓存预热:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.List;
@Component
public class CachePreheating {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Scheduled(cron = "0 0 2 * * *") // 每天凌晨 2 点执行
public void preheatCache() {
// 假设从数据库获取热点数据的方法
List<Object> hotData = getHotDataFromDatabase();
for (Object data : hotData) {
// 假设数据有 id 作为 key
String key = "cache:" + data.hashCode();
redisTemplate.opsForValue().set(key, data, 1, TimeUnit.HOURS);
}
}
private List<Object> getHotDataFromDatabase() {
// 实际实现从数据库查询热点数据
return null;
}
}
在上述代码中,CachePreheating
类使用 @Scheduled
注解定义了一个定时任务,每天凌晨 2 点执行 preheatCache
方法,该方法从数据库获取热点数据并加载到 Redis 缓存中,设置过期时间为 1 小时。
- 启用缓存降级:当缓存服务出现问题或者数据库压力过大时,通过缓存降级策略返回兜底数据,避免大量请求直接打到数据库。例如,在电商系统中,当缓存雪崩发生时,可以返回一个静态的商品列表页面,提示用户系统繁忙,稍后重试。可以通过熔断器(如 Hystrix)来实现缓存降级。以下是一个简单的使用 Hystrix 实现缓存降级的 Java 示例:
import com.netflix.hystrix.HystrixCommand;
import com.netflix.hystrix.HystrixCommandGroupKey;
import redis.clients.jedis.Jedis;
public class CacheFallbackCommand extends HystrixCommand<String> {
private static final Jedis jedis = new Jedis("localhost", 6379);
private final String key;
public CacheFallbackCommand(String key) {
super(HystrixCommandGroupKey.Factory.asKey("CacheGroup"));
this.key = key;
}
@Override
protected String run() throws Exception {
return jedis.get(key);
}
@Override
protected String getFallback() {
// 返回兜底数据
return "fallback data";
}
}
在这个示例中,CacheFallbackCommand
继承自 HystrixCommand
,run
方法尝试从 Redis 中获取数据,如果执行过程中出现异常(如 Redis 故障),则会执行 getFallback
方法返回兜底数据。
缓存穿透
缓存穿透的定义与现象
缓存穿透是指客户端请求的数据在缓存和数据库中都不存在,导致请求每次都绕过缓存直接查询数据库。正常情况下,缓存可以拦截大部分请求,只有在缓存未命中时才会查询数据库,并将查询结果存入缓存。但缓存穿透时,由于数据在数据库中也不存在,所以每次请求都会打到数据库,这不仅浪费了数据库资源,还可能导致数据库被大量无效请求压垮。
比如在一个用户信息查询系统中,恶意用户不断请求查询一个不存在的用户 ID,由于该用户 ID 在缓存和数据库中都不存在,每次请求都会直接访问数据库,这就是缓存穿透现象。
缓存穿透产生的原因
- 恶意攻击:恶意用户故意构造大量不存在的数据请求,意图使系统性能下降。例如,在上述用户信息查询系统中,恶意用户通过脚本不断发送查询不存在用户 ID 的请求,以达到破坏系统的目的。
- 业务逻辑漏洞:在一些业务场景中,如果没有对查询参数进行严格的合法性校验,可能会导致无效数据进入查询流程。比如,在商品查询功能中,如果没有对商品 ID 进行范围检查,用户可能输入一个超出合理范围的商品 ID,导致查询在缓存和数据库中都无法命中。
应对缓存穿透的策略
- 参数校验:在接收客户端请求时,对请求参数进行严格的合法性校验。例如,对于用户 ID,校验其是否为合法的数值范围;对于商品 ID,校验其是否在已有的商品 ID 范围内。在 Java 代码中,可以使用正则表达式或者自定义校验规则来实现参数校验。以下是一个简单的使用正则表达式校验邮箱格式的示例:
import java.util.regex.Pattern;
public class ParameterValidator {
private static final Pattern EMAIL_PATTERN = Pattern.compile(
"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,6}$"
);
public static boolean isValidEmail(String email) {
return EMAIL_PATTERN.matcher(email).matches();
}
}
在实际应用中,可以将这种参数校验应用到用户登录、注册等涉及数据查询的接口中,避免无效数据进入缓存和数据库查询流程。
- 布隆过滤器:布隆过滤器是一种高效的概率型数据结构,用于判断一个元素是否在一个集合中。它的原理是通过多个哈希函数将元素映射到一个位数组中,对应位置设为 1。当查询一个元素时,通过相同的哈希函数计算其在位数组中的位置,如果对应位置不全为 1,则该元素一定不存在;如果全为 1,则该元素可能存在。在处理缓存穿透问题时,可以将数据库中已有的数据主键(如用户 ID、商品 ID 等)预先添加到布隆过滤器中。当有请求到来时,先通过布隆过滤器判断数据是否存在,如果不存在,则直接返回,不再查询数据库。以下是一个使用 Google Guava 库实现的布隆过滤器示例:
import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;
import java.nio.charset.StandardCharsets;
public class BloomFilterUtil {
private static final BloomFilter<String> bloomFilter = BloomFilter.create(
Funnels.stringFunnel(StandardCharsets.UTF_8), 10000, 0.01
);
public static void add(String key) {
bloomFilter.put(key);
}
public static boolean mightContain(String key) {
return bloomFilter.mightContain(key);
}
}
在上述代码中,BloomFilterUtil
类创建了一个布隆过滤器,预计元素数量为 10000,误判率为 0.01。add
方法用于将元素添加到布隆过滤器中,mightContain
方法用于判断元素是否可能存在于布隆过滤器中。在实际应用中,可以在系统启动时,将数据库中的所有主键数据添加到布隆过滤器中,然后在处理请求时,先调用 mightContain
方法进行判断。
- 缓存空值:当查询数据库发现数据不存在时,也将该查询结果(空值)缓存起来,并设置一个较短的过期时间。这样,后续相同的请求就会命中缓存中的空值,而不会再次查询数据库。例如,在使用 Redis 缓存时,可以这样实现:
import redis.clients.jedis.Jedis;
public class CacheNullValue {
private static final Jedis jedis = new Jedis("localhost", 6379);
public static String get(String key) {
String value = jedis.get(key);
if (value == null) {
// 从数据库查询
value = getFromDatabase(key);
if (value == null) {
// 缓存空值,设置较短过期时间
jedis.setex(key, 60, "");
return "";
} else {
jedis.setex(key, 3600, value);
return value;
}
}
return value;
}
private static String getFromDatabase(String key) {
// 实际从数据库查询逻辑
return null;
}
}
在上述代码中,get
方法首先从 Redis 缓存中获取数据,如果缓存未命中,则从数据库查询。如果数据库中也不存在该数据,则将空字符串缓存起来,设置过期时间为 60 秒,避免后续相同请求再次查询数据库。
缓存击穿
缓存击穿的定义与现象
缓存击穿是指在高并发场景下,一个热点数据的缓存过期瞬间,大量请求同时访问该数据,由于缓存已过期,这些请求会同时绕过缓存直接查询数据库,导致数据库瞬间承受巨大压力。与缓存雪崩不同的是,缓存击穿针对的是单个热点数据,而缓存雪崩是大量缓存数据同时过期。
以电商系统中的限时抢购商品为例,该商品在抢购期间是热点数据,缓存设置了过期时间。当缓存过期的那一刻,大量用户同时请求抢购该商品,这些请求就会直接打到数据库,这就是缓存击穿现象。
缓存击穿产生的原因
- 热点数据过期:热点数据在高并发场景下被频繁访问,一旦其缓存过期,大量请求就会瞬间涌向数据库。例如,在直播带货场景中,主播推荐的商品成为热点,缓存中的商品库存等信息过期后,大量观众同时下单请求查询库存,就会引发缓存击穿。
应对缓存击穿的策略
- 使用互斥锁:在缓存过期时,通过互斥锁(如 Redis 的 SETNX 命令)保证只有一个请求能查询数据库并更新缓存,其他请求等待。当第一个请求更新完缓存后,其他请求就可以从缓存中获取数据。以下是一个使用 Redis 实现互斥锁应对缓存击穿的 Java 示例:
import redis.clients.jedis.Jedis;
public class MutexLockCache {
private static final Jedis jedis = new Jedis("localhost", 6379);
public static String get(String key) {
String value = jedis.get(key);
if (value == null) {
String lockKey = "lock:" + key;
String lockValue = System.currentTimeMillis() + 10000 + "";
if ("OK".equals(jedis.set(lockKey, lockValue, "NX", "EX", 10))) {
try {
value = getFromDatabase(key);
if (value != null) {
jedis.setex(key, 3600, value);
}
} finally {
jedis.del(lockKey);
}
} else {
// 等待一段时间后重试
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
return get(key);
}
}
return value;
}
private static String getFromDatabase(String key) {
// 实际从数据库查询逻辑
return null;
}
}
在上述代码中,当缓存未命中时,首先尝试获取互斥锁(lockKey
),如果获取成功,则查询数据库并更新缓存,最后释放锁。如果获取锁失败,则等待 100 毫秒后重试获取数据。
- 设置热点数据永不过期:对于一些热点数据,可以设置其缓存永不过期。但这样可能会导致数据一致性问题,因为数据库中的数据可能会更新,而缓存数据不会自动过期更新。为了解决这个问题,可以采用定期更新缓存数据的方式,或者在数据发生变化时主动更新缓存。例如,在商品库存数据场景中,可以设置库存缓存永不过期,同时使用定时任务每 5 分钟从数据库获取最新的库存数据更新缓存:
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class HotDataCache {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Scheduled(cron = "0 0/5 * * * *") // 每 5 分钟执行一次
public void updateHotDataCache() {
// 假设从数据库获取热点数据的方法
Object hotData = getHotDataFromDatabase();
if (hotData != null) {
redisTemplate.opsForValue().set("hotDataKey", hotData);
}
}
private Object getHotDataFromDatabase() {
// 实际实现从数据库查询热点数据
return null;
}
}
在上述代码中,HotDataCache
类使用 @Scheduled
注解定义了一个定时任务,每 5 分钟执行一次 updateHotDataCache
方法,从数据库获取热点数据并更新到 Redis 缓存中。这样既保证了热点数据在缓存中不会过期导致缓存击穿,又能在一定程度上保证数据的一致性。
通过对缓存雪崩、穿透与击穿的深入分析以及相应应对策略的介绍和代码示例,希望开发人员在后端开发中能够更好地设计和管理缓存,提高系统的稳定性和性能。在实际应用中,需要根据具体的业务场景和系统架构选择合适的策略,并且可以多种策略结合使用,以达到最佳的缓存效果。