(Redis篇)Redis数据结构底层分析

386 阅读12分钟

Redis K-V底层设计

key : string (所有的key都是string类型)
value : string,hash,set,,sorted set,list

数据结构

redis存储数据用到的数据结构是数组+链表的形式,是数组就会有数组下标,redis计算数组下标是用hash(key)来得到一个大自然数,那么redis在初始化数组大小肯定不是无限大的,我们假设它数组长度初始化为8,通过hash得到大自然然后在取模8就可以得到这个数组下标了,当出现hash碰撞则会转成一个列表结构(头插法)。
redis kv在所在的源码位置

typedef struct redisDb {
    dict *dict;                 /* The keyspace for this DB    */
    dict *expires;              /* Timeout of keys with a timeout set    过期时间字典 */
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP)*/
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    int id;                     /* Database ID */
    long long avg_ttl;          /* Average TTL, just for stats */
    unsigned long expires_cursor; /* Cursor of the active expire cycle. */
    list *defrag_later;         /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;

dict保存着数据库中的所有键值对

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];// ht[0] , ht[1] =null
    long rehashidx; /* rehashing not in progress if rehashidx == -1 */
    unsigned long iterators; /* number of iterators currently running */
} dict;

字典中dictht 是一个容量为2的数组,这个具体含义在下面渐进式rehash会说到,接着看这个dictht的内容

typedef struct dictht {
    dictEntry **table;
    unsigned long size; //  hashtable 容量
    unsigned long sizemask;  // size -1
    unsigned long used;  // hashtable 元素个数   used / size =1
} dictht;
typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        int64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;

dictEntry就是存放具体kv的一个对象,key就是string,value类型有五种redis也是做了一个更深层的封装对象redisObject

//  redisObject对象 :  string , list ,set ,hash ,zset ...
typedef struct redisObject {
    unsigned type:4;        //  4 bit, sting , hash
    unsigned encoding:4;    //  4 bit 
    unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
                            * LFU data (least significant 8 bits frequency
                            * and most significant 16 bits access time). 
                            *    24 bit 
                            * */
    int refcount;           // 4 byte  
    void *ptr;              // 8 byte  总空间:  4 bit + 4 bit + 24 bit + 4 byte + 8 byte = 16 byte  
} robj;

type 是具体的类型,encodfing是不同类型下的编码

redis扩容机制

条件

1)Redis目前没有在执行BGSAVE命令或BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。
2)Redis目前在执行BGSAVE命令或BGREWRITEAOF命令,并且哈希表的负载因子大于等于5

渐进式rehash

redis扩容都是成倍扩容的,上面例子初始化为8时,当符合扩容条件,redis会创建一个新的数组大小是8*2,这里就会同时产生两个dictht,这也是为什么上面dictht容量为2的原因,这里redis扩容后的数据迁移不是一次性直接搬运来的,而是而是分多次、渐进式地完成的。
渐进式rehash 的详细步骤:

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

2、在几点钟维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash 开始;

3、在rehash 进行期间,每次对字典执行CRUD操作时,程序除了执行指定的操作以外,还会将ht[0]中的数据rehash 到ht[1]表中,并且将rehashidx加1;

4、当ht[0]中所有数据转移到ht[1]中时,将rehashidx 设置成-1,表示rehash 结束;

采用渐进式rehash 的好处在于它采取分而治之的方式,避免了集中式rehash 带来的庞大计算量。

Redis String

redis是由C语言去实现的,但他的String并没用采用C语言的实现而是自定了一种叫做sds(Simple Dynamic String)的简单动态字符串,并将 sds 用作 Redis 的默认字符串。

sds数据结构 redis 3.2之前

struct sdshdr {
    int len;
    int free;
    char buf[];
};

len -- 用于记录buf数组中使用的字节的数目,和SDS存储的字符串的长度相等
free -- 用于记录buf数组中没有使用的字节的数目
buf[] -- 字节数组,用于储存字符串 buf的大小等于len+free+1,其中多余的1个字节是用来存储’\0’的(\0在C语言中表示一个字符串的结尾)

sds数据结构 redis 3.2之后

struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];// buf[0]: z:  0101001
};
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[];
};

len -- 用于记录buf数组中使用的字节的数目,和SDS存储的字符串的长度相等
alloc -- 用于记录给buf[]分配的空间大小
flags -- 大小是1个字节表示8位,它将这8位分成两部分组成,前三位是type(hdr5->0,hdr8->1,hdr16->2,hdr32->3,hdr64->4),后五位是len(只有在hdr5的情况才会代表业务长度)

sds和c字符串的区别

1.获取本身字符长度更快,c语言中并没有记录本身的字符长度每次获取时都需要遍历整个字符串。

2.内存分配策略不同,sds内存分配分为预分配+惰性释放,c语言中都要拓展底层的char数组空间大小然后在将旧char数据copy过来。

3.sds的字符串的内存预分配策略能有效避免缓冲区溢出问题,C字符串每次操作增加长度时,都要分配足够长度的内存空间,否则就会产生缓冲区溢出。

4.sds以二进制存储数据的,可以存储任意数据。因此不管buf保存什么格式的数据,都是存入什么数据,读取就什么数据,二进制安全。

Redis Hash

Hash数据结构底层实现为一个字典(dict),也是RedisDb用来存储kv的数据结构,当数据量比较小时底层用ziplist存储。
ziplist是一个经过特殊编码的双向链表,它的设计目标就是为了提高存储效率。ziplist可以用于存储字符串或整数,其中整数是按真正的二进制表示进行编码的,而不是编码成字符串序列。

ziplist数据结构

image.png

1.结构说明

(1)zlbytes: 表示整个ziplist占用的字节总数。

(2)zltail:表示ziplist表中最后一项(entry)在ziplist中的偏移字节数。

(3)zllen:16bit,表示ziplist中数据项(entry)的个数。当ziplist里数据大于2^16-1后,再获取元素个数时,ziplist从头到尾遍历。

(4)entry:表示真正存放数据的数据项,长度不定,采用变长编码(对于大的数据,就多用一些字节来存储,而对于小的数据,就少用一些字节来存储)。

(5)zlend: ziplist最后1个字节,是一个结束标记,值固定等于255。

2.entry结构说明

(1)prevrawlen: 表示前一个数据项占用的总字节数。作用是为了让ziplist能够从后向前遍历(从后一项的位置,只需向前偏移prevrawlen个字节,就找到了前一项),这个字段采用变长编码。

(2)len: 表示当前数据项的数据长度(即部分的长度)。也采用变长编码。

(3)data:存储的数据。

ziplist(压缩表)的特点

1.内存空间连续:ziplist为了提高存储效率,从存储结构上看ziplist更像是一个表(list),但不是一个链表(linkedlist)。ziplist将每一项数据存放在前后连续的地址空间内,一个ziplist整体占用一大块内存。而普通的双向链表每一项都占用独立的一块内存,各项之间用指针连接,这样会带来大量内存碎片,而且指针也会占用额外内存。

2.查询指定元素性能会降低,需要遍历整个ziplist。

3.插入和修改引发的内存重新分配操作会有更大的概率造成内存拷贝从而降低性能。

Redis Set

Set为无序的,自动去重的集合数据类型,Set 数据结构底层实现为一个value 为 null 的 字典(dict),当数据可以用整形表示时,Set集合将被编码为intset数据结构。两个条件任意满足时Set将用hashtable存储数据:1.元素个数大于set-max-intset-entries,2.元素无法用整形表。

intset的数据结构

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

encoding -- intset的类型编码
length -- 集合包含的元素数量
contents[] -- 元素存储

image.png

intset数据集合特点

整数集合是一个有序的,存储整型数据的结构。整型集合在Redis中可以保存int16_t,int32_t,int64_t类型的整型数据数据并且保证元素不会出现重复。

Redis ZSet

ZSet为有序,自动去重的集合数据类型,ZSet数据结构底层为字典(dict) + 跳表(skiplist),当数据比较少时,用ziplist编码结构存储。

skiplist(跳表)

skiplist首先它是一个list。实际上,它是在有序链表的基础上发展起来的。先来看一个有序链表,如下图(最左侧的灰色节点表示一个空的头结点):

image.png 这样种链表中,如果要查找某个数据,需要从头开始逐个进行比较,直到找到等于 或 大于(没找到)给定数据为止,时间复杂度为O(n)。同样,当插入新数据的时候,也要经历同样的查找过程,从而确定插入位置。

有了上面出现的问题后进一步优化,假如我们这样来设计,在每相邻两个节点增加一个指针,让指针指向下下个节点,如下图:

image.png 这样所有新增加的指针连成了一个新的链表,但它包含的节点个数只有原来的一半(上图中是7, 19, 26)。现在当查找数据的时候,可以先沿着这个新链表(第一层链表)进行查找。当碰到比待查数据大的节点时,再回到第二层链表进行查找。

比如,要查找23,查找的路径是沿着下图中标红的指针所指向的方向进行的:整个查询路线如红色箭头。

image.png 先在第一层链表上查询,23首先和7比较,再和19比较,比它们都大,继续向后比较。但23和26比较的时候,比26要小,因此回到下面的链表(原链表),与22比较。

再在第二层链表上查询,23比22要大,沿下面的指针继续向后和26比较。23比26小,说明待查数据23在原链表中不存在,而且它的插入位置应该在22和26之间。

在这个查找过程中,由于新增加的指针,不再需要向原链表一样,每个节点都逐个进行比较。需要比较的节点数大概只有原来的一半。 利用同样的方式,可以在上层新产生的链表上,继续为每相邻的两个节点增加一个指针,从而产生第三层链表。如下图:

image.png 在这个新的三层链表结构上,如果还是查找23,那么沿着最上层链表首先要比较的是19,发现23比19大,接下来我们就知道只需要到19的后面去继续查找,从而一下子跳过了19前面的所有节点。可以想象,当链表足够长的时候,这种多层链表的查找方式能让我们跳过很多下层节点,大大加快查找的速度。

skiplist正是受这种多层链表的想法的启发而设计出来的,实际上,按照上面生成链表的方式,上面每一层链表的节点个数,是下面一层的节点个数的一半,这样查找过程就非常类似于一个二分查找,使得查找的时间复杂度可以降低到O(log n)。但是,这种方法在插入数据的时候有很大的问题。新插入一个节点之后,就会打乱上下相邻两层链表上节点个数严格的2:1的对应关系。如果要维持这种对应关系,就必须把新插入的节点后面的所有节点(也包括新插入的节点)重新进行调整,这会让时间复杂度重新蜕化成O(n)。删除数据也有同样的问题。

skiplist为了避免这一问题,它不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是为每个节点随机出一个层数(level)。比如:一个节点随机出的层数是3,那么就把它链入到第1层到第3层这三层链表中。

skiplist中一个节点的层数(level)是随机出来的,而且新插入一个节点不会影响其它节点的层数。因此,插入操作只需要修改插入节点前后的指针,而不需要对很多节点都进行调整。这就降低了插入操作的复杂度。而节点的层数(level)也不全是没有规则随机的,而是按照节点平均指针数目计算出来的。如下图各个节点层数(level)是随机出来的一个skiplist,我们依然查找23,查找路径如图:

image.png

Redis List

List是一个有序的数据结构,Redis采用quicklist(双端链表)和ziplist作为List的底层实现

image.png quicklist是一个双向链表,而且是一个ziplist的双向链表。即quicklist双向链表是由多个节点(Node)组成,而quicklist的每个节点又是一个ziplist。 结构如图。

quicklist结构设计为什么选用ziplist:

1.普通双向链表便于在表的进行插入和删除节点操作,但是它的内存开销比较大。除了要保存本身的数据之外还要保存两个指针,当链表长度增长到一定程度时占用的内存是十分庞大的,而且内存空间不是连续的会产生一定的内存碎片。

2.ziplist由于是一整块连续内存,所以存储效率很高。但是,它不利于修改操作。