你真的了解Redis的数据结构吗

583 阅读22分钟

Redis作为高并发程序的必备神器,基本是必不可少的一款中间件,在日常使用中,对Redis的增删改查的过程中,你有没有想过每条命令在Redis中是如何执行的,不同的数据类型是通过什么样的数据结构保存在Redis的内存中呢?什么!不知道? 没关系,看完这篇文章你就知道了~

Redis在日常开发中常用的五种数据结构,分别为StringListHashSetZset,不同的应用场景不同的数据结构来支持,下面就一起看看到底是什么样的数据结构让Redis这么快,开干!

对象

Redis数据库中,我们常用的五种数据类型并不是由某种数据结构直接实现,而基于数据结构构建了对象系统,包含字符串对象列表对象哈希对象集合对象有序集合对象,每种对象都至少使用到2中数据结构进行实现!

  • 对象的结构

    typeof struct redisObject {
        // 对象类型
        unsigned type:4;
        // 编码
        unsigned encoding:4;
        // 指向底层具体数据结构的指针
        void *ptr;
    }
    
    • 类型:一共有5中类型,分别为字符串对象列表对象哈希对象集合对象有序集合对象

      type key -- 查看key的类型

    • 编码:编码标识对象使用了什么样的编码,即具体的底层数据结构是什么(不同的编码,则不同的数据结构来实现),并且不同编码之间会根据具体保存的数据转换。

    • 指针:执行对象的底层数据结构的指针

字符串对象String

String在Redis中准确的应该是字符串对象,字符串对象的底层实现数据结构分别有:intrawembstrsds等。

int、raw、embstr编码格式

这三种实现分别是字符串的不同编码格式,当不同类型的字符串时,使用不同的编码格式进行存储,最大程度的节约内存和提升效率。当字符串中保存的都是整数,会使用int类型的编码,当保存的字符串较短时,则使用rawembstr进行保存。

SDS编码格式

众所周知,Redis是使用C语言写的,但是其中的字符串并没有使用C语言中的字符串,而是自己实现了字符串,名为简单动态字符串(SDS)

Redis中不仅将SDS作为String的底层数据结构的实现,同时SDS还应用于各种场景,例如:列表、Redis的key等。

SDS的实现

既然Redis舍弃了C语言中的字符串, 单独实现了SDS作为字符串的实现,那么SDS有哪些优点呢

  • SDS的结构

    struct sdshdr {
        // 记录sds字符串的长度,相当于buf数组中已使用的长度
        int len;
        // 记buf数组中还没有使用的长度,可用长度
        int free;
        // 字符数组,保存字符串
        char buf[];
    }
    

    首先可以看到sds的结构定义,记录了自身的字符串的长度,没有使用的内存长度,相对于C字符串可以提升较大的使用效率

  • 与C字符串区别

    • 当使用命令strlen时:C字符串并不记录自身的长度,如果需要获取自身长度,那么需要遍历整个字符串,时间复杂度为O(N),而SDS的结构中已经记录了当前字符串的长度,如果需要统计长度,那么可以直接获取到长度,时间复杂度为O(1)。

    • 当需要对字符串拼接时,由于C字符串不记录自身的长度,则在拼接时可能会由于剩余内存不足导致缓冲区溢出,而SDS保存了自身的字符串长度,并且还记录当前未使用的空间大小,在进行字符串拼接时,会先检查当前的内存空间是否满足当前操作,如果不满足则进行扩容,不会出现缓冲区溢出的问题。

    • C字符串在频繁拼接或缩短字符串的操作中,会频繁的进行系统调用进行内存的重分配,非常消耗性能。SDS在在空间重分配方面采取了空间预分配策略惰性释放空间策略来优化性能。

      • 空间预分配

        当需要进行内存分配时,不仅会分配指定的内存大小,还会多分配额外的内存空间保证在频繁字符串拼接的过程中,不会出现连续内存分配的情况

        • 当SDS的长度小于1M的时候,会额外分配和len属性相同大小的额外空间
        • 当SDS大于1M时,额外分配的内存空间为1M
      • 空间惰性释放

        当字符串进行缩短时,SDS并不会立即回收这部分需要释放的内存空间,而是使用free属性记录当前多出来的字节长度,等待将来进行字符串拼接时使用。

        当然,是以使用更多的内存为代价来提升效率低。以空间换时间。SDS也提供了API可以手动的释放内存空间

      • 二进制安全

        C语言的字符串,字符串总是以\0结尾,这就导致C语言的字符串无法保证输入的字符串和输出的字符串是完全相等的数据,并且C语言的字符串需要符合某种编码,所以C语言的字符串不能保存图片、音频、视频等二进制数据。而Redis中的SDS是二进制安全的,因为SDS不会根据特殊的标志进行字符串的区分。

        例如:123\0456,在C语言中保存为:123\0,而Redis中保存的是:123\0456

        二进制安全可以简单理解为,字符串不是根据某种特殊标志进行解析的,原始的输入和输出是相同的,不会根据某种特殊格式处理。

    • 当然,SDS也兼容了部分C语言字符串特性,在有需要时,SDS可以直接使用C字符串的部分函数

编码转换

int编码的字符串对象和embstr编码的字符串对象在条件满足的情况下会被转换为raw编码的字符串对象

  • 当字符串对象保存的字符串时整数时,使用int编码,否则使用raw编码
  • 当对字符串对象进行修改时,会将embstr编码转换为raw编码,因为embstr编码的对象并没有提供修改的函数

列表对象List

列表作为一种常用的数据结构,Redis也重新实现了链表,而没有使用C中的链表,这也是Redis链表可以高效的一个原因。列表对象的编码可以是ziplist(压缩列表)或者linkedlist*(双端链表)

压缩列表

压缩列表

压缩列表是Redis为了节约内存开发的一个连续内存块组成的顺序性数据结构,一个压缩里诶便可以包含任意多个节点,每个节点可以保存一个字节数组或者一个整数值。

  • 数据结构
  • zlbytes:记录整个压缩列表占用的内存字节数
    • zltail:记录压缩列表的起始地址到尾节点的字节长度
    • zllen:记录压缩列表中包含的节点数量
    • entry:压缩列表中的节点,每个节点中保存的是对应的数据
    • zllend:用于标记压缩列表的末端
双端链表

Redis中List结构的链表包含了前指针和后指针,可以快速的获取每个节点的前后节点,并且可以通过头指针和尾指针从双端开始遍历链表数据,这也是链表结构可以实现队列的因素。并且链表中保存了长度计数器,可以通过O(1)的时间复杂度获取到链表的长度。

双端链表

  • 数据结构
    • **head:**头指针,指向链表节点的头结点
    • **tail:**尾指针,指向链表节点的尾节点
    • **len:**链表节点的长度,一共有多少个链表的节点
    • **dup:**dup函数用于复制链表节点中的值
    • **free:**free函数用于释放链表节点中的值
    • **match:**match函数用于对比链表节点中保存的值和另一个输入值是否相等
编码转换

当列表对象可以同时满足下面的条件时,列表对象使用ziplist作为底层的编码格式,否则使用链表作为底层实现

  • 列表对象保存的所有字符串元素的长度都小于64字节
  • 列表对象保存的元素数量小于512个

上面的两个条件中的上限值都是可以通过参数进行调整。

当满足上述的条件时,压缩列表中的元素就会被迁移并保存到双端链表中

哈希对象Hash

Redis中的哈希对象同样也是用了两种编码格式实现,在不同的场景下使用不同的数据结构。哈希对象的编码可以是ziplisthashtable

ziplist

压缩列表

  • 和列表对象相同的是,哈希对象也使用ziplist作为底层实现之一,当数据量较少的时候,压缩列表保存数据时可以节省内存。保存的结构是key和value是相邻的两个元素,保存了同一个键值的两个节点总是紧挨在一起,并且保存键的节点在前,保存值的节点在后。

    在压缩列表中查找值的时候,也是通过先找到对应的键,然后通过将指针向后移动一位,找到该键对应的值

hashtable

hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对保存

  • 字典

    字典结构

    Redis中的字典使用哈希表作为底层实现,哈希表就是常用的数组+链表的实现方式来保存键值对。

    // 字典结构
    typedef struct dict {
        // 类型特定函数
    	dictType *type;
        // 私有数据
        void *privdata;
        // 哈希表
        dicht ht[2];
        // rehash索引
        int trehashindx;
    
    } dict;
    
    // 哈希表结构
    typedef struct dictht {
        //哈希表数组
        dictEntry **table;
        //哈希表大小
        unsigned long size;
        // 哈希表大小掩码,总是等于size-1,用于计算索引的值
        unsigned long sizemask;
        // 哈希表已有节点数量
        unsigned long used;
    } dictht;
    
    // 哈希表节点结构
    typedef struct dictEntry {
        //键
        void *key;
        //值
        union {
            void *val;
            uint64_t u64;
            int64_t s64;
        } v;
        // 指向下一个哈希表节点,形成链表
        struct dictEntry *next;
    } dictEntry;
    
    • 属性说明:
      • dicht ht[2]:ht属性是包含两个元素的数组,并且都是哈希表的类型元素,一般情况下,字典只会使用**ht[0]**的哈希表作为数据的保存,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用
      • type:包含特定的哈希表需使用到的函数,例如:计算哈希值的函数、对比键的函数、删除键的函数、删除值的函数等
  • 哈希冲突

    因为字典采用了数组+链表的数据结构,所以尽管Redis的哈希算法可以给出很好的随机分布性,但是仍然会有部分哈希冲突存在。Redis的哈希表使用链地址法来解决哈希冲突,通过每个哈希表的next指针来构建为单项链表。

    哈希冲突

  • rehash

    熟悉Java中的HashMap的小伙伴都知道,HashMap中的数据超过负载因子的长度时,HashMap就会进行扩容,在扩容的过程中会进行rehash操作,而Redis中的哈希表也是相同的原理,所以在哈希表中的键值对过多或者过少的时候,会触发哈希表的扩容和缩容,这个过程中就会执行rehash操作。

    • rehash的步骤:

      • 对ht[1]的哈希表分配内存空间,哈希表的大小取决于要执行的操作,以及ht[0]中包含的键值对的数量

        • 对于扩展操作,那么扩展的哈希表的大小为第一个大于等于ht[0].used*2的2的n次方
        • 对于收缩操作,那么收缩哈希表的大小为第一个大于等于ht[0].used的2的n次方的长度
      • 接下来将所有保存在ht[0]中的键值对rehash到ht[1]上面

        重新计算所有键的哈希值、数组索引值

      • 将ht[0]中的所有的键值对迁移到ht[1]之后,会将ht[0]的空间释放,并交换指针,将ht[1]设置为ht[0],并给ht[1]建立一个新的哈希表,为下次rehash做准备

    • 哈希表的扩容和收缩

      当下面的条件任意一个满足时,就会进行哈希表的扩容或者收缩:

      • 当服务器没有执行BGSAVE或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1

      • 服务器正在执行BGSAVE或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5

        负载因子 = 哈希表已保存的节点数量 / 哈希表大小

      因为BGSAVE命令和BGREWRITEAOF命令在执行时,都需要使用额外的内存空间,BGSAVE命令是生成内存快照RDB文件时,采用COW机制,通过子进程对内存的当前数据做快照保存,所以内存会有额外的内存使用。BGREWRITEAOF命令是对AOF文件的重写时,会使用到额外的AOF重写缓冲区。Redis在执行这两个命令时,将负载因子增大的目的是为了节约内存,最好不要在内存使用较大的时机进行rehash操作。

  • 渐进式rehash

    在哈希表进行rehash的过程中,并不是一次性的将所有的键值对都rehash到ht[1]中,因为当键值对的数量过大,那么在rehash过程中的计算量是非常大的,redis在这个过程中是无法提供服务的。所以Redis采用了渐进式rehash操作,将所有的键通过分批次、渐进式的完成迁移。

    • 渐进式rehash操作的步骤:

      • 为ht[1]分配内存空间,让字典同时持有ht[0]和ht[1]两个哈希表

      • 将字典中的索引计数器变量rehashidx设置为0,表示当前正在rehash

      • rehash的过程中,每次对字典执行添加、删除、查找或更新操作时,程序除了执行原本的指令操作之外,还会将ht[0]哈希表中的部分键值对进行rehash

        部分键值对并不是随机查找的,而是通过rehashidx对应的哈希表的索引上面的键值对进行rehash,并且在一次rehash完成之后,将rehashidx自增

      • 在rehash完成之后,再讲rehashidx设置为-1,并交换ht[0]和ht[1]的指针

      rehash的优点就在于分而治之的思想,将大量的操作平均到每个小的操作过程中,防止集中的操作带来的较大的计算量

    • 渐进式rehash过程中哈希表操作

      在渐进式rehash过程中,字典会同时拥有ht[0]和ht[1]两个哈希表,所以在执行查找、删除、更新操作时,程序会现在ht[0]中查找,如果没有找到的话,那么就需要在ht[1]中查找。并且在rehash的过程中,新添加的键值对都会直接添加到ht[1]中,所以ht[0]中的键值对是只减不增,那么最终会迁移完成。

集合对象Set

集合对象可以保存的键的特点为无序、不重复,所以这一点和字典非常像,同样的集合对象也有两种编码格式:intset或者hashtable

intset

当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合对象的底层实现,目的也是为了节约内存。

  • 整数集合的结构

    typedef struct intset {
        // 编码方式
        uint32_t encoding;
        // 集合包含的元素数量
        uint32_t length;
        // 保存元素的数组
        int8_t contents[];
    } intset;
    

    属性说明:

    • encoding:整数集合的编码方式有3种
      • INTSET_ENC_INT16:表示数组为int16_t类型的数组,每一项都是一个int16_t类型的整数值(short的范围:-32768 — 32767),每一项占用16位内存空间
      • INTSET_ENC_INT32:表示数组为int32_t类型的数组,每一项都是int32_t类型的整数值(int的范围:-2147483648 — 2147483467),每一项占用32为内存空间
      • INTSET_ENC_INT64:表示数组为int64_t类型的数组,每一项都是int64_t类型的整数值(long的范围:-9223372036854775808 — 9223372036854775807),每一项占用64位内存空间
    • length:数组中保存的元素数量
    • contents:保存元素的数组,虽然类型为int8_t,但是数组中元素类型是通过encoding的编码方式决定。
  • 整数集合的升级

    整数集合的数组的编码方式是固定的,所以数组的编码方式只能是同一种,当数组中添加的新元素类型比现有集合中所有的元素类型都要长时,那么整数集合就需要升级,并且数组中的每个元素的类型都会转换为最大的类型。

    • 升级的步骤:

      • 根据新元素的类型,扩展整数集合的底层数组的空间大小,并未新元素分配内存空间
      • 将数组中的所有元素都转换为新元素的类型,并将转换之后的元素放置到正确的位置上
      • 将新元素添加到数组中
    • 升级的好处

      • 提升灵活性:为了避免类型错误,不会将两种不同的类型放入到一个数组中,而通过升级,让数组中的所有元素的类型保持一致
      • 最大程度节约内存:避免类型错误,不会再同一个数组中使用不同的类型元素,而如果直接使用int64_t的类型,那么如果这个数组不会保存int64_t的元素类型,那么就会出现内存浪费,所以通过数组类型升级的方式可以避免内存浪费,节约内存。
    • 整数集合的降级

      整数集合不支持降级,一旦数组升级,那么数组的编码格式不会改变

hashtable

hashtable编码的集合对象使用字典作为底层数据结构,字典的每个键都是一个集合元素,而字典的值全部默认设置为NULL。

集合对象

编码转换

intset或者hashtable在一定的条件下都是可以相互转换的数据类型

当符合下面的条件时,集合对象使用intset作为底层编码:

  • 集合对象保存的所有元素都是整数值
  • 集合对象保存的元素数量小于512个

当元素中含有非整数值或元素的数量大于512个,那么就会使用hashtable编码作为集合的底层实现

有序集合对象

有序集合的特点是根据score自动进行排序、保证key的唯一性。有序集合的底层编码可以是ziplistskiplist两种

ziplist

压缩列表

有序集合底层使用压缩列表实现当然也是为了在适当的数量时最大程度的节约内存空间

压缩列表在多个redis对象中均有使用,列表对象、哈希对象、有序集合对象等,不过每个对象在使用压缩列表保存数据时,根据每个对象的结构的特性不同保存数据的方式也有些不同

  • 有序集合在保存集合元素时,会使用两个紧挨在一起的压缩列表的节点进行保存数据,第一个节点保存的是元素的成员(key),第二个节点保存的是元素的分值(score),这样就可以根据key找到相应的score,并且在压缩列表中集合元素按照分值进行排序,分值较小的元素被放置在靠近表头的位置,而分值较大的元素则被放置在靠近表尾的位置。
skiplist

使用了skiplist编码实现的有序集合,底层使用的数据结构为zset,一个zset结构,同时包含一个字典跳跃表

  • 跳跃表

    跳跃表数据结构

    skiplist编码实现新增的数据结构为跳跃表,跳跃表在有序集合的数据结构中,主要作用是对score进行排序,在使用ZRANKZRANGE命令时,就是通过基于跳跃表的API实现,可以让命令执行的更快,提高效率。

    • redis中跳跃表的数据结构

      /* 跳跃表的节点对象 */
      typedef struct zskiplistNode {
          // value
          sds ele;
          // 分值
          double score;
          // 后退指针
          struct zskiplistNode *backward;
          // 层
          struct zskiplistLevel {
              // 前进指针
              struct zskiplistNode *forward;
              // 跨度
              unsigned long span;
          } level[];
      } zskiplistNode;
      
      /* 跳跃表的对象,内部维护跳跃表的节点对象 */
      typedef struct zskiplist {
          // 跳跃表头指针
          struct zskiplistNode *header, *tail;
          // 表中节点的数量
          unsigned long length;
          // 表中层数最大的节点的层数
          int level;
      } zskiplist;
      

    redis中的跳跃表和数据结构中的跳跃表稍微有些不同,因为Redis中的跳跃表最大的层数为32层,至于为什么默认只有32层,查阅了相关资料,并没有说明,不过根据猜测可能是32层就完全够用了, 因为根据概率进行计算,32层的元素的概率较低,当数据量较大的时候才会有32层的的数据

    为什么不用链表、数组,而是用跳跃表

    因为需要随机插入和删除,而如果使用链表和数组,那么时间复杂度会是O(N),跳跃表的增删改查都是O(logN)的时间复杂度

  • zset的数据结构

    zset的数据结构中包含了字典和跳跃表

    zset数据结构

    typedef struct zset {
        zskiplist *zsl;
        dict *dict;
    } zset;
    
    • zskiplist:通过跳跃表实现,主要是作为score的排序,对成员进行范围查询
    • dict:通过字典保存了score和成员之间的映射关系,key是成员,value是score
  • zset的执行原理

    zset中既包含字典、也包含跳跃表,不过两者的作用不同,分工明确。

    • 字典

      zset结构中的字典保存的是成员到分值的映射关系,可以通过成员以O(1)的时间复杂度获取到相应的分值,其中ZSCORE命令就是根据字典的特性实现

    • 跳跃表

      跳跃表中将成员的分数按照从小到大的顺序已经自动排序,每个跳跃表的节点既保存了成员对象也保存了成员对象的分值,通过跳跃表,可以对有序集合范围型操作,并且时间复杂度较低,例如ZRANGEZRANK等命令就是通过跳跃表实现

    因为跳跃表虽然能够快速的范围性查找,但是对于根据成员查找分值这种特定的操作,跳跃表查询的时间复杂度为O(logN),而哈希表的查找时间复杂度为O(1)。哈希表在顺序性的范围查找的时间复杂度为O(NlogN),而跳跃表的时间复杂度为O(logN)。

    虽然Redis内存同时使用了跳跃表和哈希表两种数据结构,但是这两种数据结构会通过指针来共享相同元素的成员和分值,所以尽管Redis使用了两种数据结构保存成员和分值,但是不会占用额外的内存,不会造成内存浪费的情况。

编码转换

ziplistskiplist在适当的条件下会进行编码的转码,同时底层的数据结构也会进行改变,在节省内存的同时,也会同时保证命令的高效执行

当有序集合对象满足下面的条件时,对象会使用ziplist编码:

  • 有序集合保存的元素数量小于128个
  • 有序集合保存的元素成员的长度都小于64字节

如果不满足上面的条件,那么有序集合将采用skiplist编码格式实现

总结

对于Redis中的字符串、列表、哈希、集合、有序集合,每种类型的对象至少都有两种或两种以上的编码方式,不同的编码方式在底层数据结构的实现上可能完全不同,使用不同的编码方式在不同的场景上优化对象的使用效率,这也是Redis之所以可以这么快的一个主要原因之一。

参考资料

《Redis 设计与实现》 - redisbook.com/

《Redis 深度历险》 - book.douban.com/subject

《Redis(2)——跳跃表》- zhuanlan.zhihu.com/p/109946103



你Get到了吗~

微信公众号指尖上的代码,欢迎关注~ 一起学习 一起进步

原创不易, 点个赞再走呗~ 欢迎关注,给你带来更精彩的文章!

你的点赞关注是写文章最大的动力~