一文看懂redis各种数据类型的底层实现

redis已经成为了现今构建互联网应用最常用的中间件之一,它对使用者暴露的数据类型有string、list、hash、set、sorted set等,我们在使用这些数据类型的同时,肯定也会对其内部的设计和实现感兴趣。这篇文章将探究这些数据类型底层的数据结构实现,比如sds、ziplist、quicklist、dict、skiplsit等

本文引用的redis源码版本为redis 6.2

一、SDS#

redis string对象底层使用SDS来实现。

redis虽然使用C语言开发,却没有直接使用C语言的默认字符串,而是自己构建了一种名叫SDS(simple dynamic string)的数据结构。

熟悉go语言的同学会发现,这个数据结构跟go语言中的slice切片的底层实现非常相似,下面来详细解析

sds结构定义在sds.h中

typedef char *sds;

sds定义成char *类型,这是为了和传统C语言的字符串保持兼容,但是sds并不等同char *,实际上sds还包含了一个header结构

struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

一个完证的sds字符串,由内存地址上前后相邻的两部分组成:一个header,和一个char [] 字节数组。下面来看header的构成:

  • len:sds字符串的长度

  • alloc:sds字符串底层字节数组的最大容量(不包括header),即buf数组的长度-1(不包括结束符 ‘\0’)

  • buf:用于保存字符串的字节数组,没有具体长度标识,是一个柔性数组,柔性数组并不占用结构体的空间

  • flags:用来标记不同类型的sds,只使用了低3位来标记,高5位暂未使用(除了sdshdr5)

根据alloclen的类型不同,sds分为几种类型

  • sdshdr5:使用高5位(5 msb)来表示len, alloc固定是5 msb的最大值 2^5,因而没有办法动态扩容
  • sdshdr8:长度为小于2^8的字符串
  • sdshdr16:长度为小于2^16的字符串
  • sdshdr32:长度为小于2^32的字符串
  • sdshdr64:其他所有长度都使用此类

__attribute__ ((__packed__)) 关键字用来告诉编译器这个结构体不在遵循内存对齐规则,而是字段成员紧致排列,方便通过偏移量来访问结构体中的字段,比如buf向前偏移一个字节,就可以访问到flags字段,以此来判断这个sds的类型

之所以要定义5种header,是为了能让不同的sds字符串可以使用不同的header,短的sds字符串能够使用小的header,尽可能的节省内存。

sds

上图展示了两个有不同header sds字符串的内存布局,以及如何根据sds字符串指针获取对应header起始指针。先从sds字符指针向地址偏移一个字节获取到flag,从flag的低3bit得到header的类型,知道了header的类型,也就很容易的能够计算出header的起始指针位置。

sds字符串的header,其实隐藏在真正的字符串数据的前面(低地址方向)。这样定义有如下几个好处:

  • header和数据相邻,这有利于减少内存碎片,提高存储效率
  • 虽然header有多个类型,但sds可以用统一的char *来表达。且它与传统的C语言字符串保持类型兼容。我们可以直接把它传给C函数,比如使用printf进行打印。

SDS特点(区别于C字符串)#

  • 内存预分配
    • redis作为数据库,字符串数据经常被频繁修改。C语言的字符串增长或者缩短,就必须对整个底层数组进行一次重新分配,SDS可以在字符串增长时,给SDS分配额外未使用空间(buf),以减少内存分配次数
    • 字符串小于1M时,翻倍扩容,大于1M时,每次增加1M
  • 惰性释放
    • SDS缩短字符串时,只需要通过len字段记录新的字符串长度,而不必立即重新分配内存
    • SDS也提供了相应的API,让我们在需要的时候释放SDS未使用的空间
  • 二进制安全
    • SDS使用len字段而不依懒于空字符\0来判断字符串的结尾,所以可以使用buf来保存任何二进制格式的数据
    • 比如我们可以使用redis来保存ProtoBuf压缩过的二进制数据
  • 兼容C字符串
    • SDS被定义为char *,并且会在字符串的末尾加上\0,所以能够兼容C字符串,以复用C字符串相关函数

二、链表List#

redis3.2之前,list对象的底层实现之一是链表,但在3.2版本之后,list类型就改成用quicklist+ziplist来实现了。不过链表依然在发布订阅、监视器、保存客户端状态信息保存、客户端输出缓冲区等场景被使用,所以做个简单介绍

双链表list结构定义在adlist.h中

typedef struct listNode {
    struct listNode *prev;
    struct listNode *next;
    void *value;
} listNode;

typedef struct list {
    listNode *head;
    listNode *tail;
    void *(*dup)(void *ptr);
    void (*free)(void *ptr);
    int (*match)(void *ptr, void *key);
    unsigned long len;
} list;

listNode 用两个指针 prevnext保存左右节点,用void *指针value保存节点的值

list保存了头尾指针headtail,方便链表的操作。len来记录链表的长度。dupfreematch这三个成员是三个函数的指针,指向用于实现多态链表所需的类型特定函数

  • dup用于复制节点值
  • free用于释放节点保存的值
  • match用于比较节点的值与输入值是否相等

三、压缩列表ziplist#

zset和hash对象在元素个数比较少的时候,底层使用压缩列表ziplist来进行存储。

ziplist是redis为了节约内存而设计的,是由一系列特殊编码的连续内存块组成的序列型数据结构。它本质上就是一大块连续的字节数组,但是会比常规的数字更节省内存,因为数组要求每个元素的大小相同,这就导致很多内存的浪费。而压缩列表的元素长度则是动态的、不固定的。

压缩列表#

ziplist示意图

属性 类型 长度 用途
zlbytes uint32_t 4字节 记录整个压缩列表占用的内存字节数:在对压缩列表进行内存重分配, 或者计算 zlend 的位置时使用。
zltail uint32_t 4字节 记录表尾节点与起始地址的偏移量: 用来计算表位节点的地址
zllen uint16_t 2字节 记录了压缩列表包含的节点数量: 当这个属性的值小于 UINT16_MAX65535)时, 这个属性的值就是压缩列表包含节点的数量; 当这个值等于 UINT16_MAX 时, 节点的真实数量需要遍历整个压缩列表才能计算得出。
entryX 列表节点 不定 压缩列表包含的各个节点,节点的长度由节点保存的内容决定。
zlend uint8_t 1字节 特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。

压缩列表节点#

每个压缩列表节点有三部分构成

  • prevrawlen 表示前一个节点的长度

    • 当ziplist倒叙遍历时,可以根据prevrawlen计算出前一个节点的起始地址
    • 前一个节点的长度小于254字节,那么prevlen使用一个字节
    • 前一个节点的长度大于254字节,那么prevlen使用5个字节,第一个字节固定是0xFE(254),后四个字节用来保存前一个节点的长度(第一个字节为什么不是255呢?因为255被zlend使用了)
  • encoding 记录了节点的长度,和content属性所保存的数据的类型

    • 0001 或者 10 的是字节数组编码(一个字节,两个字节,五个字节)。数组的长度由出去最高两位之后的其他位记录
    • 11 开头的是整数编码,整数的类型由除去最高两位的
    • encoding告诉我们content表示的是字符串(字节数组)还是整数类型(int16_t、int32_t...)

    字节数组编码

    编码 编码长度 content 属性保存的值
    00bbbbbb 1 字节 长度小于等于 63 字节的字节数组。
    01bbbbbb xxxxxxxx 2 字节 长度小于等于 16383 字节的字节数组。
    10______ aaaaaaaa bbbbbbbb cccccccc dddddddd 5 字节 长度小于等于 4294967295 的字节数组。

    整数编码

    编码 编码值 编码长度 content 属性保存的值
    11000000 0xC0 1 字节 int16_t 类型的整数。
    11010000 0xD0 1 字节 int32_t 类型的整数。
    11100000 0xE0 1 字节 int64_t 类型的整数。
    11110000 0xF0 1 字节 24 位有符号整数。
    11111110 0xFE 1 字节 8 位有符号整数。
    1111xxxx 1 字节 使用这一编码的节点没有相应的 content 属性, 因为编码本身的 xxxx的值在00011101之间,表示从1到13,所以它无须 content 属性。
  • content 保存节点的值,可以保存任意二进制序列(字节数组)

压缩列表插入和查找的平均复杂度为O(N),因为ziplist没有指针,所以每次插入或者删除节点,都要重新调整节点的位置,因而会发生内存拷贝,所以ziplist只适合用来保存少量的数据。

四、快速列表quicklist#

linkedlist 每个节点只能保存一个元素,而且需要使用 prev 和 next 两个指针,占据了16个字节,空间附加值太高,而且每个节点都是单独分配,会加剧内存的碎片化。因此在 redis3.2 版本之后,使用了新的 quicklist+ziplist 结构代替了 linkedlist 来实现 list 列表对象。

quicklist还是一个双向链表,只不过每个节点都是一个压缩列表,先来看两个相关配置参数:

  • 压缩列表的长度由参数list-max-ziplist-size来控制,用户可以自己设置,默认值是 -2代表8KB(正数代表个数,负数的意义参考redis.conf)
  • 当列表很长时,两端的数据是最容易被访问的,而中间的数据访问频次比较低,所以redis提供了一个选项,能够把中间的节点的数据进行压缩,进一步节约内存。参数list-compress-depth代表quicklist两端不被压缩的节点个数,默认是特殊值0,表示都不压缩

quicklist相关结构在quicklist.h中定义

typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl;
    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;

typedef struct quicklistLZF {
    unsigned int sz; /* LZF size in bytes*/
    char compressed[];
} quicklistLZF;

typedef struct quicklist {
    quicklistNode *head;
    quicklistNode *tail;
    unsigned long count;        /* total count of all entries in all ziplists */
    unsigned long len;          /* number of quicklistNodes */
    int fill : QL_FILL_BITS;              /* fill factor for individual nodes */
    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;

tip:quicklistNode结构体中count、encoding等字段后面跟着一个冒号和一个数字,这是C语言结构体位域的语法,它告诉编译器,这个字段所使用的位的宽度,可以把数据以位的形式紧凑存储,并允许程序员对此结构的位进行操作

quicklist节点#

quicklistNode字段含义如下:

  • prev、next:是前后指针
  • zl:是一个char* 字节数组指针,如果节点没有被压缩,它指向一个ziplist;否则,它指向一个quicklistLZF结构
  • sz:size,ziplist的字节大小
  • count: 节点的元素个数
  • encoding:标识节点的编码方式,1代表ziplist,2代表压缩过的节点(quicklistLZF)
  • container:1代表直接存储数据(预留,实际未实现),2代表使用ziplist存储数据
  • recompress:如果我们访问了被压缩的节点数据,需要把数据暂时解压,这时设置recompress=1,表示后面需要把数据重新压缩

压缩过的节点#

quicklistLZF表示一个被压缩过的ziplist,redis使用LZF算法压缩ziplist

  • sz:压缩后的大小
  • compressed:一个柔性数组,存储压缩后的数据

quicklist#

struct quicklist 是快速列表的的真正结构

  • head、tail 首位指针
  • count:元素个数(所有节点元素个数的总和)
  • len:节点数
  • fill:保存 list-max-ziplist-sized的值,表示ziplist的容量
  • compress:保存list-compress-depth的值,表示节点压缩深度

quicklist diagram

上图是一个 list-max-ziplist-size=3 list-compress-dept=2 的quicklist 示意图

五、字典dict#

redis的hash对象底层使用字典来实现,字典是一种保存键值对的抽象数据结构,在redis中应用相当广泛,redis的数据库也是使用字典来作为底层实现的

dict相关结构在dict.h中定义

typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;

typedef struct dictType {
    uint64_t (*hashFunction)(const void *key);
    void *(*keyDup)(void *privdata, const void *key);
    void *(*valDup)(void *privdata, const void *obj);
    int (*keyCompare)(void *privdata, const void *key1, const void *key2);
    void (*keyDestructor)(void *privdata, void *key);
    void (*valDestructor)(void *privdata, void *obj);
    int (*expandAllowed)(size_t moreMem, double usedRatio);
} dictType;

/* This is our hash table structure. Every dictionary has two of this as we
 * implement incremental rehashing, for the old to the new table. */
typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    int16_t pauserehash; /* If >0 rehashing is paused (<0 indicates coding error) */
} dict;

哈希表#

dictht定义了哈希表的结构, ht是hash table意思

  • table:是一个元素为dictEntry *的数组的指针,每个dictEntry保存一个键值对

  • size:是table数组的长度

  • sizemask:总是等于size-1,一个键的哈希值可以和sizemask做与运算(比取余效率高),快速得到其在数组中的索引位置

  • used:记录哈希表已有键值对个数

哈希表节点#

dictEntry定义了哈希表节点

  • key:用来保存键值对中的键,void *指针类型,可以指向任何值

  • v:用来保存键值对中的值,是一个union结构,可以直接保存uint64_t、int64_t或double类型,也可以是void*

  • next:指向另一个哈希表节点,当多个键的索引相同时,组成链表来解决哈希冲突问题,这种通过链表解决哈希冲突的方法通常叫做链地址法(拉链法)。速度考虑,新节点总是被添加在链表头的位置(复杂度O(1))

字典#

dict就是redis的字典结构了

  • type:指向一个dictType结构,用来实现不同类型的多态字典

  • privdata:保存了需要传给特定类型函数的可选参数

  • ht:包含了两个dctht,一般情况下,字典只会使用ht[0],ht[1]只会在对ht[0]进行rehash的时候使用。

  • rehashidx:记录了rehash的进度,没有在进行rehash的话,值为-1

  • pauserehash:大于0时表示rehash被暂停

dictType则保存了一些用于特定类型键值对的函数

  • hashFunction:用来计算键的哈希值

  • keyDupvalDupkeyDestructorvalDestructor:分别是复制键和值的函数,销毁键和值的函数

  • keyCompare:比较键的函数

rehash#

当哈希表保存过多的键值对时,哈希冲突的可能性大大增减,为了保持O(1)的访问复杂度,需要对哈希表进行扩容;当哈希表中的键被大量的删除,缩容能释放哈希表的内存占用。

负载因子(load factor)表示哈希表已存储的元素个数和哈希表数组的比值 load_factor = ht[0].uesd / ht[0].size

  • 当哈希表的负载因子大于1的时候,redis会对哈希表执行扩容操作(如果服务器正在执行RDB备份,或者AOF文件重写,负载因子大于5才开始扩容)
  • 当负载因子小于0.1的时候,会执行缩容操作

rehash的过程

  1. 为ht[1]分配表空间,其大小总是2^n,即每次都是2的整数次幂的倍数扩缩容,这样sizemask才能用来快速计算哈希值的索引位置
  2. 将保存在ht[0]上的键值对rehash到ht[1]上,即重新计算键的哈希值和其在ht[1]索引值,然后将键值对放在ht[1]指定的位置上
  3. 当ht[0]包含的所有键值对都迁移到了ht[1]上后,释放ht[0],将ht[1]设置为ht[0],然后在ht[1]新建一个空表哈希表,为下次rehash做准备

渐进式rehash

rehash这个动作不是一次性集中完成的,而是分多次,渐进式的

  1. rehashidx记录着rehash的进度,他的值是当前需要被rehash的索引位置,值设置为0,代表rehash开始(没有rehash时是-1)
  2. rehash期间,每次对字典执行增删改查的同时,也会将ht[0]上rehashidx索引位置的全部键值对rehash到ht[1]上,然后将rehashidx的值加一
  3. 除了在对字典操作时会执行rehash操作,服务器的定时任务也会主动的进行rehash,可以通过activerehashing参数配置,默认值yes
  4. rehash结束后rehashidx会被设置为-1
  5. 渐进式rehash执行期间,新添加到字典的价值对会被保存在ht[1]中,查找、删除、更新的操作会先在ht[0]中查找对应的键,如果没找到的话,就会继续在ht[1]里面进行查找,然后执行对应的逻辑

六、跳跃表skiplist#

跳跃表是一种有序的数据结构,有两大特点

  • 支持平均O(logN)时间复杂度的节点查找
  • 可以通过顺序性操作来批量处理节点

大部分情况下跳跃表的效率可以和平衡树媲美,而且跳跃表实现更简单,范围查询也比平衡树更方便,效率更好

跳跃表是zset的实现之一(集合包含元素较少多,或者字符串较长时)

redis跳跃表由zskiplistNodezskiplist 实现,在server.h中定义

#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^64 elements */
#define ZSKIPLIST_P 0.25      /* Skiplist P = 1/4 */

typedef struct zskiplistNode {
    sds ele;                           //sds 字符串
    double score;                      //分值 
    struct zskiplistNode *backward;    //后退指针,每次只能退一个节点
    struct zskiplistLevel {            
        struct zskiplistNode *forward; //每层都有一个前进指针,层数越高,跨度越大
        unsigned long span;            //跨度记录两个节点之间的距离
    } level[];                         //层高在1-32之间,根据幂次定律,越大的数出现的概率越小
} zskiplistNode;

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

先看两个相关的常量

  • ZSKIPLIST_MAXLEVEL:跳跃表的最大层数为32层
  • ZSKIPLIST_P:如果一个节点有第i层(i>=1)指针,那么它有第(i+1)层指针的概率为p=1/4。

跳跃表结构#

zskiplist结构,跟普通的双链表很像

  • headertail:分别指向跳跃表的头节点、尾结点

  • length:跳跃表的长度,即节点个数(头节点不计算在内)

  • level:跳跃表当前的最大层高,即层数最大的节点的层高(头节点的层数不计算在内)

跳跃表节点#

跳跃表的节点zskiplistNode

  • ele:保存了一个sds,即zset中的成员,成员不可重复
  • score:ele对应的分数,跳跃表中,跳跃表节点按照分数从小到大排序
  • backward:节点的后退指针,指向前一个节点,用来从后向前遍历跳跃表
  • level:level数组也是柔性数组,具体层数不确定,在新建节点的时候会根据ZSKIPLIST_P计算出当前节点的层数,越大的层数出现的概率越低
    • forward:每一层里有一个前进指针,指向后面的某个节点
    • span:记录前进指针的跨度,是当前节点和前进指针指向的节点的距离,用于计算元素排名(rank)

跳跃表

上图展示了一个跳跃表示例,以及遍历一个跳跃表的过程(虚线)

skiplist与平衡树的比较#

  • 在做范围查找的时候,平衡树比skiplist操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。
  • 平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。
  • 从内存占用上来说,skiplist比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。
  • 从算法实现难度上来比较,skiplist比平衡树要简单得多。

七、整数集合#

intset是set集合类型的底层实现之一,当set的元素数量小于set-max-intset-entries(默认值 512),并且只包含整数类型时,redis会使用intset作为set的底层实现

intsest可以保存int16_t、int32_t、int64_t类型的整数,源码定义在intset.h和intset.c

typedef struct intset {
    uint32_t encoding;
    uint32_t length;
    int8_t contents[];
} intset;

#define INTSET_ENC_INT16 (sizeof(int16_t)) // 值为2
#define INTSET_ENC_INT32 (sizeof(int32_t)) // 值为4
#define INTSET_ENC_INT64 (sizeof(int64_t)) // 值为8
  • contents:contents柔性数组是整数集合的底层实现,集合元素在数组中从小到大排列,并且不重复。虽然其声明为int8_t类型的数组,但实际上contents数组的真正类型取决于encoding的值
  • encoding:encoding的值决定了contents数组的类型,有三种取值
    • INTSET_ENC_INT16,表示contents是一个int16_t类型的数组
    • INTSET_ENC_INT32,表示contents是一个int32_t类型的数组
    • INTSET_ENC_INT64,表示contents是一个int64_t类型的数组
  • length:记录了整数集合的元素个数

如果我们将一个新元素插入到整数集合中,并且新元素的类型比现有的所有元素类型都要大,整数集合就要先进行升级操作。升级的步骤大致如下:

  1. 根据新元素的类型为整数集合底层数组重新分配空间大小(包含新元素的空间)
  2. 将底层数组的类型都调整为新的类型,并从后向前依次将他们放到升级后的位置上
  3. 触发升级的新元素要么小于所有元素(负数),要么大于所有元素,所以新元素要么放在数组开头,要么放在数组末尾

intset通过自动升级底层数组来适应新元素,所以我们可以随意的将int16_t、int32_t、int64_t类型的整数添加到集合中,而不是像普通的数组那样只能保存一种类型的元素;另外又可以确保升级只在需要的时候进行,以此来尽量的节省内存。

另外,intset不支持降级操作。

不像ziplist能够存储任何二进制序列,每个元素采用变长编码,intset只能用来存储整数,而且每个元素的长度相同(编码相同)


以上我们陆续介绍了redis中用到的主要数据结构,但redis并没有直接使用这些数据结构来实现数据库的各种类型,而是基于这些数据结构创建了一个对象系统。下一篇文章,将详细的介绍redis的对象系统。

参考:

Redis设计与实现

Redis内部数据结构详解

posted @   RobinVoyage  阅读(400)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
主题色彩