Loading

Redis 高级篇 Part 1

😉 本文共6413字,阅读时间约12min

Redis 底层数据结构

动态字符串SDS

我们都知道Redis中保存的Key是字符串,value往往是字符串或者字符串的集合。可见字符串是Redis中最常用的一种数据结构。

为什么不使用C语言中字符串?

  1. 获取字符串长度的需要通过运算
  2. 非二进制安全
  3. 不可修改(用字符串常量给其赋值,指针就会指向不可修改的常量区)

SDS结构

-- Redis 会在底层创建两个SDS,key和value各一个
set name 虎哥
  1. SDS结构体代码

    header(长度 + 申请字节数 + sdshdr头类型)+ 数据(char数组)

    1653984624671

  2. SDS图例

    1653984648404

动态扩容

SDS之所以叫做动态字符串,是因为它具备动态扩容的能力,例如一个内容为“hi”的SDS:

1653984787383

  • 假如我们要给SDS追加一段字符串“,Amy”,这里首先会申请新内存空间:
    • 如果新字符串小于1M,则新空间为扩展后字符串长度的两倍+1;
    • 如果新字符串大于1M,则新空间为扩展后字符串长度+1M+1。称为内存预分配。

1653984822363

1653984838306

Intset

IntSet结构

IntSet是Redis中set集合的一种实现方式,基于整数数组来实现,并且具备长度可变、有序等特征。
结构如下:

1653984923322

header(编码方式 + 元素个数) + 数据(int数组)

其中的encoding包含三种模式,表示存储的整数大小不同,对应的范围也不同。

升序排列 + 二分查找 + 类型升级

  1. 为了方便查找,Redis会将intset中所有的整数按照升序依次保存在contents数组中,结构如图:

    1653985149557

  2. 我们向该其中添加一个数字:50000,这个数字超出了int16_t的范围,intset会自动升级编码方式到合适的大小。以当前案例来说流程如下:

    1. 升级编码到32位,重置数组大小
    2. 倒序将旧元素以新编码方式拷贝到新位置,新元素大于零,插入数组尾部

    1653985276621

  3. 源码如下:

    1. 判断当前插入数字是否超出编码范围
    2. 未超出,二分查找是否有一样的数字
      1. 有,不插入
      2. 无,插入并进行数组扩容。移动pos之后元素到pos+1,给新元素腾出空间,重置长度
    3. 超出
      1. 升级编码,重置数组大小
      2. 倒序遍历旧元素以新编码方式拷贝到新位置,新元素<0插入数组头,>0插入数组尾

image-20230114084101044

小总结

Intset可以看做是特殊的整数数组,具备一些特点:

  • Redis会确保Intset中的元素唯一、有序
  • 具备类型升级机制,可以节省内存空间
  • 底层采用二分查找方式来查询

Dict

我们知道Redis是一个键值型(Key-Value Pair)的数据库,我们可以根据键实现快速的增删改查。而键与值的映射关系正是通过Dict来实现的。

Dict结构

  • 类似java的HashTable,底层是数组加链表来解决哈希冲突,没有红黑树
  • Dict包含两个哈希表,ht[0]平常用,ht[1]用来rehash

1653985570612

添加元素

  1. Redis首先根据key计算出hash值(h)
  2. 然后利用 h & sizemask来计算元素应该存储到数组中的哪个索引位置。
  3. 我们存储k1=v1,假设k1的哈希值h =1,则1&3 =1,因此k1=v1要存储到数组角标1位置。
    1. 然后看是否有跟他相等的,则替换
    2. 没有相等的,头插法

1653985497735

1653985640422

扩容:渐进型hash

  1. Dict中的HashTable就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低。
  2. Dict在每次新增键值对时都会检查负载因子(LoadFactor = used/size) ,满足以下两种情况时会触发哈希表扩容:
    1. 哈希表LoadFactor >= 1,且服务器没有执行 BGSAVE 或者 BGREWRITEAOF 等后台进程;
    2. 哈希表LoadFactor > 5 ;
  3. 收缩:当LoadFactor小于0.1时,Dict收缩
  4. 扩容还是找下一个2^n

1653985716275

  1. 渐进性rahash
    1. 不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size和sizemask变化,而key的查询与sizemask有关。因此必须对哈希表中的每一个key重新计算索引,插入新的哈希表,这个过程称为rehash。
    2. 过程是这样的:
      1. 计算新hash表的realSize,值取决于当前要做的是扩容还是收缩:
        1. 如果是扩容,则新size为第一个大于等于dict.ht[0].used + 1的2^n
        2. 如果是收缩,则新size为第一个大于等于dict.ht[0].used的2^n (不得小于4)
      2. 按照新的realSize申请内存空间,创建dictht,并赋值给dict.ht[1]
      3. 设置dict.rehashidx = 0,标示开始rehash
      4. 将dict.ht[0]中的每一个dictEntry都rehash到dict.ht[1]
      5. 将dict.ht[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存
      6. 将rehashidx赋值为-1,代表rehash结束
    3. rehash过程,rehash时ht[0]只减不增,新增操作只在ht[1]执行,其它操作在两个哈希表。

整个过程可以描述成:

1653985824540

感觉这个和一些JVM的垃圾收集算法中的新生代的survivor0、survivor1的工作流程有一点类似

如何体现渐进性?

如果一次性把哈希表1的大量数据拷贝到哈希表2,那么会造成Redis线程阻塞。

渐进式,顾名思义,就是不一次性将所有的数据进行转移,而是一步一步的进行转移,直到最后全部完成。避免了一次性大批量的在两个全局hash表之间拷贝数据造成线程阻塞。

每次访问Dict时执行一次rehash:在上述第二步拷贝数据时,Redis 仍然正常处理客户端请求,每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。

ZipList

ZipList结构

ZipList 压缩列表是一种特殊的“双端链表” ,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作, 并且该操作的时间复杂度为 O(1)。

极致压缩,redis设计中想方设法省内存的一种体现(之前体现就是sds,intset分了好多种类型)。

总字节数 + 尾偏移量 + 节点个数 + entry顺序存储 + 结束标识

1653985987327

ZipList Entry的结构

列表的节点之间不是通过指针连接,而是记录上一节点和本节点长度来寻址,内存占用较低

ZipList 中的Entry并不像普通链表那样记录前后节点的指针,因为记录两个指针要占用16个字节,浪费内存。而是采用了下面的结构:

1653986055253

  • previous_entry_length:前一节点的长度,占1个或5个字节。
    • 如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
    • 如果前一节点的长度大于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据
  • encoding:编码属性,记录content的数据类型(字符串还是整数)以及本节点长度,占用1个、2个或5个字节
  • contents:负责保存节点的数据,可以是字符串或整数

问题

  • 如果列表数据过多,导致链表过长,可能影响查询性能
  • 增或删较大数据时有可能发生连续更新问题
  • ZipList虽然节省内存,但申请内存必须是连续空间,如果内存占用较多,申请内存效率很低

ZipList的连锁更新问题

连锁更新是由于previous_entry_length造成的。
现在考虑这样一种情况:在一个压缩列表中,有多个连续的,长度介于250字节到253字节之间的节点e1-eN,如下图:

在这里插入图片描述

因为e1到eN的长度都介于250字节到253字节之间,所以previous_entry__length均为1字节。

现在将一个长度大于254字节的节点插入到e1前面,如下图:

在这里插入图片描述

因为newE的长度大于254字节,所以他后面的e1的previous_entry__length长度需要从1字节变为5字节。但是由于e1原本的长度为250字节到253字节之间,这样子扩展后,长度必然超过254字节,就会导致后面的e2的previous_entrylength也需要改变…以此类推

后面的节点previous_entry__length都需要扩展。Redis将这种特殊情况下产生的连续多次空间扩展操作称之为"连锁更新"。

除了插入节点会发生"连锁更新"。删除节点也会,如下图:

在这里插入图片描述

假设small及后面的节点的长度都介于250-253字节,big长度大于254字节,现在把samll删除了,导致e1的previous_entry__length需要扩展,后面依此类推,这样就会发生"连锁更新"。

因为连锁更新在最坏的情况下需要对压缩列表执行N次空间重分配,而每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N^2).

造成连锁更新的概率是很低的,因为压缩列表恰好有多个连续的、长度介于250~253之间的节点,连续更新才有可能被触发,实际情况下出现的概率很低其次,出现连锁更新时,只要被更新的节点数量不多,也不会造成性能问题

QuickList

引入原因

问题1:ZipList虽然节省内存,但申请内存必须是连续空间,如果内存占用较多,申请内存效率很低。怎么办?

​ 答:为了缓解这个问题,我们必须限制ZipList的长度和entry大小。

问题2:但是我们要存储大量数据,超出了ZipList最佳的上限该怎么办?

​ 答:我们可以创建多个ZipList来分片存储数据。

问题3:数据拆分后比较分散,不方便管理和查找,这多个ZipList如何建立联系?

​ 答:Redis在3.2版本引入了新的数据结构QuickList,它是一个双端链表,只不过链表中的每个节点都是一个ZipList。

1653986474927

为了避免QuickList中的每个ZipList中entry过多,Redis提供了一个配置项:list-max-ziplist-size来限制。
如果值为正,则代表ZipList的允许的entry个数的最大值
如果值为负,则代表ZipList的最大内存大小

QuickList结构

双向链表,只不过每一个节点都保存了一个ziplist的头指针

1653986718554

特点

  • 是一个节点为ZipList的双端链表
  • 节点采用ZipList,解决了传统链表的内存占用问题
  • 控制了ZipList大小,解决连续内存空间申请效率问题
  • 中间节点可以压缩,进一步节省了内存,因为频繁访问的经常只是头节点和尾节点

SkipList

跳表结构

  • SkipList(跳表)首先是双向链表,但与传统链表相比有几点差异:
    • 元素按照升序排列存储
    • 节点可能包含多个指针,指针跨度不同
      • 前一个节点指针只有跨度为1的一个
      • 后一个是一个数组,保存多个下一个节点指针和指针跨度

1653986771309

image-20230114095843389

1653986877620

特点

  • 跳跃表是一个双向链表,每个节点都包含score和element值
  • 节点按照score值排序,score值一样则按照element字典排序
  • 每个节点都可以包含多层指针,层数是1到32之间的随机数
  • 不同层指针到下一个节点的跨度不同,层级越高,跨度越大
  • 增删改查效率与红黑树基本一致,实现却更简单
    • 随着我们层数的增加,查询的效率接近二分查找的O(logn)

设计跳表

详细讲解设计跳表的三个步骤(查找、插入、删除)

leetcode 设计跳表

Redis Object

Redis中的任意数据类型的键和值都会被封装为一个RedisObject,也叫做Redis对象

出现原因

从Redis的使用者的角度来看,⼀个Redis节点包含多个database(非cluster模式下默认是16个,cluster模式下只能是1个),而一个database维护了从key space到object space的映射关系。

  • 这个database存了两个dict
    • 一个是key-value,用来存数据的。
      • 这个映射关系的key是string类型,⽽value可以是多种数据类型,比如:string, list, hash、set、sorted set等。我们可以看到,key的类型固定是string,而value可能的类型是多个。
    • 还有一个是key-ttl,用来内存回收的。

⽽从Redis内部实现的⾓度来看,database内的这个映射关系是用⼀个dict来维护的。dict的key固定用⼀种数据结构来表达就够了,这就是动态字符串sds。而value则比较复杂,为了在同⼀个dict内能够存储不同类型的value,这就需要⼀个通⽤的数据结构,这个通用的数据结构就是robj,全名是redisObject。

1653986956618

type表示应用层数据结构

encoding表示用来实现的编码,对应底层不同的数据结构

LRU表示最后一次访问时间,回收用

refcount表示对象引用计数器,为0表示无人引用,可以回收

ptr指向存放实际数据的空间

五种应用层数据结构对应编码实现

Redis中会根据存储的数据类型不同,选择不同的编码方式。每种数据类型的使用的编码方式如下:

数据类型 编码方式
OBJ_STRING int、embstr、raw
OBJ_LIST LinkedList和ZipList(3.2以前)、QuickList(3.2以后)
OBJ_SET intset、HT
OBJ_ZSET ZipList、HT、SkipList
OBJ_HASH ZipList、HT

Redis应用层数据结构

String

raw+embstr+int

robj+ptr+sds

如何实现

  • String在Redis中是一个Redis Object,其encoding可能有三种:
    • raw,当字符串大于44字节时,存储上限为512mb
      • SDS的数据量变多变大了,SDS和RedisObject布局分家各自过
      • raw类型将会调用两次内存分配函数,分配内存空间,一块用于包含redisObject结构,而另一块用于包含sdshdr结构。
    • embstr,小于44字节,则会采用EMBSTR编码
      • 只分配一块连续的内存空间,空间中依次包含的redisObject与sdshdr两个数据结构。
      • 只需要调用一次内存分配函数,效率更高。让元数据、指针和SDS是一块连续的内存区域,这样就可以避免内存碎片。足够小,可以找到这样的连续内存。
    • int,如果存储的字符串是整数值,并且大小在LONG_MAX范围内,则会采用INT编码。直接将数据保存在RedisObject的ptr指针位置(刚好8字节),不再需要SDS了,省内存。
  • raw和embstr都是用sds存储,int直接把string存成了long类型,保存在robj的指针位置

图例

  • raw

1653987103450

  • embstr

1653987172764

  • int

1653987202522

List

双端列表,Redis的List类型可以从首、尾操作列表中的元素:

1653987240622

如何实现

哪一个数据结构能满足上述特征?

  • LinkedList :普通链表,可以从双端访问,内存占用较高,内存碎片较多
  • ZipList :压缩列表,可以从双端访问,内存占用低,存储上限低
  • QuickList:LinkedList + ZipList,可以从双端访问,内存占用较低,包含多个ZipList,存储上限高

Redis的List结构类似一个双端链表,可以从首、尾操作列表中的元素:

在3.2版本之前,Redis采用ZipList和LinkedList来实现List,当元素数量小于512并且元素大小小于64字节时采用ZipList编码,超过则采用LinkedList编码(找不到那么大内存了)。

在3.2版本之后,Redis统一采用QuickList来实现List:

1653987313461

Set

Set是Redis中的单列集合,满足下列特点:

  • 不保证有序性
  • 保证元素唯一
  • 求交集、并集、差集

1653987342550

如何实现

可以看出,Set对查询元素的效率要求非常高,思考一下,什么样的数据结构可以满足?

  1. Dict,key用来存储元素,value统一为null。
  2. IntSet当存储的所有数据都是整数,并且元素数量不超过set-max-intset-entries时,Set会采用IntSet编码,以节省内存。

1653987388177

结构如下:1653987454403

Hash

如何实现?

  1. 数据量较少时,用压缩列表ziplist进行存储数据。
    1. 默认采用ZipList编码,用以节省内存。
    2. ZipList中相邻的两个entry 分别保存field和value。
  2. 数据量较多或任意entry太大时,转为dict存储

1653992413406

为什么不完全用ziplist?

  1. ziplist数据量过多时,需要遍历,查找效率低
  2. 数据量过多时,插入删除更大概率造成连锁更新

Zset

1653992091967

很像hash,(member,score),member唯一,能根据member查询分数

但要能按score排序。

如何实现

数据量大
  • SkipList:可以排序,并且可以同时存储score和ele值(member)
  • Dict:可以键值存储,并且可以根据key找value

各存一份。。。。

1653992121692

1653992172526

数据量小
  • ZipList,当元素数量不多时,HT和SkipList的优势不明显,而且更耗内存。因此zset还会采用ZipList结构来节省内存。同样元素数量和最大元素大小有要求,否则转为上面两个。
    • 连续内存,因此score和element是紧挨在一起的两个entry, element在前,score在后
    • score越小越接近队首,score越大越接近队尾,按照score值升序排列

1653992299740

1653992238097

Redis 内存回收

当内存使用达到上限时,就无法存储更多数据了。为了解决这个问题,Redis提供了一些策略实现内存回收。

内存过期策略(过期Key处理)

expire k1 5,当k1的TTL到期以后,再次访问k1返回的是nil,说明这个key已经不存在了。

但这部分内存何时回收?

Redis之所以性能强,最主要的原因就是基于内存存储。然而单节点的Redis其内存大小不宜过大,会影响持久化或主从同步性能。我们可以通过修改配置文件来设置Redis的最大内存:maxmemory 1gb

  • Redis database结构体中,有两个Dict:一个用来记录key-value;另一个用来记录key-TTL

1653983423128

1653983606531

惰性删除

惰性删除:并不是在TTL到期后就立刻删除,而是在访问一个key的时候,检查该key的存活时间,如果已经过期才执行删除。

1653983652865

周期删除

通过一个定时任务,周期性的抽样部分过期的key,然后执行删除。

  • 执行周期有两种:

    • Slow模式:Redis服务初始化函数initServer()中设置定时任务serverCron(),按照server.hz的频率来执行过期key清理,模式为SLOW。低频高时长
    • Fast模式:Redis的每个事件循环前会调用beforeSleep()函数,执行过期key清理,模式为FAST。高频低时长,避免阻塞线程。
  • SLOW模式规则:

    • 执行频率受server.hz影响,默认为10,即每秒执行10次,每个执行周期100ms。
    • 执行清理耗时不超过一次执行周期的25%.默认slow模式耗时不超过25ms
    • 逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期。如果没达到时间上限(25ms)并且过期key比例大于10%,再进行一次抽样,这是一个循环,否则结束
  • FAST模式规则(过期key比例小于10%不执行 ):

    • 执行频率受beforeSleep()调用频率影响,但两次FAST模式间隔不低于2ms(不同点)
    • 执行清理耗时不超过1ms(不同点)
    • 逐个遍历db,逐个遍历db中的bucket,抽取20个key判断是否过期。如果没达到时间上限(1ms)并且过期key比例大于10%,再进行一次抽样,否则结束

img

img

总结

  • RedisKey的TTL记录方式:在RedisDB中通过一个Dict记录每个Key的TTL时间

  • 过期key的删除策略:

    • 惰性清理:每次查找key时判断是否过期,如果过期则删除
    • 定期清理:定期抽样部分key,判断是否过期,如果过期则删除。
  • 定期清理的两种模式:

    • SLOW模式执行频率默认为10,每次不超过25ms
    • FAST模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms

内存淘汰策略

什么是内存淘汰?

内存淘汰:当Redis内存使用达到设置的上限时,主动挑选部分key删除以释放更多内存的流程。

Redis会在处理客户端命令的方法processCommand()中尝试做内存淘汰:

1653983978671

8种淘汰策略

  • noeviction: 不淘汰任何key,但是内存满时不允许写入新数据,默认就是这种策略。
  • volatile-ttl: 对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰
  • allkeys-random:对全体key ,随机进行淘汰。也就是直接从db->dict中随机挑选
  • volatile-random:对设置了TTL的key ,随机进行淘汰。也就是从db->expires中随机挑选。
  • allkeys-lru: 对全体key,基于LRU算法进行淘汰
  • volatile-lru: 对设置了TTL的key,基于LRU算法进行淘汰
  • allkeys-lfu: 对全体key,基于LFU算法进行淘汰
  • volatile-lfu: 对设置了TTL的key,基于LFI算法进行淘汰
  1. volatile针对设置ttl的,allkeys针对全体key
  2. random随机、lru、lfu、ttl
  3. noeviction 不淘汰策略

比较容易混淆的有两个:

  • LRU(Least Recently Used),最少最近使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。
  • LFU(Least Frequently Used),最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。

Redis的数据都会被封装为RedisObject结构:

1653984029506

LFU的访问次数之所以叫做逻辑访问次数,是因为并不是每次key被访问都计数,而是通过运算:

  • 生成0~1之间的随机数R
  • 计算 (旧次数 * lfu_log_factor + 1),记录为P
  • 如果 R < P ,则计数器 + 1,且最大不超过255
  • 访问次数会随时间衰减,距离上一次访问时间每隔 lfu_decay_time 分钟,计数器 -1

最后用一副图来描述当前的这个流程吧

1653984085095

posted @ 2023-01-14 21:40  iterationjia  阅读(98)  评论(0编辑  收藏  举报