Redis数据结构
1、redis特点
redis是一个key-value内存数据库,key和value的最大长度限制是512M,有以下特点:
(1)性能极高,读的速度是110000次/s,写的速度是81000次/s。
(2)支持数据的持久化。
(3)支持丰富的数据类型。
(4)支持数据备份(master-slave)。
(5)所有单个操作都是原子的,多操作支持事务。
(6)其他高级特性:发布与订阅、二进制位数组、慢日志查询等。
CentOS7 安装 Redis 单实例 https://gper.club/articles/7e7e7f7ff7g5egc4g6b
Docker 安装Redis 集群 https://gper.club/articles/7e7e7f7ff7g5egc5g6c
2、redis数据结构与数据对象
redis底层实现了很多中数据结构,如:简单动态字符串(SDS)、链表、字典(dict)、跳跃表、整数集合、压缩列表(ziplist)。redis并不是直接使用这些数据结构来存储键值对,而是基于这些数据结构实现了一个对象系统,包括字符串对象、列表对象、哈希对象、集合对象、有序集合对象五中类型。
使用对象的好处:
1)根据对象类型,可以直接判断是否可以执行给定的命令。
2)针对不同的使用场景,可以根据对象的类型动态地选择存储结构和可以使用的命令,实现节省空间和优化查询速度。
3)redis实现了基于引用计数的内存回收机制,当程序不再引用某个对象的时候,这个对象就会被回收。
4)对象共享机制,在适当的条件下,让多个键共享一个对象来节约内存。
5)redis对象带有访问时间记录信息,用于计算键的空转时长,如果服务器启用了maxmemory功能,空转时长较大的键可能会优先被服务器删除。
redis中对象的定义:redisObject
typedef struct redisObject{ unsigned type:4;/* 对象的类型,包括:REDIS_STRING、REDIS_LIST、REDIS_HASH、REDIS_SET、REDIS_ZSET*/ unsigned encoding:4;/* 具体的数据结构 */ unsigned lru:LRU_BITS;/*24 位,对象最后一次被命令程序访问的时间,与内存回收有关 */ int refcount;/* 引用计数。当 refcount 为 0 的时候,表示该对象已经不被任何对象引用,则可以进行垃圾回收了 */ void *ptr;/* 指向对象实际的数据结构 */ }robj;
对象类型和编码:
3、redis数据库底层数据模型
redis数据库使用字典作为底层实现,字典dict的定义:
typedef struct dict{ dictType *type;/* 字典类型 */ void *privdata;/* 私有数据 */ dicththt[2];/* 一个字典有两个哈希表 */ long rehashidx;/*rehash 索引 */ unsigned long iterators;/* 当前正在使用的迭代器数量 */ }dict;
字典底层是通过哈希表(hashtable)实现的,字典中hashtable的定义:
typedef struct dictht{ //哈希表数组 dictEntry **table; //哈希表大小 unsigned long size; //哈希表大小掩码,用于计算索引值,总是等于size-1 unsigned long sizemask; //该哈希表已有节点的数量 unsigned long used; }dictht;
哈希表中节点的定义为dictEntry,每个哈希表节点(dictEntry)保存了一个键值对,dictEntry的定义:
typedef struct dictEntry{ void*key;/*key 关键字定义 */ union{ void*val; uint64_tu64;/*value 定义 */ int64_ts64; doubled; }v; struct dictEntry*next;/* 指向下一个键值对节点 */
}dictEntry;
在向字典中插入新的键值对时,先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希节点(dictEntry)放到哈希表数组的指定索引上面。
字典存储结构:从最底层到最高层 dictEntry——dictht——dict。
dict会定义两个哈希表,默认使用 ht[0],ht[1]不会初始化和分配空间,其用于扩展与收缩。
* 解决键冲突
当有两个以上的键被分配到哈希表数组的同一个索引上面时,就会发生键冲突。redis的哈希表使用链地址法(separate chaining)来解决键冲突(类似于hashMap),每个dictEntry都有一个 next 指针,多个 dictEntry 节点通过 next 指针构成一个单向链表,被分配到同一个索引上的dictEntry 以链表的方式连接起来。
* 哈希表的扩展和收缩
哈希表的扩展与收缩取决于哈希表的负载因子,负载因子定义:
load_factor = ht[0].used / ht[0].size , 即 负载因子 = 哈希表已保存的节点数量 / 哈希表大小
当以下条件中的任意一个被满足时,程序会自动对哈希表进行扩展操作:
1)服务器目前没有在执行BGSAVE或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1;
2)服务器目前正在执行BGSAVE或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。
因为在执行BGSAVE或者BGREWRITEAOF的过程中,redis需要创建子进程,为了提高子进程的使用率,服务器会提高执行扩展所需的负载因子,尽量避免子进程存在期间进行哈希表的扩展操作。(大多数操作系统会使用写时复制技术来优化子进程的使用,此时扩展数据转移会增加不必要的内存写入)
当哈希表的负载因子小于0.1时,程序会自动对哈希表进行收缩。
* 扩展与收缩的步骤
扩展与收缩哈希表通过rehash(重新散列)操作来完成。步骤如下:
1)为字典的 ht[1] 哈希表分配空间,空间大小取决于要执行的操作,以及 ht[0] 当前包含的键值对数量(即 ht[0].used 属性值)
如果执行扩展操作,那么 ht[1] 的大小为第一个大于等于 ht[0].used * 2 的2n;(如 ht[0].used = 3,ht[1] 的大小就是8,因为8是大于等于6的第一个2的3次幂)
如果执行收缩操作,那么 ht[1] 的大小为第一个大于等于 ht[0].used 的2n。
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做准备。
4、数据类型(数据对象)
4.1、String字符串
(1)存储类型
可以用来存储字符串、整数、浮点数。
(2)实现原理
key是字符串,redis并没有直接使用C 的字符数组,而是存储在自定义的SDS(简单动态字符串)中。value则存储在redisObject中。内部编码如下:
字符串类型的内部编码有三种:
1)int,存储8个字节的长整型(long,2^63-1)。
2)embstr,代表embstr格式的SDS,存储小于44个字节的字符串。
3)raw,存储大于44个字节的字符串。
* 什么是SDS?
redis中字符串的实现,在3.2以后的版本中,SDS又有多种结果(sds.h):sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64,用于存储不同长度的字符串,分别代表2^5=32byte,2^8=256byte,2^16=65536byte=64KB,2^32byte=4GB。
/*sds.h*/ struct__attribute__ ((__packed__)) sdshdr8{ uint8_tlen;/* 当前字符数组的长度 */ uint8_talloc;/*当前字符数组总共分配的内存大小 */ unsignedcharflags;/* 当前字符数组的属性、用来标识到底是 sdshdr8 还是 sdshdr16 等 */ charbuf[];/* 字符串真正的值 */ };
* redis为什么用SDS实现字符串?
C语言本身没有字符串类型(只能用字符数组char[]实现),但是作为字符串的实现会有很多弊端:
1)使用字符数组必须先给目标变量分配足够的空间,否则可能会溢出。
2)如果要获取字符长度,必须遍历字符数组,时间复杂度是O(n)。
3)C字符串长度的变更会对字符数组做内存重分配。
4)通过从字符串开始到结尾碰到的第一个'\0'来标记字符串的结束,因此不能保存图片、音频、视频、压缩文件等二进制(bytes)保存的内容,二进制不安全。
SDS的特点:
1)不用担心内存溢出问题,如果需要会对SDS进行扩容。
2)获取字符串长度时间复杂度为O(1),因为定义了len属性。
3)通过“空间预分配”( sdsMakeRoomFor)和“惰性空间释放”,防止多次重分配内存。
4)判断是否结束的标志是len属性(它同样以'\0'结尾是因为这样就可以使用C语言中函数库操作字符串的函数了),可以包含'\0'。
* embstr和raw的区别?
embstr 的使用只分配一次内存空间(因为RedisObject 和SDS是连续的), 而 raw需要分配两次内存空间(分别为RedisObject和SDS分配空间)。因此与 raw 相比,embstr 的好处在于创建时少分配一次空间,删除时少释放一次空间,以及对象的所有数据连在一起,寻找方便。而 embstr 的坏处也很明
显,如果字符串的长度增加需要重新分配内存时,整个RedisObject和SDS都需要重新分配空间,因此 Redis 中的 embstr 实现为只读。
* 编码转换
当 int 数 据 不 再 是 整 数 , 或 大 小 超 过 了 long 的 范 围时,自动转化为 embstr;如果embstr编码的字符串进行修改,自动转换为raw类型后再进行修改,因此,只要是修改 embstr 对象,修改后的对象一定是 raw 的,无论是否达到了 44个字节。编码转换是在redis写数据时完成的,是不可逆的(不包
含重新set)。
4.2、Hash哈希
(1)存储类型
包含键值对的无序散列表。value只能是字符串,不能嵌套其他类型(字符串也是唯一可以嵌套的对象类型)。同样是存储字符串,Hash 与 String的主要区别:
1)把所有相关的值聚集到一个key中,节省内存空间
2)只使用一个key,减少key冲突
3)当需要批量获取值的时候,只需要使用一个命令,减少内存/IO/CPU的消耗
Hash不适合的场景:
1)Field不能单独设置过期时间
2)没有bit操作
3)需要考虑数据量分布的问题(value值非常大的时候,无法分布到多个节点)
(2)实现原理
Hash类型可以使用两种数据结构实现:ziplist(压缩列表):REDIS_ENCODING_ZIPLIST,hashtable(哈希表):REDIS_ENCODING_HT。
hashtable是一个数组+链表的结构,在前面已经分析过。
* ziplist压缩列表
ziplist是一个经过特殊编码的双向链表(连续的空间),它不存储指向上一个链表节点和指向下一个链表节点的指针,而是存储上一个节点的长度和当前节点的长度。通过牺牲部分读写性能,来换取高效的内存空间利用率,是一种时间换空间的思想,只用在字段个数少,字段值小的场景里。
1)内部结构与定义
typedef struct zlentry{ unsigned int prevrawlensize;/* 上一个链表节点占用的长度 */ unsigned int prevrawlen; /* 存储上一个链表节点的长度数值所需要的字节数 */ unsigned int lensize; /* 存储当前链表节点长度数值所需要的字节数 */ unsigned int len; /* 当前链表节点占用的长度 */ unsigned int headersize; /* 当前链表节点的头部大小(prevrawlensize+lensize),即非数据域的大小 */ unsigned char encoding; /* 编码方式 */ unsigned char *p; /* 压缩链表以字符串的形式保存,该指针指向当前节点起始位置 */ }zlentry;
2)Hash对象什么时候使用ziplist存储?
当hash对象同时满足以下两个条件的时候,使用ziplist编码:
——所有的键值对的健和值的字符串长度都小于等于64byte(一个英文字母一个字节);
——哈希对象保存的键值对数量小于512个。
一个哈希对象超过配置的阈值(键和值的长度有>64byte,键值对个数>512个)时,会转换成哈希表(hashtable)。
4.3、List列表
(1)存储类型
存储有序的字符串(从左到右),元素可以重复。可以充当队列和栈的角色。如下:
(2)实现原理
3.2版本之前,数据量较小时用ziplist存储,达到临界值时转换为linkedlist进行存储,分别对应REDIS_ENCODING_ZIPLIST和REDIS_ENCODING_LINKEDLIST。
3.2版本之后,统一用quicklist来存储。quicklist为一个双向链表,每个节点都是一个ziplist。
* quicklist(快速列表)
quicklist是ziplist和linkedlist的结合体,外层是一个双向链表(linkedlist),linkedlist中每个节点都是一个压缩列表(ziplist)。定义如下:head和tail指向双向列表的表头和表尾。
typedef struct quicklist { quicklistNode *head; /* 指向双向列表的表头 */ quicklistNode *tail; /* 指向双向列表的表尾 */ unsigned long count; /* 所有的 ziplist 中一共存了多少个元素 */ unsigned long len; /* 双向链表的长度,node 的数量 */ int fill : 16; /* fill factor for individual nodes */ unsigned int compress : 16; /* 压缩深度,0:不压缩; */ } quicklist;
quicklistNode中的*zl指向一个ziplist,一个ziplist可以存放多个元素。
typedef struct quicklistNode { struct quicklistNode *prev; /* 前一个节点 */ struct quicklistNode *next; /* 后一个节点 */ unsigned char *zl; /* 指向实际的 ziplist */ unsigned int sz; /* 当前 ziplist 占用多少字节 */ unsigned int count : 16; /* 当前 ziplist 中存储了多少个元素,占 16bit(下同),最大 65536 个 */ unsigned int encoding : 2; /* 是否采用了 LZF 压缩算法压缩节点,1:RAW 2:LZF */ unsigned int container : 2; /* 2:ziplist,未来可能支持其他结构存储 */ unsigned int recompress : 1; /* 当前 ziplist 是不是已经被解压出来作临时使用 */ unsigned int attempted_compress : 1; /* 测试用 */ unsigned int extra : 10; /* 预留给未来使用 */ } quicklistNode;
4.4、Set集合
(1)存储类型
String类型的无序集合,最大存储数量2^32-1(40亿左右)。
(2)实现原理
redis用 intset(整数集合) 或 hashtable 存储set。如果元素都是整数类型,就用 intset存储。如果不都是整数类型,就用 hashtable(数组+链表)。如果元素数量超过512个,也会用 hashtable存储。
* KV如何存储set集合元素?
key就是元素值,value为null。
4.5、ZSet有序集合
(1)存储类型
sorted set,有序的set,每个元素有个score,score相同时,按照key的ASCII码排序。
(2)实现原理
ZSet使用 ziplist 或 skiplist(跳跃表)+dict存储。同时满足以下条件时才使用ziplist,否则使用 skiplist+dict:
1)元素数量小于128个;
2)所有元素的长度都小于64字节。
在ziplist内部,按照score排序递增来存储,插入的时候要移动后面的元素。
ZSet使用skiplist+dict存储的结构如下:
zset的定义如下:
typedef struct zset{ dict *dict; /* 字典结构 */ zskiplist *zsl; /* 跳跃表 */ }zset;
跳跃表 zskiplist 的定义:
typedef struct zskiplist{ struct zskiplistNode *header, *tail;/* 指向跳跃表的头结点和尾节点 */ unsigned long length; /* 跳跃表的节点数 */ int level;/* 最大的层数 */ }zskiplist;
跳跃表节点 zskiplistNode的定义:
typedef struct zskiplistNode{ sds ele;/*zset 的元素 */ double score;/* 分值 */ struct zskiplistNode*backward;/* 后退指针 */ struct zskiplistLevel{ struct zskiplistNode*forward;/* 前进指针,对应 level 的下一个节点 */ unsigned long span;/* 从当前节点到下一个节点的跨度(跨越的节点数) */ }level[];/* 层 */ }zskiplistNode;
1)跳跃表按score分值从小到大存储元素,可以通过跳跃表对有序集合进行范围型操作,如zrank、zrange就是基于跳跃表API实现的。
2)zset结构中dict为有序集合创建了一个从成员到分值的映射,每个字典的键值对保存了一个元素,键就是元素的成员,值就是元素的分值。通过字典,可以用O(1)复杂度找到给定成员
的分值,如zscore就是基于这个原理。
3)有序集合元素的成员是一个字符串对象,元素的分值是一个double类型的浮点数。
4)虽然zset同时使用skiplist和dict保存了集合元素,但这两种数据结构都会通过指针来共享相同元素的成员和分值,所以不会产生任何重复的成员或分值,也不会因此浪费额外的内存。
* skiplist 跳跃表
先来看一下有序链表:
在这样一个链表中,如果要查找某个数据,那么需要从头开始逐个进行比较,直到找到包含数据的那个节点,或者找到第一个比给定数据大的节点为止(没找到)。也就是说,时间
复杂度为O(n)。同样,当要插入新数据的时候,也要经历同样的查找过程,从而确定插入位置。而二分查找法只适用于有序数组,不适用于链表。
假如每相邻两个节点增加一个指针,让指针指向下下个节点,如下为两层的跳跃表结构:
这样所有新增加的指针连成了一个新的链表,但它包含的节点个数只有原来的一半(上图中是7, 19, 26)。在插入一个数据的时候,决定要放到那一层,取决于一个算法(在redis中
t_zset.c 有一个zslRandomLevel这个方法)。
现在当想查找数据的时候,可以先沿着这个新链表进行查找。当碰到比待查数据大的节点时,再回到原来的链表中的下一层进行查找。比如,想查找23,查找的路径是沿着下图中标红的
指针所指向的方向进行的:
1) 23首先和7比较,再和19比较,比它们都大,继续向后比较。
2) 但23和26比较的时候,比26要小,因此回到下面的链表(原链表),与22比较。
3) 23比22要大,沿下面的指针继续向后和26比较。23比26小,说明待查数据23在原链表中不存在
在这个查找过程中,由于新增加的指针,我们不再需要与链表中每个节点逐个进行比较了。需要比较的节点数大概只有原来的一半。这就是跳跃表。
为什么不用AVL树或者红黑树?因为skiplist更加简单,但是效率是差不多的。
4.6、其他数据结构
(1)BitMap
Bitmap是在字符串类型上面定义的位操作。一个字节由8个二进制位组成。GETBIT 命令用于返回位数组 bitarray
在 offset
偏移量上的二进制位的值:
GETBIT key <offset>
GETBIT 命令的执行过程如下:
1)计算 , byte
值记录了 offset
偏移量指定的二进制位保存在位数组的哪个字节。
2)计算 , bit
值记录了 offset
偏移量指定的二进制位是 byte
字节的第几个二进制位。
3)根据 byte
值和 bit
值, 在位数组 bitarray
中定位 offset
偏移量指定的二进制位, 并返回这个位的值。
如下例子:GETBIT key 10 将执行以下操作:
1) 的值为 1
2) 的值为 3
。
3)定位到 buf[1]
字节上面, 然后取出该字节上的第 3
个二进制位的值。
4)向客户端返回二进制位的值 0
。
命令的执行过程如图所示:
(2)Hyperloglogs
提供了一种不太准确的基数统计方法,比如统计网站的UV,存在一定的误差。
(3)Streams
5.0推出的数据类型。支持多播的可持久化的消息队列,用于实现发布订阅功能,借鉴了kafka的设计。