文章目录
一、redis简介
redis是单进程单线程的NOSQL内存数据库,所以如果只有一个redis能写数据的话,是线程安全的。redis是用C语言实现的。本文讲解基于版本2.9,本文所有图片大部分来自于redis设计与实现(黄建宏)。
1.1 基本数据结构(6种)
- 简单动态字符串(simple dynamic string,SDS)
- 链表(listNode)
- 字典(dict)
- 跳跃表(skiplist)
- 整数集合(intset)
- 压缩列表(ziplist)
1.2 对象系统(5种)
- 字符串对象
- 列表对象
- 哈希对象
- 集合
- 有序集合
二、简单动态字符串(simple dynamic string,SDS)
redis没有直接使用C语言的传统字符串表示(空字符结尾的字符数组,简称C字符串),而是自己实现了简单动态字符串(simple dynamic string,SDS)。但是C字符串在redis里面会用在不会对字符串字面量修改的地方,比如日志。在数据库中可以被修改的字符串是时候SDS实现的,比如字符串值的键值对(包括键和值,以及列表等结构中的每个字符串对象),缓冲区(AOF中的缓冲区,客户端输入缓冲区)。
2.1. SDS定义
SDS实现保留了C字符串以空字符结尾的惯例(这样SDS可以重用一些C字符串函数库的函数,比如printf函数),这个空字符占用1字节的空间(不计算在SDS的len属性里面,也不算做free属性里面),额外分配1字节空间和在字符串末尾添加空字符是SDS函数自动完成的,对用户是透明的。
2.2 SDS与C字符串的区别
C字符串不能满足redis对字符串在安全性,效率,以及功能方面的要求
2.2.1 SDS常熟复杂度获取字符串长度
C字符串没有记录字符串长度的属性,每次获取长度需要从头遍历,遇到字符进行计数。SDS中有len属性可以直接获取。设置和更新SDS长度是由SDS在API执行时自动完成的,无需自己手动修改。(键值对的键一般是不需要修改的,但是也用了SDS实现是因为可以用常数复杂度调用STRLEN命令获取字符串长度)。
2.2.2 SDS 杜绝缓冲区溢出
C字符串没有记录字符串长度的属性,这可能会造成缓冲区溢出。C字符串调用sdscat(s1,s2)拼接字符串时,如果s1长度小于s1+s2的长度会造成缓冲区溢出。但是SDS在调用API时会检查SDS空间是否满足要求,不满足要求会自动扩容(所以不需要手动扩容)
2.2.3 减少修改字符串带来的内存重分配次数
C字符串每次增加或删除字符都需要内存重分配(否则,增加会内存溢出,删除会内存泄漏),内存重分配涉及复杂算法,并且可能需要系统调用,所以通常很费时。redis对速度很严苛,如果每次增加删除字符串的字符都内存重分配是接受不了的。通过free属性 redis实现了空间预分配和惰性空间释放两种优化策略,这样可以减少内存重分配次数。
1. 空间预分配
优化SDS的字符串增长操作。如果原始字符串的len+free大于字符串增长后的长度那么不重新分配空间,否则:
1)如果修改之后SDS的长度小于1MB,redis分配和len同样大小的未使用空间(free属性将和len相等)。
2)如果修改之后SDS的长度大于1MB,redis分配1MB大小的未使用空间(free属性将和len相等)。
2. 惰性空间释放
优化SDS的字符串缩短操作。缩短字符串的长度对应free属性增加的大小,redis有相应的API真正释放未使用空间,所以free属性只会造成短暂的内存浪费。
2.2.4 二进制安全
C字符串必须符合某种编码(比如ASCⅡ),在字符串之间不能出现空字符,因此C字符串只能保存文本数据,不能保存图片,音频,视频,压缩文件等二级制数据。redis是二进制安全的,redis的API都会以处理二进制的方式处理SDS存放在buf数组里的数据,程序不会进行任何限制,过滤,或者假设,在写入时是什么样,读出时就是什么样。这也是为什么叫buf的原因,buf保存的是二进制数,而不是字符。
2.2.5 兼容部分C语言函数
可以重用一部分C语言函数。
2.2.6 总结
2.2.7 SDS的API
参考原书p17(22/392)
三、链表(listNode)
C语言没有链表数据结构,redis自己实现了链表数据结构。列表键的底层实现之一就是链表(当列表键数量比较多或者列表的元素都是比较长的字符串时使用)发布,订阅,慢查询,监视器等功能都用到了链表,也使用链表保存多个客户端的状态信息和使用链表构建客户端输出缓冲区。
3.1 链表和链表节点的实现(双端链表)
虽然使用listNode就可以实现链表,但是redis使用adlist.h/list来持有链表,操作起来更方便。
特性:
- 双端:获取某节点的前后节点时间复杂度都是O(1)。
- 无环:对链表的访问以null为终点。
- 带表头指针和表尾指针:获取表头和表尾节点时间复杂度都是O(1)。
- 带链表长度计数器:获取链表节点数量时间复杂度是O(1)。
- 多态:链表节点使用void*指针保存节点的值,可以通过dup、free、match属性为节点值设置特定函数,所以链表可以保存不同类型的值。
3.2 链表和链表节点的API
参考原书p21(25/392)
四、字典(dict)
C没有字典结构,redis自己实现的。redis数据库就是使用字典来作为底层实现的,对数据的增删改查也是建立在字典的操作之上。也是哈希键的底层实现之一(包含键值对比较多或者键值对中的圆度都是比较长的字符串时)。redis的不少功能也是字典实现的。
4.1 字典的实现
redis字典以哈希表作为底层实现,一个哈希表可以有多个哈希表节点(对应一个键值对)
4.1.1 哈希表
table数组中每个元素都是指向dict.h/dictEntry结构(保存一个键值对)的指针,下图展示了一个大小为4的空哈希表。
4.1.2 哈希表节点
哈希表节点使用dictEntry结构表示,每个dictEntry结构都保存一个键值对
值可以是指针或者uint64_t或者int64_t整数
4.1.3 字典
type和privdata属性是针对不同类型的键值对,为创建多态字典设置的。
type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于擦操作特定类型键值对的函数,redis回味用途不同的字典设置不同类型特定函数。
privdata属性保存了需要传给那些特定类型特定函数的可选参数。
ht是一个长度为2的数组,数组中每一项都是一个dictht哈希表,一般只用ht[0],ht[1]只在rehash时使用。
4.2 哈希算法
rehash记录rehash的进度,目前没有进行时为-1。
dict->type->hashFuntion()函数用于计算hash值,算出ht[0]的位置的算法与java的hashmap实现一样,即用&代替取余(数组长度必须是2的整数倍)
当字典用作数据库或者哈希键的底层实现时,hash算法用MurmurHash2算法(计算速度快,即使输入的键有规律也有很好的随机分布性)
4.3 解决键冲突
使用链地址法解决。因为没有指向链表尾部节点的指针,所以采用头插法。
4.4 rehash
- 为字典ht[1]哈希表分配空间
1)如果扩展,ht[1]的大小为第一个大于等于ht[0].used*2的2n。
2)如果收缩,ht[1]的大小为第一个大于等于ht[0].used的2n。 - 将ht[0]中的所有键值对rehash(重新计算哈希值和索引)到ht[1]的指定位置上。
- 当ht[0]包含的所有键值对都前移到了ht[1]后(ht[0]为空表)释放ht[0],将ht[1]设置为ht[0],ht[1]新创建一个空表,为下次rehash做准备。
4.4.1 哈希表的扩展与收缩
- 扩展,以下任意一个条件满足时,程序会做那个对哈希表执行扩展操作:
1) 服务器当前没在执行BGSAVE命令或者BGWRITEAOF命令,哈希表的负载因子(ht[0].used/ht[0].size)大于等于1 。
2) 服务器当前正在执行BGSAVE命令或者BGWRITEAOF命令,哈希表的负载因子(ht[0].used/ht[0].size)大于等于5 。
在执行BGSAVE命令或者BGWRITEAOF命令过程中,redis需要创建当前进程的子进程,大多数操作系统都采用写时复制技术来优化子进程的使用效率,在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免子进程存在期间进行哈希表的扩展操作,这可以避免不必要的内存写入操作,最大限度的节约内存。 - 当负载因此小于0.1时,程序自动对哈希表进行收缩操作。
4.5 渐进式rehash
rehash动作不是一次性、集中地完成的,而是多次、渐进式的完成的。如果一次进行大量的键值对要从ht[0]移动到ht[1],计算量可能会导致服务器在一段时间内停止服务。
步骤:
- 为ht[1]分配空间,这是字典同时拥有ht[0]和ht[1]两个哈希表。
- 在字典中维持一个索引计数器rehashidx(表示ht[0]数组的索引),并将他的值设为0,表示rehash正式开始。
- 在rehash期间,每次对字典进行添加、删除、查找、或者更新操作,在执行指定操作意外,还会将ht[0]表在rehashidx索引上的键值对rehash到ht[1],当rehash完成之后,rehashidx值加一。
- 当ht[0]所有键值对rehash到ht[1]上,rehashid置为-1,表示rehash已经完成
在rehash过程中,删除、查找、更新、等操作会在两个哈希表都进行,但是增加操作只会在ht[1]上进行。这保证了ht[0]的键值对数量不会增加。
4.6 字典API
参考原书p36(40/392)
五、跳跃表(skiplist)
跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问的目的。
支持平均O(logN)、最坏O(N)复杂度的节点查找,还可以通过顺序性批量处理节点。(大部分情况效率可以和平衡树媲美,但是跳跃表实现简单,所以不少程序都使用跳跃表代替平衡树)
跳跃表在redis只在两处用到了:
- 实现有序集合键。
- 在集群节点中用作内部数据结构。
5.1 跳跃表实现
由redis.h/zskiplist和redia.h/zskiplistNode两种结构定义,redis.h/zskiplist存储跳跃表节点信息,redia.h/zskiplistNode存储跳跃表节点
redis.h/zskiplist包含:
- header 指向表头结点
- tail 指向表尾节点
- level 记录目前跳跃表内部层数最大的那个节点的层数(表头结点的层数不计算在内)
- length 记录跳跃表的长度,即跳跃表目前包含节点的数量(表头结点不计算在内)。
redia.h/zskiplistNode包含: - 层(level)每个层有两个属性,前进指针(用于表头向表尾遍历)和跨度。
- 后退指针(backward),用于表尾向表头遍历
- 分值(score)跳跃表按照分值从小到大排序(1.0, 2.0, 3.0)
- 成员对象(obj)保存的成员对象
表头节点和其他节点是一样的,只不过后退指针、分值、成员对象用不到所以没有画出。
5.1.1 跳跃表节点
1. 层
level数组可以包含多个元素,每个元素包含指向其他节点的指针。每次创建跳跃表节点的时候都会根据幂次定律(越大的数出现概率越小)随机生成1~32之间的值作为level数组的大小,即层的高度。
2.前进指针
level[i].forword属性。用于遍历。
3.跨度
level[i].span属性。用于记录两个节点之间的距离。指向null的所有前进指针的跨度都为0,因为他们没有连向任何节点。
遍历操作只需要使用前进指针就可以完成。跨度用来计算排位(rank)的,在查找没有节点过程中将多有的跨度累加得到的就是该节点的位置(类似于数组索引)。
4. 后退指针
前进指针可以有多个,并且可以一次跳过多个节点,每个节点只有一个后退指针,并且每次只能后退至前一个节点。
5. 分值和成员
分值为double类型浮点数,跳跃表节点按照分值从小到大排序。
成员是一个指针,指向一个字符串对象,字符串保存着SDS值。
成员对象必须唯一,分值可以相同,相同分值的对象按照成员的字典序排列。
5.1.2 跳跃表
仅靠多个跳跃表节点可以实现跳跃表,但是通过redis.h/zskiplist结构持有更方便,比兔包含头结点、尾节点、长度、最大层数等信息。
5.2 跳跃表API
参考原书p45(49/392)
六、整数集合(intset)
是集合键的底层实现之一(当集合键只包含整数值并且数量不多时)。
6.1 整数集合的实现
集合中每个元素对应contents数组的一个数组项,contents的值按照从小到大排列,并且不会重复。
length对应contents数组长度。
contents数组的类型不取决于int8_t而取决于encoding属性。
encoding有INSERT_ENC_INT16, INSERT_ENC_INT32, INSERT_ENC_INT64三种属性,对应contents的int16_t ,int32_t,int64_t.。
6.2升级
当新添加的元素比整数集合中现有的任何元素类型都要长时,整数集合要先进行升级(update)然后才能添加。所以添加新元素的时间复杂度O(N)
因为引发升级的元素长度总是比现有集合的所有元素长度大,多以这个元素要么最小(放到0位置),要么最大(放到最后的位置)。
6.3 升级的好处
6.3.1 提升灵活性
因为有升级操作,我们可以自由的放int16_t ,int32_t,int64_t.三种类型的数到集合,不必担心类型错误。
6.3.2 尽可能节约内存
如果都用int64_t.保存不会升级,但是会浪费内存,这种升级可以尽可能的减少内存的浪费。
6.4 降级
整数集合不支持降级,即升级之后不会降级。
6.5 整数集合的API
参考原书p51(55/392)
七、压缩列表(ziplist)
压缩链表是列表键和哈希键的底层实现之一(列表建只包含少量列表项,并且每个列表项要么是小整数值,要么是长度比较短的字符串)
7.1 压缩列表的构成
是为节约内存开发的,是由一系列特殊编码的连续内存块组成的有序型数据结构。
7.2 压缩列表节点构成
一个压缩列表可以包含任意多个节点(entry),一个节点可以保存一个字节数组或者一个整数值。
字节数组可以是一下三种长度的一种:
- 长度小于等于63(26-1)字节的字节数组。
- 长度小于等于16383(214-1)字节的字节数组。
- 长度小于等于4294967295(232-1)字节的字节数组。
整数可以是一下六种长度的一种
4. 4位长,介于0-12之间饿无符号整数。
5. 1字节长的有符号整数。
6. 3字节长的有符号整数
7. int16_t类型
8. int32_t类型
9. int64_t类型
压缩列表节点由三部分组成
10. previous_entry_length
11. encoding
12. content
7.2.1 previous_entry_length
记录压缩列表前一个节点长度(可以根据当前节点指针计算前一节点的起始地址,可以根据这个从尾至前遍历压缩列表)。属性长度可以是1字节(前一节点长度小于254字节)或者5字节(前一节点长度大于等于254字节,并且第一字节会被设置为0xFE即十进制254,之后四字节保存前一节点长度)
7.2.2 encoding
记录content属性保存数据的类型及长度。编码前两位表示类型
- 值的最高位为00,01或者10是字节数组编码,分别表示有1字节,2字节,5字节长。编码除去最高两位之后的其他记录表示数组长度。
- 值的最高位为11表示整数,编码一字节长,编码除去最高两位之后的其他记录表示整数类型和长度。
7.2.3 content
保存节点值
7.3 连锁更新
previous_entry_length属性长度是可变的,考虑极端情况,如果压缩列表的所有entry长度为250-253,如果在表头插入一个长度大于等于254的节点,会造成所有节点的长度顺序更新。将这种特殊情况下产生的多次空间扩展操作称为连锁更新。
还有一种情况会触发连锁更新。e1-eN大小都介于250-253,big节点长度大于254,这时如果删除small节点也回触发连锁更新。
连锁更新最坏情况下,需要n次空间重分配,每次重分配的最坏复杂度为O(n),连锁更新最坏复杂度为O(n2)。
这种情况出现的概率不是很高,原因如下:
- 压缩列表恰好有多个连续的长度为250-253的节点出现的概率不是很大。
- 即使出现连锁更新,只要被更新的节点数量不是很多,就不会对性能有很大影响。
7.4 压缩列表API
参考原书p59(63/392)
八、对象
redis基于基本对象创建了对象系统,每种对象用到了至少前面的一种数据结构(根据不同的场景选择不同的数据结构,优化在不同场景下的使用效率)。
redis对象系统实现了基于引用计数的内存回收机制,当程序不再使用某个对象的时候,这个对象的内存就会被自动释放。还通过引用计数实现了对象共享机制,这一机制可以在适当条件下通过让多个数据库键共享同一对象来节约内存。
redis对象带有访问时间记录信息,该信息可以用于计算数据库键的空转时长,在服务器启用了maxmemory功能的情况下,空转时间较大的那些键可能会优先被服务器删除。
8.1 对象类型和编码
redis使用对象来表示数据库中的键和值,新建键值对时,至少会创建两个对象(键和值)。每个对象都由一个redisObject结构表示。
8.1.1 类型(type)
键总是字符串类型,值可以是以下五种。(所谓的字符串键,列表键都是指值为字符串或者列表,TYPE命令返回的值也是指值的类型)
8.1.2 编码和底层实现
对象的ptr指针指向对象底层的数据结构,这些数据结构由对象的encoding属性决定。有以下取值
每种对象都使用了至少两种数据结构。对应关系如下
OBJECT ENCODING命令可以查看底层编码。
续表
通过encoding属性来设定对象所使用的编码,可以提升灵活性和效率。(可以根据不同场景使用不同编码)比如:
当对象数量较少时,使用压缩列表作为列表对象的底层实现。(压缩列表比双端链表更节约内存,并且在元素数量较少时,由于压缩列表在内存中是连续的方式存储的,可以被更快的载入内存,当数量更多时,会转变为功能更强,更适合保存大量元素的双端链表)
8.2 字符串对象
是唯一一种会被其他四种类型对象嵌套的对象。
可以是int, raw或者embstr,可以用longdouble表示的浮点数,在redis也是用字符串表示的,当需要计算时,进行转化为浮点数-计算-转化为字符串
- 当一个字符串对象保存的值是可以用long类型表示的整数时,整数值将保存在ptr指针里面(将ptr的void*转换为long),字符串编码为int。
- 当保存的一个长度大于32字节的字符串值时,使用SDS保存,编码为raw
- 当保存的一个长度小于等于32字节的字符串值时,使用SDS保存,编码为embstr。
embstr是专门用于保存短字符串的一种编码方式。raw和embstr都使用redisObject和sdshdr结构表示字符串,raw会调用两次内存分配来分别创建,embstr会调用一次内存分配函数创建一块连续的空间,空间依次包含redisObject和sdshdr结构。
使用embstr编码的好处
- 创建embstr编码的字符串所需的内存分配函数次数从raw的两次变为一次。
- 释放embstr编码的字符串所需的内存释放函数次数从raw的两次变为一次。
- 连续的内存空间可以更好的利用缓存带来的优势。
8.2.1 编码转换
int或者embstr在特定情况下会变为raw
- 当进行某些操作(比如调用append命令添加字符串)后,保存的值变为字符串,编码方式会转变为raw。
- int和embstr类型编码没有修改方式,其实都是只读的,要进行修改就会转变为raw。
8.2.2 字符串命令的实现
8.3 列表对象
编码可以是ziplist或者linkedlist
使用ziplist时,每个压缩列表节点(entry)保存一个列表元素
使用linkedlist时,每个双端链表节点(node)保存一个字符串对,每个字符串对象保存一个列表元素。
8.3.1 编码转换
同时满足以下两个条件时使用ziplist。否则使用linkedlist
- 列表保存的所有字符串元素的长度都小于64字节。对应于配置文件的list-max-ziplist-value
- 列表保存的元素数量小于512。对应于配置文件的list-max-ziplist-entries
8.3.2 列表命令实现
8.4 哈希对象
可以是ziplist或者hashtable
使用ziplist实现的哈希对象,每次添加一个键值对,先在ziplist末尾添加键,再添加值,因此键值对总是成对出现的。
使用hashtable编码的哈希对象使用字典作为底层实现。哈希对象中的一个键值对对应字典中的一个键值对。字典中的键和值都是字符串对象。
8.4.1 编码转换
同时满足以下两个条件时使用ziplist。否则使用hashtable
- 保存的所有键值对的键和值的长度都小于64字节。对应于配置文件的hash-max-ziplist-value
- 保存的键值对数量小于512。对应于配置文件的hash-max-ziplist-entries
8.4.2 哈希命令实现
8.5 集合对象
可以是intset或者hashtable
intset编码使用整数集合保存。
hashtable使用字典实现,每一个键都是一个字符串对象,每个字符串对象包含了一个集合元素,字典的值为null
8.5.1 编码的转换
当集合同时满足以下条件时使用intset编码,否则使用hashtable编码
- 保存的所有元素都是整数值。
- 数量不超过512个。对应配置文件中的set-max-intset-entries。
8.5.2 集合命令实现
8.6 有序集合对象
编码可以是ziplist或者skiplist
ziplist编码使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点保存,第一个节点保存元素的成员(member),第二个元素保存分值。
压缩列表内元素按照分值从小到大排序。
编码为skiplist有序集合使用zset机构作为底层实现,zset同时包含一个字典和一个跳跃表
zsl表示跳跃表结构,按照分值从小到大排序,跳跃表节点的object属性保存成员,score属性保存分值,通过这个跳跃表可以对有序集合进行范围操作。
zset结构中的dict字典为有序集合创建了从成员到分值的映射。
有序集合的成员都是字符串,分值都是double类型的浮点数。使用跳跃表和dict结构实现有序集合会通过指针共享元素的成员和分值。所以不会造成额外的内存浪费。
8.61 编码的转换
当集合同时满足以下条件时使用ziplist编码,否则使用zskiplist编码
- 有序集合保存的元素数量下于128个。对应配置文件中的zset-max-ziplist-entries。
- 所有元素成员的长度下于等于64字节。对应配置文件中的set-max-ziplist-value。
8.6.2 有序集合命令实现
8.7 类型检查和命令多态
命令主要分为两种类型,
一种是可以对任何类型的键执行,比如DEL, EXPIRE, RENAME,TYPE, OBJECT等命令。
8.7.1 类型检查实现
在执行一个特定的命令之前,redis会检查类型是否正确(通过redisObject的type属性实现)。
8.7.2 多态命令的实现
根据值对象的编码方式选择争取的命令实现代码来执行命令。其实DEL, EXPIRE,TYPE等命令是基于类型的多态,LLEN是基于编码实现的多态。
8.8 内存回收
C语言没有自动回收功能,redis自己实现了基于引用计数的内存回收功能,每个对象的引用计数基于redisObject的refcount属性记录。
8.9 对象共享
对象的引用计数还带有对象共享的作用。不但键可以共享,数据结构中嵌套了字符串对象的数据结构也可以共享。
需要经历两个步骤
- 将数据库的值指针指向一个现有的值对象·
- 将共享的值对象的引用计数+1
初始化是默认创建了0-9999的字符串对象。(可以通过redis.h/REDIS_SHARED_INTEGEGERS常量来修改)。
8.10 对象的空转时长
除了type,encoding, ptr, refcount属性之外,redisObject结构包含的最后一个属性为lru属性,该属性记录了对象最后一次被命令程序访问的时间
OBJECT IDLETIME命令就是通过将当前时间减去lru记录的时间计算得出的(该命令不会修改lru的时间)。
还有另一个作用,如果服务器打开了maxmemory选项,并且服务器用于回收内存的算法为volatile-lru或者allkeys-lru,那么当服务器占用的内存超过了meamemory选项设置的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存。