Redis为什么那么快-存储原理篇

前言:

Redis 是一个基于内存操作的高性能非关系型数据库,大多数人对于它的第一印象就是快,但是它咋就这么快嘞,这得益于他的几个特点

  • 单线程的处理机制
    一个主线程负责读写数据,其他附属的线程负责维护 Redis 服务的稳定,单线程的一个好处就是没有线程资源竞争的问题,采用多线程开发一般会引入同步原语来保护共享资源的并发访问,这也会降低系统代码的易调试性和可维护性。为了避免这些问题,Redis 直接采用了单线程模式。Redis 6.0 支持了多线程,具体大家可以自行了解。

  • io 的多路复用模型
    使用了 epoll 多路复用的模型,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果,本质上就是通过事件监听的方式,如果有个连接没有请求,那就进入休眠状态,让出时间片,等待被**。

  • 优秀的底层数据结构
    一个好的数据存储结构决定了一半的性能,Redis 底层实现了多种数据结构,包括 简单动态字符串(SNS),压缩列表,双向链表,哈希表,跳表,整形数组等底层数据结构,当然它也同样支持我们将自己实现的数据结构也添加到底层中,Redis 会根据性能来判断选择使用哪一个数据结构作为存储,并作自动转换,除了字符串类型统一使用了SNS,其他的类型(list,hash, set ,sortset )均有两种底层结构的支持

  • 基于内存的读写
    Redis 是直接读写内存中的数据的,我们知道 只有内存中的数据才能够被程序使用,很多存储都有基于内存的优化策略,像 MySQL 的buffer机制,有效的利用内存,可以减少大量的 磁盘随机 io 读取,毕竟从文件中读取数据到内存是一个十分消耗性能的操作,为了数据的稳定,Redis 也提供了 RDB 和 AOF 机制,来保证数据的稳定性

一.Redis 的存储原理

1.1 存储结构

  Redis 的存储方式使用的是散列表的形式,我们都知道,数据结构的最底层物理结构只有数组和链表,而其他的比如说队列、树、图等数据结构都是基于这两个物理结构所实现的逻辑数据结构。
而数组和链表的区别主要在于读写的时间复杂度和内存的拓容上,因为数组要拓容的成本很高,因此大多数情况下会选用链表来实现各种逻辑数据结构。例如 PHP 的数组本质上就是一个使用拉链法的散列表。

Redis为什么那么快-存储原理篇

(图片来源于geektime)

  在 Redis 的底层存储中,会维护一个全局的哈希表(本质上就是数组),它有无数个哈希桶(数组元素)组成,每一个哈希桶的存储了两个指针,一个是指向 key 的指针,另一个则是指向 value 的指针。目前 Redis 的 key 只有一种形式,就是字符串,而 value 却有多种支撑,包括 字符串类型,列表,哈希,集合,有序集合。我们知道数组取下标元素的时间复杂度为 O(1), 因为想要获得获取对应的哈希桶,实际上消耗的时间是哈希函数的计算,这个计算和哈希桶的数据量是没有关系的,一般来说哈希函数对应的时间复杂度为 O(1)。这种设计十分的巧妙,把资源放在其他地方,只保留对应资源的指针,在维护上会有很大的便捷性,这就好比说,我不需要把货物都放在房子里,我只需要一张纸,记录着我的每个货物的位置即可。这不经让我想起的MySQL 的表结构和数据分开存储的异曲同工之处。

1.2 rehash

学过数据结构的同学都知道,没有一个绝对无敌的哈希函数可以实现无冲突处理,而一般来说解决冲突的方式有两大类(注意这里是分的大类),一个是开放地址法,另一个是链地址法。而 Redis 采用的正是第二种方式。

Redis为什么那么快-存储原理篇
(图片来于geektime)

当存在冲突的时候,会将对应的冲突实体放在一起搞成一个链表,此时哈希桶的指针会增加一个next 指针,用来指向下一个冲突实体,当数据量足够大的时候,这个链表可能会被拉的越来越长,因为链表的查询只能从头结点开始遍历下去,也就是 O(n) 的复杂度(n为链表长度),所以当链表过长时,会影响最终的查询速度。所以这时候不能袖手旁观。

因此,在这种情况下,Redis 会有另外一个线程专门去处理。我们称这种方式为 rehash ,和很多语言的数组底层一样,这种rehash 机制也是基于复制状态机的,说白了就是申请另一块空间(通常是原来的两倍),然后把数据信息复制过去,再释放原来的空间。通常来说程序不会快死的时候才想着救自己,当数值达到预警值时,就会开始自救,我们常常将其称之为装填因子,计算方式十分的简单,就是 use/total, 不同的存储对装填因子的判断规则不同,内容也比较底层,这里我们就不展开叙述了。

所以在 Redis 中,会存在另一张全局哈希表,其实它就是一个备胎,每次需要拓容的时候,这个备胎往往空间要比原配更大,rehash 线程会将原配的数据复制到备胎中,然后备胎就可以转正了。但是在复制的过程中,会有两个问题,一个是内存占用率会突然飙升,另一个就是Redis 阻塞的问题,复制的操作又耗时又耗空间,因此我们还需要更加聪明一点,能不能让一次的操作分成多步呢?温水煮青蛙听过没,如果每次来一个请求我就迁徙一点,这样的话,是不是慢慢的我就复制完了。这就是 Redis 的渐进式 rehash

1.3 渐进式 rehash

两个人的感情是需要慢慢的培养的,程序的处理也是可以慢慢来的,有些事情不非要一次性搞完的,干嘛要加班加点傻乎乎的通宵去做呢?慢工出细活,一天学一个小时专业知识,一万个小时不也就 417 * 24 天而已吗?好吧,不扯这个,看知识点。我们首先需要知道,Redis 是怎么去渐进式 rehash的,假设我要取 key 为 **** 的 value ,而通过hash 算法得到的 索引位置为 1,但是该索引上有一个三个 entry, 此时处理的线程正常的去遍历这个链表拿到真正正确的值,此时 rehash进程 顺便把这个索引的 entry 从 ht0 复制到 ht1 中。并且释放 ht0 该索引的空间。大致流程如下图所示:
Redis为什么那么快-存储原理篇
(图片来源于geektime)

这时候我们就会纠结一个问题了,如果有两种全局哈希表,那么Redis 的 crud 是怎么去操作的呢?

Redis的删除、查找、更新等操作会在两个全局哈希表上进行,比如说, 要在字典里面查找一个键的话, 程序会先在 ht0 里面进行查找, 如果没找到的话, 就会继续到 ht1 里面进行查找,并且新添加到Redis的键值对一律会被保存到 ht[1] 里面, 而 ht[0] 则不再进行任何添加操作: 这一措施保证了 ht[0] 包含的键值对数量会只减不增, 并随着 rehash 操作的执行而最终变成空表,就完成了备胎转正流程。

下集预告:Redis 底层数据结构剖析