PART1:Redis的数据结构:5+3数据类型<----------------->数据结构【未来随着 Redis 新版本的发布,可能会有新的数据结构出现,通过查阅 Redis 官网【[Redis官网命令中心](http://www.redis.cn/commands.html)】
对应的介绍,你总能获取到最靠谱的信息。】
从docke容器中,docker exec -it redis-test /bin/bash,然后redis-cli
】Redis用到的主要数据结构,比如简单动态字符串(SDS)、双端链表、字典、压缩列表、哈希表、跳表、整数集合、快速链表、listpack等等。Redis并没有直接使用这些数据结构来实现键值对数据库,而是基于这些数据结构创建了一个对象系统,并规定Reedis的键值对中的key就是字符串对象,然后键值对中的值value就是字符串对象、列表对象、哈希对象、集合对象和有序集合对象这五种类型的对象中的某一种,从而实现了Redis的键值对数据库】
。通过这五种不同类型的对象,Redis可以在执行命令之前,根据对象的类型来判断一个对象是否可以执行给定的命令
。
(Redis所有的数据结构都是以唯一的key字符串作为名称来获取相应的value数据。五种不同类型的对象结构的差异就是由唯一
的key获取到的value的结构不一样
)。换句话说,Redis数据库里面的每个键值对都是由对象组成的,其中数据库键总是一个字符串对象,数据库键对应的值可以是字符串对象、列表对象(list object)、 哈希对象(hash object)、集合对象(set object)、有序集合对象 (sorted set object)这五种对象中的其中一种
。【也可以说,这五种对象就是唯一的字符串对象的键对应的不同的值的类型】
同一数据类型会根据键的数量和值的大小也有不同的底层编码类型实现
。在 Redis 2.2 版本之后,存储集合数据(Hash、List、Set、SortedSet)在满足某些情况下会采用内存压缩技术来实现使用更少的内存存储更多的数据。当这些集合中的数据元素数量小于某个值且元素的值占用的字节大小小于某个值的时候,存储的数据会用非常节省内存的方式进行编码,理论上至少节省 10 倍以上内存(平均节省 5 倍以上)
【所以我们需要尽可能地控制集合元素数量和每个元素的内存大小,这样能充分利用紧凑型编码减少内存占用
。】
对一种数据类型实现多种不同编码方式主要原因是想通过不同编码实现效率和空间的平衡
【比如当我们的存储只有100个元素的列表,当使用双向链表数据结构时,需要维护大量的内部字段。比如每个元素需要:前置指针,后置指针,数据指针等,造成空间浪费,如果采用连续内存结构的压缩列表(ziplist),将会节省大量内存,而由于数据长度较小,存取操作时间复杂度即使为O(n) 性能也相差不大,因为 n 值小 与 O(1) 并明显差别。】该属性记录了对象最后一次被命令程序访问的时间
:(OBJECT IDLETIME命令可以打印出给定键的空转时长,这一空转 时长就是通过将当前时间减去键的值对象的lru时间计算得出的:)
Redis中的五个对象中的每个对象都由一个redisObject结构表示,该结构中和保存数据有关的三个属性分别是type属性、encoding属性和ptr属性
:(使用OBJECT ENCODING命令可以查看一个数据库键的值对象的编码)对象的type属性:记录了对象的数据库的键所对应的值类型,键对应的值对应的的对象类型有五种
对象的ptr指针指向对象的底层实现数据结构
,而这些数据结构由对象的encoding属性决定encoding属性记录了对象所使用的编码
,也即是说这个对象使用了什么数据结构作为对象的底层实现
极大地提升了Redis的灵活性和效率
,因为Redis可以根据不同的使用场景来为一个对象设置不同的编码,从而优化对象在某一场景下的效率。
对一种数据类型实现多种不同编码方式是想通过不同编码实现效率和空间的平衡
。】/* The actual Redis Object */
/*
* Redis 对象
*/
#define REDIS_LRU_BITS 24
#define REDIS_LRU_CLOCK_MAX ((1<<REDIS_LRU_BITS)-1) /* Max value of obj->lru */
#define REDIS_LRU_CLOCK_RESOLUTION 1000 /* LRU clock resolution in ms */
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 对象最后一次被访问的时间
unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */
// 引用计数
int refcount;
// 指向实际值的指针,也就是指向底层实现数据结构的指针
void *ptr;
} robj;
常用的5种对象系统具体如下:
PART1-1.五种对象系统之字符串对象(string)
:
Redis没有直接使用C语言的char*字符数组
来实现字符串【因为你C语言的char*数组有一些缺点人家Redis接受不了】,而是自己封装了一个名为简单动态字符串(SDS)的数据结构来表示字符串对象
Redis 的 SDS 不光可以保存文本数据还可以保存二进制数据,并且获取字符串长度复杂度为 O(1)
(C 字符串为 O(N)),除此之外,Redis 的 SDS API 是安全的,不会造成缓冲区溢出
数据库键总是一个唯一的字符串对象,不同的是数据库键对应的值是不同的对象,五种中的一种对象而已
】,值可以是字符串、数字(包括整数和浮点数)【value最多可以容纳的数据长度是512M】
并且这个字符串值的长度大于32字节
,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串值,并将对象的编码设置为raw。【字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串,并将对象的编码设置为raw】(如下图举例:如果我们执行以下命令,那么服务器将创建一个如图所示的raw编码的字符串对象作为story键的值:SET story “Long, long ago there lived a king …”)小于等于32字节
,那么字符串对象将使用embstr编码**的方式来保存这个字符串值。【字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串,对象的编码是embstr】调用两次内存分配函数
来分别创建redisObject结构和 sdshdr结构,而embstr编码则通过调用一次内存分配函数
来分配一块连续的
空间,空间中依次包含redisObject和sdshdr两个结构。)embstr编码的字符串对象在执行命令时,产生的效果和raw编码的字符串对象执行命令时产生的效果是相同的,但使用embstr编码的字符串对象来保存短字符串值有以下好处
:
创建字符串对象所需的内存分配次数
从raw编码的两次降低为一次
。embstr也是有缺点的
:如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,所以embstr编码的字符串对象实际上是只读的,redis没有为embstr编码的字符串对象编写任何相应的修改程序。当我们对embstr编码的字符串对象执行任何修改命令(例如append)时,程序会先将对象的编码从embstr转换成raw,然后再执行修改命令
。用long double类型表示的浮点数在Redis中也是作为字符串值来保存的
。如果我们要保存一个浮点数到字符串对象里面,那么程序会先将这个浮点数转换成字符串值,然后再保存转换所得的字符串值。redis可以支持编码转换,是为了二进制安全只取字节流,为了避免二进制截断溢出【不同客户端语言对同一类型的定义或者理解不一样】,也就是数据被破坏
。一般序列化后面都跟一个编解码器,也为了避免二进制截断溢出,也就是数据被破坏】
键和值在底层都是由SDS实现的
。【(redis中的SDS,叫简单动态字符串,Simple Dynamic String),在内存中以字节数组的形式存在,类似于ArrayList,SDS是Redis的默认字符串表示。】
因为SDS在len属性中记录了SDS本身的长度,所以获取一个SDS长度的复杂度仅为O(1),因为我只需要访问SDS的len属性就可以立即知道SDS的长度为5Byte
。
设置和更新SDS长度的工作是由SDS的API在执行时自动完成的
, 咱们使用SDS无须进行任何手动修改长度的工作。而一旦这个假定不成立,就会发生缓冲区溢出将可能会造成程序运行终止,这不就出错了嘛
),SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作。如果空间足够的话,API就会直接使用未使用空间,而无须执行内存重分配
,所以使用SDS既不需要手动修改SDS的空间大小,也不会出现前面所说的缓冲区溢出问题。
SDS 不仅可以保存文本数据,还可以保存二进制数据
。因为 SDS 使用 len 属性的值而不是空字符来判断字符串是否结束,并且 SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在 buf[] 数组里的数据。所以 SDS 不光能存放文本数据,而且能保存图片、音频、视频、压缩文件这样的二进制数据。
因为内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较耗时的操作
),因为C字符串中需要额外的一个字符空间用于保存空字符,你得重新分配内存从而保证这种关联关系)。在SDS中,buf数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就由SDS的free属性记录
。 通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略
。
程序不仅会为 SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间
。
redis中的字符串是一个带长度信息的字节数组
SDS来保存之前提到的特殊数据格式就没有任何问题, 因为SDS使用len属性的值而不是空字符来判断字符串是否结束
。
用一行
来表示,每行的第一个格子buf[i]表示这是buf数组的哪个字节,而buf[i]之后的八个格子则分别代表这一字节中的八个位。 需要注意的是,buf数组保存位数组的顺序和我们平时书写位数组的顺序是完全相反的,例如,在上图的buf[0]字节中,各个位的值分别是1、0、1、1、0、0、1、0,这表示buf[0]字节保存的位数组为01001101。使用逆序来保存位数组可以简化SETBIT命令的实现/*
* 每个sds.h/sdshdr结构表示一个SDS值:保存字符串对象的结构
*/
struct sdshdr {
// buf中已占用空间的长度(记录buf数组中已使用字节的数量),等于SDS所保存字符串的长度
int len;
// buf中剩余可用空间的长度,记录buf数组中未使用字节的数量,free属性的值为0,表示这个SDS没有分配任何未使用空间
int free;
// 数据空间,字节数组,用于保存字符串
char buf[];
};
...
struct SDS<T> {
T capacity; // 表示所分配数组的长度
T len; // 表示字符串的实际长度
byte flags; // 特殊标识位,不理睬它
byte[] content; // 存储真正的字符串内容
}
字符串对象的应用场景
:
需要计数的场景
,比如被用来统计用户的访问次数【页面单位时间的访问数】
、热点文章的点赞转发数量
、粉丝数
等,简单的分布式锁也会用到该类型
缓存 session、token、图片地址、序列化后的对象(相比较于 Hash 存储更节省内存):SET、GET
】
在绝大部分情况,我们建议使用 String 来存储对象数据即可
String 存储的是序列化后的对象数据,存放的是整个对象
。Hash 是对对象的每个字段单独存储,可以获取部分字段的信息,也可以修改或者添加部分字段,节省网络流量。如果对象中某些字段需要经常变动或者经常需要单独查询对象中的个别字段信息,Hash 就非常适合
SETNX key value
命令可以实现一个最简易的分布式锁;会使用 Session 来保存用户的会话(登录)状态
,这些 Session 信息会被保存在服务器端,但这只适用于单系统应用,如果是分布式系统
此模式将不再适用。
PART1-2.五种对象系统之列表对象(list)
:类似于LinkedList
简单的字符串列表
,按照插入顺序排序,可以从头部或尾部向 List 列表添加元素。列表的最大长度为 2^32 - 1,也即每个列表支持超过 40 亿个元素
】【 许多高级编程语言都内置了链表的实现比如 Java 中的 LinkedList,但是 C 语言并没有实现链表,所以 Redis 实现了自己的链表数据结构。人家Redis也很跟随时代潮流呀,你们都有我也跟
。Redis 的 List 的实现为一个 双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销
。】列表的元素个数小于 512 个
(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节
(默认值,可由 list-max-ziplist-value 配置),Redis 会使用压缩列表
作为 List对象类型的底层数据结构;
每个压缩列表节点(entry)保存了一个列表元素
。struct ziplist<T> {
int32 zlbytes; // 整个压缩列表占用字节数
int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点,然后就可以倒着遍历(压缩列表是为了支持双向遍历所以才有的ztail_offset这个字段)
int16 zllength; // 元素个数
T[] entries; // 元素内容列表,挨个挨个紧凑存储,entry 块随着容纳的元素类型不同,也会有不一样的结构。
int8 zlend; // 标志压缩列表的结束,值恒为 0xFF
}
struct entry {
int<var> prevlen; // 表示前一个 entry 的字节长度,当压缩列表倒着遍历时需要通过这个字段来快速定位到下一个元素的位置。
int<var> encoding; // 存储元素内容的编码类型信息,ziplist 通过这个字段来决定后面的 content 内容的形式
optional byte[] content; // 元素内容, 定义为optional 类型,表示这个字段是可选的
}
当一个列表键只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现
。【在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素。】压缩列表的总长
):在对压缩列表进行内存重分配,或者计算zlend的位置时使用记录压缩列表表尾节点距离压缩列表的起始地址有多少字节:通过这个偏移量
,程序无须遍历整个压缩列表就可以确定表尾节点的地址
这表示如果我们有一个指向压缩列表起始地址的指针p,那么只要用指针p加上偏移量60,就可以计算出表尾节点entry2的地址。
特殊值 0xFF (十进制255),用于标记压缩列表的末端
压缩列表是Redis为了节约内存而开发的
,是由一系列特殊编码的连续内存块组成的顺序型(sequential)数据结构。一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值
。前一个节点的长度
(程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。)。【也可以说压缩列表里的每个节点中的 prevlen 属性都记录了前一个节点的长度,而且 prevlen 属性的空间大小跟前一个节点长度值有关】。previous_entry_length属性的长度可以是1字节或者5 字节:
压缩列表的从表尾向表头遍历操作就是使用这一原理实现的
,只要我们拥有了一个指向某个节点起始地址的指针,那么通过这个指针以及这个节点的previous_entry_length属性,程序就可以一直向前一个节点回溯,最终到达压缩列表的表头节点类型以及长度
:【encoding 属性的空间大小跟数据是字符串还是整数,以及字符串的长度有关:】
当我们往压缩列表中插入数据时,压缩列表就会根据数据是字符串还是整数,以及数据的大小,会使用不同空间大小的 prevlen 和 encoding 这两个元素里保存的信息,这种根据数据大小和类型进行不同的空间大小分配的设计思想
,正是 Redis 为了节省内存而采用的。
压缩列表新增某个元素或修改某个元素
时,如果空间不不够,压缩列表占用的内存空间就需要重新分配
。而当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起连锁更新问题,导致每个元素的空间都要重新分配
,造成访问压缩列表性能的下降。双向链表
作为 List 对象类型的底层数据结构;【列表键的底层实现之一就是链表。当一个列表键包含了数量比较多的元素,又或者列表中包含的元素都是比较长的字符串时,Redis就会使用链表作为列表键的底层实现。】
/*
* 双端链表节点
*/
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
/*
* 双端链表结构
*/
typedef struct list {
// 表头节点,也就是表头指针head
listNode *head;
// 表尾节点,表尾指针tail
listNode *tail;
/**
*dup、free和match成员则是用于实现多态链表所需的类型特定函数:
*/
// 节点值复制函数,dup函数用于复制链表节点所保存的值
void *(*dup)(void *ptr);
// 节点值释放函数,free函数用于释放链表节点所保存的值
void (*free)(void *ptr);
// 节点值对比函数,match函数则用于对比链表节点所保存的值和另一个输入值是否相等
int (*match)(void *ptr, void *key);
// 链表所包含的节点数量,链表长度计数器len
unsigned long len;
} list;
在 Redis 3.0 之前,List 对象的底层数据结构是双向链表或者压缩列表。在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 【快速链表quicklist:】实现了,替代了双向链表和压缩列表
。
Redis 底层存储的不是一个简单的 linkedlist ,而是称之为快速链表 quicklist 的一个结构。(数据量比较多的时候才会改成 quicklist (quicklist是ziplist和linkedlist的混合体
,Redis 有时候可以将链表和 ziplist 结合起来组成了 quicklist
,也就是将多个 ziplist 使用双向指针串起来使用
。这样既满足了快速的插入删除性能,又不会出现太大的空间冗余。它将 linkedlist 按段切分,每一段使用 ziplist 来紧凑存储,多个 ziplist 之间使用双向指针串接起来
。)。因为普通的链表需要的附加指针空间太大,会比较浪费空间,而且会加重内存的碎片化)【或者这样说,其实 quicklist 就是双向链表 + 压缩列表组合,因为一个 quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表
。】
虽然压缩列表是通过紧凑型的内存布局节省了内存开销,但是因为它的结构设计,如果保存的元素数量增加,或者元素变大了,压缩列表会有「连锁更新」的风险,一旦发生,会造成性能下降
。【quicklist 解决办法,通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。因为压缩列表元素越少或越小,连锁更新带来的影响就越小,从而提供了更好的访问性能
。】
quicklist的实现:quicklist 的结构体跟链表的结构体类似,都包含了表头和表尾,区别在于 quicklist 的节点是 quicklistNode。【quicklistNode 结构体里包含了前一个节点和下一个节点指针,这样每个 quicklistNode 形成了一个双向链表。但是链表节点的元素不再是单纯保存元素值,而是保存了一个压缩列表,所以 quicklistNode 结构体里有个指向压缩列表的指针 *zl
。】
typedef struct quicklist {
//quicklist的链表头
quicklistNode *head; //quicklist的链表头
//quicklist的链表头
quicklistNode *tail;
//所有压缩列表中的总元素个数
unsigned long count;
//quicklistNodes的个数
unsigned long len;
...
} quicklist;
//quicklistNode 的结构定义:
typedef struct quicklistNode {
//前一个quicklistNode
struct quicklistNode *prev; //前一个quicklistNode
//下一个quicklistNode
struct quicklistNode *next; //后一个quicklistNode
//quicklistNode指向的压缩列表
unsigned char *zl;
//压缩列表的的字节大小
unsigned int sz;
//压缩列表的元素个数
unsigned int count : 16; //ziplist中的元素个数
....
} quicklistNode;
列表对象的value是一个map
,key肯定不用说就是一个字符串对象喽
通过 LRANGE 命令可以基于 List 实现分页查询,性能非常高
!LPUSH、LRANGE
。PART1-3.五种对象系统之哈希对象(hash)(字典)
:
如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构
;】,每当有新的键值对要加入到哈希对象时,程序会先将保存了键的压缩列表节点推入到压缩列表表尾
,然后再将保存了值的压缩列表节点推入到压缩列表表尾
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了
Redis 在 5.0 新设计一个数据结构叫 listpack,目的是替代压缩列表,它最大特点是 listpack 中每个节点不再包含前一个节点的长度了,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患
。用一块连续的内存空间来紧凑地保存数据
,并且为了节省内存的开销,listpack 节点会采用不同的编码方式保存不同大小的数据
。【listpack 没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度
,当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题
】hashtable编码的哈希对象使用字典作为底层实现
,哈希对象中的每个键值对都使用一个字典键值对来保存。【如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的底层数据结构
。或者说当一个哈希键包含的键值对比较多,又或者键值对中的元素都是比较长的字符串时,Redis就会使用字典作为哈希键的底层实现。】
哈希表的最大好处就是让我们可以用 O(1) 的时间复杂度来快速查找到键值对
Redis的字典使用哈希表作为底层实现,一个哈希表里面可以有多个哈希表节点,而每个哈希表节点就保存了字典中的一个键值对。内部实现结构上同 Java 的 HashMap 也是一致的,同样的数组 + 链表二维结构。第一维hash的数组位置碰撞时,就会将碰撞的元素使用链表串接起来
【Redis 采用了「链式哈希」来解决哈希冲突,在不扩容哈希表的前提下,将具有相同哈希值的数据串起来,形成链接起,以便这些数据在表中仍然可以被查询到
。】多个哈希表节点可以用next指针构成一个单向链表
,被分配到同一个索引上的多个节点可以用这个单向链表连接起来
,这就解决了键冲突的问题。要想解决这一个问题就需要进行rehash,也就是对哈希表的大小进行扩展
。当字典被用作数据库的底层实现,或者哈希键的底层实现时, Redis使用MurmurHash2算法来计算键的哈希值。这种算法的优点在于,即使输入的键是有规律的,算法仍能给出一个很好的随机分布性,并且算法的计算速度也非常快。
Redis 的字典的值只能是字符串
,另外它们rehash的方式不一样,因为 Java 的 HashMap在字典很大时,rehash 是个耗时的操作,需要一次性全部 rehash。Redis 为了高性能,不能堵塞服务,所以采用了 渐进式 rehash 策略
为了让哈希表的负载因子(load factor)维持在一个合理的范围之内
,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩,而扩展和收缩哈希表的工作可以通过执行rehash(重新散列)操作来完成。
当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作
。当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作
将保存在ht[0]中的所有键值对rehash到ht[1]上面
:rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上, 从而避免了集中式rehash而带来的庞大计算量
。),循序渐进地将旧 hash 的内容一点点迁移到新的 hash 结构中。当搬迁完成了,就会使用新的hash结构取而代之。每次对字典执行添加、删除、查找或者更新操作时
,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在 rehashidx索引上的所有键值对rehash到ht[1]
,当rehash工作完成之后**,程序将rehashidx属性的值增一**。
要在字典里面查找一个键的话,程序会先在ht[0]里面进行查找,如果没找到的话,就会继续到ht[1]里面进行查找
,诸如此类。新添加到字典的键值对一律会被保存到ht[1]里面
,而ht[0]则不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增,并随着rehash操作的执行而最终变成空表最终在某个时间点上,ht[0]的所有键值对都会被rehash至ht[1]
,这时程序将rehashidx属性的值设为-1,表示rehash操作已完成。Redis的数据库就是使用字典来作为底层实现的
,对数据库的增、删、查、改操作也是构建在对字典的操作之上的。/*
- 哈希表:Redis 的哈希表结构
- - 每个字典都使用两个哈希表,从而实现渐进式 rehash 。
*/
typedef struct dictht {
// 哈希表数组。dictht中的table属性是一个数组,数组中的每个元素都是一个指向 dict.h/dictEntry结构的指针,每个dictEntry结构保存着一个键值对。
dictEntry **table;//哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针。
// 哈希表大小,也即是table数组的大小,
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1。sizemask属性的值总是等于 size-1,这个属性和哈希值一起决定一个键应该被放到table数组的哪个索引上面。
unsigned long sizemask;
// 该哈希表已有节点的数量。used属性则记录了哈希表目前已有节点(键值对)的数量
unsigned long used;
} dictht;
...
struct RedisDb {
dict* dict; // all keys key=>value
dict* expires; // all expired keys key=>long(timestamp)
...
}
struct zset {
dict *dict; // all values value=>score
zskiplist *zsl;
}
...
/*
* 哈希表节点。dictht中的table属性是一个数组,数组中的每个元素都是一个指向 dict.h/dictEntry结构的指针,每个dictEntry结构保存着一个键值对。
* dictEntry 结构里不仅包含指向键和值的指针,还包含了指向下一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对链接起来,以此来解决哈希冲突的问题,这就是链式哈希。
*/
typedef struct dictEntry {
// 键。key属性保存着键值对中的键,而v属性则保存着键值对中的值,其中键值对的值可以是一个指针,或者是一个uint64_t整数,又或者是一个int64_t整数。
void *key;
// 值。而v属性则保存着键值对中的值,其中键值对的值可以是一个指针,或者是一个uint64_t整数,又或者是一个int64_t整数。
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表。next属性是指向另一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对连接在一起,也就是拉链法,以此来解决键冲突(collision)的问题。
struct dictEntry *next;
} dictEntry;
/*
* 字典
*/
typedef struct dict {
/**
*type属性和privdata属性是针对不同类型的键值对,为创建多态字典 而设置的:
*/
// 类型特定函数。type属性是一个指向dictType结构的指针,每个dictType结构保存了一簇用于操作特定类型键值对的函数,Redis会为用途不同的字典设置 不同的类型特定函数。
dictType *type;
// 私有数据。而privdata属性则保存了需要传给那些类型特定函数的可选参数
void *privdata;
// 哈希表。ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht[0]哈希表进行rehash时使用
dictht ht[2];
// rehash 索引
// 当rehash不在进行时,值为-1。除了ht[1]之外,另一个和rehash有关的属性就是rehashidx,它记录了rehash目前的进度,如果目前没有在进行rehash,那么它的值为-1。
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
// 目前正在运行的安全迭代器的数量
int iterators; /* number of iterators currently running */
} dict;
/*
* 字典类型特定函数
*/
typedef struct dictType {
// 计算哈希值的函数
unsigned int (*hashFunction)(const void *key);
// 复制键的函数
void *(*keyDup)(void *privdata, const void *key);
// 复制值的函数
void *(*valDup)(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);
} dictType;
相关命令 :HSET (设置单个字段的值)、HMSET(设置多个字段的值)、HGET(获取单个字段的值)、HMGET(获取多个字段的值)
PART1-4.五种对象系统之集合对象(set)
:类似于HashSet
Set 类型除了支持集合内的增删改查,同时还支持多个集合取交集、并集、差集
。当一个 Set 对象只包含整数值元素,并且元素数量不大时,就会使用整数集这个数据结构作为底层实现】intset编码的集合对象使用整数集合作为底层实现
【如果集合中的元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构
】,集合对象包含的所有元素都被保存在整数集合里面。typedef struct intset {
// 编码方式.虽然intset结构将contents属性声明为int8_t类型的数组,但实际上 contents数组并不保存任何int8_t类型的值,contents数组的真正类型取决 于encoding属性的值:如果encoding属性的值为INTSET_ENC_INT16,那么contents就是一个int16_t类型的数组,数组里的每个项都是一个int16_t类型的整数值(最小值为-32768,最大值为32767)。如果encoding属性的值为INTSET_ENC_INT32,那么contents就是一个int32_t类型的数组,数组里的每个项都是一个int32_t类型的整数值(最小值为-2147483648,最大值为2147483647)。如果encoding属性的值为INTSET_ENC_INT64,那么contents就是一个int64_t类型的数组,数组里的每个项都是一个int64_t类型的整数值(最小值为-9223372036854775808,最大值为9223372036854775807)。
uint32_t encoding;
// 集合包含的元素数量.length属性记录了整数集合包含的元素数量,也即是contents数组的长度。
uint32_t length;
// 保存元素的数组.contents数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项(item),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。
int8_t contents[];
} intset;
集合对象的底层编码或者叫做底层数据结构是整数集合,而这个整数集合保存元素的容器是一个 contents 数组
,虽然 contents 被声明为 int8_t 类型的数组,但是实际上 contents 数组并不保存任何 int8_t 类型的元素,contents 数组的真正类型取决于 intset 结构体里的 encoding 属性的值【不同类型的 contents 数组,意味着数组的大小也会不同】
。比如:
新元素的类型(int32_t)比整数集合现有所有元素的类型(int16_t)都要长时
】,整数集合需要先进行升级
(upgrade)(每次升级都需要对底层数组中已有的所有元素进行类型转换,所以向整数集合添加新元素的时间复杂度为O(N)),然后才能将新元素添加到整数集合里面。【也就是按新元素的类型(int32_t)扩展 contents 数组的空间大小,然后才能将新元素加入到整数集合里,当然升级的过程中,也要维持整数集合的有序性
。】
整数集合升级的过程不会重新分配一个新类型的数组,而是在原本的数组上扩展空间,然后在将每个元素按间隔类型大小分割
,如果 encoding 属性值为 INTSET_ENC_INT16,则每个元素的间隔就是 16 位。升级整数集合
并添加新元素共分为三步进行:
转换
成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变。简单做法就是直接使用 int64_t 类型的数组
。不过这样的话,当如果元素都是 int16_t 类型的,就会造成内存浪费的情况
。【整数集合升级就能避免这种情况
,如果一直向整数集合添加 int16_t 类型的元素,那么整数集合的底层实现就一直是用 int16_t 类型的数组,只有在我们要将 int32_t 类型或 int64_t 类型的元素添加到集合时,才会对数组进行升级操作
。】hashtable编码的集合对象使用字典作为底层实现
,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素, 而字典的值则全部被设置为NULL。因为集合对象不能有重复元素
,所以来于数据不能重复的场景、无序、多个数据源交集和并集的场景
数据去重和保障数据的唯一性
,还可以用来统计多个集合的交集、错集和并集
等,当我们存储的数据是无序并且需要去重的情况下,比较适合使用集合类型进行存储
。
在数据量较大的情况下,如果直接执行这些计算,会导致 Redis 实例阻塞
。【在主从集群中,为了避免主库因为 Set 做聚合计算(交集、差集、并集)时导致主库被阻塞,我们可以选择一个从库完成聚合统计
,或者把数据返回给客户端,由客户端来完成聚合统计】
Set 类型可以保证一个用户只能点一个赞
可以基于 Set 轻易实现交集、并集、差集的操作:共同好友(交集)、共同粉丝(交集)、共同关注(交集)、好友推荐(差集)、音乐推荐(差集) 、订阅号推荐(差集+交集) 等场景。相关命令:SINTER(交集)、SINTERSTORE (交集)、SUNION (并集)、SUNIONSTORE(并集)、SDIFF(差集)、SDIFFSTORE (差集)
】,所以可以用来计算共同关注的好友、公众号等。【key 可以是用户id,value 则是已关注的公众号的id。】Set 类型因为有去重功能,可以保证同一个用户不会中奖两次
。key为抽奖活动名,value为员工名称,将所有员工名称放入抽奖箱中PART.1-5.五种对象系统之有序集合对象(zset(有序列表))
:
Zset 类型(有序集合对象类型)相比于 Set 对象类型多了一个排序属性 score(分值)或者说权重参数 score
,对于有序集合 ZSet 来说,每个存储元素相当于有两个值组成的,一个是有序集合的元素值,一个是排序值或者叫分值。这个有序集合的元素值虽说不能重复但是排序值或者叫分值可以重复(使得集合中的元素能够按 score 进行有序排列,还可以通过 score 的范围来获取元素的列表。有点像是 Java 中 HashMap 和 TreeSet 的结合体)
】压缩列表ziplist
作为底层实现【如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构】,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的成员(member),而第二个元素则保存元素的分值(score)
。
skiplist编码的有序集合对象使用zset结构作为底层实现【如果有序集合的元素不满足元素个数小于 128 个,并且每个元素的值小于 64 字节的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构】,一个zset结构同时包含一个字典和一个跳跃表
:Redis 只有在 Zset 有序集合对象的底层实现用到了跳表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找
。
同时使用了两个数据结构
来实现的 Redis 对象,这两个数据结构一个是跳表,一个是哈希表
。这样的好处是 既能进行高效的范围查询,也能进行高效单点查询
。
这是因为它的数据结构设计采用了跳表,而又能以常数复杂度获取元素权重(如 ZSCORE 操作)【这是因为它同时采用了哈希表进行索引】
。/* ZSETs use a specialized version of Skiplists */
/*
* 跳跃表节点。zskiplistNode结构用于表示跳跃表节点,
*/
typedef struct zskiplistNode {
// 成员对象.跳跃表节点的object属性保存了元素的成员
robj *obj;
// 分值.跳跃表节点的score属性则保存了元素的分值
double score;
// 后退指针。每个跳表节点都有一个后向指针,指向前一个节点,目的是为了方便从跳表的尾节点开始访问节点,这样倒序查找时很方便。
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
/*
* 跳跃表。而zskiplist结构则用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等等。
*/
typedef struct zskiplist {
// 跳表的表头节点和表尾节点,便于在O(1)时间复杂度内访问跳表的头节点和尾节点;
struct zskiplistNode *header, *tail;
// 表中节点的数量,或者说跳表的长度,便于在O(1)时间复杂度获取跳表节点的数量;
unsigned long length;
// 表中层数最大的节点的层数,便于在O(1)时间复杂度获取跳表中层高最大的那个节点的层数量
int level;
} zskiplist;
/*
* 有序集合
*/
typedef struct zset {
// 字典,键为成员,值为分值。zset结构中的dict字典为有序集合创建了一个从成员到分值的映射,字典中的每个键值对都保存了一个集合元素:字典的键保存了元素的成员,而字典的值则保存了元素的分值。通过这个字典,程序可以用O(1)复杂度查找给定成员的分值
// 用于支持 O(1) 复杂度的按成员取分值操作
dict *dict;
// 跳跃表,按分值排序成员.zset结构中的zsl跳跃表按分值从小到大保存了所有集合元素,每个 跳跃表节点都保存了一个集合元素,通过这个跳跃表,程序可以对有序集合进行范围型操作
// 用于支持平均复杂度为 O(log N) 的按分值定位成员操作
// 以及范围操作
zskiplist *zsl;
} zset;
在每个节点中维持多个指向其他节点的指针【跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表,这样的好处是能快读定位数据。跳表是一个带有层级关系的链表,而且每一层级可以包含多个节点,每一个节点通过指针连接起来,实现这一特性就是靠跳表节点结构体中的zskiplistLevel 结构体类型的 level 数组。】
,从而达到快速访问节点的目的。下图是一个跳跃表。并且因为跳跃表的实现比平衡树要来得更为简单,所以有不少程序都使用跳跃表来代替平衡树
。
Redis只在两个地方用到了跳跃表
,除此之外,跳跃表在Redis里面没有其他用途。我个人感觉 skip list相当于是一个三项链表,多了一个level数值,相当于就解决了链表查询的缺点,基本上可以算是从任意一个开始查询到任意一个,也就是牺牲存储空间来提高查询速度,跟树一样一样的。
【skip list,类平衡树,保持平衡】
zskiplistNode结构用于表示跳跃表节点,多个跳跃表节点zskiplistNode就可以组成一个跳跃表。那为什么还需要zskiplist结构呢(是因为通过使用一个zskiplist结构来持有这些节点,程序可以更方便地对整个跳跃表进行处理,比如快速访问跳跃表的表头节点和表尾节点, 或者快速地获取跳跃表节点的数量(也即是跳跃表的长度)等信息)
。
level 数组中的每一个元素代表跳表的一层
,也就是由 zskiplistLevel 结构体表示,比如 leve[0] 就表示第一层,leve[1] 就表示第二层
。zskiplistLevel 结构体里定义了 指向下一个跳表节点的指针和跨度,跨度时用来记录两个节点之间的距离
。】一般来说,层的数量越多,访问其他节点的速度就越快
。 每次创建一个新跳跃表节点的时候,程序都根据幂次定律(power law,越大的数出现的概率越小)随机生成一个介于1和32之间的值作为 level数组的大小,这个大小就是层的“高度”。
为了降低查询复杂度,我们就需要维持相邻层结点数间的关系。跳表的相邻两层的节点数量最理想的比例是 2:1,查找复杂度可以降低到 O(logN)。
。会带来额外的开销
。Redis 则采用一种巧妙的方法是,跳表在创建节点的时候,随机生成每个节点的层数【跳表在创建节点时候,会生成范围为[0-1]的一个随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数。】
,并没有严格维持相邻两层的节点数量比例为 2 : 1 的情况。】。相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64跳跃表中的所有节点都按分值从小到大来排序
。它指向一个字符串对象,而字符串对象则保存着一个SDS值
。zskiplist结构则用于保存跳跃表节点的相关信息,比如节点的数量,以及指向表头节点和表尾节点的指针等等。多个跳跃表节点zskiplistNode就可以组成一个跳跃表。那为什么还需要zskiplist结构呢(是因为通过使用一个zskiplist结构来持有这些节点,程序可以更方便地对整个跳跃表进行处理,比如快速访问跳跃表的表头节点和表尾节点, 或者快速地获取跳跃表节点的数量(也即是跳跃表的长度)等信息
。zskiplist结构包含以下属性:
level属性则用于在O(1)复杂度内获取跳跃表中层高最大的那个节点的层数量
,注意表头节点的层高并不计算在内。【跳表的最大层数,便于在O(1)时间复杂度获取跳表中层高最大的那个节点的层数】跳表会从头节点的最高层开始
,逐一遍历每一层
。在遍历某一层的跳表节点时,会用跳表节点中的 SDS 类型的元素和元素的权重来进行判断
,共有两个判断条件:该层
上的下一个节点。该层
上的下一个节点。下一层
指针,然后沿着下一层
指针继续查找,这就相当于跳到了下一层接着查找
。在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了
但无论单独使用字典还是跳跃表,在性能上对比起同时使用字典和跳跃表都会有所降低
。有序集合经常用于需要对数据根据某个权重进行排序的场景,我们可以自己来决定每个元素的权重值【比如说,我们可以根据元素插入 Sorted Set 的时间确定权重值,先插入的元素权重小,后插入的元素权重大。】。
。比如在直播系统中
,实时排行信息(包括直播间在线用户列表
、各种礼物排行榜
、弹幕消息排行榜
【也就是不同角度下的消息排行榜
】,这些都是数据更新频繁或者需要分页显示
,可以优先考虑使用 Sorted Set)等
相关的一些 Redis 命令: ZRANGE (从小到大排序) 、 ZREVRANGE (从大到小排序)、ZREVRANK (指定元素排名)。
把当前时间戳和延时时间相加,也就是到期时间,存入Redis中,然后不断轮询,找到到期的,拿到再删除即可
。),Zset可以看作是缩小版的redis,可以看作是用来存储键值对的集合,是集合名-K-V的结构,在Zset中,会按照Score进行排序。
zset有一个score值,可以在添加数据的时候,使用zadd把score写成未来某个时刻的unix时间戳,然后按照时间大小进行排序,定时去查询redis的zset队列首部,即可查询到最早过期的数据,进行处理。以此完成延时逻辑
。
HashMap里放的是成员到score的映射
,而跳跃表里存放的是所有的成员
,排序依据是HashMap里存的score,
使用跳跃表的结构可以获得比较高的查找效率,并且在实现上比较简单
。PART1-6:三种特殊数据类型:
主要用于存储地理位置信息。通过 GEO 我们可以轻松实现两个位置距离的计算、获取指定位置附近的元素等功能。
,基于 Sorted Set 实现【GEO 中存储的地理位置信息的经纬度数据通过 GeoHash 算法转换成了一个整数,这个整数作为 Sorted Set 的 score(权重参数)使用。】
。】,两地之间的距离。【Redis GEO 是 Redis 3.2 版本新增的数据类型,主要用于存储地理位置信息
,并对存储的信息进行操作。】
HyperLogLog 提供不精确的去重计数
。基数:数学上集合的元素个数,是不能重复的。这个数据结构常用于统计网站的 UV(独立访客,00:00-24:00内相同的客户端只被计算一次。)。
基数统计就是指统计一个集合中不重复的元素个数
。但要注意,HyperLogLog 是统计规则是基于概率完成的,不是非常准确,标准误算率是 0.81%
。
在输入元素的数量或者体积非常非常大时,计算基数所需的内存空间总是固定的、并且是很小的
。在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基数
,和元素越多就越耗费内存的 Set 和 Hash 类型相比,HyperLogLog 就非常节省空间。
相关命令 :PFADD、PFCOUNT
PFADD PAGE_1:UV USER1 USER2 ...... USERn
PFCOUNT PAGE_1:UV
通过最小的单位 bit 来进行0或者1的设置
,bitmaps是一串连续的只有0和1的二进制数组,可以通过偏移量(offset)定位元素
,表示某个元素对应的值或者状态。一个 bit 的值,或者是0,或者是1;也就是说一个 bit 能存储的最多信息是2。
Bitmap 看作是一个 bit 数组
。】非常节省空间【在记录海量数据时,Bitmap 能够有效地节省内存空间】
,特别适合一些数据量大且使用二值统计的场景
,比如用户签到情况、活跃用户情况、用户行为统计(比如是否点赞过某个视频)。相关命令 :SETBIT、GETBIT、BITCOUNT、BITOP。
签到统计时,每个用户一天的签到用 1 个 bit 位就能表示,一个月(假设是 31 天)的签到情况用 31 个 bit 位就可以,而一年的签到也只需要用 365 个 bit 位,根本不用太复杂的集合类型
。】
返回数据表示 Bitmap 中第一个值为 bitValue 的 offset 位置
。】可以通过可选的 start 参数和 end 参数指定要检测的范围。 命令将检测整个位图。setbit这个命令
来统计用户登陆天数,且窗口随机,随机窗口就指得是这个长条bitmap数组。之前用数据库【如果用数据库的话,还要读磁盘,多慢呀对吧】,要建一个数据库表,记录用户名、登录时间等等。而redis用50个字节可以记录某个用户全年的登陆状态01.....01总共400个代表第一天第二天...第400天,为1就代表你登陆为0代表你没登陆。
消息队列的实现方式都有着各自的缺陷【基于以下问题,Redis 5.0 便推出了 Stream 类型也是此版本最重要的功能,用于完美地实现消息队列,它支持消息的持久化、支持自动生成全局唯一 ID、支持 ack 确认消息的模式、支持消费组模式等,让消息队列更加的稳定和可靠】
,例如:
发布订阅模式,不能持久化也就无法可靠的保存消息
,对于离线重连的客户端不能读取历史消息的缺陷
;List 实现消息队列的方式不能重复消费
,一个消息消费完就会被删除,生产者需要自行实现全局唯一 ID
。PART2:
容器型数据结构的通用规则
过期时间:Redis 所有的数据结构都可以设置过期时间,时间到了,Redis 会自动删除相应的对象。需要注意的是过期是以对象为单位,比如一个 hash 结构的过期是整个 hash 对象的过期,而不是其中的某个子 key。还有一个需要特别注意的地方是如果一个字符串已经设置了过期时间,然后你调用了 set 方法修改了它,它的过期时间会消失。
类型检查与命令多态:
其中一种命令可以对任何类型的键执行
,比如说DEL命令、 EXPIRE命令、RENAME命令、TYPE命令、OBJECT命令等:
在执行一个类型特定的命令之前, Redis会先检查输入键的类型是否正确,然后再决定是否执行给定的命令
。
通过redisObject结构的type属性
**来实现的:
还会根据值对象的编码方式,选择正确的命令实现代码来执行命令
。
内存回收:因为C语言并不具备自动内存回收功能,所以Redis在自己的对象系统中构建了一个引用计数(reference counting)技术实现的内存回收机制,通过这一机制,程序可以通过跟踪对象的引用计数信息,在适当的时候自动释放对象并进行内存回收。
/* The actual Redis Object */
/*
- Redis 对象
*/
#define REDIS_LRU_BITS 24
#define REDIS_LRU_CLOCK_MAX ((1<<REDIS_LRU_BITS)-1) /* Max value of obj->lru */
#define REDIS_LRU_CLOCK_RESOLUTION 1000 /* LRU clock resolution in ms */
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 对象最后一次被访问的时间
unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */
// 引用计数
int refcount;
// 指向实际值的指针
void *ptr;
} robj;
Redis会在初始化服务器时,创建一万个字符串对象, 这些对象包含了从0到9999的所有整数值,当服务器需要用到值为0到 9999的字符串对象时,服务器就会使用这些共享对象,而不是新创建对象
。
巨人的肩膀:
https://www.javalearn.cn
B站各位大佬
高性能MySQL
mysql技术内幕
小林coding
Redis设计与实现