第二章:简单动态字符串
1.Redis没有直接使用C语言传统的字符串表示(以空字符结尾的字符数组,以下简称C字符串),而是自己构建了一种名为简单动态字符串( simple dynamic string,SDS)的抽象类型,并将SDS用作 Redis的默认字符串表示。
Redis里面,C字符串只会作为字符串字面量( string literal)用在一些无须对字符串值进行修改的地方,比如打印日志:
redislog (REDIS WARNING,"Redis is now ready to exit, bye bye..");
2.空间预分配用于优化SDS的字符串增长操作:当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间。
其中,额外分配的未使用空间数量由以下公式决定:
- 如果对SDS进行修改之后,SDS的长度(也即是1en属性的值)将小于1MB,那么程序分配和1en属性同样大小的未使用空间,这时SDS1en属性的值将和free属性的值相同。举个例子,如果进行修改之后,SDS的1en将变成13字节,那么程序也会分配13字节的未使用空间,SDS的buf数组的实际长度将变成13+13+1=27字节(额外的一字节用于保存空字符)。
- 如果对SDS进行修改之后,SDS的长度将大于等于1MB,那么程序会分配1MB的未使用空间。举个例子,如果进行修改之后,SDS的len将变成30MB,那么程序会分配1MB的未使用空间,SDS的buf数组的实际长度将为30MB+1MB+1byte。
通过空间预分配策略,可以减少连续执行字符串增长操作所需的内存重分配次数。
3.惰性空间释放用于优化SDS的字符串缩短操作:当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待将来使用。
通过惰性空间释放策略,SDS避免了缩短字符串时所需的内存重分配操作,并为将来可能有的增长操作提供了优化。
与此同时,SDS也提供了相应的API,让我们可以在有需要时,真正地释放SDS的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。
4.SDS的API都是二进制安全的。
第三章:链表
1.integers列表键的底层实现就是一个链表,链表中的每个节点都保存了一个整数值。除了链表键之外,发布与订阅、慢查询、监视器等功能也用到了链表, Redis服务器本身还使用链表来保存多个客户端的状态信息,以及使用链表来构建客户端输出缓冲区output buffer)。
2.list结构为链表提供了表头指针head、表尾指针tail,以及链表长度计数器len,而dup、free和 match成员则是用于实现多态链表所需的类型特定函数:
- dup函数用于复制链表节点所保存的值;
- free函数用于释放链表节点所保存的值;
- match函数则用于对比链表节点所保存的值和另一个输入值是否相等。
3.Redis的链表实现的特性可以总结如下:
- 双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)。
- 无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点。
- 带表头指针和表尾指针:通过1ist结构的head指针和tai1指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。
- 带链表长度计数器:程序使用1ist结构的len属性来对1ist持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)。
- 多态:链表节点使用void*指针来保存节点值,并且可以通过1ist结构的dup、free、 match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。
第四章:字典
1.Redis字典所使用的哈希表由dict.h/dictht结构定义:
table属性是一个数组,数组中的每个元素都是一个指向dict.h/dicentra结构的指针,每个 dictentry结构保存着一个键值对。size属性记录了哈希表的大小,也即是tab1e数组的大小,而used属性则记录了哈希表目前已有节点(键值对)的数量。sizemask属性的值总是等于size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面。
2.哈希表节点使用 dictentry结构表示,每个 dictentry结构都保存着一个键值对:
key属性保存着键值对中的键,而ⅴ属性则保存着键值对中的值,其中键值对的值可以是一个指针,或者是一个uint64t整数,又或者是一个int64t整数。
next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一次,以此来解决键冲突( collision)的问题。
3.Redis中的字典由dict.h/dict结构表示:
type属性和 privata属性是针对不同类型的键值对,为创建多态字典而设置的:
- type属性是一个指向 ditype结构的指针,每个 dicttype结构保存了一簇用于操作特定类型键值对的函数, Redis会为用途不同的字典设置不同的类型特定函数。
- 而 privata属性则保存了需要传给那些类型特定函数的可选参数。
ht属性是一个包含两个项的数组,数组中的每个项都是一个 dicth哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1哈希表只会在对ht[0哈希表进行 rehash时使用。
除了ht[1]之外,另一个和 rehash有关的属性就是 rehashidx,它记录了hash目前的进度,如果目前没有在进行 rehash,那么它的值为-1。
Redis计算哈希值和索引值的方法如下:
4.当字典被用作数据库的底层实现,或者哈希键的底层实现时, Redis使用 Murmurhash2算法来计算键的哈希值。
Murmurhash算法最初由 Austin Appleby于2008年发明,这种算法的优点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快。
Murmurhash算法目前的最新版本为 Murmurhash3,而 Redis使用的是 Murmurhashi2,关于Murmurhash算法的更多信息可以参考该算法的主页:http:/code.google.com/p/smasher/。
5.当有两个或以上数量的键被分配到了哈希表数组的同一个索引上面时,我们称这些键发生了冲突( collision)。
Redis的哈希表使用链地址法( separate chaining)来解决键冲突,每个哈希表节点都有个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,这就解决了键冲突的问题。
6.随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子( load factor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。
扩展和收缩哈希表的工作可以通过执行 rehash(重新散列)操作来完成, Redis对字典的哈希表执行 rehash的步骤如下:
1).为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及h[O当前包含的键值对数量(也即是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[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次 rehash做准备。
6.哈希表的扩展与收缩
当以下条件中的任意一个被满足时,程序会自动开始对哈希表执行扩展操作:
1).服务器目前没有在执行 BGSAVE命令或者 BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。
2).服务器目前正在执行 BGSAVE命令或者 BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。
其中哈希表的负载因子可以通过公式:
#负载因子=哈希表已保存节点数量/哈希表大小
load_factor =ht[0].used/ht[0].size计算得出。
例如,对于一个大小为4,包含4个键值对的哈希表来说,这个哈希表的负载因子为:
load_factor =4/4=1
又例如,对于一个大小为512,包含256个键值对的哈希表来说,这个哈希表的负载因子为:
load_factor =256/ 512=0.5
根据 BGSAVE命令或 BGREWRITEAOF命令是否正在执行,服务器执行扩展操作所需的负载因子并不相同,这是因为在执行 BGSAVE命令或 BGREWRITEAOF命令的过程中, Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-onwrite)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写入操作,最大限度地节约内存。
另一方面,当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作。
7.渐进式rehash
上一节说过,扩展或收缩哈希表需要将ht[0]里面的所有键值对 rehash到ht[1里面,但是,这个 rehash动作并不是一次性、集中式地完成的,而是分多次、渐进式地完成的。
这样做的原因在于,如果ht[0]里只保存着四个键值对,那么服务器可以在瞬间就将这些键值对全部 rehash到ht[1];但是,如果哈希表里保存的键值对数量不是四个,而是四百万、四千万甚至四亿个键值对,那么要一次性将这些键值对全部 rehash到ht[1]的话庞大的计算量可能会导致服务器在一段时间内停止服务。
因此,为了避免 rehash对服务器性能造成影响,服务器不是一次性将ht[0j里面的所有键值对全部 rehash到ht[1],而是分多次、渐进式地将ht[0]里面的键值对慢慢地 rehash到ht[1]。
以下是哈希表渐进式 rehash的详细步骤:
1)为ht[1]分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。
2)在字典中维持一个索引计数器变量 rehashidx,并将它的值设置为0,表示 rehash工作正式开始。
3)在 rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在 rehashidx索引上的所有键值对rehash到ht[1],当 rehash工作完成之后,程序将 rehashidx属性的值增一。
4)随着字典操作的不断执行,最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1],这时程序将 rehashidx属性的值设为-1,表示 rehash操作已完成。渐进式 rehash的好处在于它采取分而治之的方式,将 rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式 rehash而带来的庞大计算量。
8.渐进式 rehash执行期间的哈希表操作
因为在进行渐进式 rehash的过程中,字典会同时使用ht[0]和ht[1]两个哈希表,所以在渐进式 rehash进行期间,字典的删除( delete)、查找(fnd)、更新( update)等操作会在两个哈希表上进行。例如,要在字典里面查找一个键的话,程序会先在ht[0]里面进行查找,如果没找到的话,就会继续到ht[1]里面进行查找,诸如此类。
另外,在渐进式 rehash执行期间,新添加到字典的键值对一律会被保存到ht[1]里面,而ht[0]则不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增,并随着 rehash操作的执行而最终变成空表。
第五章:跳跃表
1.跳跃表( skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
跳跃表支持平均O(logN、最坏O(N)复杂度的节点查找,还可以通过顺序性操作来批量处理节点。
在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更为简单,所以有不少程序都使用跳跃表来代替平衡树。
Redis使用跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员(member)是比较长的字符串时, Redis就会使用跳跃表来作为有序集合键的底层实现。
图5-1展示了一个跳跃表示例,位于图片最左边的是 zskiplist结构,该结构包含以下属性:
- header:指向跳跃表的表头节点。
- tail:指向跳跃表的表尾节点。
- level:记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)。
- length:记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)。
位于skiplist结构右方的是四个zskiplistNode结构,该结构包含以下属性:
- 层( level):节点中用L1、L2、L3等字样标记节点的各个层,L1代表第一层,L2代表第二层,以此类推。每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。在上面的图片中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。
- 后退( backward)指针:节点中用BW字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退指针在程序从表尾向表头遍历时使用。
- 分值( score):各个节点中的1.0、2.0和3.0是节点所保存的分值。在跳跃表中节点按各自所保存的分值从小到大排列。
- 成员对象(obj):各个节点中的o1、o2和o3是节点所保存的成员对象。
注意表头节点和其他节点的构造是一样的:表头节点也有后退指针、分值和成员对象,不过表头节点的这些属性都不会被用到,所以图中省略了这些部分,只显示了表头节点的各个层。
2.跳跃表节点的实现由redis.h/zskiplistNode结构定义:
1.层
跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快。
每次创建一个新跳跃表节点的时候,程序都根据幂次定律(power law,越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小,这个大小就是层的“高度”。
2.前进指针
每个层都有一个指向表尾方向的前进指针(level[i]. forward属性),用于从表头向表尾方向访问节点。
3.跨度层的跨度
- (level[i].span属性)用于记录两个节点之间的距离口两个节点之间的跨度越大,它们相距得就越远。
- 指向NULL的所有前进指针的跨度都为0,因为它们没有连向任何节点。
4.后退指针
节点的后退指针( backward属性)用于从表尾向表头方向访问节点:跟可以一次跳过多个节点的前进指针不同,因为每个节点只有一个后退指针,所以每次只能后退至前一个节点。
5.分值和成员
节点的分值( score属性)是一个doub1e类型的浮点数,跳跃表中的所有节点都按分值从小到大来排序。
节点的成员对象(obj属性)是一个指针,它指向一个字符串对象,而字符串对象则保存着一个SDS值。
在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分值相同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表尾的方向)。
3.仅靠多个跳跃表节点就可以组成一个跳跃表,但通过使用一个 skip1ist结构来持有这些节点,程序可以更方便地对整个跳跃表进行处理,比如快速访问跳跃表的表头节点和表尾节点,或者快速地获取跳跃表节点的数量(也即是跳跃表的长度)等信息。
header和tail指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位表头节点和表尾节点的复杂度为O(1)。
通过使用1 ength属性来记录节点的数量,程序可以在O()复杂度内返回跳跃表的长度。
level属性则用于在O(1)复杂度内获取跳跃表中层高最大的那个节点的层数量,注意表头节点的层高并不计算在内。
第六章:整数集合
1.整数集合(intset)是集合键的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时, Redis就会使用整数集合作为集合键的底层实现。
整数集合(intset)是Redis用于保存整数值的集合抽象数据结构,它可以保存类型为int16_t、int32_t或者int64_t的整数值,并且保证集合中不会出现重复元素。
每个intset.h/intset结构表示一个整数集合:
contents数组是整数集合的底层实现:整数集合的每个元素都是 contents数组的个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。
length属性记录了整数集合包含的元素数量,也即是 contents数组的长度。
虽然 intset结构将 contents属性声明为int8t类型的数组,但实际上 contents数组并不保存任何int8t类型的值, contents数组的真正类型取决于encoding属性的值:
2.每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级( upgrade),然后才能将新元素添加到整数集合里面。
升级整数集合并添加新元素共分为三步进行:
- 1)根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
- 2)将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变。
- 3)将新元素添加到底层数组里面。
3.因为每次向整数集合添加新元素都可能会引起升级,而每次升级都需要对底层数组中已有的所有元素进行类型转换,所以向整数集合添加新元素的时间复杂度为O(N)。
4.升级之后新元素的摆放位置
因为引发升级的新元素的长度总是比整数集合现有所有元素的长度都大,所以这个新元素的值要么就大于所有现有元素,要么就小于所有现有元素:
- 在新元素小于所有现有元素的情况下,新元素会被放置在底层数组的最开头(索引0);
- 在新元素大于所有现有元素的情况下,新元素会被放置在底层数组的最末尾(索引length-1)。
5.升级的好处
整数集合的升级策略有两个好处,一个是提升整数集合的灵活性,另一个是尽可能地节约内存。
6.降级
整数集合不支持降级操作,一旦对数组进行了升级,编码就会一直保持升级后的状态。