Redis原理篇

七种数据结构

1. 动态字符串 SDS

是redis自己编写的一种数据结构,使用一个buff数组来保存字符

结构

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; // 长度
    uint8_t alloc; //容量
    unsigned char flags; //不同SDS的头类型,用来控制SDS的头大小
    char buf[];
};

flags

优点

  • 获取字符串长度的时间复杂度为O(1):使用len来获取,无需遍历
  • 支持动态扩容,避免缓冲区溢出:先len检查,再判断是否需要扩容
  • 减少内存分配次数
  • 二进制安全:使用 len 属性判断字符串是否结束,而不是空字符\0

减少内存分配次数:

  • 空间预分配:当 SDS 需要增加字符串时,Redis 会为 SDS 分配好内存,并且根据特定的算法分配多余的内存
  • 惰性空间释放:当 SDS 需要减少字符串时,这部分内存不会立即被回收,会被记录下来,等待后续使用(支持手动释放,有对应的 API)

2. 整数集合 intset

结构

其中的encoding包含三种模式,表示存储的整数大小不同

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

为什么后面的元素(5、10、20)要统一编码格式(int16_t)?

为了方便寻址,可以通过数组角标进行快速定位

升级

我们向该其中添加一个数字:50000,这个数字超出了int16_t的范围,intset会自动升级编码方式到合适的大小

流程:

  1. 升级编码为INTSET_ENC_INT32,占4字节,并按照新的编码方式及元素个数 扩容数组
  2. 倒序依次将数组中的元素拷贝到扩容后的正确位置
  3. 将待添加的元素放入数组末尾
  4. 最后,将inset的 encoding属性 改为INTSET_ENC_INT32,将 length属性 改为4

        变为   ->

为什么倒序?

正序会覆盖数据,导致数据丢失

特点

  • Redis会确保Intset中的元素唯一、有序
  • 具备类型升级机制,可以节省内存空间(因为数据小的时候会使用较小的空间存储)
  • 底层采用二分查找方式来查询

3. 字典 Dict

结构

Dict由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)

字典采用哈希表作为底层结构,类似于Java的HashMap,哈希表是一个数组(dictht),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针,每个哈希表节点不仅包含指向键和值的指针,还包含了指向下一个哈希表节点的指针,可以用来解决哈希冲突

dictEntry 存储产生哈希冲突的链表,dictht 存储哈希数组key和value

当我们向 Dict 添加键值对时,Redis首先根据key计算出hash值(h),然后利用 h & sizemask 来计算元素的索引位置。

产生哈希冲突时,使用 头插法+单向链表 来加入新插入的数据(k2、v2)

扩容

Dict在每次新增键值对时都会检查负载因子(LoadFactor = used/size) ,满足以下两种情况时会触发哈希表扩容:
1. 哈希表的 LoadFactor >= 1,并且服务器没有执行 BGSAVE 或者 BGREWRITEAOF 等后台进程
2. 哈希表的 LoadFactor > 5 

 为什么要求服务器没有执行后台进程?

因为此类进程对于cpu使用非常高,还有大量io读写,可能会影响性能,导致线程阻塞

缩容

Dict每次删除元素时,也会对负载因子做检查,当 LoadFactor< 0.1 时,会做哈希表收缩

rehash

不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size和sizemask变化,而key的查询与sizemask有关。因此必须对哈希表中的每一个key重新计算索引,插入新的哈希表,这个过程称为rehash。过程是这样的:

  1. 计算新hash表的realeSize,值取决于当前要做的是扩容还是收缩:

    1. 如果是扩容,则新size为 第一个大于等于dict.ht[0].used + 1 的 2^n
    2. 如果是收缩,则新size为 第一个大于等于dict.ht[0].used 的 2^n (不得小于4)
  2. 按照新的realeSize申请内存空间,创建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]的内存

        如果Dict中包含数百万的entry,要在一次rehash完成,极有可能导致主线程阻塞。因此Dict的rehash是分多次、渐进式的完成,因此称为渐进式rehash

修改流程如下:
        4. 将dict.ht[0]中的每一个dictEntry都rehash到dict.ht[1]
        4. 每次执行新增、查询、修改、删除操作时,都检查一下dict.rehashidx是否大于-1,如果是则将 dict.ht[0].table[rehashidx] 的entry链表rehash到dict.ht[1],并且将rehashidx++。直至dict.ht[0]的所有数据都rehash到dict.ht[1]

        6. 将rehashidx赋值为-1,代表rehash结束

        7. 在rehash过程中,新增操作,则直接写入ht[1]查询、修改和删除则会在 dict.ht[0]和dict.ht[1] 依次查找并执行。这样可以确保ht[0]的数据只减不增,随着rehash最终为空

总结

        在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间。随着数据逐步增多,触发了 rehash 操作,这个过程分为三步:

  1. 给「哈希表 2」 分配空间,一般会比「哈希表 1」 大一倍(两倍的意思);
  2. 将「哈希表 1 」的数据迁移到「哈希表 2」 中;
  3. 迁移完成后,「哈希表 1 」的空间会被释放,并把「哈希表 2」 设置为「哈希表 1」,然后在「哈希表 2」 新创建一个空白的哈希表,为下次 rehash 做准备。

    问题:如果「哈希表 1 」的数据量非常大,那么在迁移至「哈希表 2 」的时候,因为会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求。


        所以 Redis 采用了渐进式 rehash,也就是将数据的迁移的工作不再是一次性迁移完成,而是分多次迁移。

  1. 给「哈希表 2」 分配空间;
  2. 在 rehash 进行期间,每次哈希表元素进行 新增、删除、查找或者更新 操作时,Redis 除了会执行对应的操作之外,还会顺序将「哈希表 1 」中索引位置上的所有 key-value 迁移到「哈希表 2」 上;
  3. 随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间点会把「哈希表 1 」的所有 key-value 迁移到「哈希表 2」,从而完成 rehash 操作。

        这样就巧妙地把一次性大量数据迁移工作的开销,分摊到了多次处理请求的过程中,避免了一次性 rehash 的耗时操作。

        在进行渐进式 rehash 的过程中,会有两个哈希表,所以在渐进式 rehash 进行期间,哈希表元素的删除、查找、更新等操作都会在这两个哈希表进行。比如,查找一个 key 的值的话,先会在「哈希表 1」 里面进行查找,如果没找到,就会继续到哈希表 2 里面进行找到。

        另外,在渐进式 rehash 进行期间,新增一个 key-value 时,会被保存到「哈希表 2 」里面,而「哈希表 1」 则不再进行任何添加操作,这样保证了「哈希表 1 」的 key-value 数量只会减少,随着 rehash 操作的完成,最终「哈希表 1 」就会变成空表。

4. 压缩列表 ZipList

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

结构

尾偏移量:为了快速获得tail节点

ZipListEntry:

entry大小不固定:为了节省内存

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

  • previous_entry_length:前一节点的长度,占1个或5个字节。

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

  • contents:负责保存节点的数据,可以是字符串或整数

encoding:

特点

  1. 压缩列表的可以看做一种连续内存空间的"双向链表"
  2. 列表的节点之间不是通过指针连接,而是记录上一节点本节点长度来寻址,内存占用较低
  3. 如果列表数据过多,导致链表过长,可能影响查询性能
  4. 增或删较大数据时有可能发生连续更新问题

5. 快速列表 quickList

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

结构

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

  • -1:每个ZipList的内存占用不能超过4kb
  • -2:每个ZipList的内存占用不能超过8kb
  • -3:每个ZipList的内存占用不能超过16kb
  • -4:每个ZipList的内存占用不能超过32kb
  • -5:每个ZipList的内存占用不能超过64kb

其默认值为 -2:

        除了控制ZipList的大小,QuickList还可以对节点的ZipList做压缩。通过配置项                list-compress-depth 来控制。因为链表一般都是从首尾访问较多,所以首尾是不压缩的。这个参数是控制首尾不压缩的节点个数:

  • 0:特殊值,代表不压缩
  • 1:标示QuickList的首尾各有1个节点不压缩,中间节点压缩
  • 2:标示QuickList的首尾各有2个节点不压缩,中间节点压缩

以此类推
默认值:0

特点

  1. 是一个节点为ZipList的双端链表
  2. 节点采用ZipList,解决了传统链表的内存占用问题
  3. 控制了ZipList大小,解决连续内存空间申请效率问题
  4. 中间节点可以压缩,进一步节省了内存

6. 跳表 skipList

SkipList(跳表)首先是链表,但与传统链表相比有几点差异:
元素按照升序排列存储
节点可能包含多个指针level,指针跨度不同。

结构

特点

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

总结

        跳跃表是一种有序数据结构,它通过在链表的基础上添加多层索引指针来加速查找操作,双向链表、升序排序、多层指针、跨度不同,效率。

        查询结点的过程:

        查找一个跳表节点时,跳表会从头节点的最高层开始,逐一遍历每一层。在遍历某一层的跳表节点时,会用节点中的 SDS 类型的 元素和元素的权重 来进行判断,共有两个判断条件:

  • 如果当前节点的权重「小于」要查找的权重时,跳表就会访问该层的下一个节点。
  • 如果当前节点的权重「等于」要查找的权重时,并且当前节点的 SDS 类型数据「小于」要查找的数据时,跳表就会访问该层的下一个节点。

        如果上面两个条件都不满足,或者下一个节点为空时,跳表就会使用目前遍历到的节点的 level 数组里的下一层指针,然后沿着下一层指针继续查找,这就相当于跳到了下一层接着查找。

        为什么使用跳表:

范围查询、节省内存、实现简单

RedisObject

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

Redis中会根据存储的数据类型不同,选择不同的编码方式,共包含11种不同类型: 

Redis 5 种基本数据类型对应的底层数据结构

StringListHashSetZset
SDS

LinkedList 和 ZipList(3.2以前)、

QuickList(3.2以后)

Dict、ZipListDict、IntsetZipList、SkipList

Zset

ZSet也就是SortedSet,其中每一个元素都需要指定一个score值和member值:

  • 可以根据score值排序后
  • member必须唯一
  • 可以根据member查询分数

因此,zset底层数据结构必须满足键值存储、键必须唯一、可排序这几个需求。之前学习的哪种编码结构可以满足?

SkipList、HT(Dict)

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

ZipList

元素数量不多时,HT和SkipList的优势不明显,而且更耗内存。因此zset还会采用ZipList结构来节省内存,不过需要同时满足两个条件:

  • 元素数量小于zset_max_ziplist_entries,默认值128
  • 每个元素都小于zset_max_ziplist_value字节,默认值64

ziplist本身没有排序功能,而且没有键值对的概念,因此需要有zset通过编码实现:

  • ZipList是连续内存,因此score和element是紧挨在一起的两个entry,element在前,score在后
  • score越小越接近队首,score越大越接近队尾,按照score值升序排列

为什么不用红黑树、二叉树、B+树?

  • 适合范围查询范围查询时只需要 O(log n) 时间复杂度定位到范围的起点,然后再遍历链表,而红黑树还需要中序遍历其它节点进行查询
  • 实现简单跳表是一种随机化的数据结构,它通过多层链表来实现。实现较为简单,在进行插入删除时只需要修改相邻节点的指针,不需要像 B+树 那样插入时发现失衡时还需要对节点分裂与合并
  • 占用内存少B+树更适合作为 数据库和文件系统 中常用的索引结构之一,它的核心思想是通过尽可能少的 IO 定位到尽可能多的索引来获得查询数据,因此每个节点会包含多个指向子节点的指针。但是Redis本身就是基于内存的,对IO没有这方面的要求,因此只需要包含下一个节点的指针,节约内存

网络模型

1. 用户和内核空间

结构

ubuntu和Centos 都是Linux的发行版,发行版可以看成 对linux包了一层壳,任何Linux发行版,其系统内核都是Linux。我们的应用都需要通过Linux内核与硬件交互

用户应用,比如redis,mysql等其实是没有办法去执行访问我们操作系统的硬件的,所以我们可以通过发行版的这个壳子去访问内核,再通过内核去访问计算机硬件

内核(通过寻址空间)可以操作硬件的,但是内核需要不同设备的驱动,有了这些驱动之后,内核就可以去对计算机硬件去进行 内存管理,文件系统的管理,进程的管理等等

         

内核本身上来说也是一个应用,所以他本身也需要一些内存(cpu等设备资源),如果不加任何限制,让用户随意地去操作我们的资源,就有可能导致一些冲突,甚至可能导致系统崩溃,因此我们需要把用户和内核隔离开

寻址空间

        进程的寻址空间划分成两

部分:内核空间、用户空间

        什么是寻址空间呢?我们的 应用程序和内核空间 都不能直接使用物理内存,而是通过分配一些虚拟内存映射到物理内存中,我们的内核和应用程序去访问虚拟内存的时候,就需要一个虚拟地址,这个地址是一个无符号的整数,比如一个32位的操作系统,他的带宽就是32,他的虚拟地址就是2的32次方,也就是说他寻址的范围就是0~2的32次方, 这片寻址空间对应的就是2的32次方个字节,就是4GB,会有3GB分给用户空间,1GB给内核系统

               

用户内核切换

        用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问,内核空间可以执行特权命令(Ring0),调用一切系统资源。如果应用程序需要去调用系统资源,去调用一些内核空间的操作,此时就需要在用户态和内核态之间进行切换

Linux系统为了提高IO效率,会在用户空间和内核空间都设置 buffer缓冲区

  • 数据时,要把 用户缓冲数据 拷贝到 内核缓冲区,然后写入设备
  • 数据时,要从 设备读取数据到 内核缓冲区,然后拷贝到 用户缓冲区

        我们的用户在读数据时:① 会去向内核态申请,读取内核的数据(read);② 内核数据等待驱动程序从硬件上读取数据(wait for data 准备数据;③ 当从磁盘上加载到数据之后,内核会将数据写入到内核的缓冲区中;④ 然后再将数据拷贝(copy)到用户态的buffer中;⑤ 然后再返回给应用程序。

        整体而言,速度慢,有两个原因(黄色背景标记)。

2. 阻塞IO

3. 非阻塞IO

虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增。

4. IO多路复用

无论是阻塞IO还是非阻塞IO,用户应用在一阶段都需要调用recvfrom来获取数据,差别在于无数据时的处理方案:

  • 如果调用recvfrom时,恰好没有数据阻塞IO会使CPU阻塞,非阻塞IO使CPU空转,都不能充分发挥CPU的作用。
  • 如果调用recvfrom时,恰好有数据,则用户进程可以直接进入第二阶段,读取并处理数据

比如服务端处理客户端Socket请求时,在单线程情况下,只能依次处理IO事件,如果正在处理的IO事件恰好未就绪(数据不可读或不可写),线程就会被阻塞,所有IO事件都必须等待,性能自然会很差。

就比如服务员(操作系统)给顾客(硬盘)点餐(处理IO事件,拷贝数据),分两步:

  • 顾客思考要吃什么(等待数据就绪)
  • 顾客想好了,开始点餐(读取数据)

提高效率有几种办法?

  1. 增加更多服务员(多线程,线程切换开销大)
  2. 不排队,谁想好了吃什么(数据就绪了),服务员就给谁点餐(用户应用就去读取数据)
  • 用户如何判断数据是否就绪?

文件描述符(File Descriptor):简称FD,是一个从0开始的无符号整数,用来关联Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。

IO多路复用:利用单线程 监听多个FD,并在某个FD可读、可写时 得到通知,从而避免无效的等待,充分利用CPU资源。

        当用户去读取数据的时候,不再去直接调用recvfrom了,而是调用select函数,select函数会将需要监听的数据交给内核,由内核去检查这些数据是否就绪了,就绪了,就通知应用程序。

监听FD通知的方式

        select

        poll

        epoll

  • select和poll 只会通知用户进程有FD就绪,但不确定具体是哪个FD,需要用户进程逐个遍历FD来确认
  • epoll 则会在通知用户进程FD就绪的同时,把已就绪的FD写入用户空间
select

fd_set能存储1024位,每个fd只占一个位

--> 

  1. 将已连接的 Socket 都放到一个文件描述符集合,通过位图来管理文件描述符集合。
  2. 然后调用 select 函数将文件描述符集合拷贝到内核里,让内核来检查是否有网络事件产生(数据就绪通知)
  3. 检查的方式很粗暴,就是通过遍历文件描述符集合的方式,当检查到有事件产生后,将此 Socket 标记为可读或可写,
  4. 接着再把整个文件描述符集合拷贝回用户态里
  5. 然后用户态还需要再通过遍历的方法找到可读或可写的 Socket,然后再对其处理。

        缺点:

  1. 需要将整个fd_set从用户空间拷贝到内核空间,select结束还要再次拷贝回用户空间
  2. select无法得知具体是哪个fd就绪,需要遍历整个fd_set
  3. fd_set监听的fd数量不能超过1024
poll

poll 不再用 BitsMap 来存储所关注的文件描述符,取而代之用动态数组,以链表形式来组织,突破了 select 的文件描述符个数限制,当然还会受到系统文件描述符限制。

epoll

第一点,epoll 在内核里使用红黑树来跟踪进程所有要监听的文件描述符,把需要监控的 socket 通过 epoll_ctl() 函数加入内核中的红黑树里 ,并且每个FD会关联一个callback回调函数,减少了内核和用户空间大量的数据拷贝和内存分配。

第二点, epoll 使用事件驱动的机制,内核里维护了一个链表来记录就绪事件,当某个 socket 有事件发生时,通过回调函数内核会将其加入到这个列表中,当用户调用 epoll_wait() 函数时,只会返回有事件发生的文件描述符的个数,不需要像 select/poll 那样轮询扫描整个 socket 集合,大大提高了检测的效率。

        优点:

  1. 基于epoll实例中的红黑树保存要监听的FD,理论上无上限,而且增删改查效率都非常
  2. 每个FD只需要执行一次 epoll_ctl 添加到红黑树,以后每次epol_wait无需传递任何参数,无需重复拷贝FD到内核空间,但是 epoll_wait 实现的内核代码中调用了 __put_user 函数,这个函数就是将数据从内核拷贝到用户空间
  3. 利用 ep_poll_callback 机制来监听FD状态,无需遍历所有FD,因此性能不会随监听的FD数量增多而下降

事件通知模式

当FD有数据可读时,我们调用epoll_wait(或者select、poll)可以得到通知。事件通知的模式有两种:

  • LevelTriggered:简称 LT,也叫做水平触发。只要某个FD中有数据可读,每次调用epoll_wait 都会得到通知。
  • EdgeTriggered:简称 ET,也叫做边沿触发。只有在某个FD有状态变化时,调用 epoll_wait才会被通知。

结论

  • ET模式 避免了 LT模式 可能出现的惊群现象(多个进程监听同一个fd,fd就绪时,会导致lt模式下的所有进程都被通知)
  • ET模式最好结合非阻塞IO读取FD数据,相比LT会复杂一些

基于epoll的web服务流程

初始化(连接服务端socket和epoll)

1. epoll_create()创建epoll对象,socket() 创建服务端 socket

2. bind()绑定端口,listen() 监听 服务端socket

3. epoll_ctl() 将 服务端socket 加入到 epoll,同时注册「连接事件」处理函数(用来监听客户端连接并加入epoll)

事件循环函数(处理具体事件)

调用 epoll_wait() 等待事件的到来:

连接事件:调用 连接事件处理函数:accpet() 获取 客户端socket -> epoll_ctl() 将 客户端socket 加入到 epoll -> 注册「读事件」处理函数

读事件:调用 读事件处理函数:调用 read 获取客户端发送的数据 -> 解析命令 -> 处理命令 -> 将客户端对象添加到发送队列 -> 将执行结果写到发送缓存区等待发送

写事件:调用 写事件处理函数:通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会继续注册写事件处理函数,等待 epoll_wait 发现可写后再处理 

5. 信号驱动IO

当有大量IO操作时,信号较多,SIGIO处理函数不能及时处理可能导致信号队列溢出,而且内核空间与用户空间的频繁信号交互性能也较低。

6. 异步IO

进程不阻塞,就会不停的去处理新的请求,大量请求会导致高并发,IO读写的任务堆积,导致内存占用过多崩溃

IO操作是同步还是异步,关键看数据在内核空间与用户空间的拷贝过程,也就是阶段二是同步还是异步

7. 线程模型

Redis到底是单线程还是多线程?

  • 如果仅仅聊Redis的核心业务部分(命令处理请求),答案是单线程
  • 如果是聊整个Redis,那么答案就是多线程(关闭文件、AOF 刷盘、unlink释放内存等耗时操作,避免阻塞主线程),BIO

在Redis版本迭代过程中,在两个重要的时间节点上引入了多线程的支持:

  • Redis v4.0:引入多线程异步处理一些耗时较长的任务,例如异步删除命令unlink
  • Redis v6.0:在核心网络模型中引入 多线程,进行IO读写操作,进一步提高对于多核CPU的利用率

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值