1、概述
1.1 数据类型与数据结构的关系
1.2 Redis 如何存放键值对
Redis 的键值对数据是通过哈希表来保存的,这使得查找键值对仅需要 O(1) 的时间复杂度。
哈希表就是一个数组,数组中的元素叫做哈希桶,哈希桶存放指向键值对的指针(dictEntry*)。而键值对也是通过 void* key 和 void* value 指针指向实际的 key 和 value。
- redisDb:表示 Redis 数据库的结构,结构体里存放了指向了 dict 结构的指针。
- dict:存放了 2 个哈希表,正常情况下都是用哈希表-1,哈希表-2 在 rehash 的时使用。
- dictht:表示哈希表的结构,结构里存放了哈希表数组,数组中的每个元素都是指向一个 dictEntry 的指针。
- dictEntry:表示哈希表节点的结构,结构里存放了 key 和 value 指针。
- key:指向 String 对象。
- value:指向 String、Hash、List、Set、ZSet 等数据结构。
1.3 Redis 对象
Redis 中的每个对象都由 redisObject 表示,而 key、value 都是直接指向 redisObject。redisObject 如下图所示:
- type:标识对象类型,如:String、Hash、List、...
- encoding:标识底层数据结构,如:SDS、QuickList、HashTable、...
- ptr:指向底层数据结构的指针。
2、SDS
SDS:Simple Dynamic String,简单动态字符串,是 Redis 定义的一种可变长字符串。
2.1 C 语言字符串问题
Redis 是使用 C 语言开发的,C 语言中字符串是通过 char* 字符数组表示,那么为什么还要自定义字符串呢?
主要因为 char* 字符串是通过指向字符数组起始位置,结尾是通过 '\0' 标识的,这就会引发一些问题:
- 字符串内容不能包含
'\0',也不能保存二进制数据,如图片、音频、视频等。 - C 语言规定的字符串操作函数不安全,容易造成缓冲区溢出。
- 获取字符串长度需要运算,时间复杂度为 O(n)。
2.2 SDS 结构
Redis 推出 SDS 就是为了解决了这些问题,首先需要了解以下 SDS 的数据结构。
- len:字符串长度。
- alloc:分配给字符数组的空间长度。
- flags:SDS 类型,提供了 5 种类型(sdshdr5、sdshdr8、sdshdr16、sdshdr32 以及 sdshdr64)。
- buf[]:字符数组,用来保存实际数据。
2.3 SDS 如何解决问题
-
字符串长度:SDS 结构加入了 len 成员变量,获取字符串长度只需要返回 len 的值即可,时间复杂度为 O(1)。
-
二进制安全:SDS 不再需要
'\0'来标识字符串结尾,而是通过 len 变量保存字符串长度,使得 SDS 不仅可以保存文本数据,还可以保存任意格式的二进制数据。 -
缓冲区溢出:SDS 通过 alloc 和 len 变量,可以通过 alloc - len 获取剩余可用空间大小,因此再进行字符串操作时,可以判断缓冲区是否足够。
2.4 扩容
Redis 允许 SDS 在运行时根据需要自动调整其分配的内存大小。这种机制使得 SDS 能够高效地处理字符串的增长和缩减,同时减少内存的浪费和复制操作。以下是 SDS 动态扩容的工作原理:
- 预分配内存:SDS 在初始化或扩容时,会预先分配额外的内存空间。这样可以减少因为字符串增长而导致的内存分配。
- 扩容策略:当 SDS 中字符串长度超过了当前申请的字节数时,
SDS会执行扩容操作。假设扩展后的字符串大小为x,若x小于1M,则申请的空间为2*x + 1。若x大于1M,则申请的空间为x + 1M + 1。 - 惰性空间释放:SDS 在缩减操作时,不会立即释放内存。这是为了保留未使用的空间,以便后续字符串增长可以直接使用,避免频繁的内存分配。
- 内存碎片管理:由于 SDS 的动态扩缩容,可能会导致内存中出现碎片。为了管理这些碎片,
SDS提供了一些内存碎片管理机制,如在一定条件下将 SDS 的内存空间压缩到实际需要的大小、在Redis重新启动时清理无用的内存。 - 编码转换:SDS 支持多种编码方式(如
sdshdr5、sdshdr8、sdshdr16...),可以根据字符串的长度来选择合适的编码方式,可以在不同长度的字符串进行转换,来优化内存的使用。
扩容相关代码:
hisds hi_sdsMakeRoomFor(hisds s, size_t addlen) {
// ...
// 1. 判断剩余空间是否足够,若足够,则无需扩展
if (avail >= addlen)
return s;
// 2. 获取长度
len = hi_sdslen(s);
sh = (char *)s - hi_sdsHdrSize(oldtype);
// 3. 计算扩展后长度
newlen = (len + addlen);
// 3.1 若新长度 < 1M,则分配 2*newlen
if (newlen < HI_SDS_MAX_PREALLOC)
newlen *= 2;
// 3.2 若新长度 >= 1M,则分配 newlen + 1M
else
newlen += HI_SDS_MAX_PREALLOC;
// ...
}
2.5 SDS 类型
SDS 为了灵活保存不同大小的字符串,从而节省内存空间,提供了 5 种类型,分别是:
| SDS 类型 | 处理字符串最大长度 | len 类型 | alloc 类型 |
|---|---|---|---|
| sdshdr5 | <= 32 Byte | ||
| sdshdr8 | 33 Byte ~ 64 Byte | uint8_t | uint8_t |
| sdshdr16 | 65 Byte ~ 16383 Byte | uint16_t | uint16_t |
| sdshdr32 | 16384 Byte ~ 4194303 Byte | uint32_t | uint32_t |
| sdshdr64 | >= 4194304 Byte | uint64_t | uint64_t |
示例:sdshdr16
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len;
uint16_t alloc;
unsigned char flags;
char buf[];
}
优化对齐:是一种编译器优化技术,用于改善数据结构的内存对齐情况,从而提高访问数据的效率。
内存对齐:数据在内存中按照特定的边界(通常是 4 字节或 8 字节)对齐存储,这样处理器就可以更快地访问这些数据。
为了节省内存空间,还使用了编译优化来节省内存空间,即在 struct 声明 __attribute__ ((__packed__)),告诉编译器取消在编译过程中的优化对齐,按照实际占用字节数进行对齐。
3、ZipList
ZipList:是一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应编码。
3.1 ZipList 结构
- zlbytes:整个 ZipList 占用对内存字节数。
- zltail:尾节点距离起始地址由多少字节。
- zllen:包含的节点数量。
- zlend:结束点,固定值 0xFF(十进制255)。
由于 ZipList 独特的结构,对于第一个元素和最后一个元素查找的复杂度为 O(1),其余元素均需要顺序查找,复杂度为 O(n)。
3.2 Entry 结构
1)prevlen:前一个 Entry 长度,可以根据 prevlen 进行从后向前遍历。
prevlen 的大小与前一个节点长度有关:
- 前一个节点长度 < 254 Byte,prevlen = 1 Byte
- 前一个节点长度 >= 254 Byte,prevlen = 5 Byte
2)encoding:当前 Entry 实际数据的类型和长度。
encoding 的取值:
- 当前节点的数据是整数:encoding 会使用 1 Byte 进行编码。
- 当前节点的数据是字符串,encoding 会使用 1 Byte/ 2 Byte/ 5 Byte 进行编码。
| encoding 编码 | encoding 长度 | data 类型 |
|---|---|---|
| 00 xxxxxx | 1 Byte | 最大长度为 63 的字节数组 |
| 01 xxxxxx xxxxxxxx | 2 Byte | 最大长度为 2^14-1 的字节数组 |
| 10 xxxxxx xxxxxxxx xxxxxxxx xxxxxxxx xxxxxxxx | 5 Byte | 最大长度为 2^32-1 的字节数组 |
| 1100 0000 | 1 Byte | int16 整数 |
| 1101 0000 | 1 Byte | int32 整数 |
| 1110 0000 | 1 Byte | int64 整数 |
| 1111 0000 | 1 Byte | 24 位整数 |
| 1111 1110 | 1 Byte | 8 位整数 |
| 1111 xxxx | 1 Byte | - |
3)data:当前 Entry 的实际数据。
3.3 连锁更新
连锁更新:在新增/修改元素时,引发的多个 Entry 的 prevlen 同时更新的情况。
示例:ZipList 中存在多个 prevlen 在 250 ~ 253 之间的 Entry,在这些 Entry 前添加一个 prevlen 大于 254 的 Entry。
- 新增:在头部新增一个 Entry,由于下一个 Entry1 原本在头部,其 prevlen=0,新增后,prevlen = 5。
- 更新:Entry1 长度在增加 5 后,超过 254,Entry2 的 prevlen 从 1 -> 5,也超过了 254,继续更新。
- 结束:直到 Entry 更新完成,结束新增操作。
连锁更新一旦发生,就会导致压缩列表占用的内存空间要多次重新分配,这会导致 ZipList 性能降低。因此 ZipList 只适用于节点数量较少的场景。
4、HashTable
HashTable:是 Redis 中保存键值对(key-value)的数据结构。
哈希表中的 key 是唯一的,可以根据 key 找到与之对应的 value,并能够以 O(1) 的时间复杂度查询数据。
哈希表通过对 key 进行 hash() 运算,得到在表中对应的索引,从而根据索引快速查找数据。但随着数据的增多,可能会出现哈希冲突。
4.1 哈希冲突
哈希冲突:不同 key 经过 hash() 得到了相同的哈希值。
解决哈希冲突问题通常有以下方法:
- 链地址法:在哈希表的每个槽(slot)维护一个链表,将所有映射到该槽的键值对都添加到链表末尾。
- 开放寻址法:当发生冲突时,算法会寻找哈希表中的下一个空闲位置来存储该键值对。
- 再哈希法:使用另一个哈希函数重新计算哈希值,如果再次发生冲突,则继续使用不同的哈希函数,直到找到一个空闲槽。
Redis 的哈希表解决哈希冲突应用了链地址法,被分配到同一个哈希桶上的多个节点可以用这个单向链表连接起来。
4.2 哈希表设计
dict 源码:
typedef struct dict {
// ...
dictht ht[2]; // 两个哈希表交替使用
// ...
}
dictht 的源码:
typedef struct dictht {
dictEntry **table; // 哈希表数组
unsigned long size; // 哈希表大小
unsigned long sizemask; // 哈希表大小掩码,用于计算索引值
unsigned long used; // 哈希表已有的节点数量
}
dictEntry 的源码:
typedef struct dictEntry {
void *key; // 键
union {
void *val; // 指向实际数据的指针
uint64_t u64; // 64 位无符号整数
int64_t s64; // 64 位有符号整数
double d; // 浮点数
} v; // 值
struct dictEntry *next; // 指向下一个 dictEntry 指针
}
4.3 refresh
refresh:当已存储元素的数量与哈希表大小的比例达到一定阈值时,为了保持操作的效率,哈希表会进行扩容,即创建一个更大的新哈希表,并将所有元素重新映射(rehash)到新的哈希表中。
在正常服务阶段,插入的数据都会写入到 ht[0],而 ht[1] 并没有被分配内存。
随着数据的增多,就会触发 refresh 操作,以下是 refresh 的流程:
-
分配内存:为 ht[1] 分配空间,一般是 ht[0] 的 2 倍。
-
数据迁移:将 ht[0] 的数据迁移到 ht[1] 中。
-
结束处理:释放 ht[0],将 ht[0] 设置为 ht[1],为 ht[1] 创建一个空白的哈希表。
若 ht[0] 的数据量很大,在数据迁移时,会存在许多数据拷贝,在此期间可能会造成 Redis 阻塞,无法处理命令。
4.4 渐进式 refresh
渐进式 refresh:为了避免 refresh 期间造成长时间阻塞,Redis 会将数据迁移的工作分多次完成。
渐进式 refresh 流程:
- 分配空间:为 ht[1] 分配空间,一般是 ht[0] 的 2 倍。
- 数据迁移:在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将 ht[0] 中索引位置上的所有 key-value 迁移到 ht[1] 上。
- 结束处理:最终在某个时间点会把 ht[0] 的所有 key-value 迁移到 ht[1] 中,释放 ht[0],将 ht[0] 设置为 ht[1],为 ht[1] 创建一个空白的哈希表。
4.5 refresh 触发条件
refresh 的触发条件与 loadfactor 有关。
loadfactor = 哈希表已保存节点数量 / 哈希表大小
refresh 的触发条件:
- loadfactory >= 1,并且 Redis 没有执行 bgsave 或 bgrewriteaof,就会执行 refresh。
- loadfactory >= 5,不管 Redis 是否执行 bgsave 或 bgrewriteaof,都会执行 refresh。
5、IntSet
IntSet:是 Redis 中用于存储整数集合的数据结构。
5.1 IntSet 结构
IntSet 源码:
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
}
1)encoding:编码方式,决定了 contents 的数据类型。
- encoding = INTSET_ENC_INT16:contents 是一个 int16_t 类型的数组,数组中每一个元素都是 int16_t。
- encoding = INTSET_ENC_INT32:contents 是一个 int32_t 类型的数组,数组中每一个元素都是 int32_t。
- encoding = INTSET_ENC_INT64:contents 是一个 int64_t 类型的数组,数组中每一个元素都是 int64_t。
2)length:集合包含的元素数量。
3)contents:保存元素的数组,虽然是 int8_t 类型数组,但真正数据取决于 encoding。
5.2 IntSet 升级
IntSet 升级:新增元素时,若 new_type 比当前集合数据 type 长,按 new_type 扩展扩展 contents 数组的空间大小,然后才能将新元素加入到整数集合中。
示例:向已存在 5 个元素 int16_t 的 IntSet 添加 65535。
- 65535 需要用 int32_t 保存,比 int16_t 更大,需要进行扩容。
- 计算需要的内存空间,32 * 6 - 16 * 5 = 112,申请内存。
- 将 IntSet 中原数据扩展为 int32_t,并放到正确位置。
注意:IntSet 虽然支持升级操作,但不支持降级操作,一旦对数组进行了升级,就会一直保持升级后的状态。
6、SkipList
SkipList:是 Redis 中多层级的有序链表数据结构。
6.1 SkipList 结构
SkipList 源码:
typedef struct zskiplist {
struct zskiplistNode *header, *tail; // 头指针、尾指针
unsigned long length; // 跳表节点的数量
int level; // 跳表的最大层数
}
typedef struct zskiplistNode {
sds ele; // Zset对象的元素值
double score; //元素权重值
struct zskiplistNode *backward; // 后向指针
struct zskiplistLevel {
struct zskiplistNode *forward; // 每层上的前向指针
unsigned long span; // 跨度
} level[]; // level[] 中的每一个元素代表跳表的一层
} zskiplistNode;
6.2 查询
查找一个跳表节点的过程时,跳表会从头节点的最高层开始,逐一遍历每一层。在遍历某一层的跳表节点时,会用跳表节点中的 ele 和 score 来进行判断:
- 如果当前节点的 score < 要查找的 score 时,跳表就会访问该层上的下一个节点。
- 如果当前节点的 score = 要查找的 score 时,且当前节点的 ele < 要查找节点的 ele 时,会访问该层上的下一个节点。
如果都不满足或下一个节点为空,跳表会进入 level 数组里的下一层指针,沿着下一层指针继续查找。
6.3 层数设置
跳表相邻两层节点数量最理想比例为 2:1,这样查询复杂度就可以降到 O(logN)。
Redis 在创建节点时,随机生成每个节点的层数,并没有严格维持相邻两层节点数量为 2:1。
具体做法:创建节点时生成 [0~1] 的随机数。
- 若随机数 < 0.25,则层数 + 1,继续生成下一个随机数。
- 若随机数 > 0.25,结束,在计算得出的层数创建节点。
6.4 为什么使用跳表不用平衡树?
1)内存占用:
- 平衡树:每个节点包含左子树、右子树 2 个指针。
- 跳表:每个节点包含 1/(1-p) 个指针,在 Redis 中 p = 0.25,即平均 1.33 个指针。 跳表比平衡树内存占用更少。
2)范围查找:
- 平衡树:找到指定范围的小值后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。
- 跳表:找到指定范围小值后,对第 1 层链表进行若干步的遍历就可以实现。 跳表比平衡树范围查找更简单。
3)实现难度:
- 平衡树:插入和删除操作可能引发子树的调整。
- 跳表:插入和删除只需要修改相邻节点的指针。
跳表比平衡树操作实现更简单。
7、QuickList
QuickList:是 Redis 中双向链表 + 压缩列表的组合数据结构。
QuickList 中每个 quicklistNode 都存在一个 ZipList,通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。
7.1 QuickList 结构
QuickList 源码:
typedef struct quicklist {
quicklistNode *head; // 链表头
quicklistNode *tail; // 链表尾
unsigned long count; // ZipList 中的总元素个数
unsigned long len; // quicklistNodes 的个数
}
typedef struct quicklistNode {
struct quicklistNode *prev; // 上一个 quicklistNode
struct quicklistNode *next; // 下一个quicklistNode
unsigned char *zl; // 指向的压缩列表
unsigned int sz; // ziplist 的字节大小
unsigned int count : 16; // ziplist中的元素个数
}
7.2 插入元素
向 quicklist 添加一个元素的时候,不会直接新建一个链表节点。而是检查插入位置的压缩列表是否能容纳该元素。
- 能容纳,就直接保存到 quicklistNode 结构里的压缩列表。
- 不能容纳,才会新建一个新的 quicklistNode 结构。
quicklist 会控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来规避潜在的连锁更新的风险,但是这并没有完全解决连锁更新的问题。
8、ListPack
ListPack:是 Redis 推出用来替代 ZipList 的数据结构。
8.1 ListPack 结构
- total_tytes:存储 ListPack 占据的字节大小。
- num_elements:存储 ListPack 节点个数。
- end:结束表示,值为 0xFF。
8.2 Entry 结构
- encoding:元素的编码类型。
- data:实际存放的数据。
- len:encoding + data 的长度。