缓存穿透、击穿、雪崩以及预热问题
- 如何解决缓存穿透?
- 如何解决缓存击穿问题?
-
- 方案一:分布式锁
-
- 方案一改进成双重判定锁
- [高并发情况使用double check+ trylock解决](#高并发情况使用double check+ trylock解决)
- 方案二:缓存预热方案
- 方案三:热点数据永不过期
- 如何解决缓存雪崩?
-
- 对于大量缓存数据同时失效的解决办法
- [对于Redis 故障宕机的解决办法](#对于Redis 故障宕机的解决办法)
-
- 方案一:服务熔断或请求限流机制
- [方案二:构建 Redis 缓存高可靠集群](#方案二:构建 Redis 缓存高可靠集群)
如何解决缓存穿透?
缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
方案一:缓存空对象
当查询结果为空时,也将结果进行缓存 ,但是设置一个较短的过期时间。这样在接下来的一段时间内,如果再次请求相同的数据,就可以直接从缓存中获取,而不是再次访问数据库,可以一定程度上解决缓存穿透问题。
这种方式是比较简单的一种实现方案,会存在一些弊端。那就是当短时间内存在大量恶意请求 ,缓存系统会存在大量的内存占用 。如果要解决这种海量恶意请求带来的内存占用问题,需要搭配一套风控系统,对用户请求缓存不存在数据进行统计,进而封禁用户。整体设计就较为复杂,不推荐使用。
方案二:布隆过滤器
什么是布隆过滤器?
隆过滤器是一种数据结构,用于快速判断一个元素是否存在于一个集合中。具体来说,布隆过滤器包含一个位数组和一组哈希函数。位数组的初始值全部置为 0。在插入一个元素时,将该元素经过多个哈希函数映射到位数组上的多个位置,并将这些位置的值置为 1。
1字节(Byte)=8位(Bit)
在查询一个元素是否存在时,会将该元素经过多个哈希函数映射到位数组上的多个位置,如果所有位置的值都为 1,则认为元素存在;如果存在任一位置的值为 0,则认为元素不存在。
优缺点
优点:
- 高效地判断一个元素是否属于一个大规模集合。
- 节省内存。
缺点:
- 可能存在一定的误判。
方案三:接口限流
根据用户或者 IP 对接口进行限流,对于异常频繁的访问行为,还可以采取黑名单机制,例如将异常 IP 列入黑名单。
后面提到的缓存击穿和雪崩都可以配合接口限流来解决,毕竟这些问题的关键都是有很多请求落到了数据库上造成数据库压力过大。
如何解决缓存击穿问题?
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
方案一:分布式锁
因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大。
假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。
伪代码如下:
csharp
public String selectTrain(String id) {
String cacheData = cache.get(id);
// 查询缓存不存在,去数据库查询并放入到缓存
if (StrUtil.isBlank(cacheData)) {
// 为避免大量请求同时访问数据库,通过分布式锁减少数据库访问量
Lock lock = getLock(id);
lock.lock();
try {
// 获取数据库中存在的数据
String dbData = trainMapper.selectId(id);
if (StrUtil.isNotBlank(dbData)) {
// 将查询到的数据放入缓存,下次查询就有数据了
cahce.set(id, dbData);
cacheData = dbData;
}
} finally {
lock.unlock();
}
}
return cacheData;
}
这种方案有效地避免了缓存击穿问题,因为只有一个线程能够在同一时间内查询数据库 ,其他线程需要等待,不会同时穿透到后端存储系统。性能较低,不适合高并发场景
方案一改进成双重判定锁
上边还有一个问题就是,假如 100w 的请求读取一个缓存,100w 的请求全部卡在 lock.lock 获取分布式锁处,只有一个线程会执行逻辑请求数据库并放入缓存。
问题来了,剩下正在获取分布锁的请求,就是 100w 个请求减去一个获取到锁的请求,还是会继续请求数据库获取数据。大家读一下上面的伪代码就明白了。
这会造成两个实际的问题:
- 全部用户获取锁后查询数据库,会对数据库造成无用的性能浪费,因为这 100w 的请求,只有第一次是有效的。
- 查询数据库会造成用户响应时间变长,接口吞吐量下降。
双重判断:获取锁后,在查询数据库之前,再次检查一下缓存中是否存在数据。这是一个双重判断,如果缓存中存在数据,就直接返回;如果不存在,才继续执行查询数据库的操作。
伪代码如下:
csharp
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;
}
下面是这种场景下解决方案的一般步骤:
- 获取锁:在查询数据库前,首先尝试获取一个分布式锁。只有一个线程能够成功获取锁,其他线程需要等待。
- 查询数据库:如果双重判断确认数据确实不存在于缓存中,那么就执行查询数据库的操作,获取数据。
- 写入缓存:获取到数据后,将数据写入缓存,并设置一个合适的过期时间,以防止缓存永远不会被更新。
- 释放锁:最后,释放获取的锁,以便其他线程可以继续使用这个锁。
高并发情况使用double check+ trylock解决
有一万个请求同一时间访问触发了缓存击穿,如果用双重判定锁,逻辑是这样的:
- 第一个请求加锁、查询缓存是否存在、查询数据库、放入缓存、解锁,假设我们用了 50 毫秒;
- 第二个请求拿到锁查询缓存、解锁用了 1 毫秒;
- 那最后一个请求需要等待 10049 毫秒后才能返回,用户等待时间过长,极端情况下可能会触发应用的内存溢出。
像上面这种场景,类似于秒杀的架构,我们要做的就是不让用户请求在服务端阻塞过长时间。那就可以使用尝试获取锁 tryLock
API,它的语义是如果拿锁失败直接返回,而不是阻塞等待直到获取锁。
csharp
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;
}
方案二:缓存预热方案
什么是缓存预热?
缓存预热是指在应用程序启动或系统负载低峰期,提前将应用程序需要访问缓存的数据加载到缓存中,以便在实际的请求到来时能够快速响应。
缓存预热的目的是避免在实际请求到来时由于缓存冷启动而导致的延迟或性能下降。缓存冷启动是指在缓存中没有预先加载数据的情况下,第一次请求到达时需要从后端系统或数据库获取数据,并将其存储到缓存中。这个过程可能需要花费较长的时间,延迟了实际请求的响应时间。
如何进行缓存预热?
缓存预热有很多种方式,比如定时任务从数据库中查询进行预热等。我们这里在创建完短链接后就将短链接记录新增到缓存中。
csharp
@Transactional(rollbackFor = Exception.class)
@Override
public ShortLinkCreateRespDTO createShortLink(ShortLinkCreateReqDTO requestParam) {
verificationWhitelist(requestParam.getOriginUrl());
String shortLinkSuffix = generateSuffix(requestParam);
String fullShortUrl = StrBuilder.create(createShortLinkDefaultDomain)
.append("/")
.append(shortLinkSuffix)
.toString();
ShortLinkDO shortLinkDO = xxx;
ShortLinkGotoDO linkGotoDO = ShortLinkGotoDO.builder()
.fullShortUrl(fullShortUrl)
.gid(requestParam.getGid())
.build();
try {
baseMapper.insert(shortLinkDO);
shortLinkGotoMapper.insert(linkGotoDO);
} catch (DuplicateKeyException ex) {
if (!shortUriCreateCachePenetrationBloomFilter.contains(fullShortUrl)) {
shortUriCreateCachePenetrationBloomFilter.add(fullShortUrl);
}
throw new ServiceException(String.format("短链接:%s 生成重复", fullShortUrl));
}
// 将短链接新增到缓存中进行预热
stringRedisTemplate.opsForValue().set(
String.format(GOTO_SHORT_LINK_KEY, fullShortUrl),
requestParam.getOriginUrl(),
LinkUtil.getLinkCacheValidTime(requestParam.getValidDate()), TimeUnit.MILLISECONDS
);
shortUriCreateCachePenetrationBloomFilter.add(fullShortUrl);
return ShortLinkCreateRespDTO.builder()
.fullShortUrl("http://" + shortLinkDO.getFullShortUrl())
.originUrl(requestParam.getOriginUrl())
.gid(requestParam.getGid())
.build();
}
将缓存预热的话,有个小技巧,是否要设置缓存的过期时间?如果设置,那设置多少合适?
因为咱们短链接创建时是可以设置过期时间的,所以对于设置了过期时间的短链接,我们在缓存中也设置对应的时间即可。
那对于永久有效的短链接难道就不设置过期时间么?大家知道,短链接一般来说具有时效性,很多时候只会在一定时间内使用,过了这个时间后,用的人就很少了。所以,即使短链接永久有效,我们也得设置过期时间。不然,大量不使用的短链接放在缓存中,存储压力会比较大。
如果短链接设置的永久有效,我们默认一个月的过期时间。如果一个月后还有人访问,再去数据库加载数据,再设置一个月的过期时间即可。
方案三:热点数据永不过期
热点数据永不过期,指的就是可以预知的热点数据,在活动开始前,设置过期时间为 -1。这样的话,就不会有缓存击穿的风险。
这个可以搭配热点数据预加载一起完成。等对应热点缓存的活动结束后,这些数据访问量就比较低了,可以通过后台任务的方案对指定缓存设置过期时间,这样可以有效降低 Redis 存储压力。
如何解决缓存雪崩?
缓存雪崩是指在同一时段大量的缓存key同时失效 或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
对于大量缓存数据同时失效的解决办法
方案一:均匀设置过期时间
避免所有缓存在同一时间点失效,可以采用随机分布的方式设置缓存失效时间,或者使用带有随机偏移的失效时间。
通过以上几种方案组合使用,可以一定程度上减少缓存雪崩的可能性。
方案二:使用锁机制避免数据库频繁访问
当业务线程在处理用户请求时,如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存 (从数据库读取数据,再将数据更新到 Redis 里),当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
实现互斥锁的时候,最好设置超时时间,不然第一个请求拿到了锁,然后这个请求发生了某种意外而一直阻塞,一直不释放锁,这时其他请求也一直拿不到锁,整个系统就会出现无响应的现象。
方案三:后台更新缓存
对于一些热点数据,可以设置永不过期,以保证这部分数据始终在缓存中可用。同时,需要保障缓存设置的内存淘汰策略是不淘汰或者从带过期时间 Key 中去淘汰。
咱们以两种系统举例,分别是电商系统以及 12306 铁路购票系统:
电商系统:比如需要参加秒杀的商品数据,我们为了避免因设计过期时间自动过期或者不合适的 Redis 过期策略自动清楚,需要将参与秒杀的商品数据直接设置为永不过期。
12306:同上,如果在售票期间的列车数据缓存就不要设置过期时间了。
如果商品过了秒杀时间或者 12306 列车时间过了售票时间,这些数据岂不是会占用缓存空间么?
这种一般都会有定时任务在活动结束或过了售票周期后统一删除。
对于Redis 故障宕机的解决办法
方案一:服务熔断或请求限流机制
因为 Redis 故障宕机而导致缓存雪崩问题时,我们可以启动服务熔断 机制,暂停业务应用对缓存服务的访问,直接返回错误 ,不用再继续访问数据库,从而降低对数据库的访问压力,保证数据库系统的正常运行,然后等到 Redis 恢复正常后,再允许业务应用访问缓存服务。
为了减少对业务的影响,我们可以启用请求限流 机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务,等到 Redis 恢复正常并把缓存预热完后,再解除请求限流的机制。
方案二:构建 Redis 缓存高可靠集群
服务熔断或请求限流机制是缓存雪崩发生后的应对方案,我们最好通过主从节点的方式构建 Redis 缓存高可靠集群。
如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,避免了由于 Redis 故障宕机而导致的缓存雪崩问题。