这是我参与「第五届青训营」伴学笔记创作活动的第14天
今天这篇文章主要是对课上介绍的数据结构和数据类型的补充
数据结构
SDS
c语言字符串存在的问题
- 获取长度的时间复杂度为O(n)
- 非二进制安全,不能包含'\0'
- 不可修改,通过指针定义的字符串实际上是常量区的一个地址,不能通过操作指针来修改
Simple Dynamic String
包括sdshdr5,sdshdr8,sdshdr16,sdshdr32,sdshdr64
//3.0之前
struct sdshdr {
unsigned int len; /*已保存的字节数,不包含结束符*/
unsigned int free; /*空闲空间*/
char buf[];
};
//3.0之后
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* 已保存的字节数,不包含结束符*/
uint16_t alloc; /* 已申请的字节数,不包含结束符*/
unsigned char flags; /* 不同sds的头类型,用来控制sds的头大小*/
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len;
uint32_t alloc;
unsigned char flags;
char buf[];
};
扩容机制
如果对sds进行追加:新字符串长度小于1M,新空间为扩展后字符串长度的两倍+1,即len=newLength,alloc=2*newLength;新字符串长度大于1M,新空间为扩展后字符串长度+1M+1,即len=newLength,alloc=newLength+1M
简单来说:小于1M翻倍扩容,大于1M直接+1M
最大长度为512MB
sds优点
- 获取长度时间复杂度O(1)
- 支持动态扩容
- 二进制安全
- 减少内存分配次数
IntSet
集合的一种实现方式,基于整数数组,并且具备长度可变、有序等特征
typedef struct intset{
uint32_t encoding; /* 编码方式,支持存放16位,32位,64位整数*/
uint32_t length; /* 元素个数*/
int8_t contents[]; /* 数组*/
} intset;
插入元素
- 判断是否超出编码的表示范围,如果是,进入升级分支,否则进行2
- 通过二分法搜索当前元素是否在数组中已存在,在搜索过程中还会更新插入元素应处的position
- 数组扩容
- 将position之后的元素倒序copy
- 放置插入元素
- 修改属性
升级机制
当插入数据超出现有编码的表示范围时,会进行升级
- 升级编码,按照新的编码和元素个数进行扩容
- 数组倒序copy,防止正序copy时覆盖原有元素
- 将待添加的元素放入数组头或尾
- 修改encoding属性,修改length属性
Dict
由哈希表(DictHashTable),哈希节点(DictEntry),字典组成(Dict)
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小,保持2^n
unsigned long size;
//哈希表大小掩码,用于计算索引值,恒等于size-1
unsigned long sizemask;
//该哈希表已有的节点数量
unsigned long used;
} dictht;
typedef struct dictEntry {
//键值对中的键
void *key;
//键值对中的值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
//指向下一个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
typedef struct dict{
dictType *type; //dict类型,内置不同的hash函数
void *privdata; //私有数据,在做特殊hash运算时使用
dictht ht[2]; //一个存储当前数据,另一个为空,rehash时使用
long rehashidx; //rehash的进度,-1表示未进行
int16_t pauserehas; //rehash是否暂停,1则暂停,0则继续
} dict;
插入元素
首先对key进行hash运算,将得到的运算结果再与sizemask做&运算,得到entry的index(当size=2^n时,运算结果与hash做&运算等价于取模运算),当产生哈希冲突时,数组中的元素会替换为新插入的元素,将之前元素的地址赋给新插入元素的next指针
LoadFactor
used/size,即哈希表已保存节点的个数/哈希表的大小
扩容
- LoadFactor >= 1,并且服务器没有执行BGSAVE或者BGREWRITEAOF
- LoadFactor >= 5
当满足这两种情况时,Dict会进行扩容,调用dictExpand(dict,used+1)
收缩
当 LoadFactor < 0.1 且 size > 4 时,会进行收缩,调用dictExpand(dict,used)
dictExpand
这个函数有两个参数,一个是dict,另一个是size
它会找出第一个大于size的2^n,设为realsize,之后分配realsize大小的空间作为新的哈希表,将dict[1]置为新的哈希表,再设rehashidx为0
rehash
由于扩容/收缩之后哈希表size的改变,每个entry都需要重新映射,放置到正确位置上,这个过程就叫做rehash,如果数据量过多,我们想要一次执行完rehash会阻塞Redis,因此就有了渐进式rehash。
-
计算新的hash表的realsize
-
- 如果是扩容,realsize为大于等于used+1的最小2^n
- 如果是收缩,realsize为大于等于used的最小2^n
-
按照新的realsize申请空间,创建dictht,并赋值给dict.h[1]
-
设置dict.rehashidx=0,标识开始rehash
-
每次执行增删改查操作时,都检查一下rehashidx是否大于-1,如果是,则将dict.ht[0].table[rehashidx]的entry链表rehash到dict.ht[1],并且rehashidx++。直到ht[0]的所有数据都迁移到ht[1]
-
将ht[1]赋值给ht[0],将ht[1]初始化为空,释放原来的ht[0]内存
-
将rehashidx赋值为-1,标识rehash结束
注意
- 在rehash过程中,数据不会同时存在于ht[0]和ht[1]
- rehash过程中,新增命令直接写入ht[1],其他命令都会先在ht[0]和ht[1]上查找,再去执行
- rehash时ht[0]只减不增
ZipList
双端链表,连续内存。可以在任一端push或pop
- zlbytes(uint32_t):记录整个ziplist占用的字节数
- zltail(uint32_t):记录尾结点到ziplist起始地址的偏移量
- zllen(uint16_t):记录节点数量
- entry(不定):节点
- zlend(uint8_t):特殊值0xff,用于标记结束
Entry
- previous_entry_length:前一个entry的长度,占1或5个字节,如果前一个entry的长度小于254个字节,用一个字节表示,否则用5个字节表示,其中5个字节中第一个为0xfe,后四个字节才是真实长度
- encoding:编码属性,记录contents的编码类型(字符串还是整数)以及长度,占1、2或5个字节
- contents:保存数据
字符串
| 编码 | 编码长度 | 字符串大小 |
|---|---|---|
| 00xxxxxx | 1 bytes | <=63bytes |
| 01xxxxxx xxxxxxxx | 2 bytes | <=16383bytes |
| 11.................................. | 5 bytes | <= 4294967295bytes |
整数
| 编码 | 编码长度 | 整数类型 |
|---|---|---|
| 11000000 | 1 bytes | int16_t |
| 11010000 | 1 bytes | int32_t |
| 11100000 | 1 bytes | int64_t |
| 11110000 | 1 bytes | int24_t |
| 11111110 | 1 bytes | int8_t |
| 1111xxxx | 1 bytes | 直接在xxxx位置保存数值 |
连锁更新
当多个连续的entry的长度都即将达到254字节时,虽然这时候previous_entry_length都只占用一个字节,但如果在这多个连续entry前插入一个较大的entry时,由于第一个entry的previous_entry_length超出了254字节,导致后面多个entry的previous_entry_length都发生了改变,就会造成多次的申请内存,以及数据的移动,十分损耗性能
QuickList
产生背景
由于ZipList占用的是连续内存,不能存储过多的entry,因此当我们需要存储大规模的entry时,就可以采用QuickList。
QuickList是一个双端链表,它的节点是ZipList,通过前驱和后继指针将不同的节点连接起来
由于对于链表来说,一般频繁访问的都是头和尾,因此QuickList可以设置压缩中间节点
SkipList
传统链表查找元素的时间复杂度为O(N),如果我们给链表划分不同的层级,层级最低的节点与节点之间的跨度为1,越往上跨度越高,如果我们能够均匀化跨度,那我们就可以在链表上实现二分查找,达到O(logn)的查找,插入以及删除
Redis中跳表是根据score值从小到大排序的
typedef struct zskiplist {
struct zskiplistNode *header, *tail; //头节点,尾节点
unsigned long length; //节点数量
int level; //最大层级
} zskiplist;
typedef struct zskiplistNode {
//Zset 对象的元素值
sds ele;
//元素权重值
double score;
//后向指针
struct zskiplistNode *backward;
//节点的level数组,保存每层上的前向指针和跨度
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
数据类型
String
编码格式
- raw:底层采用sds,存储的字符串长度如果小于44字节,会转换成embstr编码
- embstr:底层采用sds,header和sds一起申请连续的内存空间,所以embstr是只读的,当我们对embstr编码的字符串进行修改时,会先将编码转换为raw
- int:如果存储的是整数并且小于2^8,则可以将字符串的值直接赋给ptr指针
List
编码格式
- 3.2之前,使用ZipList和LinkedList
- 3.2之后,统一使用QuickList
Set
编码格式
- dict:key为要存储的数据,value为null
- 当存储数据都是整数且存储数量不超过set-maxintset-entries时,使用IntSet编码,以节省内存
ZSet
编码格式
zset要满足3个特点:键值存储,可排序,键唯一
如果使用Dict,无法满足可排序,如果使用跳表,无法满足键唯一,并且无法快速通过键获取值。因此Redis将这两种编码结合起来,作为zset的底层编码
- 跳表和Dict:数据同时存储在跳表和Dict
- ZipList:数据量较小时使用,将键值对作为两个entry进行存储,element在前,score在后,查找和排序都通过遍历的方式
Hash
编码格式
- ZipList:数据量较小时使用,相邻的两个entry分别保存field和value
- Dict