目录
Redis数据结构对象
1.简单动态字符串(string)
简单动态字符串(simple dynamic string,SDS)。C语言中字符串类型是以空字符结尾的字符数组,当Redis中的常量(不用修改)用C语言中的字符串存储,而变量使用Redis中的SDS表示。
1.1 作用
-
保存数据库中的字符串
-
用作缓存区
1.2 SDS的定义
SDS遵循以空字符结尾的规则,但是保存空字符的一个字节不会计算到SDS的len属性里(Redis以len属性判断结尾,而不是根据空字符判断),对于这个空字符空间的分配以及添加到字符串末尾都是对使用者透明,是由SDS函数自动完成的。那为什么要遵循以空字符结尾的规则呢?因为为了直接重用一部分C语言中字符串函数库里面的函数。
1.3 SDS与字符串的区别
-
len属性记录了字符串的长度,请求长度时不用遍历,空间换时间。
-
SDS杜绝了缓冲区的溢出:当对SDS进行修改时,API会先检查SDS的空间是否满足修改所需要的需求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,然后再执行实际的修改操作。
-
减少修改字符串是带来的内存重分配次数,SDS实现了空间预分配和惰性空间释放两种优化策略。
-
空间预分配:当对一个SDS进行修改并需要进行空间扩展时,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外未使用的空间。当SDS的长度小于1MB时,会分配len属性同样大小的未使用空间;当SDS的长度大于1MB时,会分配1MB的未使用空间。空间预分配减少了连续执行字符串增长操作所需的内存重分配次数
-
惰性空间释放:当API对SDS保存的字符串进行缩短时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录起来
-
-
二进制安全:C语言字符串只能保存文本数据,而不能保存像图片、音频、视频这样的二进制数据
| C字符串 | SDS |
|---|---|
| 获取字符串长度复杂度为O(N) | 获取字符串长度复杂度为O(1) |
| 可能造成缓冲区溢出 | 不会造成缓冲区溢出 |
| 修改字符串长度需要执行N次内存重分配 | 最多需要N次内存重分配 |
| 只能保存文本数据 | 可以保存文本或二进制数据 |
| 可以使用C语言中所有string.h库中的函数 | 可以使用部分C语言中string.h函数 |
2. 链表(列表的实现)
列表键的底层实现之一就是链表,当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,Redis就会用链表作为列表键的底层实现。除了列表键之外,发布与订阅、慢查询、监视器等功能也用到了链表
2.1 链表与链表节点的实现
-
无环:表头节点的prev和表尾节点的next指针都指向NULL,对链表的访问以NULL为终点
//节点,双端节点
typedef struct listNode {
struct listNode * prev;// 前置节点
struct listNode * next;// 后置节点
void *value; // 节点的值,void表示指向任何类型的指针
}listNode;
//列表
typedef struct list {
listNode *head; // 表头节点
listNode *tail;// 表尾节点
unsigned long len; // 链表所包含的节点数量,O(1)
void *(*dup)(void *ptr); // 节点值复制函数
void (*free)(void *ptr); // 节点值释放函数
int (*match)(void *ptr,void *key); // 节点值对比函数
} list;
3. 字典(hash)
字典是一种用于保存键值对的抽象数据结构:Redis的数据库就是使用字段来作为底层实现的,对数据库的增删改查操作也是建立在对字典的操作之上的;字典还是hash的底层实现之一
3.1实现
Redis的字典是使用哈希表作为底层实现,使用链地址法来解决hash冲突
//哈希表
typedef struct dictht {
dictEntry **table;// 哈希表数组:指向哈希表节点的指针,dictEntry结构保存着一个键值对
unsigned long size;// 哈希表大小
unsigned long sizemask; // 哈希表大小掩码,用于计算索引值,总是等于 size - 1
unsigned long used;// 该哈希表已有节点的数量
} dictht;
//哈希表节点,v保存着键值对中的值,可以是一个指针,也可以是一个uint64_t整数,或者int64_t的整数
typedef struct dictEntry {
void *key; //键
union { //值
void *val;
uint64_t u64;
int64_t s64;
} v;
struct dictEntry *next;// 指向下个哈希表节点,形成链表,解决hash冲突
} dictEntry;
//字典
typedef struct dict {
dictType *type; // 类型特定函数
void *privdata; // 私有数据
dictht ht[2];// 哈希表
int rehashidx; // rehash 索引,记录了rehash的进度。当 rehash 不在进行时,值为 -1
} dict;
-
ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会对ht[0]哈希表进行rehash时使用。
3.2 哈希算法
跟hashmap类似,通过hash函数计算出key的哈希值,然后和sizemask(length-1)进行按位与操作。当发生了冲突时,Redis的哈希表使用链地址法来解决键冲突,并且总是将新节点添加到链表表头位置(因为没有指向链表表尾的位置)
3.3rehash
为了让哈希表的负载因子位置在一个合理的范围之内,当哈希表保存的键值对数量太多或太少时,程序需要对哈希表的大象进行相应的扩展或者收缩。为字典ht[1]哈希表分配空间,这个空间的大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(ht[0].used属性的值)
-
如果是扩展操作,那么 ht[1]的大小为第一个大小为第一个大于等于 [ ht[0].used * 2 ] 的 [ 2^n] (2的n次方幂)。
-
服务器目前没有在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于
1; -
服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令, 并且哈希表的负载因子大于等于
5
-
-
如果是收缩操作,那么 ht[1]的大小为第一个大于等于 ht[0].used 的 2^n
-
当哈希表的负载因子小于
0.1时, 程序自动开始对哈希表执行收缩操作。
-
rehash的动作不是一次性、集中式的完成的,而是分多次、渐进式的完成的。以防止键值对太多rehash时造成对服务器性能的影响。在rehash进行期间,每次对字典进行添加、删除、查找或者更新惭怍时,程序除了执行指定的操作外,还会顺带将 ht[0]在 rehashidx 索引上的所有键值对rehash到 ht[1] 上,当完成之后,再将 rehashidx的值增一,当所有键值对都rehash完成时,则将rehashidx的值设置为-1。
因为在进行渐进式rehash的过程中,字典会同时使用ht[0] 和 ht[1] 两个哈希表。如果要在字典里面查找一个键的话,程序会先在 ht[0] 里面进行查找,如果没找到,就继续到 ht[1] 里面进行查找。在渐进式rehash执行期间,新添加的键值对都会保存到 ht[1] 里面。
4. 跳跃表(ZSet)
跳跃表是一种有序数据结构,它通过在每个节点中位置多个指向其他节点的指针,从而达到快速访问节点的目的。Redis使用跳跃表作为有序集合的底层实现之一,如果有一个有序集合包含元素数量比较多,又或者有序集合中元素的成员是比较长的字符串时,Redis就会使用跳跃表来作为有序集合的底层实现。
4.1 实现
-
层:跳跃表节点的 level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针,程序可以通过这些层来加速访问其他节点的速度。层数是根据幂次定律随机生成的(1-32)
-
前进指针:指向表尾放下的前进指针,用于从表头放下向表尾方向访问节点
-
跨度:两个节点之间的距离,可用于计算排位:在查找某个节点的过程中,将沿途访问过的所有层的跨度累计起来,得到的结果就是目标节点在跳跃表中的排位。
-
后退指针:用于从表尾向表头方向访问节点
-
分值和成员:先按照分值排序,如果分值相等,则按照成员对象在字典序中的大小来进行排序。
//跳跃表节点
typedef struct zskiplistNode{
struct zkskiplistNode *backward;//后退指针
double score ; //分值
robj *obj;//成员对象
struct zskiplistLevel{ //层
struct zskiplistNode *forward;//前进指针
unsigned int span;//跨度
}level[];
}zskiplistNode;
//跳跃表:保存了表头和表尾,节点总数
typedef struct zskiplist{
struct zskiplistNode *header , *tail;//表头节点和表尾节点
unsigned long length;//表中节点的数量
int level;//表中国层数最大的节点的层数
}
-
表头节点其实就是一个跳跃表节点结构,只是部分属性未使用。
-
跳跃表是一种基于有序链表的扩展,类似于树(数据库索引)。当进行插入时进行排序,只用和关键节点进行比较。
5. 整数集合
当一个集合值包含数据值元素时,并且这个集合的元素数量不多时,Redis就会使用整数集合作为集合键的底层实现。
typedef struct intset{
unit32_t encoding;//编码方式,contents数组的真正类型
unit32_t length;//集合包含的元素数量
int8_t contents[];//保存元素的数组
}intset;
5.1 升级
当要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有的元素类型都要长,整数集合需要先进行升级,然后才能将新元素添加到整数集合里面。例如:当向一个底层为int16_t数组的整数集合添加一个int64_t类型的整数值时,整数集合已有的所有元素都会被转换成int64_t类型。过程如下:
-
根据新元素类型,扩展整数集合底层数组的空间大小,并为新元素类型分配空间
-
把现有元素转换成与新元素相同的类型,并防止正确的位置上
-
将新元素添加到底层数组里
好处:
-
整数集合通过自动升级底层数组来适应新元素,使用者不用担心出现类型错误问题,做法灵活
-
节约内存:升级操作只有在需要的时候才会进行,可以尽量节约内存
6. 压缩列表(列表键和哈希键的底层实现)
6.1定义
压缩列表时Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点,每个节点可以保存一个 字节数组或者一个整数值。
6.2 应用场景
当一个列表键只包含少量列表项时,并且每个列表项时小整数值,或者是长度比较短的字符串,那么Redis会使用压缩列表来做列表键的底层实现。或者当一个hash键只包含少量键值对,并且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做hash键的底层实现。
6.3实现
压缩列表:
-
zlbytes:记录整个压缩列表占用内存字节数
-
zltail:记录压缩列表表尾距离表头的起始地址有多少字节,可确定表尾节点的地址
-
zllen:记录了压缩列表的节点数量
-
entry:节点
-
zlend:末端标记
压缩列表节点:可保存一个 字节数组或者一个整数值
-
previous_entry_length:前一个节点长度,可以为1字节或5字节,如果以0XFE开头,则表示5字节
-
encoding:记录了节点content属性所保存的数据的类型以及长度。如果以11开头,则表示整数编码,否则,表示保存着字节数组。其content长度是由除去前两位之后的位数进行表示
-
content:保存节点的值
6.4连锁更新
当进行添加新节点时,或者从压缩列表中删除节点,可能会引发连锁更新,但这种操作出现的几率并不高。由于previous_entry_length可以为 1字节(254字节以内)或者5字节,当压缩列表恰好有多个连续的、长度介于250字节至253字节之间的节点,连锁更新才有可能被引发。当添加一个新节点时,其长度超过了1字节,则需要5字节来表示,则其后的节点的previous_entry_length当从1变成5字节时,并当这个节点的长度介于250字节至253字节之间时,就会引发其后面节点previous_entry_length字节数的更新。。。
7.对象
Redis用以上数据结构创建了一个对象系统,包括:字符串对象、列表对象、哈希对象、集合对象和有序集合对象。Redis还通过引用计数技术实现了对象共享机制
7.1 对象的类型和编码
Redis中每个对象都由一个 redisObject 结构表示
typedef struct redisObject{
unsigned type:4;//类型,4bits
unsigned encoding:4;//编码,4bits
void *ptr;//指向底层实现数据结构的指针
int refcount ;//引用计数器
unsigned lru:LRU_BITS;
}robj;
类型:
-
REDIS_STRING:字符串对象
-
REDIS_LIST:列表对象
-
REDIS_HASH:哈希对象
-
REDIS_SET:集合对象
-
REDIS_ZSET:有序集合对象
编码:
对象底层实现的数据结构:字典、压缩列表、双端链表等等
7.2 字符串对象(int、raw、embstr)
字符串对象的编码可以是int、raw(SDS)或者embstr(长度小于等于39字节时)。raw 编码和embstr编码区别在于:raw编码会调用两次内存分配函数来分别创建RedisObject结构和sdshdr结构,而embstr编码则通过调用一个内存分配函数来分配一块连续的空间。
使用embstr的好处:
-
内存分配次数更少,为一次
-
释放内存也只需要一次
-
连续内存,能够更好利用缓存带来的优势??(https://blog.csdn.net/broadview2006/article/details/76019843)
在Redis中,long double浮点数(长度太长)使用字符串来保存的。当进行计算时:先进行取出,然后转换为浮点进行计算,计算完成后再转换为字符串。
Redis 没有为embstr编码的字符提供修改的命令,当如果对embstr编码的字符串对象进行修改时,程序会将对象的编码从embstr转换为raw。
为什么长度大于39个字节时就用raw编码?
embstr是一块连续的内存区域,有RedisObject和sdshdr组成。其中RedisObject占16个字节,sdshdr的字节数为:16+ 8(len+free)+39(字符串)+1(\0)= 64字节(内存最小分配单元??)。但是在3.0之后,作者对sds做了内存优化,将原来的sdshdr改成了sdshdr16,sdshdr32,sdshdr64。因为对于很短的sds有空间被浪费(两个unsigned int 8个字节,len和free属性,把这两个属性的类型从 unsigned int 变成了uint8_t,unit16_t,还增加了一个char flags,所以 unsigned int *2 = 8 , unit8_t *2 + char = 3 ,所以一共减少了8-3=5个字节,所以embstr的长度最长可为39+5=44个字节
7.3 列表对象(ziplist、linkedlist)
列表对象的编码可以是ziplist 或者 linkedlist。
ziplist:压缩列表作为底层实现,每个压缩列表节点保存了一个列表元素。
linkedlist
-
Linkedlist 编码的列表对象使用双端链表作为底层实现,每个双端链表节点都保存了一个字符串对象(上一节)
何时使用压缩列表
-
当列表对象保存的所有字符串元素都小于64字节并且元素个数小于512个时。当不能满足时,压缩列表的元素会被转移并保存到双端链表里面(64和512是默认值,可修改配置)
7.4哈希对象(ziplist、hashtable)
哈希对象的编码可以为ziplist或者hashtable
ziplist
当使用压缩列表时,哈希对象的键值对总是紧挨着一起:zlbytes-zltail-zllen-k1-v1-k2-v2...-zlend(每一个键、值都是一个压缩列表节点)。尾插法的方式进行插入
hashtable
hashtable编码的哈希对象使用字典作为底层实现,哈希对象中每个键值对都使用一个字典键值来保存,每个键、每个值都是一个字符串对象
何时使用压缩列表
-
当哈希对象保存的所有键值对的键和值得字符串长度都小于64字节并且键值对数量小于512个(512对)时。当不能满足时,压缩列表的元素会被转移并保存到hashtable里。(64和512是默认值,可修改配置)
7.5集合对象(intset、hashtable)
集合对象的编码可以是intset或者hashtable
intset见上文整数集合,hashtable编码时使用字典作为底层实现,字典的每个键都是一个字符串对象,而字典的值则全部被设置为NULL
何时使用intset编码
-
当集合对象保存的所有元素都是整数值,并且集合对象保存的元素数量不超过512个
7.6有序集合对象(ziplist、skiplist)
有序集合的编码可以是ziplist或者skiplist
ziplist:ziplist编码使用压缩列表来作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员,而第二个元素则保存元素的分值。压缩列表内的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的位置,而分值较大的元素则被放置在靠近表尾的位置
skiplist:使用zset结构作为底层实现,一个zset结构同时包含一个字典和一个跳跃表
typedef struct zset{
zskiplist *zsl;
dict *dict;
}zset;
虽然zset结构同时使用跳跃表和字典来保存有序集合的元素,但是这两种数据结构都会通过指针来共享相同元素的成员和分值,所以不会产生任何重复成员或者分值,也不会因此而浪费额外的内存。
为什么有序集合需要同时使用跳跃表和字典来实现?
如果只使用字段来实现有序集合,可以以O(1)的时间复杂度查找成员的分值这一特性会被保留。但是字典会以无序的方式来保存集合元素,所以在进行范围操作时,需要对所有的元素进行排序,时间复杂度为O(NlogN),以及需要额外的O(N)内存空间(因为需要创建一个数组来保存排序后的元素)。
如果只使用跳跃表来实现有序集合,但是根据成员查找分值这一操作的复杂度将从O(1)上升为O(longN)。
所以,为了让有序集合的查找和范围型操作都尽可能快的执行,Redis选择了同时使用字典和跳跃表两种数据结构来实现有序集合。
何时使用ziplist编码
-
当有序集合保存的元素小于128个并且有序集合保存的所有元素成员的长度都小于64字节
7.7 类型检查
类型特定命令所进行的类型检查是通过RedisObject结构的type属性来实现的,在执行一个特定命令之前,服务器会检查输入的值对象是否为执行明细所需的类型,如果不是的话,会返回一个类型错误。
Redis 除了会根据值对象的类型来判断键是否能够执行指定命令之外(类型的多态),还会根据值对象的编码方式(编码的多态),选择正确的命令实现代码来执行命令(不同的编码方式底层实现不同,需调用不同的方法)。
7.8对象共享(只针对整数值)
Redis通过对象的引用计数属性来实现,当共享一个对象时,会将共享对象的引用计数器增一。Redis会在初始化服务器时,创建一万个字符串对象(0-9999),当服务器需要用到0-9999的字符串对象时,服务器就会使用这些共享对象。所以这些对象的初始引用计数为1(因为服务器一直引用着)。
Redis只针对整数值的字符串进行对象共享,因为字符串对象的验证操作复杂(校验是否一直,每个字节一次判断)
7.9对象的空转时长
RedisObject结构包含了一个lru属性,该属性记录了对象最后一个被命令程序访问的时间。而空转时间=当前时间-lru,当服务器打开了maxmemory选项,并且服务器用于回收内存的算法为lru算法时,并且当前占用内存数超过了maxmemory的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存。