1.redis基础
今天我们来聊聊Redis缓存!
说起redis,首先想到的肯定是速度极快,因为redis是基于内存的数据库嘛!那么,redis除了速度快,还存在其他优势吗?要知道,基于内存的数据库可不止redis一家啊,但它为什么可以广受人们的青睐呢?来看看redis的优势:
1.redis存在多种数据结构,可供人们在不同的业务场景中使用
2.redis支持高可用、高并发和分布式
3.redis支持分布式锁、消息队列等技术
4.redis支持事务
所以说,在现在分布式泛滥的大环境下,如果不了解redis的相关技术,那么肯定得被时代淘汰了!
要掌握redis,首先从redis的数据结构开始说起吧:
key-value结构(String):这是redis最常用的数据结构,也是我们最熟悉的结构了吧,设置key和对应的value 底层数据结构采用的是SDS(简单动态数组),解决字符串的安全性以及动态扩容和压缩问题,应用场景一般为计数器等
list结构:底层数据结构为压缩列表和双向链表,当键值个数低于512以及保存的键值容量小于64kb时为压缩列表,否则为双向链表,这种数据结构使得redis可以实现简易的消息队列
hash结构:类似java中的HashMap结构,也是K-V结构,这个的V又是一个键值对,底层数据结构为散列表和压缩链表,一般用于存储对象
set结构:存储一组数据不重复的元素,底层实现为有序数组和散列表 ,应用场景一般用于求并集等
zset结构:set集合的有序版,使用一个权重分数实现有序。底层结构是散列表和跳表,应用场景如直播间的在线用户实时排行,礼物榜等有序情况
再额外补充一种数据结构——bitmap(位图):通常用来0,1来存储,可以用来表示状态。实现了压缩空间的作用,要知道1Mb = 1024KB,1KB = 1024B 1B = 8bit,一般可以用来判断用户登录状态,是否打卡等状态的表示,非常好用!
2.redis的线程模型
我们都知道redis是一个单线程模型,但是网上又有很多人说redis6.0之后出现了多线程,那么redis到底是单线程还是多线程模型呢?这里可以划分为两部分来讲解:多线程I/O读取部分、单线程执行部分。
多线程I/O读取:redis是基于Reactor模式的,采用IO多路复用技术用来监听来自客户端的大量线程读取请求,并将其按事件先后顺序放入任务队列,等待文件处理器处理。在6.0之后,redis新增了多线程删除大容量key、清除过期缓存等操作,大大提升了IO性能和读写效率
单线程执行命令:在处理过程,redis依然是使用单线程处理任务队列中的读取请求,所以,是线程安全的;有同学可能会问了——为什么redis不在执行命令时也是用多线程呢?这样不是可以更大程度上提高效率吗?
这个问题其实官方团队也是想过的,但是考虑到redis性能的瓶颈是在内存和网络IO上面,与CPU其实关系不大,使用多线程还会因为线程上下文切换造成额外的开销,甚至有可能造成负优化;而且,redis本身使用pipline处理命令就很快,实在没必要上多线程。
3.redis内存管理
补充:前提知识点——redis有一个过期字典,这个字典的键指向的是redis数据库中的key值,value是一个时间戳,即我们设置的过期时间,是一个Long型,通过这个过期字典,可以判断key是否过期;
假设一下,我们系统分配给redis的内存有2G,在一个频繁查询数据的系统中,大量数据可能没到几小时就把redis的内存给撑满了,这个时候,redis是不是为了自身的可靠性和安全性,试图去删除一些已经存在的缓存呢?那么,他又会去删除那些缓存呢?删除的策略又会有哪些呢?这就需要我们来具体了解一下——redis的内存管理了!
解答①当我们加载缓存时,一般会为存储进来的数据设置一个过期时间,在正常情况下,只要过期时间一到,这个数据就会被删除。删除过期数据会有两种方式;第一:惰性删除,在key取出来的时候对其做过期检查——CPU友好(这样可能存在大量key存在于内存中没被删除),第二:定期删除,redis定期抽取一部分key对其做过期检查,并且也会限制删除时间以减少CPU使用,提高用户体验;那么非正常情况呢?就比如我们说的,某一时刻突然加载一个大容量数据或者是redis本身就保留了很多过期key没有删除,造成redis内存不够用了,这时redis怎么处理呢?
解答②当redis内存不够用时,这时它会尝试删除一些数据,这里的删除其实可以换一个说法——淘汰!说到淘汰,那就得介绍几种淘汰策略了:
volatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)
allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。这个应该没人使用吧!
4.0 版本后增加以下两种:
volatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰
allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key
4.redis持久化
说完内存,接下来就得考虑一下redis是如何持久化保存数据了!
redis有两种方式持久化保存数据——RDB、AOF
RDB方式:这种方式简单来理解就是——做快照;根据设置好的定时或者手动去执行BSAVE命令,生成一个RDB文件,重启时使用会运行这个快照文件将数据恢复。具体来说,redis每隔一段时间会执行一次BSAVE或者SAVE命令去生成一个快照文件,执行BSAVE命令时,这是用户主线程会中断,去fork一个子进程,fork完成之后,主线程继续工作,由子进程去执行生成RDB文件的任务。(很容易想到,这种方式是不是存在数据缺失的问题?对的,当服务器在两次生成快照的时间间隔内宕机了,这是第一次快照之后的那些数据就会全部丢失!!!)
AOF方式:从第一种方式中我们可以看出,RDB方式存在时间间隔,造成了延时性。这是就有了AOF方式——对操作指令进行日志记录,在规定时间内进行刷盘操作,通过redis配置刷盘频率:从不/一秒一次/有数据修改就写入;重启时只需要读取这个日志文件即可恢复数据。AOF具备了数据实时性!
拓展:我们一般执行的AOF日志文件一般是经过redis“加工”处理过的——AOF重写;详细过程如下:
①后台启动一个线程将命令异步写入AOF文件
②redis会在fork一个子进程去复制AOF文件,形成新的AOF文件
③在子进程复制的过程中,因为主线程还在执行命令,所以这段时间时间内主线程执行的命令会放入缓存区中
④在fork完子进程后再将缓存区中的命令写入新的AOF文件,完成重写
在redis4.0之后,支持混合方式——AOF与RDB方式并存,具体使用那种持久化方式得看具体的业务场景
5.redis集群
做过项目的应该有所体会,一般我们需要使用redis的业务环境,基本不会是单机环境。业务决定技术,于是也就有了redis集群,严格来说,redis集群并不属于开发层面的问题,更多的是与运维有关。那么在redis集群环境下,如何保证我们的应用高可用呢?这就是接下来我们要讨论的问题了!下面我们介绍几种常见的集群架构
1.主从架构:应该是大家听得最多的架构了,master-slave模式;一个主服务器用于服务,其余从服务器做数据备份,毫无疑问,既然选择这种架构,必然牵扯到数据一致性的问题~那么redis是如何实现主从架构下的数据一致性的呢?
补充:redis数据同步有两种方式——完全重同步和部分重同步
详细步骤:主从服务器实现通信的过程中,会携带两个参数——RUNID(机器运行的id,用于身份校验)、offset(同步进度参数),首先,通信是根据RUNID进行必要的身份和权限认证,验证完成之后,如果是第一次复制,那么offer是没有的,表示当前采用完全重同步方式进行数据同步,当第二次再进行复制时,这时通信双方服务器会有offset参数,在进行复制时,会对offset参数进行校验以此来判断进度,根据二者之间的差值来选择性的同步数据,也叫部分重同步。
注意:这里会存在找不到offset参数被覆盖找不到的情况,原因是主服务器记录offset参数的方式是一个环形buffer,有可能会被覆盖。如果offset参数被覆盖,这时还是会采用完全重同步的方式。
数据一致性说完,再来考虑一个问题,当主服务器出现故障后,从服务器是如何切换成主服务器的呢?——高可用
答案是哨兵模式!!!
哨兵模式:先用通俗一点的话来讲,哨兵就是监督redis服务器情况的服务器,为了高可用,所以采用的也是集群模式。每一个哨兵会不间断的去ping主服务器,以此来判断主服务器是否宕机,发生故障。一但发现主服务器发生故障,这时就会哨兵们会先选出一个领头哨兵,由领头哨兵重新挑选一个新主服务器,然后通知其他服务器与新的主服务器建立主从关系,开启数据同步。(在这个过程中不可避免会造成部分的数据丢失)
简单说一下可能造成数据丢失的原因:
一,主服务器“假死”,由于网络原因导致哨兵一直无法检测到主服务器,当固定时间类无法与主服务器通信时,判断主服务器发生故障,但实际上主服务器还是在接收用户发送的数据,此时,当新的主服务器出现后,原来的主服务器就变成了从服务器,这期间用户发送给原主服务器的数据也就丢失了
二、主从服务器在进行数据同步时,突然宕机,这时数据还未同步完,新的主服务器数据不完整,也会造成数据丢失
2.分片集群
首先还是先介绍一下分片集群的背景,上次提高主从架构模型,一个主服务器负责读写操作,剩下的从服务器用于读操作和复制数据,当主服务器内存无法满足业务需求时,单纯依靠redis的内存管理是无法满足业务的,这时,就必须考虑纵向扩容了,当多个redis服务器存储数据,多个服务器合起来的数据即为系统全量数据——分片集群的由来。
分片集群一般存在两种模式:redis cluster模式以及客户端模式(codis)
1.redis cluster模式
过程:这个模式是基于客户端的,首先服务器会初始化16384个哈希槽位,然后根据算法对服务器个数取余,分配给每个服务器一部分哈希槽位,在后续数据复制或者数据迁移时都是槽位个数为单位进行的,所有服务器分配完这些槽位之后,随即问题就出现了——客户端进行读写操作的时候怎么知道去操作那一台服务器呢?换句话说,怎样才能顺利找到我们想要的数据呢?很简单,要想查找想要的数据,无非就是要知道数据存放在那个哈希槽上,那么必然得建立一个路由映射关系,使得我们可以顺利通过服务器——> 哈希槽。所以,在分配完哈希槽之后,所以实例之间会进行通信,告诉其他实例自己所负责的哈希槽,就这样,所有实例都会建立一张路由管理表,当客户端第一次发送请求时,服务器会通过路与表去找到对应的实例返回数据,后面客户端也会缓存一张路由关系表,后续请求就直接使用本地缓存的路由表进行查找了
数据迁移:为什么会产生数据迁移?不外乎就是redis实例故障(减少),或者新增redis实例(增加);不管是增加还是减少,都需要发生数据迁移,下面我们就来了解一下数据迁移的详细过程:
之前我们说过,在数据复制或者数据迁移时我们都是以槽位为单位来实现的,所以,大家可以想象一下,当发生数据迁移时,首先是不是得知道需要迁移那些哈希槽的数据呢?当确定这些哈希槽位之后,下一步就是重新将这些哈希槽分配给已有的实例了,然后上面所说的那张路由关系表就会发生变化,所有实例之间进行通讯更新这张表,当客户端发送数据请求时,如果数据已经迁移完成,会返回move命令告诉客户端应该想那台新的redis服务器发送请求了,同时更新本地的缓存表。如果数据还在迁移过程中,则会返回ask命令让客户端去找对应的redis实例。
2.服务端路由模式
相对于把路由信息放入每个实例以及客户端,服务端路由——顾名思义,采用的思想是在一个服务端维护路由关系,客户端发送请求时,先通过这个服务端,由这个代理确定应该讲请求发送到到那台redis实例。说白了,就是有一个中间商帮你维护路由关系表,要用的时候找这个中间商就行了!
一般用的是codis,具体实现如下,在启动时,会先将所有实例注册到zookeeper集群,这里需要注意的是,codis只会初始化1024个哈希槽分配给redis实例;客户端发送请求,codis proxy代理找到相关实例,将数据返回;
当需要扩容时(引入新的redis实例),codis支持两种数据迁移方式——同步和异步迁移;
同步迁移:①原实例将对应哈希槽上的数据发送给目标实例,②目标实例接收完数据之后向原实例发送ack命令,③原实例收到ack命令之后删除哈希槽数据。迁移完成!
异步迁移:在上面步骤二时,不用等目标实例发送ack命令,原实例会接收客户端请求,但是此时未迁移的数据会标记为只读,当客户端发送写请求时是不支持的,会让客户端选择重试,最后会写到目标实例上!
big key问题:数据中,不可避免会出现大容量数据问题,这时如果一次性的迁移这条数据耗时一定非常长,那么怎么办呢?在异步迁移时,采用了指令分割的策略,举个栗子,假设这个key有十万条命令,在发送时采用一条一条的发送指令,而不是将十万条数据先压缩打包再一次性发送
额外扩展——事务和分布式锁 (非主干知识点,后面说到分布式的时候回详细讲)
事务:之前将Mysql的时候,大家对这个词应该不陌生了,当一个事务发生时,事务内的操作要不全执行,要么全不执行;在redis中也是同样的道理,这里需要引入三个命令:multi,exec,discard,分别代表事务开启、事务执行和事务丢弃。在进行事务操作时,首先将命令写入队列,等到执行exec命令时把队列中的命令进行写操作,如果discard则清空队列。存在两种操作失败的情况——①命令入队列时错误,此时执行exec命令则全部命令执行无效;②命令exec时出现错误,此时只有错误命令执行失败,其他命令正常执行
分布式锁:这个词可以拆分成两部分,分布式+锁;什么是分布式?通俗点说,酒店做一个菜需要很多人分工协作,有人负责提供食材,有人负责处理食材,有人负责烹饪食材,有人负责摆盘,最后这道菜才出现在你面前;分布式就是这么个意思了,做一个订单服务,要用到商品服务、下单服务、短信服务等,而这些服务在不同的服务器上运行,为了保证一个事务的正常进行,我们需要对其加锁处理!锁——对共享资源进行互斥限制,保证同一时刻只有一个服务使用该资源,保证事务正确!实现方式一般有三种:数据库锁、redis分布式锁、zookeeper分布式锁(感兴趣的同学可以先去了解一下,后面我会专门写一篇关于分布式的文章,到时候详细讨论一下这几种锁!)
6.生产问题
在生产中,redis一般存在三个问题——缓存击穿、缓存穿透、缓存雪崩!
先有一个直观了解,这三个问题其本质都是缓存命中率问题——用户发起请求时,在缓存中未找到数据,从而直接访问数据,导致数据库负载过大!下面在来详细讨论一下这几个问题吧
缓存击穿:某些热点key承载了大量高并发请求,在某一时刻失效后,导致大量请求直接访问数据库
缓存穿透:用户发起的大量请求key都在缓存和数据库中都不存在
缓存雪崩:在同一时刻,缓存中大量key失效,缓存无法命中!
解决方案
对于击穿:我们可以对热点key进行延期——在key快过期的时候,我们可以开启一个线程延长这个key的过期时间
对于穿透:我们可以在缓存中这这些不存在的key设置一个null值,从而避免访问数据库,或者使用布隆过滤器判断key是否存在,先过滤一遍;
对于雪崩:和击穿的思路相似,将key的失效时间离散化,这样避免同一时间大量key全部失效,导致数据库压力过大
最后的最后——聊聊生产过程中如何保证缓存与数据库的数据一致性!
数据一致性,这个问题展开来讲可以说很多内容,比如mysql主从架构数据一致性,redis主从架构数据一致性,各种中间件数据一致性,而在这里,我所说的仅仅是redis缓存与数据库之间的数据一致性——两个核心问题:操作一致性和高并发
操作一致性:我们知道,在数据更新时通常使用redis无非就会两种操作,1.操作redis数据,更新数据库2.更新数据库,再操作redis,不论顺序如何,二者之间必然是要么都成功,要么都失败,操作上必须一致,否则必然导致数据不一致。(注意上面是操作两个字,后面我们会讲具体操作)
高并发:(这里我们以先更新数据库,再更新缓存为例)当存在多个事务时,事务A更新数据库,此时尚未操作缓存,这时事务B进来,读取缓存数据并返回,紧接着事务A操作缓存,不管A是如何操作缓存的,此时事务B读取的数据必然是脏数据,可见,高并发场景下,我们必然需要一种策略维护数据一致性。
那么,带着这两个问题,我们考虑该如何解决~
高并发问题:这里就可以讲讲上面说到的——操作redis;
我们考虑一下,在开发过程中,我们为了保证数据一致性,是不是会与两种考虑,第一种——更新redis;第二种——删除redis;
这两种方案,想想是都可以保证数据一致的,第一种在写操作的时候相对来说麻烦点,所以在读的时候就不需要操作redis;第二种采用删除redis,那么在读数据的时候,必然就需要我们把新数据写入缓存;后边这种方案其实就是我们说的——旁路缓存模式;究竟哪一种更好呢?
我们不妨从两个角度来思考一下:
从缓存命中率来说——通常,需要更新的数据大概率不属于热数据,更新完成之后可能查询并不频繁,造成缓存利用率不高!
从性能上面来说——更新缓存可能涉及到分布式锁,一旦涉及到锁,那么效率和性能肯定下降,而且更新操作相对删除来说更产生并发问题。要解决必然需要引入相关技术或者中间件,增加维护成本。
综上,删除redis——可能更好一些!
那么既然选择删除redis,又得进一步考虑两种操作了,实现更新数据库再删除redis,还是先删除redis再更新数据库呢?
从并发角度来解析:
1.先删除缓存,再更新数据库
线程A要更新数据(某字段信息Info = 1)—->线程A读取数据->线程A删除缓存->线程B在缓存中未读到数据,在DB中读取->线程B更新缓存数据(Info = 1)->线程A更新缓存数据(Info = 2);
此时缓存中Info = 1,而实际数据库中数据为2,数据不一致
2.先更新数据库,再删除缓存
缓存中不存在数据—->线程A读取数据(某字段信息Info = 1)->线程B在缓存中未读到数据,在DB中读取->线程B更新数据(Info = 2)->线程B删除缓存->线程A更新缓存数据(Info = 1);
同样,此时发生数据不一致!
但是,请大家仔细想想2中的情况,其实它发生的概率极低,必须满足三个条件:
1.缓存中不存在数据 2.同一条数据读和写并发 3.更新数据库+删除缓存的时间比读数据库+更新缓存的时间要短
尤其是最后一条,我们都知道,在更新数据库操作时会加排它锁,此时更新数据的时间必然要比读数据的时间长;
所以,最终选择采用——更新数据库+删除缓存;
说清楚高并发问题解决方案后,再来聊聊第一个问题——操作一致性;
要保证更新数据库+删除缓存的操作原子性,最核心的问题就是——失败重试;
不管是那一步操作,只要操作失败就进行重试,直到成功;这里大家应该都能Get到吧,再往下走我们就得讲讲重试的方式了!
1.消息队列
一般我们将操作数据库这一步放入消息队列中,然后操作redis删除缓存,这种重试策略比较常见,而且能够保证重试的可靠性,避免消息丢失!
2.订阅binlog日志推送
采用中间件订阅binlog日志变更记录,发生变更后由中间件推送到MQ中,以canal为例:
在这种方式中,我们并不需要关注redis,只需要更新数据库即可!由中间件执行后续步骤。
常见的三种数据一致性策略:
旁路缓存策略——读:读取缓存,若缓存中没有则查询数据库并更新缓存,返回数据;写——更新数据库,删除缓存;
读写穿透策略——读:在cache中读取,如果存在直接返回,如果不存在由cache去查找数据库并写入缓存;写:写入cache,由cache写入数据库;
异步读写策略——和读写穿透策略相似,只不过读写穿透是同步操作,而这个策略是异步操作。
好了,以上就是redis的全部内容了!
面试总结系列第五面——欢迎留言讨论,共同进步!*