Redis设计与实现读书笔记-第一章

简单的动态字符串-SDS

SDS(simple dynamic string)是 Redis 自己构建的一种抽象类型。
在 Redis 中,C字符串只会作为字符串字面量,用于一些无需对字符串进行修改的地方,比如打印日志。
当 Redis 需要的不仅仅是一个字符串字面量,而是一个可以被修改的字符串值时,Redis就会使用 SDS 来表示字符串值

SDS 的定义

每个 sds.h/sdshdr 结构表示一个 SDS 值

struct sdshdr {
	int len; // 用于记录 buf 数组中已使用字节的数量,等于 SDS 所保存的字符串的长度
	int free; // 用于记录 buf 数组中未使用字节的数量
	char buf[]; // 用于保存字符串
}

带未使用空间的 SDS 实例

  • free 属性为 5,表示这个 SDS 被分配了5字节未使用空间
  • len 属性为5,表示这个 SDS 保存了一个5字节长的字符串
  • buf用于存储字符串,空格为5字节未使用空间

SDS 与 C 字符串的区别

众所周知,C字符串使用 空字符’\0’ 作为字符串的结尾。而对于 Redis,这无法满足其对字符串的安全性、效率以及功能方面的要求。

常数复杂度获取字符串长度

相较于 C 语言通过扫描 字符串结尾‘\0’ 获取字符串长度,sdshdr中记录了字符串长度,能够直接以 O(1) 的时间复杂度获取字符串长度。
设置和更新 SDS 长度的工作是由 SDS 的 API 在执行时自动完成的,使用 SDS 无需进行任何手动修改长度的工作
复杂度由 O(N) 降为 O(1), 保证了获取字符串长度不会成为 Redis 的瓶颈

杜绝缓冲区溢出

除了获取字符串长度复杂度 O(N) -> O(1) 这一点优化外, C 字符串不记录自身长度还容易造成缓冲区的溢出(buffer overflow)

举个栗子:

<string.h>/strcat 函数可以将 src 字符串的内容拼接到 dest 字符串末尾

char *strcat(char *dest,const char*src);

如果预先 malloc 足够空间则不会溢出,但如果没有,就会造成缓冲区溢出

SDS 的空间分配策略则完全杜绝了溢出的可能:当 SDS API 对 SDS 进行修改时,API会先检查 SDS 的空间是否满足所需的要求,不满足则会自动将 SDS 的空间扩展到执行所需的大小,再执行实际的修改操作(自动扩容嘛

减少修改字符串时带来的内存重分配次数

C 字符串由于不记录自身长度,所以一个 N 长度的字符串,底层长度为 N+1(多一个 ‘\0’),因此每次增长或者缩短一个 C 字符串,程序需要进行一次内存重分配操作:

  • 例如:append 操作,需要先通过内存重分配来扩展底层数组的空间大小,否则会造成缓冲区溢出
  • 例如:trim 操作(截断操作),那么需要通过重分配来释放不再使用的部分,否则会造成内存泄漏

而在 SDS 中,通过未使用空间(之前说的 free),实现了空间预分配和惰性空间释放两种优化策略。

空间预分配(解决 append 问题,感觉类似于 vector 的自动扩容)

当 SDS 的 API 对一个 SDS 进行修改,并且需要对 SDS 进行空间扩容时,除了分配必须空间,还会额外分配 free 空间。

分配规则如下:

  • 如果修改后,SDS 长度 < 1MB,那么给 free 分配与 len 相同的值
  • 如果修改后,SDS 长度 ≥ 1MB,则分配 1MB free 空间
惰性空间释放(解决 trim 问题)

用 free 将缩短后多出来的字节进行记录用于将来使用

二进制安全

由于 C 字符串以 ‘\0’ 标识结尾,因此不能保存图片、音频、压缩文件等二进制数据,但由于 SDS 以 len 记录字符串长度,无需关注 ‘\0’, 因此可以存储此类数据。

兼容部分 C 字符串函数

虽然 SDS 的 API 都是二进制安全的,但它们还是遵循 C 字符串以空字符串结尾的惯例:这些API 总会将 SDS 保存的数据的末尾设置为空字符串,并且多分配一个字节来存储这个空字符串,为的就是可以重用 <string.h>库定义的函数。

总结

对 C 字符串和 SDS 之间的区别进行总结
在这里插入图片描述

链表

链表在 Redis 中应用广泛,比如列表键的底层实现之一就是链表。当一个列表键包含了数量较多的元素,又或者列表中包含的元素都是比较长的字符串时,Redis就会使用链表作为列表键的底层实现
链表太熟了,就不加概念了。

字典

字典底层就是 hash 表,也比较熟悉,简单介绍一下 Redis 的 hash 算法、解决键冲突的方法和 rehash

hash 算法

Redis 计算 hash 值和索引值的方法如下:

  • 使用字典设置的 hash 函数(Redis使用 MurmurHash2)计算 key 的哈希值
  • 使用哈希表的 sizemask 属性和哈希值计算出索引值
    空字典

解决键冲突

Redis 使用链地址法(separate chaining) 来解决冲突

rehash

通过 rehash(重新散列),使得哈希表的负载因子(load factor)维持在一个合理的范围之内
Redis 的 rehash 步骤如下:

  • 为字典的 ht[1] 哈希表分配空间,空间大小取决于要执行的操作,以及ht[0]当前包含的键值对的数量(即 ht[0].used)
    • 如果为扩展操作,则 ht[1] = ht[0].used*2 的 2 的 N 次幂
    • 如果为收缩操作,则 大小为第一个大于等于 ht[0].used 的 2 的 N次幂
  • 将保存在 ht[0]中的所有键值对 rehash 到 ht[1] 上面:rehash 指的是重新计算键的哈希值和索引值,然后将键值对放置到 ht[1] 哈希表的指定位置上
  • 迁移完成后,释放 ht[0],然后将 ht[1] 设置为 ht[0],并在 ht[1] 新建一个空 hash 表,用于下一次 rehash

很模糊,一头雾水,还是先看例子:
需要对下图的 ht[0] 进行扩展操作:
执行 rehash 前的字典

  1. 很明显 ht[0].used 为 4, 4*2 = 8, 而 8(2 的 3次幂)恰好为第一个大于等于 4 的 2 的 N次幂,所以 ht[1] 哈希表的大小会被设置为 8
    分配后的hash表
  2. 将 ht[0] 的键值对转移到 ht[1]
    迁移后的哈希表
  3. 释放 ht[0] , ht[1] 设置为 ht[0],然后给 ht[1] 分配一个空hash表
    完成rehash后的哈希表

看完例子,比较清晰了,缩容时,仅需要离当前键值对最近的 2 的 N 次幂大小的空间即可,而扩展时,则直接扩展为离当前键值对最近的 2 的 N 次幂大小的空间大小的两倍(即 2 的 N+1 次幂),很好理解。

总结

哈希表的负载因子可以通过公式获取
load_factor = ht[0].used / ht[0].size()
因此,当负载因子 ≥ 5 时,会执行扩展操作,而 负载因子 < 0.1 时,则执行收缩操作

渐进式 rehash

上面提到的 rehash 操作,在键值对过多的情况下,很明显不能一次性的进行转移,因此需要分多次、渐进式的完成
详细步骤如下:

  1. 为 ht[1] 分配空间,让字典同时持有 ht[0] 以及 ht[1] 两个哈希表
  2. 在字典中维护一个索引计数器变量 rehashidx,设置为0,表示 rehash正式开始
  3. 在 rehash 期间,每次对字典执行 CRUD 操作时,程序除了执行指定的操作外,还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] 上
  4. 随着不断操作,所有的键值对转移完毕后,将 rehashidx 置为 -1,表示 rehash 操作完成
这期间的 hash 表操作

在渐进式 rehash 期间,字典会同时使用 ht[0] 和 ht[1] 两个hash表,首先查找 ht[0] 没找到再查找 ht[1], 而新添加的键值对则一律保存到 ht[1]

跳表(skiplist)

Redis 用到跳表的地方还是不多,只有两处,一个是实现有序集合键,另一个是在集群节点中用作内部数据结构,具体算法和实现不深入讲解。(其实理解起来,有稀疏索引的感觉)

整数集合

整合集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,那么这个集合键的底层实现就是整数集合

typedef struct intset {
	uint32_t encoding; // 编码方式
	uint32_t length; // 集合包含的元素数量
	int8_t contents[];  // 保存元素的数组
} intset;

contents 数组是整数集合的底层实现:整数集合的每个元素都是 contents 数组的一个数组项(item),各个项在数组中按值的大小从小到大地排序,并且数组中不包含任何重复项。
虽然 intset 结构将 contents 属性声明为 int8_t 类型的数组,但 contents 数组并不保存 int8_t 类型的值,contents 数组的真正类型取决于 encoding 类型。

升级

当要将一个新的元素添加到整数集合,且新元素的类型比当前所有元素的类型都长时,需先进行升级(upgrade),然后才能将新元素放入集合,具体步骤如下:

  • 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间
  • 将底层数组现有的所有元素都转换成新元素相同的类型,并将这些元素放置到正确的位上(放置过程中,需维护有序性)
  • 将新元素添加到底层数组中

降级

没想到吧,不降级

压缩列表

当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么 Redis 就会使用压缩列表作为底层实现
在这里插入图片描述
另外,当一个哈希键只包含少量键值对,且每个键值对的键、值均是小整数要么是长度较短的字符串,则也会使用压缩列表作为底层实现

简单总结

  • 压缩列表是一种为节约内存而开发的顺序型数据结构
  • 压缩列表被用作列表键和哈希建的底层实现之一
  • 压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者整数值
  • 添加新节点,或删除节点可能会导致连锁更新操作,但几率不高

对象

Object,介绍一下 Redis 内部的内存回收和对象共享。

内存回收

每个对象的引用计数信息由 redisObject 结构的 refcount 属性记录:
可以照着C++ 智能指针的方式理解

typedef struct redisObject {
	// 引用计数
	int refcount;
} robj;
  • 在创建一个新对象时,引用计数的值会被初始化为1
  • 当对象被一个新程序使用时,它的引用值+1
  • 不再被一个程序使用时,它的引用值-1
  • 当对象的引用计数值为0时,对象所占用的内存会被释放

对象共享

除了实现内存回收机制,计数属性还能实现对象共享的作用
举个栗子:(不想手敲。)
在这里插入图片描述
值得注意的是:
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值