Redis数据结构全解(一)

2.简单动态字符串

Redis没有直接使用C语言传统的字符串表示(以空字符结尾的字符数组),而是自己构建了简单动态字符串(simple dynamic string)的抽象类型,并将SDS用作Redis的默认字符串表示。

在Redis里,C字符串只会作为字符串字面量用在一些无须对字符串值进行修改的地方。

除了用来保存数据库中的字符串值之外,SDS还被用作缓冲区:AOF模块中的AOF缓冲区,客户端状态中的输入缓冲区。

1.SDS的定义

  1. SDS遵循C字符串以空字符结尾的惯例,保存空字符的1字节空间不计算在SDS的len属性里,并且为空字符分配额外的1字节空间,以及添加空字符到字符串末尾等操作,都是由SDS函数自动完成的。遵循空字符结尾这一惯例的好处是,SDS可以直接重用一部分C字符串函数库里的函数。

2.SDS与C字符串的区别

  1. 常数复杂度获取字符串长度
    1、C字符串并不记录自身的长度信息,所以获取一个C字符串的长度,复杂度为O(N)。
    而SDS在len属性中记录了SDS本身的长度,复杂度为O(1)。
  2. 杜绝缓冲区溢出
    1、C字符串不记录自身的长度,例如使用strcat函数,但空间却没有分配好,导致数据意外地被修改。而SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当SDS的API需要对SDS进行修改时,会先检查空间是否满足修改所需的要求。如果不满足,会自动将空间扩展至所需的大小,然后才执行实际的修改操作。
  3. 减少修改字符串时带来的内存重分配次数
    1、对于一个包含了N个字符的C字符串来说,C字符串的底层实现总是一个N+1个字符长的数组。所以每次增长或者缩短一个C字符串,都要进行一次内存重分配。
    如果执行的是增长的操作,容易造成缓冲区溢出。
    如果执行的是缩短的操作,需要释放内存,容易造成内存泄露。
    2、内存重分配涉及复杂的算法,并且可能需要执行系统调用,比较耗时。在一般程序中,如果修改字符串长度的情况不太常出现,那么内存重分配可接受。但是Redis作为数据库,速度要求严苛,数据被频繁修改。所以SDS通过未使用空间解除了字符串长度和底层数组长度的关联:在SDS中,数据里面可以包含未使用的字节,这些字节的数量由SDS的free属性记录。
    3、空间预分配:
    用于优化SDS的字符串增长操作:SDS进行空间扩展时,程序不仅会分配修改所必要的空间,还会分配额外的未使用空间。额外分配的未使用数量由以下公式决定:
    □如果对SDS进行修改之后,SDS的长度将小于1MB,那么程序分配和len属性同样大小的未使用空间,这时SDSlen的值将和free属性的值相同。
    □如果大于等于1MB,那么会分配1MB的未使用空间。
    在扩展SDS空间之前,会先检查free空间是否足够,如果足够,则直接使用,无须执行内存重分配。
    4、惰性空间释放:
    用于优化SDS的字符串缩短操作:当需要缩短SDS保存的字符串时,并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录下来。
    与此同时,SDS也提供了相应的API,在有需要时,真正地释放SDS的未使用空间。
  4. 二进制安全
    1、C字符串中的字符必须符合某种编码,并且除了字符串的末尾之外,字符串里不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得C字符串只能保存文本数据,而不能保存像图片、音频这样的二进制数据。
    2、因此,为了确保Redis可以使用于各种不同的使用场景,SDS的API都是二进制安全的。这也是SDS的buf属性称为字节数组的原因——不是用这个数据来保存字符,而是一系列二进制数据。
  5. 兼容部分C字符串函数
    1、虽然SDS的API都是二进制安全的,但它们一样遵循C字符串以空字符结尾的惯例:总会将SDS保存数据的末尾设置为空字符,并且总会在为buf数组分配空间时多分配一个字节来容纳这个空字符,这是为了让那些保存文本数据的SDS可以重用一部分<string.h>库定义的函数。

3.重点回顾

  1. Redis只会使用C字符串作为字面量,在大多数情况下,Redis使用SDS作为字符串表示。
  2. 比起C字符串,SDS具有以下优点:
    1、常数复杂度度获取字符串长度。
    2、杜绝缓冲区溢出。
    3、减少修改字符串长度时所需的内存重分配次数。
    4、二进制安全。
    5、兼容部分C字符串函数。

3.链表

1.链表和链表节点的实现

  1. 每个链表节点使用一个adlist.h/listNode结构来表示:多个listNode可以通过prev和next指针组成双端链表,虽然仅仅使用多个listNode结构就可以组成链表,但使用adlist.h/list来持有链表的话,操作起来更方便。list结构为链表提供了表头指针head、表尾指针tail,以及链表长度计数器len,而dup、free和match成员则是用于实现多态链表所需的类型特定函数:
    dup函数用于复制链表节点所保存的值;free函数用于释放链表节点所保存的值;
    match函数则用于对比链表节点所保存的值和另一个输入值是否相等。
  2. Redis链表实现的特性总结如下:
    1、双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)
    2、无环:表头节点的prev和表尾节点的next都指向NULL,对链表的访问以NULL为终点
    3、带表头指针和表尾指针:通过list结构的head和tail指针,获取表头和表尾节点的复杂度为O(1)
    4、带链表长度计数器:使用len属性来进行计数
    5、多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。

2.重点回顾

  1. 链表被广泛用于实现Redis的各种功能,比如列表键、发布与订阅、慢查询、监视器等。
  2. 每个链表节点由一个listNode结构来表示,每个节点都有一个指向前置节点和后置节点的指针,所以Redis的链表实现时双端链表。
  3. 每个链表使用一个list结构来表示,这个结构带有表头节点指针、表尾节点指针以及链表长度等信息
  4. 因为链表表头节点的前置节点和表尾节点的后置节点都指向NULL,所以还是无环链表
  5. 通过为链表设置不同的类型特定函数,Redis的链表可以用于保存各种不同类型的值。

4.字典

Redis的数据库就是使用字典来作为底层实现的。字典还是哈希键的底层实现之一,当一个哈希键包含的键值对比较多,又或者元素都是比较长的字符串时,Redis就会使用字典作为底层实现。

1.字典的实现

Redis的字典使用哈希表作为底层实现,一个哈希表节点就保存了字典中的一个键值对。
  1. 哈希表:

    table属性是一个数组,数组中的每个元素都是一个指向dict.h/dictEntry结构的指针,每个dictEntry结构保存着一个键值对。
    2.哈希表节点:

    3.字典:

    type和privdata属性是针对不同类型的键值对,为创建多态字典而设置的。
    type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置不同的类型特定函数。
    privdata属性则保存了需要传给那些类型特定函数的可选参数。

    ht属性是一个包含两个项的数组,每个项都是一个dictht哈希表,一般只使用ht[0]哈希表,ht[1]只会在进行rehash时使用。
    rehashidx,记录了rehash目前的进度,如果没有在进行rehash,则为-1。

  2. 哈希算法:
    当字典被用作数据库的底层实现或者哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值。优点在于,即使输入的键是由规律的,仍能给出很好的随机分布性,速度也非常快。

  3. 解决键冲突:
    Redis的哈希表使用链地址法来解决键冲突。
    因为dictEntry节点组成的链表没有指向表尾的指针,所以为了速度考虑,采用头插法,将新节点添加到表头位置O(1),排在其他已有节点的前面。

  4. rehash:
    随着操作的不断执行,哈希表保存的键值对会逐渐地增加或者减少,为了让哈希表的负载因子维持在一个合理的范围之内,需要对哈希表的大小进行相应的扩展或收缩。
    rehash的步骤如下:
    1、为字典的ht[1]哈希表分配空间,大小取决于要执行的操作,以及ht[0].used属性的值
    □如果执行的是扩展操作,那么ht[1]的大小为第一个大于等于ht[0].used*2的2的n次方幂
    □如果执行的是收缩操作,那么大小为第一个大于等于ht[0].used的2的n次方幂
    2、将保存在ht[0]中的所有键值对rehash到ht[1]上面:rehash指的是重新计算键的哈希值和索引值,然后放置到ht[1]哈希表的指定位置上。
    3、当所有键值对都迁移到了ht[1]之后,释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。
    哈希表的扩展和收缩:
    当一下条件中的任意一个被满足时,会自动开始对哈希表执行扩展操作:
    1、服务器目前没有在执行BGSAVE或者BGREWRITEAOF,并且哈希表的负载因子大于等于1.
    2、服务器目前正在执行BGSAVE或者BGREWRITEAOF,哈希表的负载大于等于5.
    负载因子=哈希表已保存节点数量/哈希表大小
    PS:执行BGSAVE或BGREWRITEAOF命令的过程中,需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制技术(只有进程空间的各段的内容要发生变化时,才会将父进程的内容复制一份给子进程)来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度节约内存。
    当负载因子小于0.1时,自动开始对哈希表执行收缩操作。

  5. 渐进式rehash:
    rehash并不是一次性、集中性完成的,而是分多次、渐进式完成的。因为如果哈希表中的键值对很多的话,庞大的计算量会导致服务器在一段时间内停止服务。
    下面是哈希表渐进式rehash的详细步骤:
    1、为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。
    2、在字典中维持一个索引计数器变量rehashidx,为设为0,表示正式开始。
    3、在rehash进行期间,每次对字典执行增删改查时,除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成后,rehashidx值加一。
    4、随着字典操作的不断执行,最终在某个事件点上,全部rehash完毕,这时rehashidx的值设为-1,表示rehash操作已完成。
    在进行渐进式rehash的过程中,字典会同时使用2个哈希表。另外,在渐进式rehash执行期间,新添加到字典的键值对一律会被保存到ht[1]里面,而ht[0]则不再进行任何添加操作。

2.重点回顾

  1. 字典被广泛用于实现Redis的各种功能,其中包括数据库和哈希键。
  2. Redis中的字典使用哈希表作为底层实现,每个字段带有2个哈希表,一个平时使用,另一个仅在进行rehash时使用。
  3. 当字典被用作数据库或哈希键的底层实现时,Redis使用MurmurHash2算法来计算键的哈希值。
  4. 哈希表使用链地址法来解决键冲突,被分配到同一个索引上的多个键值对会连接成一个单向链表。
  5. 在对哈希表进行扩展或收缩操作时,程序需要将现有哈希表包含的所有键值对rehash到新哈希表里,并且这个过程并不是一次性完成的,而是渐进式完成的。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值