一、简单动态字符串
Redis没有直接使用C语言传统的字符串表示,而是自己构建了一种名为简单动态字符串(SDS)的抽象类型作为Redis的默认字符串表示。
struct sdshdr { // 记录 buf 数组中已使用字节的数量 // 等于 SDS 所保存字符串的长度 int len; // 记录 buf 数组中未使用字节的数量 int free; // 字节数组,用于保存字符串 char buf[]; };
SDS与C字符串的区别:
1、常数复杂度获取字符串长度
- C 字符串并不记录自身的长度信息, 所以为了获取一个 C 字符串的长度, 程序必须遍历整个字符串,对遇到的每个字符进行计数, 直到遇到代表字符串结尾的空字符为止,这个操作的复杂度为 O(N) 。
- SDS 在 len 属性中记录了 SDS 本身的长度, 所以获取一个 SDS 长度的复杂度仅为 O(1) 。
设置和更新 SDS 长度的工作是由 SDS 的 API 在执行时自动完成的, 使用 SDS 无须进行任何手动修改长度的工作。
2、杜绝缓冲区溢出
- C 字符串不记录自身的长度, 如果未分配足够的内存就会产生缓冲区溢出。
- 与 C 字符串不同, SDS 的空间分配策略完全杜绝了发生缓冲区溢出的可能性: 当 SDS API 需要对 SDS 进行修改时, API 会先检查 SDS 的空间是否满足修改所需的要求, 如果不满足的话, API 会自动将 SDS 的空间扩展至执行修改所需的大小, 然后才执行实际的修改操作, 所以使用 SDS 既不需要手动修改 SDS 的空间大小, 也不会出现前面所说的缓冲区溢出问题。
3、减少修改字符串时带来的内存重分配次数
因为 C 字符串并不记录自身的长度,所以每次增长或者缩短一个 C 字符串, 程序都总要对保存这个 C 字符串的数组进行一次内存重分配操作:
- 如果程序执行的是增长字符串的操作, 比如拼接操作(append),那么在执行这个操作之前, 程序需要先通过内存重分配来扩展底层数组的空间大小 —— 如果忘了这一步就会产生缓冲区溢出。
- 如果程序执行的是缩短字符串的操作, 比如截断操作(trim),那么在执行这个操作之后, 程序需要通过内存重分配来释放字符串不再使用的那部分空间 —— 如果忘了这一步就会产生内存泄漏。
为了避免 C 字符串的这种缺陷, SDS 通过未使用空间解除了字符串长度和底层数组长度之间的关联,由 SDS 的 free 属性记录。
通过未使用空间, SDS 实现了空间预分配和惰性空间释放两种优化策略。
(1)空间预分配:
空间预分配用于优化 SDS 的字符串增长操作,当 SDS 的 API 对一个 SDS 进行修改, 并且需要对 SDS 进行空间扩展的时候, 程序不仅会为 SDS 分配修改所必须要的空间, 还会为 SDS 分配额外的未使用空间。
- 如果对 SDS 进行修改之后, SDS 的长度(也即是 len 属性的值)将小于 1 MB , 那么程序分配和 len 属性同样大小的未使用空间, 这时 SDS len 属性的值将和 free 属性的值相同。 举个例子, 如果进行修改之后, SDS 的 len 将变成 13 字节, 那么程序也会分配 13 字节的未使用空间, SDS 的 buf 数组的实际长度将变成 13 + 13 + 1 = 27 字节(额外的一字节用于保存空字符)。
- 如果对 SDS 进行修改之后, SDS 的长度将大于等于 1 MB , 那么程序会分配 1 MB 的未使用空间。 举个例子, 如果进行修改之后, SDS 的 len 将变成 30 MB , 那么程序会分配 1 MB 的未使用空间, SDS 的 buf 数组的实际长度将为 30 MB + 1 MB + 1 byte 。
通过空间预分配策略, Redis 可以减少连续执行字符串增长操作所需的内存重分配次数。在扩展 SDS 空间之前, SDS API 会先检查未使用空间是否足够, 如果足够的话, API 就会直接使用未使用空间, 而无须执行内存重分配。
(2)惰性空间释放:
惰性空间释放用于优化 SDS 的字符串缩短操作: 当 SDS 的 API 需要缩短 SDS 保存的字符串时, 程序并不立即使用内存重分配来回收缩短后多出来的字节, 而是使用 free 属性将这些字节的数量记录起来, 并等待将来使用。SDS 也提供了相应的 API , 让我们可以在有需要时, 真正地释放 SDS 里面的未使用空间。
4、二进制安全
- C 字符串中的字符必须符合某种编码(比如 ASCII), 并且除了字符串的末尾之外, 字符串里面不能包含空字符, 否则最先被程序读入的空字符将被误认为是字符串结尾 —— 这些限制使得 C 字符串只能保存文本数据, 而不能保存像图片、音频、视频、压缩文件这样的二进制数据。
- SDS 的 API 都是二进制安全的,所有 SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf 数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设 —— 数据在写入时是什么样的,它被读取时就是什么样。buf 属性不是用来保存字符, 而是用它来保存一系列二进制数据。通过使用二进制安全的 SDS ,而不是 C 字符串,使得 Redis 不仅可以保存文本数据,还可以保存任意格式的二进制数据。
5、兼容部分 C 字符串函数
虽然 SDS 的 API 都是二进制安全的, 但它们一样遵循 C 字符串以空字符结尾的惯例,通过遵循 C 字符串以空字符结尾的惯例,SDS 可以在有需要时重用 <string.h> 函数库, 从而避免了不必要的代码重复。
二、压缩列表
压缩列表(ziplist)是列表键和哈希键的底层实现之一。使用场景:
- 当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串。
- 当一个哈希键只包含少量键值对,并且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串。
压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构,一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或者一个整数值。
1、压缩列表整体结构
2、压缩列表节点结构
每个压缩列表节点可以保存一个字节数组或者一个整数值,每个压缩列表节点都由previous_entry_length、encoding、content三部分组成。
(1)previous_entry_length
- 以字节为单位,记录了压缩列表中前一个节点的长度。
- 前一节点的长度小于254字节时previous_entry_length属性的长度为1字节,前一节点的长度大于等于254字节时previous_entry_length属性的长度为5字节。
- 如果有一个指向当前节点起始地址的指针c,只要用指针c减去当前节点previous_entry_length属性的值,就可以得出一个指向前一个节点起始地址的指针p。压缩列表的从表尾向表头遍历操作就是使用这一原理实现的。
(2)encoding
记录了节点的 content 属性所保存数据的类型及长度。
(3)content
保存节点的值,节点值可以是一个字节数组或者整数,值的类型和长度由节点的encoding属性决定。
(4)连锁更新
假如压缩列表里恰好有多个连续的、长度介于 250 到 253 字节之间的节点,如果这时在这些节点前新加一个长度大于254字节的节点,后一个节点的 previous_entry_length 长度就需要从1字节增加到5字节,很可能会带来一系列空间重分配操作(视连续的长度介于 250 到 253 字节的节点个数所决定),但出现的机率一般不高,ziplistPush等命令的平均复杂度为O(N),最坏为O(N*N)。
三、链表
typedef struct listNode{ //前置节点 struct listNode *prev; //后置节点 struct listNode *next; //节点的值 void *value; }listNode;
虽然仅仅使用多个 listNode 结构就可以组成链表, 但使用 adlist.h/ 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;
dup、free 和 match 成员是用于实现多态链表所需的类型特定函数:
- dup 函数用于复制链表节点所保存的值;
- free 函数用于释放链表节点所保存的值;
- match 函数用于对比链表节点所保存的值和另一个输入值是否相等。
Redis链表特性:
- 双端:每个链表节点带有 prev 和 next 指针,获取某个节点的前置、后置节点的复杂度都是 O(1).
- 无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问以 NULL 为终点.
- 带表头指针和表尾指针:通过 list 结构的 head 指针和 tail 指针,程序获取链表的表头节点和表尾节点的复杂度为 O(1).
- 带链表长度计数器:使用 list 结构的 len 属性来对 list 持有的链表节点进行计数,获取链表节点数量的复杂度为 O(1).
- 多态:链表节点使用 void* 指针来保存节点值,并且可以通过 list 结构的 dup 、 free 、 match 三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值.
四、字典
字典是一种用于保存键值对的抽象数据结构。字典中的每个键都是独一无二的,Redis的数据库就是使用字典来作为底层实现的,字典还是哈希对象的底层实现之一。
字典使用哈希表作为底层实现,一个哈希表里可以有多个哈希表节点,每个哈希表节点就保存了字典中的一个键值对。
1、哈希表结构
typedef struct dictht{ //哈希表数组 dictEntry **table; //哈希表大小 unsigned long size; //哈希表大小掩码,用于计算索引值 //总是等于 size-1 unsigned long sizemask; //该哈希表已有节点的数量 unsigned long used; } dictht;
- table属性是一个数组,数组中的每个元素都是一个指向dict.h/dictEntry结构的指针,每个dictEntry结构保存着一个键值对。
- size属性记录了哈希表的大小,即table数组的大小。
- used属性记录了哈希表目前已有键值对的数量。
- sizemask属性的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上。
2、哈希表节点结构
typedef struct dictEntry{ //键 void *key; //值 union{ void *val; uint64_t u64; int64_t s64; }v; //指向下个哈希表节点,形成链表 struct dictEntry *next; } dictEntry;
- key属性保存着键值对中的键。
- v属性保存着键值对中的值,可以是一个指针,或者是一个uint64_t整数,或者是一个int64_t整数。
- next属性是指向另一个哈希表节点的指针,可以将多个哈希值相同的键值对连接在一起,以解决键冲突问题。
3、字典结构
typedef struct dict { // 类型特定函数 dictType *type; // 私有数据 void *privdata; // 哈希表 dictht ht[2]; // rehash 索引 // 当 rehash 不在进行时,值为 -1 int trehashidx; /* rehashing not in progress if rehashidx == -1 */ } dict;
- type属性和privdata属性是针对不同类型的键值对,为创建多态字典而设置的。
- ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用。
- rehashidx属性记录了rehash目前的进度,如果目前没有在进行rehash,值为-1。
4、哈希算法
将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。
Redis计算哈希值和索引值的方法如下:
//使用字典设置的哈希函数,计算键 key 的哈希值 hash = dict->type->hashFunction(key); //使用哈希表的 sizemask 属性和哈希值,计算出索引值 //根据情况不同, ht[x] 可以是 ht[0] 或者 ht[1] index = hash & dict->ht[x].sizemask;
5、解决键冲突
当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时, 我们称这些键发生了冲突。
Redis 的哈希表使用链地址法来解决键冲突: 每个哈希表节点都有一个 next 指针,多个哈希表节点可以用 next 指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来。因为 dictEntry 节点组成的链表没有指向链表表尾的指针, 所以为了速度考虑, 程序总是将新节点添加到链表的表头位置(复杂度为 O(1)),排在其他已有节点的前面。
6、rehash
随着操作的不断执行,哈希表保存的键值会逐渐地增多或者减少,为了让哈希表的负载因子维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。Redis对字典的哈希表执行rehash的步骤如下:
(1)为字典的 ht[1] 哈希表分配空间,这个哈希表的空间大小取决于要执行的操作以及 ht[0] 当前包含键值对数量(即 ht[0].used 属性的值):
- 如果执行的是扩展操作,ht[1]的大小为第一个大于等于ht[0].used * 2 的 2^n;
- 如果执行的收缩操作,ht[1]的大小为第一个大于等于ht[0].used的2^n;
(2)将保存在 ht[0] 中的所有键值对rehash到 ht[1] 上面:rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到 ht[1] 哈希表的指定位置上。
(3)当 ht[0] 包含的所有键值对都迁移到了 ht[1] 之后,释放 ht[0],将 ht[1] 设置为 ht[0],并在 ht[1] 新创建一个空白哈希表,为下一次rehash做准备。
哈希表的负载因子=哈希表已保存节点数量 / 哈希表大小 load_factor = ht[0].used / ht[0].size
当以下条件中任意一个被满足时,程序会自动开始对哈希表执行扩展操作:
- 服务器目前没有执行BGSAVE或BGREWRITEAOF命令,并且哈希表负载因子大于等于1。
- 服务器正在执行BGSAVE或BGREWRITEAOF命令,并且哈希表负载因子大于等于5。
当哈希表负载因子小于0.1时,程序自动开始对哈希表执行收缩操作。
7、渐进式rehash
服务器不是一次性将ht[0]里面的所有键值对全部rehash到ht[1],而是分多次、渐进式地将ht[0]里面的键值对慢慢地rehash到ht[1]。详细步骤:
- 为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。
- 在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash工作开始。
- 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1]中,当rehash工作完成之后,程序将rehashidx属性的值+1。
- 随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash到ht[1],这时将rehashidx属性设为-1,表示rehash操作完成。
因为在渐进式rehash的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式rehash进行期间,字典的删除、查找、更新等操作都会在两个表上进行。例如,要在字典里面查找一个键的话,程序会先在 ht[0] 里面进行查找,如果没找到的话,就会继续到 ht[1] 里面进行查找。
在渐进式 rehash 执行期间,新添加到字典的键值对一律会被保存到 ht[1] 里面,而 ht[0] 则不在进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增。
五、整数集合
整数集合是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。可以保存类型为int16_t、int32_t或者int64_t的整数值,并且会保证集合中不会出现重复元素。
typedef struct inset { // 编码方式 uint32_t encoding; // 集合包含的元素数量 uint32_t length; // 保存元素的数值 int8_t contents[]; } intset;
- contents:数组是整数集合的底层实现,整数集合的每个元素都是contents数组的一个数组项,各个项在数组中按值的大小从小到大有序地排列,并且不包含重复项。
- length:记录了整数集合包含的元素数量,也就是contents数组的长度。
- enconding:虽然intset结构将contents属性声明为int8_t类型的数组,但contents的真正类型取决于enconding属性的值。int16_t(最小值-32768,最大32767),int32_t(最小值-2147483648,最大值2147483647),int64_t(最小值-9223372036854775808,最大值9223372036854775807)。
升级:
每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级,然后才能将新元素添加到整数集合里面。
- 根据新元素的类型,对底层数组进行空间重分配,扩展整数集合底层数组的空间大小,并为新元素分配空间。
- 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且需要继续维持底层数组的有序性不变。
- 将新元素添加到底层数组里面。最后,修改encoding和length属性。
因为每次向整数集合添加新元素都可能会引起升级,而每次升级都需要对底层数组中已有的所有元素进行类型转换,所以向整数集合添加新元素的时间复杂度为O(N)。
因为引发升级的新元素的长度总是比整数集合现有所有元素的长度都大,所以这个新元素的值要么就大于所有现有元素(放数组最开头,索引 0),要么就小于所有现有元素(放数组最末尾,索引为 length-1)。
升级的好处:
- 提升灵活性:因为整数集合可以通过自动升级底层数组来适应新元素,所以可以随意地将int16_t、int32_t或者int64_t类型的整数添加到集合中,而不必担心出现类型错误。
- 节约内存:既可以让集合能同时保存三种不同类型的值,又可以确保升级操作只会在有需要的时候进行,尽量节省内存。
降级:整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。
六、跳跃表
跳跃表是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。跳跃表支持平均 O(logN)、最坏 O(N) 复杂度的节点查找,还可以通过顺序性操作来批量处理节点。Redis 只在两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构。
1、跳跃表节点结构
typedef struct zskiplistNode { // 后退指针 struct zskiplistNode *backward; // 分值 double score; // 成员对象 robj *obj; // 层 struct zskiplistLevel { // 前进指针 struct zskiplistNode *forward; // 跨度 unsigned int span; } level[]; } zskiplistNode;
(1)层
- 跳跃表节点的 level 数组可以包含多个元素(每个元素相当于一层),每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快。
- 每次创建一个新跳跃表节点的时候,程序都根据幂次定律随机生成一个介于 1 和 32 之间的值作为 level 数组的大小,这个大小就是层的“高度”。
- 因为 C 语言的数组索引总是从 0 开始的,所以节点的第一层是 level[0],第二层是 level[1],以此类推。
- 每个层都带有两个属性,前进指针和跨度,前进指针用于访问位于表尾方向的其他节点,跨度则记录了前进指针所指向节点和当前节点的距离。跨度实际上是用来计算排位(rank)的:在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。
(2)后退指针
用于从表尾向表头方向访问节点:跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点。
(3)分值和成员
- 节点的分值(score 属性)是一个 double 类型的浮点数,跳跃表节点按各自所保存的分值从小到大排列。
- 节点的成员对象(obj 属性)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个 SDS 值。
- 在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向)。
- 注意,节点的各层不是用来存对象的,只是用来查找节点上的对象的。
2、跳跃表结构
仅靠多个跳跃表节点就可以组成一个跳跃表,但通过使用一个 zskiplist 结构来持有这些节点,程序可以更方便地对整个跳跃表进行处理,比如快速访问跳跃表的表头节点和表尾节点,或者快速地获取跳跃表节点的数量(也即是跳跃表的长度)等信息。
typedef struct zskiplist { // 表头节点和表尾节点 structz skiplistNode *header, *tail; // 表中节点的数量 unsigned long length; // 表中层数最大的节点的层数 int level; } zskiplist;
- header 和 tail 指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位表头节点和表尾节点的复杂度为 O(1) 。
- length:记录跳跃表目前包含节点的数量(表头节点不计算在内),程序可以在 O(1) 复杂度内返回跳跃表的长度。
- level:获取跳跃表中层高最大的那个节点的层数量(表头节点的层高并不计算在内),O(1) 复杂度。
原文地址:https://www.cnblogs.com/zjxiang/p/12151880.html