SDS(simple dynamic string):

优点:
- O1时间获取长度(char *需要ON)
- 快速计算剩余空间(alloc-len) ,拼接时根据所需空间自动扩容 ,避免缓存区溢出(char *容易溢出)
- 使用字节数组存储二进制数据 ,可存储图片、视频等数据,且不会因为特殊字符"\0"意外中断字符串(char *默认末尾存储"\0",可能导致意外中断)
- flags:分为sdshdr5、sdshdr8、sdshdr16、sdshdr32和sdshdr64,用于定义len和alloc的类型,统一为uint16_t(对应sdshdr16)或uint32_t,灵活保存不同大小的字符串,节省内存空间 。同时使用了编译优化 ,取消字节对齐式的内存分配(浪费多余内存),使用紧凑方式的内存分配。


双向链表:
原双向链表结构:
cpp
typedef struct listNode {
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
} listNode;
二次包装:
cpp
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;
优点:
- 双向链表,获取某个节点的前后节点都只需要O1时间
- 增加头尾指针,快速定位表头表尾
- 保存了链表长度,O1时间获取链表大小
- 函数和值都使用void*指针 ,可以指向任意类型数据 ,因此链表节点可以保存任意不同类型的值
缺陷:
内存不连续,无法利用CPU缓存,每个节点都是一个结构体,内存开销较大。
哈希表:
概述:
Redis采用了链式哈希(拉链法)来解决哈希冲突 ,这与Java中的hashMap是相似的。
哈希表的底层其实是数组 ,通过计算对象的哈希值对数组长度取余来确定索引位置 ,可以在O1的时间获取数据。
哈希表结构:
cpp
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
unsigned long sizemask;
//该哈希表已有的节点数量
unsigned long used;
} dictht;
dictEntry是哈希数组,数组的每个元素是指向一个哈希表节点(dictEntry)的指针
哈希表节点结构:
cpp
typedef struct dictEntry {
//键值对中的键
void *key;
//键值对中的值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
//指向下一个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
特色:
dictEntry结构中的值实际上是一个联合体 ,可以存储浮点数,无符号或有符合整数,然后是对应值的地址 。这样做的好处是可以节省内存空间 ,因为简单的数据类型可以直接存储值而无需再存放地址。
rehash(本质上类似于数组的自动扩容):
拉链法解决哈希冲突有局限性 ,链表过长时查询性能就会下降 。实际Redis使用哈希表时,定义了一个dict结构体,并在里面定义了两个哈希表。
cpp
typedef struct dict {
...
//两个Hash表,交替使用,用于rehash操作
dictht ht[2];
...
} dict;
正常服务时 ,插入数据都会写入到哈希表1中,哈希表2此时没有分配空间。
随着数据增多,触发rehash操作:
- 给哈希表2分配空间,一般是哈希表1的两倍大小
- 哈希表1数据迁移到表2
- 释放表1空间 ,并将哈希表2设为表1 ,并创建新的空白表2,为下次rehash做准备
迁移过程中,若表1的数据量非常大,可能会涉及大量的数据拷贝,进而导致Redis阻塞。
因此Redis采用了渐进式rehash:
- 给表2分配空间
- rehash期间 ,每次哈希表元素进行查找或更新时 ,Redis除了进行这些操作外,还会顺序将表1中该索引位置上的所有key-value迁移到表2上。
- 随着 客户端发起的操作请求数量变多 ,最终某个时间点表1的节点会全部迁移到表2中,变成空表。
- 且rehash期间 ,查找操作会先到表1查找再到表2查找 。另外,新增操作直接在哈希表2中进行 ,保证表1的键值对数量只会减少。
rehash的触发条件:

- 当负载因子大于等于1 ,且没有进行bgsave或bgrewiteaof命令时 (也就是没有执行RDB快照或AOF重写时),就会进行rehash操作。
- 当负载因子大于等于5时 ,说明哈希冲突已经非常严重 了,无论有没有再进行RDB快照或AOF重写,都会强制进行rehash操作。
整数集合
整数集合是set对象的底层实现,若一个set对象只包含整数 ,且数量不大时,就会使用整数集合。
结构设计:
本质上是一块连续内存空间,定义如下:
cpp
typedef struct intset {
uint32_t encoding; // 编码类型(INTSET_ENC_INT16/32/64)
uint32_t length; // 元素数量
int8_t contents[]; // 实际类型由 encoding 决定
} intset;
保存元素的容器是类型为int8_t的contents数组,但实际上,int8_t只是占位符,数组保存的值的类型是encoding的属性值,不同类型的contents数组大小也不同。
- 若encoding属性值是INTSET_ENC_INT16,那么contents就是一个int16_t类型的数组
- 若encoding属性值是INTSET_ENC_INT32,那么contents就是一个int32_t类型的数组
- 若encoding属性值是INTSET_ENC_INT64,那么contents就是一个int64_t类型的数组
升级操作:
添加到整数集合的新元素类型比现有元素类型都要长时,整数集合会进行升级:
按新的类型扩展 contents数组的空间大小
将新元素加入到整数集合里
升级过程中维持集合的有序性

特点:
动态升级可按需存储数据,节省内存
不支持降级操作,会一直保持升级后的状态
跳表:
只有Zset对象底层用到了跳表,支持平均OlogN的节点查找
zset有两个数据结构:跳表和哈希表 。既能进行高效范围查询,又能进行高效单点查询
cpp
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
结构设计:
在链表基础上改造为多层的有序链表
层级为3的跳表示例:

例如我们要查找节点4的数据,使用链表需要遍历四次,但使用跳表只需要遍历两次,查找过程就是在不同层级上根据元素的权重进行遍历直到定位数据。
跳表节点结构:
cpp
typedef struct zskiplistNode {
//Zset 对象的元素值
sds ele;
//元素权重值
double score;
//后向指针
struct zskiplistNode *backward;
//节点的level数组,保存每层上的前向指针和跨度
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
跳表底层结构:
cpp
typedef struct zskiplist {
//跳表的头尾节点,便于在O(1)时间复杂度内访问跳表的头节点和尾节点
struct zskiplistNode *header, *tail;
//跳表长度,便于在O(1)时间复杂度获取跳表节点的数量
unsigned long length;
//跳表的最大层数,便于在O(1)时间复杂度获取跳表中层高最大的那个节点的层数量
int level;
} zskiplist;
查询过程:
- 如果当前节点的权重小于要查找的权重 ,访问该层下一个节点
- 如果当前节点的权重等于要查找的权重 ,比较对应的SDS类型数据,若小于则访问该层下一个节点
- 若上面两个条件都不满足,或下一个节点为空 ,则沿着 当前节点的下一层指针继续查找
查询过程示例:

如果要查找「元素:abcd,权重:4」的节点,查找的过程是这样的:
- 先从头节点的最高层开始,L2 指向了「元素:abc,权重:3」节点,这个节点的权重比要查找节点的小,所以要访问该层上的下一个节点;
- 但是该层的下一个节点是空节点( leve[2]指向的是空节点),于是就会跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[1];
- 「元素:abc,权重:3」节点的 leve[1] 的下一个指针指向了「元素:abcde,权重:4」的节点,然后将其和要查找的节点比较。虽然「元素:abcde,权重:4」的节点的权重和要查找的权重相同,但是当前节点的 SDS 类型数据「大于」要查找的数据,所以会继续跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[0];
- 「元素:abc,权重:3」节点的 leve[0] 的下一个指针指向了「元素:abcd,权重:4」的节点,该节点正是要查找的节点,查询结束
跳表节点层数设置:
跳表相邻节点数量最理想的比例是2:1,查找复杂度可以降低到OlogN
维持手段:
- 采用新增或删除节点来维持比例会带来额外开销 ,所以Redis使用概率生成来决定每个节点的层数
- 跳表创建节点时,会生成0到1的随机数,若随机数小于0.25,则该节点层数增加一层 ,然后继续生成随机数,直到结果大于0.25结束。
- 层数越高,概率越低 ,层高大小限制为64,创建跳表时,会根据层高限制创建所有头节点
为什么使用跳表而不用平衡树?
- 内存占用上 :跳表使用的内存更少 。平衡树每个节点包含两个指针 ,而跳表平均只有1.33个指针(概率为25%的情况下)
- 范围查找时 :跳表操作更简单 。平衡树找到指定范围的小值后,还需要中序遍历寻找其他不超过大值的节点 。而跳表只需要找到最小值后对第1层链表进行遍历即可。
- 算法实现难度 上:跳表实现更加简单 。平衡树的插入删除操作可能引发子树的调整 。而跳表只需要修改相邻节点的指针,与链表类似。
压缩列表:
结构设计:
连续内存块组成的顺序数组结构,类似于数组。Redis7.0后被完全废除
时间复杂度为On

四个额外字段:
- zlbytes :记录压缩列表占用的内存字节数
- zltail :记录压缩列表尾部节点的偏移量
- zllen :记录压缩列表包含的节点数量
- zlend :位于表尾,标记结束点,固定值0xFF(255)
每个键或值都作为一个独立的 entry
节点存储,节点结构包含:
prevlen
:记录前一个节点的长度(用于逆向遍历)。encoding
:标识当前节点的数据类型 (字符串或整数 )及长度。content
:存储实际的键或值数据
插入数据时,根据数据结构和类型进行不同空间大小的分配,节省内存
连锁更新:
新增或修改元素时,若空间不足 ,压缩列表占用的内存空间就需要重新分配 。插入元素较大时 ,可能会导致后续元素的prevlen占用空间全部发生变化 ,从而引起连锁更新 (后面元素的prevlen的记录量本来为1字节,但新元素大小超过了254字节,需要用5字节来存储,而当前元素扩容后,后续的元素也要相应扩容,直到所有元素扩容完成)。
缺陷:
虽然 压缩列表紧凑型的内存布局能节省内存开销 ,但元素过大会导致内存重新分配 ,甚至连锁更新 。影响压缩列表的访问性能 ,所以压缩列表只适合保存节点数量不多的场景。
后续引入的quicklist类型和listpack类型就是在保持节省内存的优势的同时解决压缩列表的连锁更新的问题。
quicklist:
3.0前,List对象底层是双向链表或压缩链表。后来底层改为quicklist实现。quicklist实质上就是双向链表+压缩链表的组合:quicklist是一个双向链表,节点存储的是压缩列表。
解决连锁更新的办法:
quicklist通过控制每个节点中压缩列表的大小或者元素个数来规避连锁更新的问题。因为压缩列表元素越少越小,连锁更新带来的影响就越小。
结构设计:
cpp
typedef struct quicklist {
//quicklist的链表头
quicklistNode *head; //quicklist的链表头
//quicklist的链表尾
quicklistNode *tail;
//所有压缩列表中的总元素个数
unsigned long count;
//quicklistNodes的个数
unsigned long len;
...
} quicklist;
节点结构设计:
cpp
typedef struct quicklistNode {
//前一个quicklistNode
struct quicklistNode *prev; //前一个quicklistNode
//下一个quicklistNode
struct quicklistNode *next; //后一个quicklistNode
//quicklistNode指向的压缩列表
unsigned char *zl;
//压缩列表的的字节大小
unsigned int sz;
//压缩列表的元素个数
unsigned int count : 16; //ziplist中的元素个数
....
} quicklistNode;

添加元素时,会先检查插入位置的压缩列表是否能容纳该元素 ,如果能容纳就直接保存 到该压缩列表,否则才新增节点和压缩列表。
本质上连锁更新是没有解决的 ,只是通过控制压缩列表的大小来尽量减少连锁更新的性能损耗
listpack:
采用了很多压缩列表的优秀设计,例如还是用连续空间来紧凑存储数据 ,并且为节省内存开销 ,listpack节点采用不同的编码方式保存不同大小的数据。
结构设计:

节点结构:

- backlen:encoding+data的总长度
- encoding:该元素的编码类型,对不同长度的整数和字符串进行编码
- data:实际存放的数据
可以发现,listpack移除了prevlen字段,只记录当前节点的长度 ,当我们向listpack加入新元素时,不会影响其他节点的内存空间 ,从而避免了压缩列表的连锁更新问题。