查询缓存击穿解决方案
在现代分布式系统中,缓存技术是提高系统性能和降低数据库负载的重要手段。然而,当缓存中的数据过期或不可用时,可能会发生缓存击穿(Cache Breakdown)。缓存击穿会导致大量的请求直接访问数据库,从而给数据库带来较大的压力,甚至可能导致数据库崩溃。为了应对这一问题,我们需要采取一系列有效的缓存击穿解决方案。
1.什么是缓存击穿?
缓存击穿是指缓存中的某个数据在缓存过期或者被删除后,下一次请求在没有命中缓存的情况下,直接访问数据库,导致缓存失效或被清除的瞬间,数据库承受大量的并发请求。简单来说,缓存击穿是缓存不可用情况下,缓存请求集中访问数据库的现象。
示例:
- 假设某个用户的个人信息被缓存到 Redis 中,但缓存设置了过期时间。
- 如果在缓存过期时,突然有大量的请求同时访问该缓存,这时就会触发缓存击穿。
- 所有请求都会绕过缓存,直接访问数据库,可能会导致数据库压力过大,响应延迟甚至崩溃。
2. 缓存击穿解决方案
2.1 预热 + 不过期
解决方案: 预热缓存是指在缓存数据过期之前提前将数据加载到缓存中。这样,在下一次请求时,即使缓存中的数据已经过期,新的数据也已被加载到缓存中,避免了缓存击穿的发生。
具体做法:
- 设置合理的缓存失效时间。
- 在缓存即将过期时,提前通过后台任务刷新缓存。
- 可以使用定时任务或者定时触发器在后台定期刷新缓存数据。
2.2 分布式锁之双重判定锁(旁路缓存模式)
解决方案: 双重判定锁(Double-Checked Locking)是一种减少并发访问对数据库影响的解决方案。它通过引入分布式锁,确保在缓存失效时,只有一个线程去查询数据库并更新缓存,其它线程等待缓存更新完毕后再访问缓存。旁路缓存模式是指当缓存无法查询时,先进行数据库查询,再通过分布式锁进行缓存更新。
public String selectTrain(String id) {
// 查询缓存不存在,去数据库查询并放入到缓存
String cacheData = cache.get(id);
if (StrUtil.isBlank(cacheData)) {
// 为避免大量请求同时访问数据库,通过分布式锁减少数据库访问量
Lock lock = getLock(id);
lock.lock();
try {
// 获取锁后双重判定
cacheData = cache.get(id);
// 理论上只有第一个请求加载数据库是有效的,因为它加载后会把数据放到缓存
// 后面的请求再请求数据库加载缓存就没有必要了
if (StrUtil.isBlank(cacheData)) {
// 获取数据库中存在的数据
String dbData = trainMapper.selectId(id);
if (StrUtil.isNotBlank(dbData)) {
// 将查询到的数据放入缓存,下次查询就有数据了
cahce.set(id, dbData);
cacheData = dbData;
}
}
} finally {
lock.unlock();
}
}
return cacheData;
}
3.高并发极端情况
在高并发情况下,如果请求数过多,可能会导致缓存穿透和击穿问题的恶化。有一万个请求同一时间访问触发了缓存击穿,如果用双重判定锁,逻辑是这样的:
第一个请求加锁、查询缓存是否存在、查询数据库、放入缓存、解锁,假设我们用了50毫秒;
第二个请求拿到锁查询缓存、解锁用了1毫秒;
那最后一个请求需要等待10049毫秒后才能返回,用户等待时间过长,极端情况下可能会触发应用的内存溢出。
3.1 尝试获取锁 tryLock
通过这种方式我们可以快速失败,告诉用户网络异常请稍后再试,等用户再尝试刷新的时候,其实获取锁的线程已经把数据放到了缓存。
因为这种方案对用户操作体验不友好,所以也只是适用于部分场景。在实际开发中,需要灵活变更。
public String selectTrain(String id) {
// 查询缓存不存在,去数据库查询并放入到缓存
String cacheData = cache.get(id);
if (StrUtil.isBlank(cacheData)) {
// 为避免大量请求同时访问数据库,通过分布式锁减少数据库访问量
Lock lock = getLock(id);
// 尝试获取锁,获取失败直接返回用户请求,并提醒用户稍后再试
if (!lock.tryLock()) {
throw new RuntimeException("当前访问人数过多,请稍候再试...");
}
try {
// 获取数据库中存在的数据
String dbData = trainMapper.selectId(id);
if (StrUtil.isNotBlank(dbData)) {
// 将查询到的数据放入缓存,下次查询就有数据了
cahce.set(id, dbData);
cacheData = dbData;
}
} finally {
lock.unlock();
}
}
return cacheData;
}
3.2 分布式锁分片
在高并发的分布式系统中,单一的分布式锁可能存在性能瓶颈,特别是在高并发请求下,所有请求都集中争夺同一个锁,可能导致阻塞和性能下降。为了避免这种情况,分布式锁分片可以通过将锁分配到多个锁空间中,使得不同请求可以同时获得不同的锁,从而减少竞争,提高系统的吞吐量。
分布式锁分片的基本原理:
分布式锁分片的核心思想是将原本的全局锁拆分为多个局部锁,每个锁对应不同的资源或数据片段。通过这种方式,可以避免所有请求都竞争同一个锁,从而提高并发性能。通常可以根据业务的特点,将数据进行分片(如通过 hash
或 mod
操作),然后根据分片结果将请求分配到不同的锁上
3.2 分布式锁分片
在高并发的分布式系统中,单一的分布式锁可能存在性能瓶颈,特别是在高并发请求下,所有请求都集中争夺同一个锁,可能导致阻塞和性能下降。为了避免这种情况,分布式锁分片可以通过将锁分配到多个锁空间中,使得不同请求可以同时获得不同的锁,从而减少竞争,提高系统的吞吐量。
分布式锁分片的基本原理
分布式锁分片的核心思想是将原本的全局锁拆分为多个局部锁,每个锁对应不同的资源或数据片段。通过这种方式,可以避免所有请求都竞争同一个锁,从而提高并发性能。通常可以根据业务的特点,将数据进行分片(如通过 hash
或 mod
操作),然后根据分片结果将请求分配到不同的锁上。
解决方案
- 将请求按某些维度分片:例如,可以按数据的
userId
、productId
等维度进行分片。每个分片拥有一个独立的锁。 - 分配每个请求一个分片:每个请求根据分片维度,获取一个对应的分片锁。这样,不同的请求可能会获取到不同的锁,减少锁竞争。
- 使用 Redis 实现分片锁:Redis 提供了
setIfAbsent
等命令,非常适合用于实现分布式锁。通过为每个分片加锁,可以实现分布式锁分片的效果。