前言
在日常开发中,为了提升系统的响应速度,我们通常首选缓存技术。然而,引入缓存的同时也会带来一系列不可避免的问题,如缓存一致性问题以及缓存失效后导致的击穿、穿透和雪崩现象。无论是在何种并发场景下,只要使用了缓存,就必然会面临缓存一致性的问题;而对于因缓存失效所导致的击穿、穿透和雪崩问题,不应简单地依赖数据库来硬抗这些请求。否则,你将不得不独自面对由此带来的系统压力,反思为何没有采取适当的措施来避免这些状况的发生。
(引流)[https://juejin.cn/post/7429520104213741609\]
一、缓存一致性问题
缓存一致性是什么?缓存一致性指的是数据库中的数据与缓存中的数据是否保持一致的状态。如果数据库中的数据与缓存中的数据不一致,就会产生缓存一致性问题。这种不一致可能会导致用户看到的数据与实际数据不符,从而影响系统的可靠性和用户体验。
关于缓存一致性这个问题,其实常见的有五种解决方案:
- 先更新数据库,后更新缓存
- 先更新缓存,后更新数据库
- 延迟双删
- 更新数据库,后删缓存
- Canal+MQ
先说结论,我们会更加推荐后两种实现,前三种都有各自一定缺陷点。
1.1、先更新数据库,后更新缓存
我们先从一个并发请求时序图来了解这种方案会导致的问题:
从图中我们可以知道,这种缓存更新只能在单线程执行时候保证一致性。那我们接下来再看看先更新缓存,后更新数据库又是怎样的吧。
1.2、先更新缓存,后更新数据库
这种其实跟上述带来的问题都是大差不差的,会有丢失风险。
1.3、延迟双删
通过一个时序图了解过程:
这个方案我们看:其实好像是没啥问题的,但是其实还是有一定问题的:
- 写数据库的请求,要休眠时间要多久?
- 读缓存请求,将在数据库之前查回的请求写入缓存,会有短暂时间数据不一致问题,是否可以接受?
1.4、先写数据库后删缓存
直接上图:
这样直接在更新完数据库后,直接删缓存,这种实现就不会出现缓存一致性问题了,同时保证的还是数据库与缓存间强一致性。这种唯一的缺陷则是:如果缓存中是一个热点数据,会造成很多的请求达到数据库中,我们可以采用DCL来解决,等下会在缓存击穿中讲到。
但是,由于这种方式实现简单,同时不需要依赖其他中间件实现,在一般的系统中,采用这种方式其实还是挺不错的。
1.5、binlog + MQ
在这个方案中,当一个请求对数据库进行了写操作之后,我们利用阿里的一款中间件 Canal 来监听数据库的 binlog(二进制日志)。Canal 将监听到的数据变化记录发送到消息队列中,再由 MQ 将这些数据变化同步至缓存系统中,以此来保持缓存与数据库之间的一致性。
为了确保数据同步过程中不会因写入失败而导致一致性问题,我们还配置了 MQ 的重试机制。这意味着,如果数据写入缓存时遇到问题,MQ 将会尝试重新发送数据,直到写入成功为止。这样的设计不仅提高了系统的可靠性,还增强了系统的容错能力,确保了缓存与数据库之间的最终一致性。
二、缓存击穿
缓存击穿是指当单个热点 key(即频繁访问的数据项)过期时,所有对该 key 的请求会直接打到数据库上,给数据库带来巨大的压力,甚至可能导致其崩溃。
这种现象可以形象地理解为单点爆破:在 key 失效的瞬间,大量的请求就像是一股洪流击穿了缓存,直接涌向数据库,仿佛在一道坚固的屏障上凿开了一个洞。
常用解决方案如下:
- 采用DCL(双重检查锁)更新缓存
- 热点数据,永不过期(设置一个逻辑过期时间)
- 多级缓存 + 数据预加载
2.1、DCL更新缓存
先来介绍一下DCL双重检查锁是什么?先来一个比较正式的解释:
DCL(Double-Checked Locking,双重检查锁)是一种常用于多线程环境下优化对象创建或访问的技术,尤其是在懒汉式单例模式中非常有名。它通过在获取锁之前进行一次粗略的检查,然后再在临界区内部进行精确检查,以此来减少不必要的同步开销。
其实我们直接看下面代码就直接能懂了:
- 第一次检查缓存中是否有
- 没有,加锁
- 加锁后,第二次检查缓存中有没有
- 有,直接返回(如果有,代表有相同的请求已经请求过了,无需重复查询数据库)
- 没有,查数据库
- 将查到的数据写入缓存
Java
public String getOneObject(){
String key = "key1";
// 第一次检查,判断缓存中有没有
String value = redisDatabase.get(key); //这里模拟的是Redis缓存的查询
if (value != null){
//有,则直接返回
return value;
}
// 没有,则加锁,然后再次检查
synchronized (this){
// 第二次检查,判断缓存中是否有
value = redisDatabase.get(key);
if (value != null){
//这里如果有的话,代表有相同的请求已经将数据写入缓存中了,避免了多次访问数据库带来的IO查询
return value;
}
//这里模拟的是数据库的查询
value = mySQLDatabase.get(key);
// 写入缓存(模拟缓存写入)
redisDatabase.set(key,value);
}
return value;
}
这种实现虽然有好的实现,但是对于在超高并发的场景下,会加锁阻塞部分线程执行,降低系统吞吐量。
如:10000个请求打向一个热点数据key,只能对一个请求线程加锁,其他的9999个请求都要加锁阻塞,对用户体验其实还是没有那么好。但是实现相对简单,易实现,其实没有特殊需求这种方式,我个人认为还是比较推荐使用的。
2.2、热点数据永不过期
热点数据,永不过期:
- 先对专门的数据进行提前预热到缓存中
- 针对热点key的过期时间设置为永不过期
伪代码实现如下:
Java
public Object getData(String key) {
CacheData cacheData = cache.get(key);
if (cacheData == null) {
// 缓存中没有数据,直接返回null或者抛出异常
return null;
}
if (cacheData.isExpired()) {
// 数据过期,更新缓存,延迟过期时间
updateCache(key);
}
return cacheData.getData();
}
但是,这种方案仍然是有缺陷的,我们无法判断哪些数据是热点热门的,无法针对性的对某些数据设置为永不过期。
2.3、多级缓存 + 热点探测 + 数据预加载
对于有条件的团队, 如果并发量比较高 ,多级缓存 + 热点探测 + 数据预加载才是比较有效的方案。
但实际上,这部分内容本来并不打算包含在本文中,因为其实现较为复杂。如果在此处详述,可能会导致篇幅过长,影响整体结构。不过,为了完整性,这里简要介绍一下基本思路,并推荐进一步阅读的相关资料。
多级缓存基本访问过程
图中展示了多级缓存的基本访问过程,但实际上,为了提高系统的性能和稳定性,还需要对热点数据进行探测并进行预加载。
热点探测与数据预加载:
除了多级缓存的实现外,还需要对热点数据进行探测和预加载。具体步骤包括:
- 热点数据探测 :
- 使用算法(如基于访问频率的统计)来识别哪些数据是最常被访问的。
- 根据业务需求,设置合理的探测周期和阈值。
- 数据预加载 :
- 在探测到热点数据后,提前将数据加载到缓存中,以减少首次访问时的延迟。
- 使用定时任务或事件驱动的方式进行预加载。
如果您对这部分内容感兴趣,可以查阅以下文章和视频:
- 文章 :
- 《深入浅出多级缓存 + 热点探测 + 数据预加载》
- 这篇文章详细介绍了多级缓存、热点探测和数据预加载的具体实现方法。
- 视频 :
- 《多级缓存 + 热点探测 + 数据预加载实战讲解》
- Redis6中解决多级缓存一致性问题
- 这个视频提供了详细的讲解和演示,有助于理解实际应用中的实现细节。
三、缓存穿透
缓存穿透是指当某个 key 在数据库中不存在数据时,缓存层(如 Redis)也无法提供有效的数据,导致每次请求都直接穿透缓存层去访问数据库。这种情况可以理解为请求直接绕过了缓存,到达了后端数据库。
在请求量较少的情况下,这种穿透现象的影响可能不大。然而,如果存在恶意用户利用这一漏洞进行 DDoS 攻击,将会导致大量的无效请求直接冲击数据库,从而使数据库承受极大的压力,严重时甚至可能导致数据库崩溃。
常用的解决方案有:
- Redis针对空值进行缓存
- 布隆过滤器
- 风控处理:做IP限流与黑名单,避免同一IP一瞬间发送大量请求
3.1、Redis空值缓存
先介绍一下请求发送的过程:
总的来说,针对数据库查询中返回空数据的情况,可以将这些空值缓存到 Redis 中。虽然这种方式实现简单,但会造成一定程度的数据不一致问题,例如:
- 查询时数据库中不存在数据,将空值缓存到 Redis 中 :
- 当查询时数据库中确实不存在对应的数据,此时将空值缓存到 Redis 中。
- 随后新增了一条数据,恰好是之前缓存的空值对应的 key :
- 如果之后数据库中新增了这条数据,但由于之前的空值仍存在于 Redis 中,导致客户端仍然获取不到实际存在的数据。
- 需等待 Redis 缓存过期后,才能访问到真实的数据 :
- 在这种情况下,客户端只能等待 Redis 中的空值缓存过期后,才能访问到数据库中新增的真实数据。
此外,在高并发场景下,这种方式还会占用 Redis 的一定存储空间。
3.2、布隆过滤器
如果不熟悉布隆过滤器,可以花几分钟了解一下:布隆过滤器简介。
采用布隆过滤器是一种预处理思想,通过对新增数据插入到布隆过滤器中,在查询时,通过布隆过滤器预先排除已知存在的数据。
然而,这种实现方式也存在一些问题:
- 数据删除后的误判:当数据库中的数据被删除时,布隆过滤器可能会误判,导致认为数据仍然存在。
- 误判率:布隆过滤器本身有一定的误判率,这会导致一些数据无法被正确排除。
为了解决这些问题,我们采用"再加一层"的实现------采用**"布隆过滤器 + Redis 缓存"的机制**:
通过结合这两种技术,可以在很大程度上减少误判带来的影响,并提高系统的整体性能和稳定性。
3.3、风控处理:IP限流与黑名单
在处理风控时,针对可能出现的频繁请求或企图恶意绕过缓存查询的行为,应当实施即时的流量限制措施。这类情形下,对相关账户采取限流措施以确保系统的稳定性和安全性是必要的。这一过程应当由专门设计的风控模块来执行,而不属于常规业务逻辑的一部分。
四、缓存雪崩
缓存雪崩指的是当缓存服务出现故障或是大量的缓存项同时过期时,原本由缓存承担的请求负担会突然涌向后端数据库,导致数据库不堪重负,进而可能引发系统崩溃的现象。
为了避免这种情况的发生,可以采取以下几种常见的解决方案:
-
提高缓存的可用性:
- 部署缓存集群,即使某个节点出现故障,其他节点仍然可以提供服务,确保系统的连续运行。
- 建立多级缓存体系结构,结合不同存储技术的特点,形成多层次的数据访问机制,增强缓存的鲁棒性。
-
优化缓存键的过期策略:
- 实施过期时间的随机化处理,防止所有缓存项在同一时段内过期,从而均匀分布请求的压力。
- 对于那些访问频率高的关键数据,考虑将其设置为永久有效或延长其有效期,以降低过期的可能性。
-
引入熔断与降级机制:
- 引入限流机制,合理控制单位时间内系统能接受的请求量,避免超出负荷。
- 设定熔断策略,在检测到异常请求模式时,临时中断某些服务,待系统恢复后再重新激活。
- 在必要的情况下,实行服务降级处理,提供简化版的服务响应,而不是完全停止服务,以保持基础功能的可用性。
-
实现数据库访问的序列化:
- 利用分布式锁来确保在同一时刻只有一个请求能够执行特定的操作,避免多个请求同时访问相同资源造成的问题。
- 使用消息队列(Message Queue, MQ)对请求进行排队处理,使后端数据库在高并发场景下也能按序接收并处理请求。
通过这些策略的应用,可以显著降低因缓存失效而给系统带来的冲击,提升系统的整体稳定性和用户体验。
五、总结
通过本文的学习,我们了解了多种关于缓存一致性和失效预防的策略。然而,每种方法都有其适用场景,在实际应用时应注意以下几点:
- 适用性评估:每种策略都有特定的应用环境。例如,多级缓存+热点探测+数据预加载适用于高并发场景,而对于小型项目,则更适合采用简单方案如设置热点数据永不过期。
- 技术选型:推荐的技术手段如 Canal+MQ 可能需要根据自身技术栈评估可行性。
- 实施成本:复杂的预防措施如多级缓存和热点探测会增加开发和维护成本,需权衡成本效益。
- 潜在副作用:某些策略如设置热点数据永不过期可能会带来新问题,如如何识别热点数据及如何清理不再热点的数据。
文章还提醒我们在设计系统时,应提前考虑可能的故障,构建包含预防措施的稳健架构,以应对突发状况,保障系统在高负载下的稳定运行。
如果本文对您有所帮助,请不要吝惜您的赞美,为我点个赞吧,您的认可是我前进的最大动力。