1. 为什么用Redis作为MySQL的缓存?
使用Redis作缓存,主要是因为Redis具备「高性能」和「高并发」两种特性。
高性能
- 假设这么个场景,你有个操作,一个请求过来,吭哧吭哧你各种乱七八糟操作 MySQL,半天查出来一个结果,耗时 600ms。但是这个结果可能接下来几个小时都不会变了,或者变了也可以不用立即反馈给用户。那么此时咋办?
- 像MySQL这种关系型数据库,多表之间数据关系复杂,扩展性差,不便于大规模集群。
- 缓存啊,折腾 600ms 查出来的结果,扔缓存里,一个 Key 对应一个 Value,下次再有人查,别走 MySQL 折腾 600ms 了,直接从缓存里,通过一个 Key 查出来一个 Value,2ms 搞定。性能提升 300 倍。
- 操作Redis缓存就是直接操作内存,所以速度相当快。
- Redis这种非关系型数据库,不存储关系,仅存储数据。
- 就是说对于一些需要复杂操作耗时查出来的结果,且确定后面不怎么变化,但是有很多读请求,那么直接将查询出来的结果放在缓存中,后面直接读缓存就好。
高并发
- MySQL这么重的数据库,压根儿设计不是让你玩儿高并发的,虽然也可以玩儿,但是天然支持不好。MySQL 单机支撑到2000QPS(每秒查询率)也开始容易报警了。
- MySQL这种关系型数据库,数据是存储在硬盘/磁盘里面的,查询实现IO操作,磁盘IO导致性能低下。MySQl为了数据可靠,它会实时的去跟内存交互。
- Redis这种非关系型数据库,数据是基于内存进行存储的。
- 所以要是你有个系统,高峰期一秒钟过来的请求有 1 万,那一个 mysql 单机绝对会死掉。你这个时候就只能上缓存,把很多数据放缓存,别放 mysql。缓存功能简单,说白了就是Key-Value式操作,单机支撑的并发量轻松一秒几万十几万,支撑高并发 so easy。单机承载并发量是 MySQL单机的几十倍。
- Redis单机的QPS能轻松破10w,所以我们考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。
- QPS(Query Per Second,每秒钟处理完请求的次数)。
- 缓存是走内存的,内存天然就支撑高并发 => 读写性能较高。
缓存的作用:
- 降低后端负载
- 提高读写效率,降低响应时间
2. 用了缓存之后会有什么不良后果?
Redis经常被用作缓存,而缓存在使用的过程中存在很多问题需要解决,常见的缓存问题有以下几个:
- 数据的一致性成本:缓存与数据库双写不一致(缓存的数据一致性问题)
- 缓存血崩、缓存穿透、缓存击穿
- 缓存并发竞争
3. Redis做缓存
- Redis由于性能高效,通常可以做数据库存储的缓存,比如给MySQL当缓存就是常见的玩法,具体而言,就是将MySQL的热点数据存储在Redis当中,通常业务都满足二八原则,80%的流量在20%的热点数据之上,所以缓存可以很大程度提升系统的吞吐量。
缓存更新策略 / 缓存淘汰策略
- 缓存更新是Redis为了节约内存而设计出来的一个东西,主要是因为内存数据宝贵,当我们向Redis插入太多数据,此时就可能导致缓存中的数据过多,所以Redis会对部分数据进行更新,或者把它叫做淘汰更合适。
- 内存淘汰机制:Redis自动进行,当Redis内存达到自己设定的max-memory的时候,会自动触发内存淘汰机制,淘汰掉部分数据,下次查询时更新缓存(可以自己设置策略方式)
- 超时剔除:当我们给Redis设置了TTL过期时间之后,Redis会自动将超时的数据进行删除。
- 主动更新:自己手动调用方法把缓存给删除掉,主动完成数据库与缓存的同时更新,通常用于解决缓存和数据库不一致问题
业务场景:
- 低一致性需求:使用内存淘汰机制
- 高一致性需求:主动更新为主,并以超时剔除作为兜底方案
四种缓存的设计模式
数据库与缓存不一致解决方案
- 由于我们缓存的数据源来自于数据库,而数据库的数据是会发生变化的,因此,如果当数据库当中的数据发生变化,而缓存却没有同步,此时就会有一致性问题的存在,其后果是:用户使用缓存中的过时数据,就会产生类似多线程数据安全问题
解决方案如下,我们先看下目前企业用的最多的缓存模型。缓存的通用模型有三种:
- Cache-Aside Pattern:旁路缓存模式
- Read Through Cache Pattern:读穿透模式
- Write Througn Cache Pattern:写穿透模式
- Write Behind Caching Pattern:异步缓存写入模式
Cache-Aside Pattern:旁路缓存模式
旁路缓存模式是最常见的模式,应用服务把缓存当作数据库的旁路,直接和缓存进行交互,由缓存调用者自己维护数据库与缓存的一致性,即:
- 读操作流程 - 查询时:应用服务接收到查询请求以后,先查询数据是否在缓存上,如果在,即命中缓存则直接返回,未命中则查询数据库并写入缓存,除了查库后加载这种模式,如果业务有需要,还可以预加载数据到缓存。
- 写操作流程 - 更新时:Cache Aside模式一般是先更新数据库,然后直接删除缓存,下次查询时自然会更新缓存。
Cache Aside的写操作时要在更新数据库的同时删除缓存,为什么不选择更新数据库的同时直接更新缓存,而是删除缓存呢?
- 因此更新相比删除会更容易造成时序性问题。
- 假如一段时间内无人查询,但是有多次更新,那这些更新都属于无效更新,那这些更新都属于无效更新,采用删除方案也就是延迟更新,什么时候有人查询了,什么时候更新。
什么情况下会出现数据库和缓存不一致的问题?
- 因为数据库和缓存的操作是两步的,所以缓存的操作和数据库的操作是存在一定的时间差的,并且这两个操作是没有办法保证原子性的,也就是说,有可能一个操作成功,一个操作失败,所以这就必然会存在不一致的情况。
如何保证缓存与数据库的操作同时成功或失败呢?
单体系统:将缓存与数据库操作放在一个事务
分布式系统:利用TCC等分布式事务解决方案
那到底是先更新数据库再删除缓存,还是先删除缓存再更新数据库呢?
- 现在假设有两个线程,一个来更新数据,一个来查询数据。我们分别分析两种策略的表现。
我们先分析策略1,先删除缓存再更新数据库:
正常情况:
异常情况:
异常情况说明:
- 线程1删除缓存后,还没来得及更新数据库
- 此时线程2来查询,发现缓存未命中,于是查询数据库并更新写入缓存,但是由于此时数据库当中的数据尚未更新,因此线程2从数据库当中查询到的是旧数据,写缓存写入的自然也是旧数据,这就意味着刚才线程1删除缓存白删了,缓存又变成旧数据了
- 然后线程1更新数据库,此时数据库是新数据库,但缓存当中的数据依然是旧数据
由于更新数据库的操作本身是比较耗时的,在期间有线程来查询数据库并更新缓存的概率非常高。因此不推荐这种方案。
再来看策略2,先更新数据库再删除缓存:
正常情况:
异常情况:
异常情况说明:
线程1查询缓存未命中,于是去查询数据库,查询到旧数据
线程1将数据写入缓存之前,线程2来了,更新数据库,删除缓存
线程1执行写入缓存的操作,写入旧数据
可以发现,异常状态发生的概率极为苛刻,线程1必须是查询数据库已经完成,但是缓存尚未写入之前,线程2要完成更新数据库同时删除缓存的两个操作,要知道线程1执行写缓存的速度在毫秒之间,速度非常快,在这么短的时间要完成数据库和缓存的操作,概率非常之低。
为什么不直接更新缓存?
- 优先考虑删除缓存而不是更新缓存,因为删除缓存更加简单,而且带来的一致性问题也更少一些,更新缓存的动作,相比于直接删除缓存,操作过程比较的复杂,而且也容易出错。
面试题:如何保证缓存的双写一致性? 缓存与数据库双写不一致(缓存的数据一致性问题)如何保证缓存与数据库双写时的数据一致性?
- 为了保证Redis和数据库的数据一致性,肯定是要缓存和数据库双写了。
- 综上,添加缓存的目的是为了提高系统性能,而你要付出的代价就是缓存与数据库的强一致性。如果你要求缓存与数据库的强一致,那就需要加分布式锁避免并行读写,但这就降低了并发性能(因为并行执行变串行执行),与缓存的目标背道而驰。
- 缓存的双写一致性很难保证强一致,只能尽可能降低不一致的概率,确保最终一致,因此不管任何缓存同步方案最终的目的都是尽可能保证最终一致性,降低发生不一致的概率。我们项目中采用的是Cache Aside模式,Cache Aside Pattern是经典的缓存一致性处理模式,说白了就是"先写库,再删缓存",简单来说就是采用先更新数据库再删除缓存的方案,并且缓存的删除是可以在旁路异步执行的,在查询时先查询缓存,如果未命中则查询数据库并写入缓存,已经将这种不一致的概率降到足够低,目的已经达到了。
- 同时我们会给缓存加上过期时间或者设置过期时间,以过期时间兜底,一旦发生缓存不一致,如果真的出现了不一致的情况,也可以通过缓存过期来保证最终一致:当缓存过期后会重新加载,数据最终还是能保证一致。这就可以作为一个兜底方案。
3. 异步更新缓存
- 数据库的更新操作完成后不直接操作缓存,而是把这个操作命令封装成消息扔到消息队列中,然后由Redis自己去消费更新数据,消息队列可以保证数据操作顺序的一致性,确保缓存系统的数据正常。
追问:为什么不采用延迟双删机制?
- 延迟双删的第一次删除并没有实际意义,第二次采用延迟删除主要是解决数据库主从同步的延迟问题,我认为这是数据库主从的一致性问题,与缓存同步无关,既然DB当中的数据已经更新,Redis缓存当中的数据也理应更新,而且延迟双删会增加缓存业务复杂度,也没能完全避免缓存一致性问题,投入回报比太低。
- 延迟双删你第二次删除的时间是很难控制的,所以延迟双删它也是有Bug,有缺陷的。
延迟双删策略,即:
- Step 1:删除缓存
- Setp 2:更新DB数据库
- Step 3:最后延迟1~2s再次删除缓存
延迟双删中两次删除的原因是什么?
第一次删除的原因:
- 之所以要先删除缓存,而不是直接更新数据库,主要是因为如果先更新/写数据库成功了,但是删缓存失败了,那么就会导致数据的不一致;而如果先删缓存成功了,后更新数据库失败了,这种情况不会导致数据不一致问题,因此缓存删除了就删除了,又不是更新,不会有错误数据,自然也就没有不一致问题。
第二次删除的原因:
- 也就是说先删除缓存,再更新数据库,然后过个几秒再删一把缓存,避免因为并发出现脏数据,延迟双删的第二次删除是为了尝试解决因为读写并发导致的不一致问题。
Read / Write Through Cache Pattern:读 / 写穿透模式
数据库自己维护一份缓存,底层实现对调用者透明。
底层实现:
- 读操作流程 - 查询时:命中则直接返回,未命中则查询数据库并写入缓存
- 写操作流程 - 更新时:判断缓存是否存在,不存在直接更新数据库,存在则更新缓存,再同步更新数据库
Write Behind Caching Pattern:异步缓存写入模式
- 读写操作都直接操作缓存,由线程异步的将缓存数据同步到数据库。
- 异步写操作极大的降低了请求延迟,但同时也放大了数据的不一致,比如有人此时直接从数据库当中查询数据,但是更新的数据还未被写入数据库,此时查询到的数据就不是最新的数据。
4种模式怎么选择?
- 各有优势,但是Cache-Aside Pattern,旁路缓存模式是最常见、最易用的。
总结:
- 梳理缓存的四种使用范式,其中旁路缓存模式是在生产环境中应用最为广泛的。
Cache Aside适用于读多写少的场景,一旦写入缓存,几乎不会修改,目前企业中使用最多的就是Cache Aside模式,因为实现起来非常简单,但缺点也很明显,就是无法保证数据库与缓存的强一致性。
缓存血崩、缓存穿透、缓存击穿知识铺垫:
缓存雪崩(Cache Avalanche)
- 缓存雪崩是指大面积的缓存击穿(大量的缓存key同时失效)或缓存服务宕机不可用,导致大量请求达到数据库,给数据库带来巨大访问压力。
- 通常,我们会使用缓存用于缓冲对DB的冲击,如果缓存宕机,所有的请求将直接访问数据库,都直接打在数据库DB上,造成数据库DB高负载甚至数据库宕机,从而形成一系列连锁反映,导致整个系统宕机。
- 对于系统 A,假设每天高峰期每秒 5000 个请求,本来缓存在高峰期可以扛住每秒 4000 个请求,但是缓存机器意外发生了全盘宕机,缓存挂了,此时 1 秒 5000 个请求全部落数据库,数据库必然扛不住,它会报一下警,然后就挂了。此时,如果没有采用什么特别的方案来处理这个故障,DBA 很着急,重启数据库,但是数据库立马又被新的流量给打死了。
这就是缓存雪崩。
如何解决呢?
4种策略(同时使用):
对缓存做高可用(对缓存做分布式集群),避免缓存服务宕机,当一个缓存失效时可以从其它地方获取数据 => 可以去采用Redis的集群方案,比如Cluster集群或者说哨兵机制等架构提升可用性;
给业务添加多级缓存 => 设置二级缓存:二级缓存指的是除了Redis本身的缓存,再设置一层缓存 - 本地缓存,将数据同时缓存在多个地方,比如先查询本地缓存,本地缓存未命中再查询Redis,Redis未命中再查询数据库,即使Redis宕机,也还有本地缓存可以扛压力,在Redis缓存失效的时候先去查询本地缓存而非查询DB数据库。
给缓存业务添加降级限流策略:使用断路器,如果缓存宕机,为了防止系统全部宕机,做限流,限制流量,保证只有少量请求进入DB,其余的请求返回断路器的默认值,避免同一时刻大量请求打崩DB。
错开缓存数据的过期时间点,防止缓存大面积失效:给不同的Key的TTL添加随机值,这样Key的过期时间不同,不会大量Key同时过期
缓存穿透(Cache Penetration)
- 我们知道,当请求查询缓存未命中时,需要查询数据库以加载缓存。
思考:如果我访问一个数据库中也不存在使得数据,会出现什么现象?
- 由于数据库中不存在该数据,缓存就不可能生效,那么缓存中肯定也不存在,因此不管请求该数据多少次,缓存永远不可能建立,请求永远会直达数据库。
- 缓存穿透是我去请求DB跟Redis都没有的数据,但是每次请求都会打到DB,每次请求都访问数据库,可能就会导致数据库因访问压力过高而宕机!
- 关键信息:缓存和数据库都没有,还老去数据库查,增加数据库的访问压力!
- 对于系统 A,假设一秒 5000 个请求,结果其中 4000 个请求是黑客发出的恶意攻击。
- 黑客发出的那 4000 个攻击,缓存中查不到,每次你去数据库里查,也查不到。
- 举个栗子。数据库 id 是从 1 开始的,结果黑客发过来的请求 id 全部都是负数。这样的话,缓存中不会有,请求每次都“视缓存于无物”,直接查询数据库。这种恶意攻击场景的缓存穿透就会直接把数据库给打死。
解决方案:
1. 接口层增加参数校验,比如用户鉴权校验、请求参数校验等,对 id <= 0的请求直接拦截,一定不存在请求的数据不去查询数据库。
2. 缓存空值
- 当我们发现请求的数据既不存在于缓存,也不存在于数据库时,将空值缓存到Redis,避免频繁查询数据库,缺点 => 会带来额外的内存消耗,实现思路如下:
2. 布隆过滤器
- 很多时候,缓存穿透是因为有很多恶意流量的请求,这些请求可能随机生成很多在缓存和数据库中都没有的Key来请求查询,那就很容易导致缓存穿透,因此,对于缓存穿透,我们可以将查询的数据条件都哈希到一个足够大的布隆过滤器中,用户发送的请求会先被布隆过滤器拦截,使用布隆过滤器判断缓存中是否存在,如果一定不存在就直接拦截返回,直接拒绝请求,过滤掉无效请求,从而避免下一步对数据库的查询压力,如果存在则去查询数据库,尽管布隆过滤器存在误差,但一般都在0.01%左右,可以大大减少数据库压力;
- 布隆过滤器是一种比较巧妙的概率性数据结构,它可以告诉你数据一定不存在或可能存在,相比Map、Set、List等传统数据结构它占用的内存更少、结构更高效。
缓存击穿(Hotspot Invalid) - 热点Key问题
- 缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
- 由于我们采用的是Cache Aside模式,当缓存失效时需要下次查询时才会更新缓存。
- 缓存击穿,就是说某个 key 非常热点,访问非常频繁,处于集中式高并发访问的情况,当这个 key 在过期或失效的瞬间,大量的并发请求打过来,此时会导致缓存没有起到作用,就击穿了缓存,请求会穿透到数据库层,直接请求数据库,尝试重建缓存,从而导致数据库压力骤增,可能一瞬间就把数据库压垮了,严重影响系统的性能。
- 缓存击穿导致数据库被打崩本质上就是一个高并发架构的问题,不是Redis本身的问题。
解决方式也很简单:
- 可以给前端做限流:通过限制并发访问的请求数量,从而减轻数据库的访问压力;
- MySQL本身也会有高可用的方案:比如搭建MySQL集群;
- 另外MySQL自身也会有高可用的措施,比如设置最大连接数;
- 将热点数据的缓存设置为永远不过期:热点key不要设置过期时间,在活动结束后手动删除;
- 加互斥锁,基于 redis分布式锁{set nx语法} or zookeeper{临时顺序节点} 实现互斥锁,在缓存未命中时,可以使用互斥锁来保证只有一个请求能够访问数据库,多个请求同时只能有一个请求进入数据库层获取数据,可以避免大量请求同时访问数据库 ===> 在并发的多个请求中,只有第一个请求线程能拿到锁并执行数据库查询操作,其它的线程拿不到锁就阻塞等待,等到第一个线程将数据写入缓存后,直接走缓存,DCL - Double Check Lock(双重检查锁):第一重检查是为了性能,第二重检查是为了保证只有一次执行后续的逻辑!
4. 为啥Redis单线程模型也能效率这么高?
- 纯内存操作
- 核心是基于非阻塞的IO多路复用机制
- C语言实现,一般来说,C语言实现的程序"距离"操作系统更新,执行速度相对会更快
- 单线程反而避免了多线程的频繁上下文切换的问题,预防了多线程可能产生的竞争问题。
5. redis 的并发竞争问题是什么?如何解决这个问题?了解 redis 事务的 CAS 方案吗?
- 某个时刻,多个系统实例都去更新某个 key。
- 可以基于 zookeeper 实现分布式锁。每个系统通过 zookeeper 获取分布式锁,确保同一时间,只能有一个系统实例在操作某个 key,别人都不允许读和写。
你要写入缓存的数据,都是从 mysql 里查出来的,都得写入 mysql 中,写入 mysql 中的时候必须保存一个时间戳,从 mysql 查出来的时候,时间戳也查出来。文章来源:https://www.toymoban.com/news/detail-704018.html
每次要写之前,先判断一下当前这个 value 的时间戳是否比缓存里的 value 的时间戳要新。如果是的话,那么可以写,否则,就不能用旧的数据覆盖新的数据。文章来源地址https://www.toymoban.com/news/detail-704018.html
到了这里,关于缓存夺命连环问的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!