Redis底层数据结构

146 阅读28分钟

RedisObject

简介

通常我们了解的Redis数据结构有字符串、双端链表、字典、压缩列表、整数集合等,但是Redis为了加快读写速度,并没有直接使用这些数据结构,而是在此基础上又包装了一层称之为RedisObject。

RedisObject 有五种对象:字符串对象(String)、列表对象(List)、哈希对象(Hash)、集合对象(Set)和有序集合对象(ZSet)。因此Redis中每一个value都可以理解为是一个RedisObject。

RedisObject数据结构

RedisObjectr.jpg

type属性

type主要存储当前value对象的数据类型。

数据类型对象名称
REDIS_STRING字符串对象
REDIS_LIST列表对象
REDIS_SET集合对象
REDIS_ZSET有序集合对象
REDIS_HASH哈希对象

对于Redis而言,key保存一定为String类型,而value则为上述表格type中的一种;

enconding属性

encoding 存储当前值对象底层编码的实现方式。不同type对象对应不同的编码。

类型编码对象
REDIS_STRINGREDIS_ENCODING_INT使用整数值实现的字符串对象
REDIS_STRINGREDIS_ENCODING_RAW使用简单动态字符串实现的字符串对象
REDIS_STRINGREDIS_ENCODING_EMBSTR使用embstr编码的简单动态字符串实现的字符串对象
REDIS_LISTREDIS_ENCODING_ZIPLIST使用压缩列表实现的列表对象
REDIS_LISTREDIS_ENCODING_LINKENDLIST使用双端链表实现的列表对象
REDIS_HASHREDIS_ENCODING_ZIPLIST使用压缩列表实现的哈希对象
REDIS_HASHREDIS_ENCODING_HT使用字典实现的哈希对象
REDIS_SETREDIS_ENCODING_INTSET使用整数集合实现的集合对象
REDIS_SETREDIS_ENCODING_HT使用字典实现的集合对象
REDIS_ZSETREDIS_ENCODING_ZIPLIST使用压缩链表实现的有序集合对象
REDIS_ZSETREDIS_ENCODING_SKIPLIST使用跳跃列表和字典实现的有序集合对象

而每一种编码又对应了一种具体的底层数据结构

编码底层数据结构备注
REDIS_ENCODING_INTlong类型整数long类型整数64位,用有符号的以二进制补码的形式表示整数,最大值为2^63 -1,最小值为-2^63
REDIS_ENCODING_RAWembstr编码的简单动态字符串embstr编码格式底层数据结构为embstr编码的SDS(Simple Dynamic String 简单动态字符串),保存长度为小于44字节的字符串
REDIS_ENCODING_EMBSTR简单动态字符串当value长度超过44位,value编码方式变为raw,底层数据结构为简单动态字符串(SDS)
REDIS_ENCODING_ZIPLIST压缩列表
REDIS_ENCODING_LINKENDLIST双端列表
REDIS_ENCODING_HT字典
REDIS_ENCODING_INTSET整数集合
REDIS_ENCODING_SKIPLIST跳跃表和字典

lru属性

lru记录此对象最后一次访问的时间。

当redis内存回收算法设置为volatile-lru或者allkeys-lru,内存使用率超过maxmemory所设置的上限时,Redis会优先释放最久没有被访问的数据。

refcount属性

一方面Redis配置了当配置maxmemory并启用LRU相关淘汰策略时采用引用计数法,类似于jvm的引用计数垃圾回收算法,用refcount进行标记引用使用的次数,当引用为0时,会将该对象销毁,进而回收内存跟lcu配合一起工作。

另一方面refcount可实现整数对象共享的功能。当Redis 未配置有maxmemory 时,存在共享对象池,[0-1000] 范围内的整数数值存在共享对象,这些共享对象被引用的次数越多相应的refcount 越大。

举例:当键A跟键B的值都是一个整数100的对象时,redis不会创建两个新对象,而是创建一个整数100的对象,将refcount设置为2,表示有两个引用,该对象同时被键A和键B共享。数据库中保存相同值的对象越多,对象共享机制就能节约更多的内存。

为什么Redis共享的是将0到9999范围内的整数数值,而不是其他的对象呢?

具体有以下几个方面综合考虑:

  • 只有两个对象完全相同的前提下,程序才会将其中一个对象作为共享对象;
  • 一个共享对象保存的数据越复杂,验证两个对象是否相同的过程就会越麻烦,变相的就会消耗更多的CPU来计算,变相的时间换空间;
  • 如果共享对象是保存整数型的字符串对象,验证复杂度为O(1);如果共享对象是保存字符串值的字符串对象,验证复杂度为O(n);如果共享对象时包含多个值的对象,比如哈希对象、列表对象,验证复杂度为O(n^2)

因此,基于上述因素考虑,将0到9999范围内的整数作为共享对象有较高的性价比;就跟java Integer的常量缓存池(默认-128~127数值)一样;

ptr 指针属性

ptr 指针是指向对象的底层实现数据结构

数据类型和底层数据结构的关系

Redis数据类型和数据结构关系.jpg

  • Redis3.2版本中List新的底层数据结构:quicklist;
  • Redis5.0版本中新增流数据类型(Stream),其底层数据结构:listpack;
  • Redis7.0版本中Ziplist编码替换为Listpack编码;

SDS 简单动态字符串

在RedisObject对象中当type为REDIS_STRING,此时的RedisObject对象为字符串对象,对应编码方式有INT、RAW、EMBSTR,编码为RAM和EMBSTR是由简单动态字符串实现的,这里的简单动态字符串就是SDS。

SDS数据结构

Redis6.x 的 SDS 的数据结构定义与 Redis3.X 相差比较大,先从Redis3.X开始

typedef char *sds;
struct sdshdr {  
	//记录buf数组中已使用字节的数量  
	//等于SDS所保存字符串的长度  
	unsigned int len;  
	//记录buf数组中未使用字节的数量  
	unsigned int free;  
	//char数组,用于保存字符串  
	char buf[];  
};

Redis3.X存储字符串climber如下图所示

sds.jpg

由上图可见在Redis3.X中一个SDS字符串的完整结构,由在内存地址上前后相邻的两部分组成:

  • 一个Header。通常包含字符串的长度(len)、剩余空间(free)。
  • 一个字符数组buf[],保存字符串,以’\0’字符结尾(’\0’不在len的长度计算范围内)。

在Redis3.0中SDS的Header均初始化为unsigned int类型,而当前主流的编译器中一般是32位机器和64位机器中int型都是4个字节;每个字符串SDS均有相同大小的Header,如果一个字符串很短,也意味着大量的内存空间都存储了Header,这样造成了严重的空间浪费;

数据结构优化

基于Redis3.X关于SDS相同Header的问题,Redis6.x将SDS划分成了五种类型的SDS

#define SDS_TYPE_5  
#define SDS_TYPE_8  
#define SDS_TYPE_16 
#define SDS_TYPE_32 
#define SDS_TYPE_64 

为了能让不同长度的字符串可以使用不同大小的header。这样,短字符串就能使用较小的header,从而节省内存。

一个SDS字符串的完整结构,由在内存地址上前后相邻的两部分组成:

  • 一个header。通常包含字符串的长度(len)、最大容量(alloc)和flags。sdshdr5有所不同
  • 一个字符数组。这个字符数组的长度等于最大容量+1。这里的+1是为了存储’\0’字符结尾,而字符数组的最大容量会大于字符的真正长度,预留的字节空间是为了给字符串数据向后做有限的扩展使用,空余未用的字节一般以字节0填充;

除了sdshdr5之外,其它4个header的结构都包含3个字段:

  • len: 表示字符串的真正长度(结尾符’\0’不在len的长度计算范围内)。
  • alloc: 表示字符串的最大容量(结尾符’\0’不在大小计算范围内)。
  • flags: 总是占用一个字节。其中的最低3个bit用来表示header的类型。header的类型共有5种,在sds.h中有常量定义。
// 注意:sdshdr5从未被使用,Redis中只是访问flags。  
struct __attribute__ ((__packed__)) sdshdr5 {  
    unsigned char flags; /* 低3位存储类型, 高5位存储长度 */  
    char buf[];  
};  
struct __attribute__ ((__packed__)) sdshdr8 {  
    uint8_t len; /* 已使用 */  
    uint8_t alloc; /* 总长度,用1字节存储 */  
    unsigned char flags; /* 低3位存储类型, 高5位预留 */  
    char buf[];  
};  
struct __attribute__ ((__packed__)) sdshdr16 {  
    uint16_t len; /* 已使用 */  
    uint16_t alloc; /* 总长度,用2字节存储 */  
    unsigned char flags; /* 低3位存储类型, 高5位预留 */  
    char buf[];  
};  
struct __attribute__ ((__packed__)) sdshdr32 {  
    uint32_t len; /* 已使用 */  
    uint32_t alloc; /* 总长度,用4字节存储 */  
    unsigned char flags; /* 低3位存储类型, 高5位预留 */  
    char buf[];  
};  
struct __attribute__ ((__packed__)) sdshdr64 {  
    uint64_t len; /* 已使用 */  
    uint64_t alloc; /* 总长度,用8字节存储 */  
    unsigned char flags; /* 低3位存储类型, 高5位预留 */  
    char buf[];  
};

内存对齐

一般CPU工作的时候与内存地址和寄存器的映射关系有关,由于不同的平台对存储的空间的处理方面也存在差异,特定的数据结构要从特定的地址开始获取,如果未按照平台的要求,则存在一定的存取效率损耗;总之内存对齐是为了使平台在存取数据的时候,提高效率;

SDS 内存非对齐

由于SDS特殊的数据结构,决定了此处内存非对齐;

sds6.x.png

Redis6.X后,SDS的指针并非指向 SDS 的起始位置(len位置),而是指向了buf[]位置;如果内存对齐,Redis6.X后不同类型的SDS,Header占用的空间大小都不一致,指针在len位置,在不知道具体SDS类型的前提下,指针到底前进和后退多少位才可以拿到buf[]或者flag?因此SDS特殊的数据结构决定了采用了内存非对齐,并且SDS指针起始位置指向了buf[]位置。

SDS 二进制安全

什么是二进制安全?

通俗地讲,C语言中,用'\0'是转义字符,值等于0,表示字符串的结束,如果字符串本身就有'\0'字符,字符串就会被截断,即非二进制安全;若通过某种机制,保证读写字符串时不损害其内容,则是二进制安全。

那么SDS是如何保证二进制安全的?

SDS Header头包含len属性,len的值决定了buf[]里面第几位是真正的结束符'\0',而不是字符串值'\0',这样便保证了字符串读取不会出现截断或者多余的现象,并且SDS 的 API 都是以处理二进制的方式来处理 SDS 存放在 buf[]里的数据,程序不会对其中的数据做任何限制,数据写入的时候时什么样的,它被读取时就是什么样的,这样便保证了二进制安全。

SDS扩容

sdsMakeRoomFor是sds实现中很重要的一个函数,该函数实现了SDS的自动扩容机制;当SDS API需要对SDS进行修改时,API会先检查 SDS 的空间是否满足修改所需的要求,如果不满足,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改SDS的空间大小,也不会出现缓冲区溢出问题。

扩容阶段

  • 若 SDS 中剩余空闲空间 avail 大于新增内容的长度 addlen,则无需扩容;
  • 若 SDS 中剩余空闲空间 avail 小于或等于新增内容的长度 addlen:
  • 若新增后总长度 len+addlen < 1MB,则按新长度的两倍扩容;
  • 若新增后总长度 len+addlen > 1MB,则按新长度加上 1MB 扩容。

内存分配阶段

  • 根据扩容后的长度选择对应的 SDS 类型:
  • 若类型不变,则只需通过 s_realloc_usable扩大 buf 数组即可;
  • 若类型变化,则需要为整个 SDS 重新分配内存,并将原来的 SDS 内容拷贝至新位置。

内存预分配策略的好处

不同的数据类型SDS,其Header中的3个字段长度都有所差异。在数据类型不变的情况下,内存分配阶段由于内存预分配策略,SDS会预留一部分空间给到该字符串,因此在字符串长度频繁增长时,也不会频繁的分配空间。

惰性空间释放机制

空间预分配是为了应对SDS字符串频繁的增长,但是当字符串长度减少的时候,SDS并不是立即重新分配内存空间,而是调用sdsclear方法直接修改len长度,空余的空间留待将来字符串增长使用。如果真正要释放内存是通过sdsRemoveFreeSpace方法来实现的。

SDS最大字符串容量

在Redis3.X版本中len字段是由int类型来修饰的,而这样便决定了len的长度为2147483647,即512M。同样在Redis6.X中sdsTypeMaxSize方法返回的最大长度也为2147483647512M

static inline size_t sdsTypeMaxSize(char type) {
    if (type == SDS_TYPE_5)
        return (1<<5) - 1;
    if (type == SDS_TYPE_8)
        return (1<<8) - 1;
    if (type == SDS_TYPE_16)
        return (1<<16) - 1;
#if (LONG_MAX == LLONG_MAX)
    if (type == SDS_TYPE_32)
        return (1ll<<32) - 1; // 最大返回2147483647即512M
#endif
    return -1; /* this is equivalent to the max SDS_TYPE_64 or SDS_TYPE_32 */
}

SDS数据结构优势

  • 获取字符串长度 O(1)复杂度(直接查询len字段即可)
  • 二进制安全
  • 不会发生缓冲区溢出
    • alloc 和 len 成员变量,保障了SDS字符串在进行修改的时候,可由程序判断缓冲区大小,避免缓冲区溢出;
  • 节省内存空间
    • Redis6.x后SDS 设计不同类型的结构体,是为了能灵活保存不同大小的字符串,从而有效节省内存空间;

linkedlist 双向链表

Redis的List数据类型底层实现之一就是链表。C 语言本身没有链表这个数据结构的,所以 Redis自己设计了一个链表数据结构。

链表节点结构设计

typedef struct listNode {
    //前置节点 如果是list的头结点,则prev指向NULL 
    struct listNode *prev;
    //后置节点 如果是list尾部结点,则next指向NULL 
    struct listNode *next;
    //节点的值
    void *value;
} listNode;

下图所示是由3个listNode组成的双向链表 image.png

linkedlist数据结构

Redis 在 listNode 结构体基础上又封装了 list 这个数据结构,这样操作起来会更方便。

typedef struct list {
    //链表头节点
    listNode *head;
    //链表尾节点
    listNode *tail;
    //链表节点数量
    unsigned long len;
    
    //下面的三个函数指针就像类中的成员函数一样 实现链表新增、删除、比较操作
    //节点值复制函数
    void *(*dup)(void *ptr);
    //节点值释放函数
    void (*free)(void *ptr);
    //节点值比较函数
    int (*match)(void *ptr, void *key);
   
} list;

如下图所示 一个由3个listNode组成的list结构 image.png

Redis链表优势

  • listNode链表节使用 void* 指针保存节点值,因此链表节点可以保存各种不同类型的值;
  • list结构的len属性,在获取链表中的节点数量的时间复杂度只需O(1);
  • list 结构因为提供了表头指针 head 和表尾节点 tail,在获取链表的表头节点和表尾节点的时间复杂度只需O(1) ;
  • 获取某个节点的前置节点或后置节点的时间复杂度只需O(1),而且这两个指针都可以指向 NULL,所以链表是无环链表;

Redis链表劣势

  • 链表每个节点之间的内存都是不连续的,通过指针前后指针串联所有的数据,因为内存不连续将意味着无法很好利用 CPU 缓存。因为 CPU 缓存的数据结构就是数组,因为数组的内存是连续的,这样就可以充分利用 CPU 缓存来加速访问;
  • list结构决定了每一个节点都需要分配空间来存储节点的结构头,尤其针对保存的对象数据较小的场景下,大部分内存都用于节点保存结构头了,造成了内存的浪费;

因此在基于上述劣势场景,针对于较小的数据对象,Redis 采用ziplist 压缩链表来存储。以节省内存空间并且有效利用 CPU 缓存加速访问。

ziplist 压缩链表

ziplist是个经过特殊编码的双向链表,它的设计标是为了提存储效率。其最大特点,就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码,这种方法可以有效地节省内存开销。

压缩列表数据结构

压缩列表是 Redis 为了节约内存而开发的,它是由连续内存块组成的顺序型数据结构,有点类似于数组。 ziplist.png

  • Header
    • zlbytes,记录整个压缩列表占用对内存字节数;
    • zltail,记录压缩列表「尾部」节点距离起始地址有多少字节,也就是列表尾的偏移量;
    • zllen,记录压缩列表包含的节点数量;
  • entity
    • prevlen,记录了「前一个节点」的长度;
    • encoding,记录了当前节点实际数据的类型以及长度;
    • data,记录了当前节点的实际数据
  • end
    • zlend,标记压缩列表的结束点,固定值 0xFF(十进制255)

在压缩列表中如果要查找第一个元素或者最后一个元素,可以根据表头Header3个字段直接定位,时间复杂度为O(1),如果要查找其他元素则需要一个一个遍历复杂度为O(N);因此这也决定了压缩列表不能存储较多的数据对象;

在entity区域,prevlen 记录了前一个节点的长度,默认为1个字节; ziplist-entity.jpg

  • 当上一个节点的长度<254则entry中的header仅仅为占据一个字节的prevlength;
  • 上一个节点的长度>=254的话,prevlength则变形为固定的254(一个字节)+占据4个字节的prevlength;

在压缩列表中,值254表示该entity的上一个节点长度超过了254。值255表示压缩列表的结束点;

每个ziplist的entry都可以用来保存整数或字符串。encoding则记录了当前节点实际数据的类型以及长度。

当存储为整数的时候 ziplist-encoding-整数.jpg

  • encoding固定为1字节即8位;
  • 高2位固定为1 1 因此整数encoding以11开头 ;
  • 低6位区分数据类型,分为INT_8,INT_16,INT_24,INT_32,INT_64;
    #define ZIP_INT_16B (0xc0 | 0<<4) /* 11000000 */
    #define ZIP_INT_32B (0xc0 | 1<<4) /* 11010000 */
    #define ZIP_INT_64B (0xc0 | 2<<4) /* 11100000 */
    #define ZIP_INT_24B (0xc0 | 3<<4) /* 11110000 */
    #define ZIP_INT_8B 0xfe           /* 11111110 */
    
    #define ZIP_INT_IMM_MIN 0xf1      /* 11110001 */
    #define ZIP_INT_IMM_MAX 0xfd      /* 11111101 */
    
  • 如果整数值1~13之间,则没有数据节点data,此时encoding的低4位用来表示data;

当存储为字符串的时候

#define ZIP_STR_06B (0 << 6) 
#define ZIP_STR_14B (1 << 6)
#define ZIP_STR_32B (2 << 6)
  • 长度 < 2^6 时,以 00 开头,后 6 位表示 data 的长度,。
  • 2^6 <= 长度 < 2^14 时,以 01 开头,后续 6 位 + 下一个字节的 8 位 = 14 位表示 data 的长度。
  • 2^14 <= 长度 < 2^32 字节时,以 10 开头,后续 6 位不用,从下一字节起连续 32 位表示 data 的长度。

压缩列表连锁更新

当压缩列表新增或者修改某个元素数据的时候,如果空间不足,压缩列表会重新分配内存空间;

ziplist-连锁扩展.jpg

当一个压缩列表entity长度均 <254,节点保存上一个节点的长度 prevlen 因为上一个节点长度小于254,此时prevlen占1个字节;

  • 如上图所示,当前压缩列表连续存在多个entity长度小于254,当在表头位置插入一个新的entity节点的时候,该entity节点长度大于等于254;
  • 此时需要修改下一个entity节点的prevlen长度,之前节点prevlen占1个字节,此时如果上一个entity长度>=254需要扩展5个字节才可以记录prevlen值(固定的254(一个字节)+占据4个字节的prevlength);
  • 因为前节点长度变动,会引起后续节点prevlen字段修改,一旦处于临界值,导致1个字节无法存储prevlen,就会引起多米诺牌效应,后面连续的entity都需要重新分配空间;

基于上述特殊情况带来的连续空间扩展称之为连锁扩展。

压缩列表优势

  • 压缩列表内存空间连续,可以有效的利用CPU缓存,提高访问性能;
  • 压缩列表结构紧凑,针对小对象存储,大大节省了内存空间;

压缩列表劣势

  • 一旦发生连锁扩展现象,会导致连续性的内存空间重分配,这样会影响压缩列表的访问性能;
  • 查找非首尾节点的数据,时间复杂度为O(N);

基于上述压缩列表的优劣,这样便决定了压缩列表只能针对较小的对象存储且对象数量不宜过多场景。在节点数量不多的情况下,即使发生连锁效应也可以接受。

quicklist 快速列表

在 Redis 3.0 之前,List 对象的底层数据结构是双向链表或者压缩列表。然后在 Redis 3.2 的时候,List 对象的底层改由 quicklist 数据结构实现。

quickList 就是「双向链表 + 压缩列表」组合,因为一个 quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表。

quicklist数据结构

typedef struct quicklist {
  // 头指针
  quicklistNode *head;
  // 尾指针
  quicklistNode *tail;
  unsigned long count;        /* 列表中的元素总个数,也就是所有ziplist中包含的元素数量之和 */
  unsigned long len;          /* 链表中节点的个数 */
  int fill : QL_FILL_BITS;              /* 表示ziplist的大小 */
  unsigned int compress : QL_COMP_BITS; /* depth of end nodes not to compress;0=off */
  unsigned int bookmark_count: QL_BM_BITS;
  quicklistBookmark bookmarks[];
} quicklist;

quicklist.png

quicklist

  • head:指向头结点的指针;
  • tail:指向尾节点的指针;
  • count:列表中的元素总个数,等于所有节点的ziplist中包含的元素数量之和;
  • len:quicklist中quicklistNode节点的个数;
  • fill:用来限制quicklistNode中ziplist的大小,为正数时代表ziplist中最多能包含的元素个数,为负数时有以下几种情况:
数值含义
-1表示ziplist的字节数不能超过4KB
-2表示ziplist的字节数不能超过8KB
-3表示ziplist的字节数不能超过16KB
-4表示ziplist的字节数不能超过32KB
-5表示ziplist的字节数不能超过64KB
  typedef struct quicklistNode {
    struct quicklistNode *prev; //上一个node节点
    struct quicklistNode *next; //下一个node
    unsigned char *zl;            //保存的数据 压缩前ziplist 压缩后quicklistLZF
    unsigned int sz;             /* ziplist size in bytes */
    unsigned int count : 16;     /* count of items in ziplist */
    unsigned int encoding : 2;   /* RAW==1 or LZF==2 */
    unsigned int container : 2;  /* NONE==1 or ZIPLIST==2 */
    unsigned int recompress : 1; /* was this node previous compressed? */
    unsigned int attempted_compress : 1; /* node can't compress; too small */
    unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;

quicklistNode

  • prev: 指向链表前一个节点的指针。
  • next: 指向链表尾一个节点的指针。
  • zl: 数据指针。如果当前节点的数据没有压缩,那么它指向一个ziplist结构;否则,它指向一个quicklistLZF结构。
  • sz: 表示zl指向的ziplist的总大小(包括zlbytes, zltail, zllen, zlend和各个数据项)。需要注意的是:如果ziplist被压缩了,那么这个sz的值仍然是压缩前的ziplist大小。
  • count: 表示ziplist里面包含的数据项个数。这个字段只有16bit。
  • encoding: 表示ziplist是否压缩了(以及用了哪个压缩算法)。目前只有两种取值:2表示被压缩了(而且用的是LZF压缩算法),1表示没有压缩。
  • container: 是一个预留字段。本来设计是用来表明一个quicklist节点下面是直接存数据,还是使用ziplist存数据,或者用其它的结构来存数据(用作一个数据容器,所以叫container)。但是,在目前的实现中,这个值是一个固定的值2,表示使用ziplist作为数据容器。
  • recompress: 当我们使用类似lindex这样的命令查看了某一项本来压缩的数据时,需要把数据暂时解压,这时就设置recompress=1做一个标记,等有机会再把数据重新压缩。
  • attempted_compress: 这个值只对Redis的自动化测试程序有用。
  • extra: 其它扩展字段。目前Redis的实现里也没用上。

quicklist总结

  • 在Redis3.2版之后引入了quicklist,quicklist是一个双向链表,链表中的每一个节点是一个ziplist。
  • quicklist中限定了ziplist的大小(参数list-max-ziplist-size设置,默认8K字节),如果超过了限制的大小,新加入元素的时候会生成一个新的quicklistNode节点,否则会在当前所在的ziplist插入数据。
  • quicklist通过限定ziplist的大小来保证一个ziplist中的元素个数不会太多,如果需要连锁更新,也只在某个quicklistNode节点指向的ziplist中更新,不会引发整个链表的更新,以此来解决压缩列表存在的问题。
  • 默认情况下,list-compress-depth参数为0,也就是不压缩数据;当该参数被设置为1时,除了头部和尾部之外的结点都会被压缩;当该参数被设置为2时,除了头部、头部的下一个、尾部、尾部的上一个之外的结点都会被压缩;

listpack 紧凑列表

quicklist 虽然通过控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来减少连锁更新带来的性能影响,但是并没有完全解决连锁更新的问题。

因此Redis在5.0 新设计一个数据结构叫 listpack,目的是替代压缩列表,它最大特点是 listpack 中每个节点不再包含前一个节点的长度了,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患。ziplist也将在Redis7.0版本彻底被listpack替换。

listpack 数据结构

struct listpack<T>{
    int32 total_bytes; //总字节数
    int16 size;        //元素个数
    T[] entries;       //紧促排列的元素列表
    int8 end;          //与zlend一样,恒为0xFF
}
  • total_bytes 记录了当前节点的总长度
  • size 表示entity元素的个数
  • entites 多个entity列表
  • end 结尾固定为255(0xFF)

与压缩列表结构比较

listpack和ziplist结构比较.jpg

  • 整体结构上少了zltail字段,listpack可以通过total_bytes反推尾节点位置;
  • entity中listpack少了prevlen,每个节点只记录自己的长度避免了前节点空间变化引起后续节点发生连锁扩展;

listpack 如何避免连锁扩展

与压缩列表结构比对发现,在entity中listpack少了prevlen字段,该字段在压缩列表中记录前一节点空间大小,正式因为该字段才导致了前节点空间变化,引起后节点空间变化,在特殊场景下进一步导致连锁扩展想象发生;

listpack正向遍历

  • listpack Header固定为6个字节
  • 每个entity有len字段记录自己节点的长度
  • 正向遍历的时候指针可以基于上述两点在从左往右遍历各个entity节点了;

listpack反向遍历

  • listpack Header中的total_bytes记录了当前listpack的总长度,可以通过total_bytes定位到listpack的尾部结束标志255(0xFF);
  • 通过listpack提供的lpPrev和lpDecodeBacklen 函数实现从右往左遍历;

dict 哈希表

哈希表是一种保存键值对(key-value)的数据结构。 它通过关键字 key 和一个映射函数 Hash(key) 计算出对应的哈希值 ,然后把键值对映射到表中哈希值对应的位置来访问记录,以加快查找的速度。

哈希表结构

typedef struct dictht {
    dictEntry **table;        //table是一个数组结构,其中的每个元素都是一个dictEntry指针
    unsigned long size;       //哈希表的数组大小
    unsigned long sizemask;   //哈希表大小掩码,用于计算索引值
    unsigned long used;       //该哈希表之中,已经保存的键值对个数
} dictht;
  • table:用于存储键值对的哈希表数组;
  • size:表示哈希表的数组大小;
  • sizemask:大小永远为 size - 1,该属性用于计算哈希值;
  • used:表示哈希表中已经存储的键值对的个数;
typedef struct dictEntry {
    //键值对中的键
    void *key;
 
    //键值对中的值
    union {
        void *val;    //用来保存一段具体的内存二进制数据
        uint64_t u64; //用来存储一个64位无符号整形数据
        int64_t s64;  //用来存储一个64位有符号整形数据
        double d;     //用来存储一个双精度浮点数据
    } v;
    //指向下一个哈希表节点,形成链表
    struct dictEntry *next;
} dictEntry;
  • key 键;
  • union是一个联合体用于表示value;当值为整数或者浮点数,则值数据直接保存到对应的u64、s64、d字段中;当值为其他数据类型则有val指针指向具体的内存空间;这样极大的节省了内存空间;
  • next 记录下一个哈希表节点的指针;

哈希表结构如下图所示

HASH.jpg

哈希表原理

哈希表是将关键字key通过哈希函数计算得到哈希值,然后找到哈希值对应的桶位,并存储key对应的value值;

hash原理.jpg

哈希函数有以下特点

  • 哈希函数易于计算且得到的哈希值尽可能的均匀分布到哈希表中;
  • 两个key计算后的哈希值相等,则两个key不一定相等(哈希冲突导致的哈希值相同);
  • 两个key计算后的哈希值不相等,则两个key一定不相等;

哈希冲突

无论哈希函数设计的多么的精细,在计算哈希值的时候均可能发生哈希冲突,即两个不相等的key得到的相同的哈希值;

避免或者降低哈希冲突的方法

  • 拉链法 拉出一个动态链表代替静态顺序存储结构,可以避免哈希函数的冲突;
  • 多哈希法 设计二种甚至多种哈希函数,可以降低冲突的概率,函数设计的越好或越多都可以将几率降到最低。
  • 开放地址法 指的是将哈希表中的空地址向处理冲突开放。当哈希表未满时,处理冲突时需要尝试另外的桶位,直到找到空的桶位为止。

Redis解决哈希冲突问题的方法

Redis 采用了链式哈希的方法(即拉链法)来解决哈希冲突,比起多哈希和开放地址法,链式哈希可以直接避免哈希冲突,且性能也相对于这两种方法较为突出;

Redis中将具有相同哈希地址的元素(或记录)存储在同一个线性链表中,通过next指针串联哈希值相同的两个元素;

链式哈希.jpg 由上图看到哈希值相同的键值对数据,均通过一个线性链表存储起来;其缺点也很明显,如果哈希冲突较严重,那么该线性链表包含的dictEntry就非常多,查询该哈希值的元素耗时会越来越长,时间复杂度为O(N);

Redis 如何解决链式哈希长链问题?

Redis 通过对哈希表 rehash 的方式实现哈希表大小拓展,因此引入了dict字典结构。

typedef struct dict {
    …
    //两个Hash表,交替使用,用于rehash操作
    dictht ht[2]; 
    …
} dict;

链式哈希-字典结构.jpg

  • dict字典结构定义了两个dictht结构;
  • 正常写入的时候,所有数据均保存到dictht结构的哈希表1中,此时哈希表2指向NULL;
  • 随着数据增长,触发了rehash操作
    • 哈希表2分配空间,一般该空间为哈希表1的两倍;
    • 哈希表1中的数据迁移到哈希表2中;为避免哈希表1数据过大,导致数据迁移拷贝阻塞Redis,采用了渐进式rehash;
    • 迁移完毕,释放哈希表1空间,为下次rehash做准备,此时哈希表2变成哈希表1

渐进式rehash

渐进式rehash是解决哈希表1数据过大,在迁移过程中阻塞Redis问题,实现原理如下;

  • 哈希表2分配空间,一般该空间为哈希表1的两倍;
  • 在Redis进行增删查改的时候,同步会将将该索引上数据,从哈希表1迁移到哈希表2;新插入的数据则直接写入哈希表2中,删改查操作在保证一致性的前提下,一定会先操作哈希表1,如果在哈希表1中没有操作成功,会继续操作哈希表2。;
  • 哈希表1数据会越来越少,在某个时间点哈希表1数据将会全部迁移到哈希表2中;

渐进式rehash,是将一次开销极大的迁移任务,拆分到每次请求中,降低对redis的阻塞,最终完成数据的迁移拷贝;

负载因子

在HashMap中,中有一个 threshold 字段,这个字段在作为扩容阈值时默认情况下为 0.75 * capacity,意思是当哈希表中键值对的数量达到哈希表容量的 0.75 倍时就需要对哈希表进行扩容。 Redis的哈希表同样也有自己的负载因子

# 负载因子 = 哈希表已保存节点数量 / 哈希表大小
load_factor = ht[0].used / ht[0].size

rehash触发的条件

Redis的扩容和缩容操作都是通过rehash来完成的;

Redis扩容条件

  • 哈希表的负载因子>= 1时,在服务器当前没有执行BGREWRITEAOFBGSAVE命令时,此时会触发rehash;
  • 哈希表的负载因子大>= 5,无论服务器当前是否正在执行 BGSAVE BGREWRITEAOF 命令,此时都会触发rehash;

Redis缩容条件

  • 哈希表的负载因子< 0.1 时, 程序自动开始对哈希表执行收缩操作;

inset 整数集合

整数集合(inset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多,redis就会使用集合作为集合键的底层实现。可以保存类型为 int16_t 、int32_tint64_t 的整数值。

整数集合结构

typedef struct intset {
    //编码方式
    uint32_t encoding;
    //集合包含的元素数量
    uint32_t length;
    //保存元素的数组
    int8_t contents[];
} intset;
  • contents数组是整数集合的底层实现:整数集合的每个元素都是 contents 数组的一个数组项(item),各个项目在数组中按值得大小从小到大有序地排列,并且数组中不包含任何重复项。
  • length 记录了整数集合包含的元素数量,也即是 contents 数组地长度。
  • encoding 记录了编码方式,虽然contents定义为int8_t类型,但是contents真正的类型取决于encoding;
    • 如果encoding=INTSET_ENC_INT16,则contents类型为int16_t,数组中每个元素类型也是int16_t;
    • 如果encoding=INTSET_ENC_INT32,则contents类型为int132_t,数组中每个元素类型也是int32_t;
    • 如果encoding=INTSET_ENC_INT64,则contents类型为int64_t,数组中每个元素类型也是int64_t;

整数集合升级

整数集合中encoding决定了contents真正的数据类型,并且contents数组中的每一个item元素都是encoding记录的数据类型;

当整数集合中要保存一个新的item元素,此时当前encoding的数据类型不足以保存新元素,此时便会触发整数集合类型升级,具体流程如下:

  • 根据新的元素能保存的最小数据类型扩容当前的contents数组的空间大小(该空间大小可以容纳数据类型升级后的所有数据);

  • 将contents数组已存在的元素均转换成新的数据类型,并将转换后的元素按照整数集合的有序性依次放置;

  • 将新的元素放置到升级类型后的contents数组中;

整数集合升级通过contents数组类型升级来完成的,只有当前数据类型无法容纳新的元素才会触发整数集合升级流程,针对保存较小数据的时候无需一次性分配最大的内存,这样节省了内存资源;

整数集合升级.jpg 当然整数集合升级是不可逆的,也就是一旦该整数集合升级后便无法降级了;

我们看到一旦一个新的元素超过当前整数集合的数据类型,整数集合就要升级类型,随之带来的是contents数组原有的元素数据类型也升级了,这样一定程度上也浪费了大量的空间来保存原本就已经空间足够的元素,或者说contents数组为什么要求每个元素都要保持同样的数据类型?这个原因的取舍在于整数集合的查找采用了二分法查找,相同的数据类型可以提高查询效率,但是也会带来一定的空间冗余或者空间损耗(空间换时间);

整数集合数据查找

整数集合是一个有序集合,要查找一个元素分以下两步:

  • 判断整数集合是否为空;
  • 判断查找的元素是否在整数集合范围内即是否大于等于整数集合最小值,小于等于整数集合最大值;
  • 如果在整数集合范围内,采用二分法查找;

skiplist 跳表

什么是跳表

跳表在原来的有序链表上加上了多级索引,通过索引来快速查找;可以支持快速的删除、插入和查找操作;

跳表结构.jpg 如上图所示是一个3级索引的跳表示例图,如果查找5只需要从L2->3->4->5 仅需3步就可完成(红色箭头所示方向),比普通数组查找遍历次数减少了,提高了查询效率,当数据量非常大的时候,跳表的查找复杂度就是 O(logN)。;

跳表具有如下特点

  • 跳表结合了链表和类似二分查找的思想;
  • 有很多层结构,由原始链表和一些通过“跳跃”生成的链表组成;
  • 每一层都是一个有序的链表;
  • 最底层的链表包含所有元素,越上层“跳跃”的越高,元素(索引)越少;
  • 查找时从顶层向下,不断缩小搜索范围;
  • 上层链表是下层链表的子序列;
  • 每个节点包含两个指针,一个指向同一链表中的下一个元素,一个指向下面一层的元素。

Redis 跳表数据结构

/* 跳跃表节点 */
typedef struct zskiplistNode {  
    sds ele;                              // Zset成员对象
    double score;                         // 对象权重 
    struct zskiplistNode *backward;       // 后退指针
    struct zskiplistLevel {
        struct zskiplistNode *forward;    // 前进指针
        unsigned int span;                // 跨度
    } level[];
};

/* 跳跃表 */
typedef struct zskiplist
{
    struct zskiplistNode *header;    // 头节点
    struct zskiplistNode *tail;      // 尾节点
    unsigned long length;            // 记录跳跃表的长度,即跳跃表中包含的结点的数量(不包括表头结点)
    int level;                       // 记录当前跳跃表中,层数最大的那个结点的层数(不包括表头节点的层数)
} zskiplist;

redis 跳表结构.jpg 跳表随机生成层数

/* 
 * 返回一个随机值,用作新跳跃表节点的层数。
 * 返回值介乎 1 和 ZSKIPLIST_MAXLEVEL 之间(包含 ZSKIPLIST_MAXLEVEL),
 * 根据随机算法所使用的幂次定律,越大的值生成的几率越小。
 *
 * T = O(N)
 */
#define ZSKIPLIST_P 0.25      /* Skiplist P = 1/4 */
#define ZSKIPLIST_MAXLEVEL 32
int zslRandomLevel(void) {
    int level = 1;

    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;

    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

每次生成一个新的跳表节点时都要指定该节点的层数,具体如下:

  • 根据0-1区间的随机数判断,如果概率小于0.25,则level增加1层,直到概率大于0.25的时候截止;
  • 最终返回level如果大于ZSKIPLIST_MAXLEVEL (默认32)则返回ZSKIPLIST_MAXLEVEL,如果小于ZSKIPLIST_MAXLEVEL ,则返回level值;

本分参考