【Redis我可以讲一个小时】

数据存储篇

Redis


各数据类型应用场景

  • 工作中有很多场景经常用到redis, 比如在使用String类型的时候,字符串的长度不能超过512M,可以set存储单个值,也可以把对象转成json字符串存储;还有我们经常说到的分布式锁,就是通过setnx实现的,返回结果是1就说明获取锁成功,返回0就是获取锁失败,这个值已经被设置过。又或者是网站访问次数,需要有一个计数器统计访问次数,就可以通过incr实现。

  • 除了字符串类型,还有hash类型,它比string类型操作消耗内存和cpu更小,更节约空间。像我之前做过的电商项目里面,购物车实现场景可以通过hset添加商品,hlen获取商品总数,hdel删除商品,hgetall获取购物车所有商品。另外如果缓存对象的话,修改多个字段就不需要像String类型那样,取出值进行类型转换,然后设值进行类型转换,把它转成字符串缓存进行了。

  • 还有列表list这种类型,是简单的字符串列表,按照插入顺序排序,可以添加一个元素到列表的头部或者尾部,它的底层实际上是个链表结构。这种类型更多的是用在文章发布上面,类似微博消息和微信公众号文章,在我之前的项目里面也有用到,比如说我关注了二个媒体,这二个媒体先后发了新闻,我就可以看到先发新闻那家媒体的文章,它可以通过lpush+rpop队列这种数据结构实现先进先出,当然也可以通过lpush+lpop实现栈这种数据结构来到达先进后出的功能。

  • 然后就是集合set,底层是字典实现的,查找元素特别快,另外set 数据类型不允许重复,利用这两个特性我们可以进行全局去重,比如在用户注册模块,判断用户名是否注册。可以通过sadd、smembers等命令实现微信抽奖小程序,微信微博点赞,收藏,标签功能。还可以利用交集、并集、差集的特性实现微博微信的关注模型,交集和并集很好理解,差集可以解释一下,就是用第一个集合减去其他集合的并集,剩下的元素,就是差集。举个微博关注模型的例子,我关注了张三和李四,张三关注了李四和王五,李四关注了我和王五。
    在这里插入图片描述我进入了张三的主页
    查看共同关注的人(李四),取出我关注的人和张三关注的人,二个集合取交集得出结果是李四,就是通过SINTER交集实现的。
    查看我可能认识的人(王五),取出我关注的人和张三关注的人,二个集合取并集得出结果是(张三,李四,王五),拿我关注的人(张三,李四)减去并集里的元素,剩下的王五就是我可能认识的人,可以通过并集和差集实现。
    查看我关注的人也关注了他(王五),取出我关注的人他们关注的人,(李四,王五)(我,王五)的交集,就是王五。

  • 最后就是有序集合zset,有序的集合,可以做范围查找,比如说排行榜,展示当日排行前十。

各数据类型实现原理

Redis的数据类型底层实现原理,Redis的键值对,有两个对象,一个是键对象,一个是值对象,键总是一个字符串对象,值对象都是由redisObject结构来表示。
redisObject对象内部的

  • type属性表示数据类型,可以通过type key命令来判断对象类型、

  • encoding属性表示编码、

  • ptr属性指向数据存储的位置,是个指针变量,存放地址、

  • refcount属性表示引用计数,C语言不具备自动回收内存功能,Redis自己构建了一个引用计数的内存回收机制,除此之外refcount属性还被用到共享内存中,共享内存就是二个不同的键有相同的值,键的指针指向一个有值的对象,被共享的值对象引用refcount属性加1,判断两个对象是否相等,需要消耗运算的额外的时间所以内存共享只适用于整数值的字符串、

  • lru属性记录最后一次被程序访问的时间,通过当前时间减去键值对象lru记录的时间,最后可以计算出最少空闲时间,最少空闲时间的数据是最有可能被访问到,这些数据会被保存,而那些不可能被使用的数据就会根据淘汰策略进行淘汰,在Redis配置文件里有三个配置,最大内存配置,触发数据后的淘汰策略,随机采样的精度,当有条件符合配置文件中三个配置的时候,继续往Redis中加key时,会触发执行 lru 策略,进行内存清除,lru算法根据数据的历史访问记录进行数据淘汰,淘汰最近最少使用的key,它的运行原理是数据插入到链表头部,当缓存数据被访问之后,数据会移到链表头,链表满的时候,链表尾部的数据会被丢弃。redis对过期key的删除策略有两种:惰性删除:每次获取键的时候都检查键是否过期,过期就删除键;没过期就返回键。定期删除:每隔一段时间,进行一次检查,删除里面的过期键。定期删除漏掉了很多过期 key,同时这些过期key也就没走惰性删除,大量过期 key 堆积在内存里,当内存不足,不能新写入数据的时候,就会走内存淘汰机制,内存淘汰机制有五种,第一种是新写入操作会报错,第二种是随机移除某个 key,第三种是在设置了过期时间的键里面,选择移除最近最少使用的 key,第四种是在设置了过期时间的键里面,随机移除某个 key,第五种是在设置了过期时间的键里面,把有更早过期时间的 key 优先移除。推荐使用第五种方式进行内存淘汰。

各数据类型的编码和数据结构

  • 字符串对象的编码,可以是int,raw或者embstr。int编码是用来保存整数值,当int编码保存的值不再是整数,或大小超过了long的范围时,自动转化为raw。raw编码是用来保存长字符串,它会分配两次内存空间,分别为redisObject和sds分配空间。embstr是用来保存短字符串,它只分配一次内存空间,redisObject和sds是连续的,如果字符串的长度增加需要重新分配内存时,不再使用embstr编码,会转为raw编码。SDS是Redis专门为了处理字符串创建的数据结构,len记录了字符串的长度,通过len 属性检查内存空间是不是需要进行扩展内存,如果字符串长度增加,长度超过了len,就会增加相应的内存,接着修改。如果字符串长度缩短了,它也不会立马就重新分配内存,而是有一个free属性记录下来,等你后面什么时候用了,重新计算或者分配内存。C语言是以空字符串结尾标识的,而SDS是以len长度作为结尾标识的,避免了C语言无法正确读取字符串的问题。

  • list列表的编码,3.2之前最开始的时候是用ziplist压缩列表,当列表保存元素个数超过512个,每个元素长度超过64字节就会切换编码,改用linkedlist双端链表,ziplist会有级联更新的情况,时间复杂度高,除此之外链表需要维护额外的前后节点,占用内存,所以元素个数到达一定数量就不能再用ziplist了。而新版本的Redis对列表的数据结构进行了改造,使用quicklist代替了原有的数据几个,quicklist是ziplist和linkedlist的混合体,它让每段ziplist连接起来,对ziplist进行LZF算法压缩,默认每个ziplist长度8KB。ziplist压缩列表是由一些连续的内存块组成的,有顺序的存储结构,是一种专门节约内存而开发的顺序型数据结构。在物理内存固定不变的情况下,随着内存慢慢增加会出现内存不够用的情况,这种情况可以通过调整配置文件中的二个参数,让list类型的对象尽可能的用压缩列表编码,从而达到节约内存的效果,但是也要均衡一下编码和解码对性能的影响,如果有一个几十万的列表长度进行列表压缩的话,在查询和插入的时候,进行编解码会对性能造成特别大的损耗。如果有不可避免的长列表的存储的话,需要在代码层面配合降低redis存储的内存,在存储redis的key的时候,在保证唯一性和可读性的时候,尽量简化redis的key,可以比较直接的节约redis空间的一个作用,还有就是对长列表进行拆分,比如说有一万条数据,压缩列表的保存元素的个数配置的是2048,我们就可以将一万条数据拆分成五个列表进行缓存,将它的元素个数控制在压缩列表配置的2048以内,当然这么做需要对列表的key进行一定的控制,当要进行查询的时候,可以精准的查询到key存储的数据。这是对元素个数的一个控制,元素的长度也类似,将每个大的元素,拆分成小的元素,保证不超过配置文件里面每个元素大小,符合压缩列表的条件就可以了,核心目标就是保证这二个参数在压缩列表以内,不让它转成双端列表,并且在编解码的过程中,性能也能得到均衡,达到节约内存的目的。除了上面的优化可以进行内存优化以外,还可以看我们缓存的数据,是不是可以打包成二进制位和字节进行存储,比如用户的位置信息,以上海市黄浦区举例说明,可以把上海市,黄浦区弄到我们的数组或者list里面,然后只需要存储上海市的一个索引0和黄浦区的一个索引1,直接将01存储到redis里面即可,当我们从缓存拿出这个01信息去数组或者list里面取到真正的一个消息。

  • 哈希对象的编码,一开始也是压缩列表,当列表保存元素个数超过512个,每个元素长度超过64字节就会切换编码,改用hashtable,hashtable编码的哈希表对象,底层使用字典这种数据结构,这种数据结构是Redis自己创造的,字典的底层又是通过哈希表实现的,而哈希表又基于数组,类似于key-value的结构形式进行存储的,它的值通过哈希函数映射到数组的下标的。

  • 集合对象set的编码,集合对象 set 是 string 类型的无序集合,整数也会转换成string类型进行存储,集合中的元素是无序的,不能通过索引来操作元素,元素也不能有重复。当集合对象中所有元素都是整数并且所有元素数量不超过512个的时候,会使用intset编码,不满足这二个条件的时候才会使用hashtable,intset编码的集合对象使用整数集合作为底层实现。

  • 有序集合的编码,有序集合为每个元素设置一个分数作为排序依据。当保存的元素数量小于128、保存的所有元素长度都小于64字节的时候,使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,第二个节点保存元素的分值。并且压缩列表内的集合元素按分值从小到大的顺序进行排列,小的放置在靠近表头的位置,大的放置在靠近表尾的位置。当不满足这二个条件的时候,skiplist编码,skiplist编码的有序集合对象使用zet 结构作为底层实现,一个 zset 结构同时包含一个字典和一个跳跃表,字典的键保存元素的值,字典的值则保存元素的分值;跳跃表由zskiplistNode和skiplist两个结构,跳跃表skiplist中的object属性保存元素的成员,score 属性保存元素的分值。这两种数据结构会通过指针来共享相同元素的成员和分值,所以不会产生重复成员和分值,造成内存的浪费。假如我们单独使用字典,虽然能直接通过字典的值查找成员的分值,但是因为字典是以无序的方式来保存集合元素,所以每次进行范围操作的时候都要进行排序;假如我们单独使用跳跃表来实现,虽然能执行范围操作,但是查找操作就会变慢,所以Redis使用了两种数据结构来共同实现有序集合。除了这二个属性之外,还有层属性,跳跃表基于有序链表的,在链表上建索引,每两个结点提取一个结点到上一级,我们把抽出来的那一级叫作索引,每个跳跃表节点的层高都是1至32之间的随机数。比如有一个有序链表,节点值依次是1->3->4->5。取出所有值为奇数的节点作为索引,这个时候要插入一个值是2的新节点,就不需要将节点一个个比较,只要比较1,3,5,确定了值在1和3之间,就可以快速插入,加一层索引之后,查找一个结点需要遍历的结点个数减少了,虽然增加了50%的额外空间,但是查找效率提高了。当大量的新节点通过逐层比较,最终插入到原链表之后,上层的索引节点会慢慢的不够用,由于跳跃表的删除和添加节点是无法预测的,不能保证索引绝对分步均匀,所以通过抛硬币法:随机决定新节点是否选拔,每向上提拔一层的几率是50%,让大体趋于均匀。

持久化

  • RDB持久化可以通过配置与手动执行命令生成一个二进制的RDB文件, 比如说设置让 Redis 满足“ 60 秒内有至少有 1000 个键被改动”进行持久化。redis 主进程fork一个子进程,让子进程执行磁盘 IO 操作来进行 RDB持久化,对外提供的读写服务,影响非常小。
  • 开启AOF持久化, 每当 Redis 执行一个添加或者删除命令的时候, 这个命令就会被追加到 AOF 文件的末尾,程序可以通过执行 AOF 文件中的命令来达到重建数据集的目的,但是AOF文件里可能有太多没用指令,所以AOF会定期重新生成aof文件去除无用的命令。
  • 当开启混合持久化时,fork出的子进程先将共享的内存副本,全量的以RDB方式写入aof文件,然后重写缓冲区的增量命令以AOF方式写入到文件,等到重写完新的AOF文件才会覆盖原有的AOF文件,完成新旧两个AOF文件的替换。

主从架构下的数据同步

主从复制/数据同步

master会启动一个后台线程,开始生成一份RDB快照文件,同时还会将从客户端收到的所有写命令缓存在内存中。RDB文件生成完毕之后,master会将这个RDB发送给slave,slave会先写入本地磁盘,然后再从本地磁盘加载到内存中。然后master会将内存中缓存的写命令发送给slave,slave也会同步这些数据。

主从架构下的数据部分复制(断点续传)

当redis是主从架构时,主节点同步数据到从节点进行持久化,这个过程可能会因为网络/IO等原因,导致连接中断,当主节点和从节点断开重连后,一般都会对整份数据进行复制,这个过程是比较浪费性能的。从redis2.8版本开始,redis改用可以支持部分数据复制的命令去主节点同步数据,主节点会在内存中创建一个复制数据用的缓存队列,缓存最近一段时间的数据,主节点和它所有的从节点都维护复制的数据下标和主节点的进程id,当网络连接断开后,从节点会请求主节点继续进行数据同步,从记录数据的下标开始同步数据。如果主节点进程id变化了,或者从节点数据下标太旧,不在主节点的缓存队列里,会进行一次全量数据的复制。

数据丢失发生的场景以及解决方案
  • 异步复制导致的数据丢失:主节点到从节点的复制是异步的,主节点有部分数据还没复制到从节点,主节点就宕机了。
  • 脑裂导致的数据丢失:脑裂导致的数据丢失:某个 主节点 所在机器突然脱离了正常的网络,跟其他从节点机器不能连接,但是实际上 主节点还运行着,这个时候哨兵可能就会认为 主节点 宕机了,然后开启选举,将其他从节点切换成了 主节点,集群里就会有两个主节点 ,也就是所谓的脑裂。虽然某个从节点被切换成了 主节点,但是可能 client 还没来得及切换到新的主节点,还继续向旧的主节点写数据,当旧的主节点再次恢复的时候,会被作为一个从节点挂到新的 主节点上去,自己的数据会清空,从新的主节点复制数据,新的主节点并没有后来 client写入的数据,这部分数据也就丢失了。

解决方案:

  • 针对异步复制导致的数据丢失,可以通过控制复制数据的时长和ack的时间来控制,一旦从节点复制数据和 ack 延时太长,就认为可能主节点宕机后损失的数据太多了,那么就拒绝写请求,这样可以把主节点宕机时由于部分数据未同步到从节点导致的数据丢失降低的可控范围内。
  • 针对脑裂导致的数据丢失:如果一个主节点出现了脑裂,跟其他从节点断了连接,如果不能继续给从节点发送数据,而且从节点超过10 秒没有给自己ack消息,那么就直接拒绝客户端的写请求,这样即便在脑裂场景下,最多就丢失10 秒的数据。在redis的配置文件里面有二个参数,min-slaves-to-write 3表示连接到master的最少slave数量,min-slaves-max-lag 10表示slave连接到master的最大延迟时间,通过这二个参数可以把数据丢失控制在承受范围以内。

主从/哨兵/集群区别

主从架构

主数据库可以进行读写操作,当写操作导致数据变化的时候,会自动将数据同步给从数据库,从数据库一般是只读的,接受主数据库同步过来的数据。

哨兵

当主数据库遇到异常中断服务后,需要通过手动的方式选择一个从数据库来升格为主数据库,让系统能够继续提供服务,难以实现自动化。 Redis 2.8中提供了哨兵工具来实现自动化的系统监控和故障恢复功能,哨兵的作用就是监控redis主、从数据库是否正常运行,主数据库出现故障,自动将从数据库转换为主数据库。

集群

即使使用哨兵,redis每个实例也是全量存储,每个redis存储的内容都是完整的数据,浪费内存,有木桶效应。为了最大化利用内存,可以采用集群,就是分布式存储,每台redis存储不同的内容,Redis集群共有16384个槽,每个redis分得一些槽,客户端请求的key,根据公式,计算出映射到哪个分片上。

高可用/哨兵集群/主备切换

Redis哨兵集群实现高可用,哨兵是一个分布式系统,可以在一个架构中运行多个哨兵进程,这些进程使用流言协议来接收关于主节点是否下线的信息,并使用投票协议来决定是否进行自动故障迁移,选择哪个备节点作为新的主节点。每个哨兵会向其它哨兵、主节点、备节点定时发送消息,以确认对方是否”活”着,如果发现对方在指定时间内未回应,则暂时认为对方已挂。若“哨兵群”中的多数哨兵,都报告某一主节点没响应,系统才认为该主节点"彻底死亡",通过算法,从剩下的备节点中,选一台提升为主节点,然后自动修改相关配置,比如主节点名称,IP,端口号,选举次数,主服务器的密码,心跳检测毫秒数,做多少个节点等。

缓存雪崩

假设同一接口同一时间有一万次请求,先走缓存后走数据库,正常情况是没啥问题的,如果缓存宕机了,或者缓存设置了相同的过期时间,导致缓存在同一时刻同时失效,请求会全部落到数据库上,数据库立马就死掉了,数据库抗不住,如果重启数据库,立马又会被新的请求打死了,这就是缓存雪崩。
解决方案:
事前:redis高可用,主从+哨兵,redis cluster,避免全盘崩溃
事中:本地缓存 + hystrix限流&降级,避免MySQL被打死
事后:redis持久化RDB+AOF,快速恢复缓存数据,缓存的失效时间设置为随机值,避免同时失效

缓存穿透

缓存不起效果,直接查数据库。举个例子,用户id为正数,黑客构造的用户id为负数,如果黑客每秒一直发送一万个请求,缓存查询不到用户id,就去数据库里查询,高并发请求下数据库很快就被打死。
解决方案:
使用布隆过滤器,快速判断key是否在数据库中存在,不存在直接返回。布隆过滤器可以判断某个数据一定不存在,但是无法判断一定存在。布隆过滤器可以看成是一个二进制数组,里面存放的不是0,就是1,但是初始默认值都是0。向布隆过滤器中添加一个数据,数组是从0开始计数的,当要向布隆过滤器中添加一个元素key时,通过多个hash函数,算出一个值,然后将这个值所在的方格改为1,多个不同的数据通过hash函数算出来的结果是会有重复的,所以会存在某个位置是别的数据通过hash函数置为的1,但是存在一个不是1的情况,这个新数据一定不存在于这个布隆过滤器中。

缓存击穿

热点数据承载着高并发,当热点数据的key过期,从MySQL加载数据放到缓存需要一段时间,大量的请求会直接走数据库,把数据库打死。
解决方案:

  • 设置key永远不过期
  • 快过期的时候通过另一个异步线程重新设置key
  • 当从缓存拿到的数据为null,重新从数据库加载数据的过程中上分布式锁。

Redis与数据库的数据一致性

先更新数据库,再更新缓存

举个例子
(1)线程A更新了数据库;
(2)线程B更新了数据库;
(3)线程B更新了缓存;
(4)线程A更新了缓存;
请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存,出现脏数据。

先删除缓存,再更新数据库

举个例子
(1)请求A进行写操作,删除缓存;
(2)请求B查询发现缓存不存在;
(3)请求B去数据库查询得到旧值;
(4)请求B将旧值写入缓存;
(5)请求A将新值写入数据库;
缓存和数据库的数据不一致了,如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。

延时双删策略

举个例子
MySQL的读写分离架构中
(1)请求A进行写操作,删除缓存;
(2)请求A将数据写入数据库了;
(3)请求B查询缓存发现,缓存没有值;
(4)请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值;
(5)请求B将旧值写入缓存;
(6)数据库完成主从同步,从库变为新值;
(7)休眠1秒,评估自己的项目的读数据业务逻辑的耗时加上主从同步的延时时间然后再加几百ms即可
(8)删除缓存
采用这种同步淘汰策略,因为延迟了时间,线程需要休眠,吞吐量会降低,并且第二次删除可能失败。

解决方案也有加上将第二次删除作为异步的,自己起一个线程,异步删除,删除失败就多删除几次,引入删除缓存重试机制,把删除失败的key放到消息队列,消费消息队列的消息,获取要删除的key,重试删除缓存操作,这种方式会造成好多业务代码入侵。

解决方案: 启动一个订阅程序去订阅数据库的binlog,获得需要操作的数据,在应用程序中,另起一段程序,获得这个订阅程序传来的信息,进行删除缓存操作。订阅binlog程序在mysql中有现成的中间件叫canal,可以完成订阅binlog日志的功能。重试机制,采用的是消息队列的方式。如果对一致性要求不是很高,直接在程序中另起一个线程,每隔一段时间去重试。

本图文内容来源于网友网络收集整理提供,作为学习参考使用,版权属于原作者。
THE END
分享
二维码

)">
< <上一篇
下一篇>>