底层的数据结构大概有六种:
- 简单动态字符串
- 链表
- 字典
- 跳跃表
- 整数集合
- 快速列表
简单动态字符串
简介
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格式编码:
在redisObject中,有一个指针ptr是指向实际数据地址的,那如果一个实际数据是整型值且小于8byte的话,是不是就可以直接将这个数据存道ptr中?这样既不需要再去开辟一块新内存,减少了内存开销,又减少了一次寻址的时间,所以当数据值小于20byte且可以转换成整型时,redis会将它直接存到指针ptr中。
- embstr:长度在44byte及一下是embstr编码,3.2版本及以前的这个长度是39byte;
这里要先理解几个概念:
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)级别的,效率就比较高了,但是对于链表来说,二分法也是用不上的,因为不知道中分的下标,这个时候就可以使用跳跃表这种数据结构了,其实跳跃表的设计和二分法比较相似。
原理
先看一张图:
现在有一条有序链表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了。。。。
压缩列表和快速列表
压缩列表原理
压缩这两个字其实就已经说明用途了,用来压缩数据,减少内存浪费的。
一般数组的每个元素的大小都是一样的,但是实际存储的数据大小又是不一样的,为了防止内存溢出,数组元素的大小必须设置成数据最大的大小,这样就会有大量内存空间被浪费了,压缩列表压缩的就是这部分内存空间。
- 普通数组:
- 压缩列表:
源码
- 压缩列表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。