本文是基于《Redis设计与实现》的读书笔记。
一、命令
1、 开启服务端:redis-server redis.windows.conf
2、 开启服务端:redis-cli
3、 查看所有的key:keys *
4、 添加新键:set key value
5、 删除键:del key
6、 更新键:set key newValue
7、 获取键:get key
8、 对于键空间的操作:
a) flushdb:删除键空间中的所有键值对
b) randomkey:从键空间中随机返回一个键
c) dbsize:返回键空间中键值对的数量
d) exists:检查给定键是否存在于键空间中
e) rename:在键空间中,对给定键进行改名
在开始学习Redis之前,先了解一下Redis的单线程架构
-
当client有命令发出给Redis时,是通过网络进行发送(无论是通过外网还是内网),然后Redis内部执行好命令之后,将结果发送给client。
-
Redis是单线程执行命令,所以当有大量的命令需要执行时,会使用队列进行排队缓冲,如果有若干个client同时发送命令,则发送顺序是不确定的,这与网络有关。
-
-
而Redis队列的排队机制有下面几种思路:
-
简单队列实现,使用brpop和lpush,阻塞地形成消息队列。
-
可以添加优先级设定,将高优先级的任务rpush,低优先级的任务lpush,但是这种方法有可能会导致低优先级任务“饥饿”,还会违背FIFO原则
-
优化上述优先级设定,使用两种队列,一种高优先级队列,另一个是低优先级队列,redis可以自行选择消费哪个队列。
-
如果有100个优先级,那么就要设100个队列吗?可以使用一个队列,但是有若干个优先级,每次一个既定优先级的任务来报道,就将其插入队列的既定位置,当然,队列还是施行FIFO原则,但是这里可以依照优先级,先进先出。
-
二、数据结构与对象
在Redis中,SDS除了用来保存数据库中字符串值之外,SDS还被用做缓冲区。
1. 简单动态字符串(SDS)
1.1 定义
struct sdshdr{ //记录buf数组中已使用字节的数量 //等于SDS所保存字符串的长度 int len; //记录buf数组中未使用字节的数量 int free; //字节数组,用于保存字符串 //SDS以空字符结尾 char[] buf; }
SDS保存空字符的1字节空间不计算在SDS的len属性内,并且为空字符分配额外的1字节空间,以及添加空字符到字符串末尾等操作,都是由SDS函数自动完成的。所以,空字符对用户来说是完全透明的。
但是好处是,SDS可以直接重用一部分C字符串函数库里面的函数。
1.2 SDS与C字符串的区别
1.2.1 常数复杂度获取字符串长度
因为C字符串并不记录自身的长度信息,所以需要遍历整个字符串知道遇到空字符为止,这个操作的复杂度为O(n)。
而SDS在len属性中就已经记录了SDS本身的长度,所以复杂度为O(1)。
1.2.2 杜绝缓冲区溢出
在c字符串中strcat函数可以将src字符串中的内容拼接到dest字符串的末尾:
char *strcat(char *dest, const char *src)
因为c字符串不记录自身的长度,所以strcat假定用户在执行这个函数时,已经为dest分配了足够多的内存,可以容纳src字符串中的所有内容,而一旦这个假定不成立时,就会产生缓冲区溢出。
可是SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS空间扩展至执行修改所需的大小,然后才执行湿垃圾的修改操作。
1.2.3 减少修改字符串时带来的内存重分配次数
因为C字符串不记录自身的长度,所以对于一个包含了N个字符串的C字符串来说,这个C字符串的底层实现总是一个N+1个字符长的数组(字符串内容+空字符)。
因为C字符串的长度和底层数组的长度之间存在着这种关联关系,所以每次增长或缩短一个C字符串,程序否要进行一次内存重分配操作:
-
程序执行增长字符串的操作钱,会先通过内存重分配来扩展底层数组的空间大小——否则会产生缓冲区溢出。
-
程序缩短字符串操作之后,程序需要通过内存重分配来释放字符串不再使用的那部分空间——否则会产生内存泄露。
而SDS为了避免C字符串的缺陷,通过未使用空间接触了字符串长度和底层数组长度之间的关联:在SDS中,buf数组的长度不一定就是字符数量+1,数组里面可以包含为未使用的字节,而这些字节的数量就由free属性记录。通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略。
-
空间预分配
空间预分配用于优化SDS的字符串增长操作:当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间。
-
如果SDS的len<1MB,则分配和len属性一样大小的未使用空间,这时SDS的len属性的值将会和free属性的值一样。
-
如果SDS的len>=1MB,则分配1MB的未使用空间。
所以在扩展SDS空间之前,SDS API会先检查未使用空间是否足够,如果足够的话,API就会直接使用未使用空间,而无需执行内存重分配。
-
惰性空间释放
惰性空间释放用于优化SDS的字符串缩短操作:当SDS的API需要缩短SDS保存到字符串,会使用free属性将这些字节的数量记录起来,并等待将来使用。
通过惰性空间释放策略,SDS避免了缩短字符串时所需的内存重分配的操作,并为将来可能有的增长操作提供了优化。
1.2.4 二进制安全
C字符串中除了字符串末尾之外,字符串中不能包含空字符,否则会被误会为字符串结尾,所以C字符串只能保存文本数据,而不能保存二进制数据。
但SDS可以保存二进制数据,因为SDS使用len属性的值而不是空字符串来判断字符串是否结束。
1.2.5 兼容部分C字符串函数
因为SDS和C字符串一样以空字符结尾,所以SDS可以重用一部分
通过遵循C字符串以空字符结尾的惯例,SDS可以在有需要时重用
2. 链表
链表在Redis的应用除了列表键的底层,还有发布与订阅、慢查询、监视器、使用链表来保存多个客户端的状态信息以及使用链表来构建客户端输出缓冲区等功能。
2.1 链表和链表节点的实现
typedef struct listNode{ //前置节点 struct listNode *prev; //后置节点 struct listNode *next; //节点的值 void *value; }listNode;
虽然可以通过多个listNode可以通过prev和next指针组成双端链表,但是也可以使用以下的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;
Redis的链表实现特性总结如下:
-
双端:链表节点有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)。
-
无环:表头节点的prev指针和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点。
-
带表头指针和表尾指针:透过list结构的head和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。
-
带链表长度计数器:程序使用list结构的len属性来对list持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)。
-
多态:链表节点使用void*指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。
3. 字典
字典,是一种用于保存键值对的抽象数据结构。一个键和一个值进行关联,这些关联的键和值就称为键值对。每个键都是独一无二的。
字典可以用作Redis的底层实现,也可以是哈希键的底层实现之一。
3.1 字典的实现
Redis的字典用哈希表作为底层实现,一个哈希表里可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对,
3.1.1 哈希表
typedef struct dictht{ //哈希表数组,数组中的每个指针都指向一个dictEntry结构的指针,每个dictEntry结构保存着一个键值对 dictEntry **table; //哈希表大小 unsigned long size; //哈希表大小掩码,用于计算索引值,总是等于size-1 unsigned long sizemark; //该哈希表已有的节点数量 unsigned long used; }dictht;
3.1.2 哈希表节点
typedef struct dictEntry{ //键 void *key; //值,可以是一个指针,或是一个uint64_t整数,又或是一个int64_t整数 union{ void *val; uint64_t u64; int64_t s64; }v; //指向下个哈希表节点,形成链表 struct dictEntry *next; //next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,一次来解决键冲突的问题 }
3.1.3 字典
typedef struct dict{ //类型特定函数->保存了一簇用于操作特定类型键值对的函数 dictType *type; //私有数据->保存了需要传给那些 类型特定函数的可选参数 void *privdata; //哈希表->一般情况下只用到h[0],h[1]只会在对h[0]进行rehash时使用 dicht ht[2]; //rehash索引,当rehash不在进行时,值为-1->记录rehash目前的进度 int rhashidx; }dict;
接下来是dictType的结构:
typedef struct dictType{ //计算哈希值的函数 unsigned int (*hashFunction)(const void *key); //复制键的函数 void *(*keyDup)(void *privdata, const void *key); //复制值的函数 void *(valueDup)(void *privdata, const void *obj); //对比键的函数 int (*keyCompare)(void *privdata, const void *key1, const void *key2); //销毁键的函数 void (*keyDestructor)(void *privdata, void *key); //销毁值的函数 void (*valDestructor)(void *privdata, void *obj); }
3.2 哈希算法
当要将一个新的键值对添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到哈希表数组的指定索引上面。
-
使用字典设置的哈希函数,计算key的哈希值
hash = dict->type->hashFunction(key);
-
使用哈希表的sizemask属性和哈希值,算出索引值,可以根据情况选定h[x]是h[1]还是h[0]
index = hash & dict->ht[x].sizemark;
当字典被用做数据库或哈希键的底层实现,Redis使用的是MurmurHash算法。
3.3 解决键冲突
冲突:当有两个或两个以上的键被分配到哈希表数组的同一个索引上面。
冲突解决:使用链地址法--->每个哈希表节点上都有一个next指针,多个哈希表节点可以用next指针形成一个单向链表。
3.4 rehash
目的:为了在哈希表保存的键值对增多或减少后,哈希表的负载因子维持在一个合理的范围之内,所以哈希表需要进行相应的扩展或收缩。
步骤:
-
为字典的ht[1]分配一个ht[0].userd属性值大小的空间。
-
如果执行的是扩展操作,ht[1]的大小=ht[0].used*2的2的n次方幂。
-
如果是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2的n次方幂
-
-
将保存在ht[0]中的所有键值对rehash到ht[1]上面。
-
当ht[0]包含所有的键值对都迁移到ht[1]后,释放ht[0],将ht[1]设置为ht[0],并在其创建一个新的空白哈希表。
收缩与扩展
负载因子 = 哈希表已保存节点数量/哈希表大小
-
扩展操作:
-
在没有执行BGSAVE命令或BGREWRITEAOF命令,并且哈希表的负载均衡因子大于等于1。
-
在执行BGSAVE命令或BGREWRITEAOF命令,并且哈希表的负载均衡因子大于等于5。
-
有没有执行BGSAVE命令或BGREWRITEAOF命令,它们的负载因子是不一样的,因为在执行过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制(copy-on-write)技术优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作的负载因子,从而尽可能避免在子进程存在期间进行哈希扩展操作。
-
-
收缩操作:
-
哈希表的负载因子小于0.1
-
3.5 渐进式rehash
为了避免在一次性将ht[0]键值对移动到ht[1]时,因为庞大的计算量可能使得服务器在一段时间内停止服务,rehash动作分多次、渐进式地完成。
步骤:
-
为ht[1]分配空间,使得字典同时拥有ht[0]和ht[1]两个哈希表;
-
在字典中维持一个索引计数器变量rehashidx,将其值从-1设为0,表示rehash工作开始;
-
在rehash进行期间,每次对字典执行增删改查操作时,程序还会将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增一。
-
当在某个时间点上,ht[0]中的所有键值对都移动到ht[1]上,这使将rehashidx属性值设为-1,表示rehash操作已完成。
注意:在rehash过程中,如果有其他键值对插入,会一律保存到ht[1]中的,而ht[0]不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表。
4. 跳跃表
跳跃表:一种有序数据结构,通过在每个节点维持多个指向其他节点的指针,从而达到快速访问结点的目的。
跳跃表是有序集合键的底层实现之一,也是集群节点中用作内部数据结构。
4.1 跳跃表的实现
跳跃表是由一个zskiplist和若干个zskiplistNode这两种结构定义,zskiplist用于保存跳跃表节点的相关信息,zskiplistNode结构用于表示跳跃表节点。一个跳跃表由一个表头节点和若干个跳跃表节点组成,表头节点虽然与跳跃表节点的结构一致,但是只用到了显示各层节点的功能。
4.1.1 跳跃表节点
typedef struct zskiplistNode{ //后退指针,指向位于当前节点的前一个节点,用于从表尾遍历到表头 struct zskiplistNode *backward; //分值 节点保存的分值。在跳跃表中,节点按各自所保存的分值从小到大排序 double score; //成员对象,这是一个指向一个字符串对象的指针,其中存着一个SDS值 robj *obj; //层,结点中用L1、L2等字样标记节点的各个层,L1代表第一层,L2代表第二层,以此类推 struct zskiplistLevel{ //前进指针,用于访问位于表尾方向的其他节点 struct zskiplistNode *forward; //跨度,记录前进指正所指向节点和当前节点的距离 unsigned int span; }level[]; }zskiplistNode;
而且,跳跃表节点的level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度。层数越多,访问节点速度越快。
跨度实际上是用来计算排位的:在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。
4.1.2 跳跃表
typedef struct zskiplist{ //表头节点和表尾节点 structz skiplistNode *header, *tail; //表中节点的数量,也就是跳跃表目前包含节点的数量 unsigned long length; //记录目前跳跃表内,层数最大的节点的层数,注意,表头节点的层高不在计算范围内 int level; }
5. 整数集合
整数集合(intset)是集合键的底层实现之一,集合只包含整数值。
5.1 整数集合的实现
typedef struct intset{ //编码方式 uint32_t encoding; //集合包含的元素数量 uint32_t length; //保存元素的数组 int8_t contents[]; }intset;
contents数组是整数集合的底层实现:集合的每个元素都是contents的数组项,各个项在数组中按值的大小从小到大有序排列,并且数组中不包括重复的数据项。
contents数组的真正类型取决于encoding属性的值:INTSET_ENC_INT16、INTSET_ENC_INT32、INTSET_ENC_INT64。
5.2 升级
如果要将一个类型比整数集合现有所有元素的类型都要长的元素,放入整数集合中,整数集合需要先进行升级,然后才能把新元素添加到整数集合里面。
步骤:
-
根据新元素的类型,扩展整数集合底层数组的空间大小,为新元素分配空间;
-
将底层现有所有元素都转换成新元素相同的类型,并将类型转换后的元素放到正确的位置上,在放置元素的过程中需要继续维持底层数组的有序性质不变;
-
因为引发升级的新元素长度异于整数集合,所以新元素的值要么大于所有现有元素,要么小于所有现有元素。
-
在新元素小于所有现有元素的情况下,新元素会被放置在底层数组的最开头(索引0);
-
在新元素大于所有现有元素的情况下,新元素会被放置在底层数组的最末尾(索引length-1)。
-
升级的好处:
-
提升灵活性:因为整数列表会自动升级来适应新元素,不用担心类型错误。
-
节约内存:可以让集合同时保存三种不同类型的值,又可以确保升级操作只会在需要的时候进行,这可以尽量节省内存。
5.4 降级
整数集合不支持降级操作,一旦对数组进行了升级,编码J就会一直保持升级后的状态。
6. 压缩列表
压缩列表是列表建和哈希键的底层实现之一。
6.1 压缩列表的构成
压缩列表是由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值。
压缩列表的结构如下:
zlbytes--zltail--zllen--entry1--entry2--...--entryN--zlend
-
其中,zlbytes记录整个压缩列表占用的内存字节数,在对压缩列表进行内存重分配,或计算zlend的位置时使用。
-
zltail记录压缩列表尾节点距离压缩列表的起始地址有多少个字节,通过这个偏移量,程序无须遍历整个压缩列表姐可以确定表尾节点的地址。
-
zllen记录压缩列表包含的节点数量,但是如果节点数量等于UINT16_MAX时,节点的真实数量需要遍历整个压缩列表才能计算出来。
-
entryX是压缩列表包含的节点,节点的长度由节点的内存决定。
-
zlend用于标记压缩列表的末端。
6.2 压缩列表节点的构成
每个压缩列表节点可以保存一个字节数组或一个整数值,每个压缩列表节点的组成如下:
previous_entry_length--encoding--content
-
previous_entry_length记录了压缩列表中前一个节点的长度,其长度有两种情况:
-
如果前一节点长度小于254字节,则长度为1字节。
-
如果前一节点长度大于等于254字节,则长度为5字节。
-
因为节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。
-
可以先获取一个指向某个节点起始地址的指针,然后通过这个指针以及这个节点的previous_entry_length属性,程序一直向前一个节点回溯,最终到达压缩列表的表头节点,这就是压缩列表从表尾到表头遍历操作的原理。
-
-
encoding记录了节点的content属性所保存数据的类型以及长度
-
一字节/两字节/五字节长,值的最高位为00/01/10的字节数组编码:表示content属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录。
-
一字节长,值的最高位为11开头的整数编码:表示content属性保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录。
-
content保存节点的值,节点值的类型和长度由节点的encoding属性决定
6.3 连锁更新
连锁更新:在特殊情况下产生的连续多次空间扩展操作。
-
-
而删除节点引发的连锁更新与添加节点类似,因为长度的减小而倒是存储空间的变化,所以删除结点可能会发生从指定位置开始到end节点的更新操作。
因为连锁更在最坏情况下需要对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N的平方),但是它真正造成性能问题的几率是很小的:
-
压缩列表里有多个连续的、长度介于250~253字节之间的节点,连锁更新才有可能被引发;
-
即使出现了连锁更新,但只要被更新的节点数量不多,就不会对性能造成影响。
Redis的数据结构有6种,其中四种是Redis的内部数据结构(SDS、链表、字典、跳跃表)和两种内存映射数据结构(整数列表和压缩列表),其中比较常问到的的是SDS结构以及与字符串的不同之处,字典中引用的hash表和rehash机制,跳跃表的优秀特性和压缩列表的可能会有的问题。但是这些数据结构都是非常重要的,在接下来的对象机制中,起着底层结构的作用。