底层的数据结构大概有六种:

  • 简单动态字符串
  • 链表
  • 字典
  • 跳跃表
  • 整数集合
  • 快速列表

简单动态字符串

简介

SDS,simple dynamic string,它3.2之前的数据结构是这样的

struct sdshdr {
    unsigned int len;   //buf中已经使用的长度
    unsigned int free;  //buf中未使用的长度
    char buf[];         //柔性数组buf
};

len表示已使用的长度,free表示还未使用的长度,buf是实际存储字符的数组,它有几个特性:

  • 最大容量是512M;
  • 当可使用容量不足时,会发生扩容,开始时成倍扩容,扩容容量不会超过1M,如果超过1M,则不会成倍进行扩容,而是以1M进行扩容;
  • 容量只会扩大,不会缩小,释放出来的容量会记录在free中在后续中使用。

SDS的优点:

  • 因为记录了字符长度,所以可以O(1)级别地快速获取字符长度;
  • 预分配内存机制,减少了内存分配的操作;
  • 二进制安全,传统的C语言字符串是遇零则止的,‘\0’是结束符,当读取字符串读到\0时会忽略后面的字符,这样就会出现字符串丢失的情况,而SDS没有以‘\0’为结束符,所以它时二进制安全的。

编码

看编码之前,先看下redisObject的结构体:

typedef struct redisObject {
	// 4 bit
    unsigned type:4;
    // 4 bit
    unsigned encoding:4;
    // 24 bit
    unsigned lru:LRU_BITS;  //缓存淘汰使用
    // 4 byte
    int refcount;           //引用计数
    // 8 byte
    void *ptr; //实际数据地址的指针
} robj;
SDS对应三种编码格式int、embstr、raw:
  • int:长度小于20byte(20byte是源码中指定的),且能转换成int,则会使用int格式编码:


redis 数据量大需要更改持久化吗 redis 数据长度_redis



在redisObject中,有一个指针ptr是指向实际数据地址的,那如果一个实际数据是整型值且小于8byte的话,是不是就可以直接将这个数据存道ptr中?这样既不需要再去开辟一块新内存,减少了内存开销,又减少了一次寻址的时间,所以当数据值小于20byte且可以转换成整型时,redis会将它直接存到指针ptr中。

  • embstr:长度在44byte及一下是embstr编码,3.2版本及以前的这个长度是39byte;


redis 数据量大需要更改持久化吗 redis 数据长度_redis 数据量大需要更改持久化吗_02



这里要先理解几个概念:
1、缓存行:cache line,我们cpu在读取内存时,不是按指定大小去读取的,而是按照固定大小读取的,也就是一次会读取一行,一个缓存行一般的大小64byte;
2、redisObject结构体的大小:4bit+4bit+24bit+4byte+8byte=16byte
2、3.2新旧版本SDS的结构体
旧版本:

struct SDS {
	// 容量 4byte
    unsigned int capacity;
    // 实际长度 4byte
    unsigned int len;
    // 实际数据数组
    byte[] content;
}

SDS结构体本身长度是4byte+4byte=8byte,然后还有一个为了兼容C语言字符串的结束符’\0’,一共是9byte,读取一次内存的大小是64byte,也就是说为了能一次读完一个string,string实际的字符长度最大为64byte-16byte-9byte=39byte。
新版本:新版本有多种sds结构体,用来存储不同长度string,sdshdr5肯定不行,它的长度只有2^5-1=31,sdshdr8可以:

struct __attribute__ ((__packed__)) sdshdr8 {
    //已经使用的长度 1byte
    uint8_t len; 
    //分配长度也就是总长度 1byte
    uint8_t alloc; 
    //类型 0-4 1byte
    unsigned char flags; 
    //实际数据数组
    char buf[];
};

它的长度是1byte+1byte+1byte+1byte=4byte,所以string长度最长为64byte-16byte-4byte=44byte。

  • raw:长度超过44byte则是raw编码格式。
为什么3.2版本之前是39byte,之后就是44byte了?

链表

链表这种数据结构很熟悉了,一般会跟数组做比较:

  • 数组需要连续的存储空间,修改难,查询容易;
  • 链表不需要连续的存储空间,靠存储下一个节点的地址来维护连接,修改容易,查询难,只能从头挨个遍历。

redis中的链表

链表分为单向链表,双向链表和循环链表。
redis中使用的链表是双向链表,这也是list数据类型支持左添加和右添加的原因。
链表比较简单就不多做介绍了,看下它的结构体:

  • 节点的结构体
typedef struct listNode
{ 
	// 前置节点 
	struct listNode *prev; 
	// 后置节点 
	struct listNode *next; 
	// 节点的值 
	void *value; 
} 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;

有一个头节点和尾节点,还维护了一个链表长度,用于方便获取链表长度。

字典

整个redis都可以看作是一个很大的字典,这是value的类型不一样。
字典就不多介绍了,本质上是一个数组,通过hash运算获取hash值来确定元素的下标,形成一个散列表。

源码

  • 节点结构体
typedef struct dictEntry
{
    //键
    void *key;
    //值
    union{
	    void *val;
	    uint64_tu64;
	    int64_ts64;
     }v;
    // 指向下个哈希表节点,形成链表
    struct dictEntry *next;
}dictEntry;

这没什么说的,三个属性:Key、Value还有一个指向下一个节点的地址,形成链表

  • 字典结构体(散列表又封装了一些属性)
typedef struct dict{
     //类型特定函数
     void *type;
     //私有数据
     void *privdata;
     //哈希表
     dictht ht[2];
     //rehash 索引 当rehash不在进行时 值为-1
     int trehashidx; 
}dict;

前两个属性不懂,不敢介绍,主要看后两个属性,都是和扩容有关,详细的在扩容的时候再说,只先说下ht[0]是我们正常使用到的链表,ht[1]扩容时使用到的链表。

  • 散列表
typedef struct dictht
{
    //哈希表数组,C语言中,*号是为了表明该变量为指针,有几个* 号就相当于是几级指针,这里是二级指针,理解为指向指针的指针
    dictEntry **table;
    //哈希表大小
    unsigned long size;
    //哈希表大小掩码,用于计算索引值
    unsigned long sizemask;
    //该哈希已有节点的数量
    unsigned long used;
}dictht;

size:数组的大小,为2的n次幂;
sizemask:恒等于size-1,用于确定下标时的&运算;
used:散列表中实际的节点数量,因为可能发生碰撞,所以used >= size。

碰撞和扩容

碰撞

碰撞是hash散列表必须要面对的问题,指的是不同的值经过hash运算之后,获得到的下标值是一样的,这个时候就要考虑怎么保存这两个值,redis的解决方案是拉链法,就是在这个节点下生成一条链表用来保存发生碰撞的节点,采用的是头插法来生成链表,就是在头部添加数据,这样做的好处是不需要遍历整个链表来添加节点。

扩容(rehash)

链表查询的时间复杂度是O(n),所以碰撞的发生肯定会降低散列表的查询效率,极端情况下散列表会退化成链表,所以我们应该尽量避免碰撞的发生,扩容能有效地减少碰撞的发生。

  • 概念:为了减少碰撞,字典会扩大数组的大小,为了保证数组的大小为2^n,所以扩容都是成倍进行的;
  • 时机:dictht结构体中有两个属性:size(数组的大小)和used(已有的节点数量),这两个属性的比值radio=used/size,radio比值越大,说明发生碰撞的次数越多,是否发生扩容也是这个值来决定的,分为两种情况:
    1、自然rehash:当radio>=1,且dict_can_resize为true时,就会发生扩容,dict_can_resize一般情况下都是为true的,只有当redis进行持久化时,为了减少程序对内存的修改,会将dict_can_resize设置成false,禁止自然rehash;
    2、强制rehash:当radio>=dict_force_resize_radio时,不管是否在持久化都会发生扩容,dict_force_resize_radio默认是5。
  • 缩容:当radio<=0.1时,也会发生rehash,但是这个时候发生的是缩容,减小数组的大小。
  • 过程:dict采用的是渐进式rehash,也就是说不会直接一次就完成rehash操作,先再来看下dict的结构体:
typedef struct dict{
     //类型特定函数
     void *type;
     //私有数据
     void *privdata;
     //哈希表
     dictht ht[2];
     //rehash 索引 当rehash不在进行时 值为-1
     int trehashidx; 
}dict;

其中,ht[0]是我们日常使用的dict,ht[1]是扩容时使用到的dict,trehashidx是扩容进行到的下标,过程:
1、ht[1]数组,大小为ht[0]的两倍;
2、将ht[0]上的节点搬到ht[1]上,同时更新trehashidx的值,记录rehash进行到的下标;
3、如果这时候有查询和删除操作,则会在ht[0]和ht[1]上都执行,但是添加操作只会在ht[1]上执行;
4、完成之后,会释放ht[0]的空间,并将trehashidx的值设置成-1。

跳跃表

对于一个有序的列表,要查找一个元素的话,我们的第一反应是遍历,但是遍历的复杂度是O(n)级别的,效率不高,然后就会想到二分法,二分法的复杂度是O(logn)级别的,效率就比较高了,但是对于链表来说,二分法也是用不上的,因为不知道中分的下标,这个时候就可以使用跳跃表这种数据结构了,其实跳跃表的设计和二分法比较相似。

原理

先看一张图:

redis 数据量大需要更改持久化吗 redis 数据长度_数组_03


现在有一条有序链表L1,每次查询都需要一个个遍历显然是不现实的,所以将L1中的一些元素抽取出来生成L2链表,L2链表再生成L3,依次下去,直到生成足够少元素的L4,这样查询过程就变成了:

1、先在L4上查找,和55作比较,如果>=55则直接返回结果,如果<55则到L3上查找;

2、和21作比较,=21则直接返回结果,>21则前进到L2的37,<21则回退到L2的2;

3、和37作比较,=37则返回结果,>37则返回46,<37则返回33;

这样就查询到了结果,感觉和二分法的思路很相似。

源码

  • 节点zskiplistNode
typedef struct zskiplistNode {
	// member 对象
    robj *obj;
	// 分值
    double score;
	// 后退指针
    struct zskiplistNode *backward;
	// 层
    struct zskiplistLevel {
		// 前进指针
        struct zskiplistNode *forward;
		// 节点在该层和前向节点的距离
        unsigned int span;
    } level[];
} zskiplistNode;

score:因为是有序队列,这个分数值就是用来排序的;
backward:也就是这个节点的前驱节点。

  • 跳跃表zskiplist
typedef struct zskiplist {
	// 头节点,尾节点
    struct zskiplistNode *header, *tail;
	// 节点数量,链表长度
    unsigned long length;
	// 目前表内节点的最大层数(层高)
    int level;
} zskiplist;

level:跳跃表的层高,也就是生成多少个跳表来优化查询,每次添加节点的时候,会根据幂次定律(越大的数出现的概率越小)来随机生成一个介于1~32之间的数来表示层高,感觉有点随意。。。

使用场景

zset了。。。。

压缩列表和快速列表

压缩列表原理

压缩这两个字其实就已经说明用途了,用来压缩数据,减少内存浪费的。
一般数组的每个元素的大小都是一样的,但是实际存储的数据大小又是不一样的,为了防止内存溢出,数组元素的大小必须设置成数据最大的大小,这样就会有大量内存空间被浪费了,压缩列表压缩的就是这部分内存空间。

  • 普通数组:

redis 数据量大需要更改持久化吗 redis 数据长度_数据结构_04



  • 压缩列表:


redis 数据量大需要更改持久化吗 redis 数据长度_链表_05



源码

  • 压缩列表ziplist
struct ziplist<T>{
	//整个压缩列表占用的空间大小
	int32 zlbytes; 
	// 最后一个元素的偏移量,用于快速定位最后一个节点,支持双向
	int32 zltail_offset; 
	//元素的个数
	int16 zllength; 
	 //这个就是实际存数据的数组,是一段紧凑的连续内存空间
	T[] entries;
	//压缩列表的结束标志,恒为0xFF;
	int8 zlend; 
}
  • 节点entry
struct entry{
	// 前一个entry的字节长度
	int<var> previous_entry_length; 
	// 编码
	int<var> encoding;
	// 实际的数据内容
	optional byte[] content; 
}

previous_entry_length:表示上一个节点的字节长度,这个字段的长度可以为1Byte或者5Byte,如果前一个节点的长度小于254,则这个字段的长度为1Byte,如果大于254,则这个字段的长度为5Byte,且前1个Byte的值为0xFE(254),后4个Byte的值为前一个节点的长度,这个字段的作用是为了支持双向,从后开始遍历;
encoding:记录了数据类型(整型和字符)和长度,第一个字节的前两位表示类型:00、01、10表示字节,11表示整型,其他的位表示长度。

联级更新问题

这个问题主要出现在previous_entry_length属性上,举个例子:A元素之前的长度是251,这个时候B元素的previous_entry_length属性的长度为1Byte,然后A元素执行了更新操作,更新之后的长度变化了261,这个时候B元素的previous_entry_length长度要变成5Byte,会使B元素整个长度变大,导致C元素也要发生更新,要是B元素的长度也刚好超过254,那C元素长度还会变长,这个就是联级更新的问题,不过它出现的概率不大。

快速列表

Redis3.2版本开始已经用quicklist来替换ziplist了,快速列表就是压缩列表和双向链表相结合,一般使用List的操作是lpush、lpop、rpush、rpop,都是对两端节点的操作,所以快速列表就是将链表的中间节点用ziplist进行压缩,而两端节点不会压缩。

源码
  • quicklist,快速列表
typedef struct quicklist {
	// 头结点
    quicklistNode *head;
    // 尾节点
    quicklistNode *tail;
    // 元素节点总数
    unsigned long count;
    // 快速列表节点总数
    unsigned long len;   
    // ziplist中元素的长度
    int fill : 16;     
    // 头尾两端不用压缩的节点数
    unsigned int compress : 16; 
} quicklist;

一个quicklist占40Byte:8+8+8+8+2+2+4(内存对齐)
head、tail:头尾节点,用来快速访问头尾;
count:元素的数量;
len:quicklistNode的数量,一个quicklistNode会包含多个元素;
fill:压缩列表的长度,这个值为正数时表示的节点数,比如16表示16个节点;为负数时,则表示为容量大小,-1表示4Kb,-2表示8Kb,-3表示16Kb,-4表示32Kb,-5表示64Kb,官方推荐使用-1或者-2;
compress:表示两端不压缩的节点数,0表示都不压缩,默认为0。

  • quicklistNode,快速列表节点
typedef struct quicklistNode {
	// 前节点
    struct quicklistNode *prev;
    // 后节点
    struct quicklistNode *next;
    // ziplist
    unsigned char *zl;
    // ziplist字节大小
    unsigned int sz;
    // ziplist元素个数             
    unsigned int count : 16;    
    // ziplist是否压缩了
    unsigned int encoding : 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大小为32Byte;
encoding:1表示没有压缩,2表示压缩了。

整数集合

保存整数数据的集合,且保证数据唯一有序

源码

typedef struct intset{
    //编码方式(int16_t、int32_t、int64_t)
    uint32_t encoding;
    //元素数量
    uint32_t length;
    //保存元素的数组
    int8_t contents[];
} intset;

contents中保存的数据类型是由encoding决定的,encoding为int16_t时,contents保存的数据范围就是-32768——32767(2的15次方,一个符号位)。

升降级

升级:当contents原本存放的是16位的数据,然后来了一个32位的数据,这个时候为了能存放32位的数据,就会发生类型升级,扩展数组的大小,然后将原有的元素全部转换成32位的,然后再填充到数组中;
降级:只会升级,不会降级。

五种基本数据类型的数据结构

  • string:SDS,长度小于20且能转成整型为int;长度小于等于44为embstr;长度大于44为raw。
  • hash:当键/值长度都<=64且节点数量<=512时,使用的是ziplist,否则是hashtable。
  • list:quicklist。
  • set:整数类型的是intset,字符类型的是hashtable。
  • zset:当值的长度<=64且节点数量<=128时,使用的是ziplist,否则是skiplist。