Redis设计原理
1.简介
Redis中的每个Key-Value在内存中都会被划分成DictEntry以及代表Key和Value的对象。
DictEntry包含分别指向Key和Value对象的指针以及指向下一个DictEntry的指针。
Redis使用RedisObject来表示对象,由于Key固定是字符串类型,因此使用字符串对象来表示,Value可以是字符串、列表、哈希、集合、有序集合对象中的一种。
Redis使用redisObject结构来表示对象(存储对象的相关信息)
typedef struct redisObject {
unsigned type;
unsigned encoding;
unsigned lru;
int refcount;
void *ptr;
}robj;
type属性:存储对象的类型(String、List、Hash、Set、ZSet中的一种)
encoding属性:存储对象使用的编码方式,不同的编码方式使用不同的数据结构进行存储。
lru属性:存储对象最后一次被访问的时间。
refcount属性:存储对象被引用的次数。
*ptr指针:指向对象的地址。
使用type命令可以查看对象的类型。
使用object encoding命令可以查看对象使用的编码方式。
使用object idletime命令可以查看对象的空闲时间(即多久没有被访问)
使用object refcount命令可以查看对象被引用的次数。
*这些命令都是通过Key找到对应的DictEntry,再从DictEntry的*value指针所指的RedisObject中进行获取。
2.字符串
Redis使用sdshdr结构来表示字符串对象,并没有直接使用C语言的字符串。
struct sdshdr {
int len;
int free;
char buf[];
};
len属性:存储字符串的长度。
free属性:存储字节数组中未使用的字节数量。
buf[]属性:字节数组,用于存储字符。
*字节数组中会有\0结束符,该结束符不会记录在len属性中。
SDS相比C语言的字符串
C语言中存储字符串的字节数组其长度总是N+1(最后一个是结束符),当对字符串进行增长和缩短操作时需要使用内存重分配来重新为对象分配内存。
为了减少内存重分配的次数,Redis自定义了字符串对象(sdshdr),通过未使用的空间解除了字符串长度与底层数组长度之间的关系,在SDS中buf数组的长度不一定等于字符串的长度+1,数组里面还可以包含未使用的字节。
通过未使用的空间,SDS实现了空间预分配和惰性空间释放两种策略,从而减少由于字符串的修改导致内存重分配的次数。
空间预分配:当需要对SDS保存的字符串进行增长操作时,程序除了会为SDS分配所必须的空间以外,还会为SDS分配额外的未使用的空间。
惰性空间释放:当需要对SDS保存的字符串进行缩短操作时,程序并不会立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些多出来的字节数量记录出来,等待将来使用。
3.字典
字典是Redis数据库以及HashTable编码方式的底层实现。
字典的底层使用散列,同时使用链地址法的方式解决散列冲突,那么最终就是指针数组的形式,数组中的每个元素都是一个指向DictEntry的指针。
Redis使用dictht结构来表示散列表
typedef struct dictht {
dictEntry **table;
unsigned long size;
unsigned long sizemask;
unsigned long used;
}dictht;
table指针:指向散列表的地址。
size属性:存储散列表的大小。
sizemask属性:用于计算索引值。
used属性:散列表中节点的个数。
Redis使用dictEntry结构来表示散列表中的节点
typedef struct dictEntry {
void *key;
union{
void *val;
uint_tu64;
int64_ts64;
}v
struct dictEntry next*;
}dictEntry;
key指针:指向Key对象。
value属性:可以是指向Value对象(指针)、uint64_t整数、int64_t整数。
next指针:指向下一个DictEntry。
Redis使用dict结构来表示字典,每个字典中包含两个dictht。
typedef struct dict{
dictType *type;
void *privatedata;
dictht ht[2];
int rehashidx;
}dict;
type指针:指向DictType,DictType定义了一系列函数。 privatadata属性:传给特定函数的可选参数。 ht数组:长度为2的dictht数组,一般情况下只会使用ht[0]散列表,ht[1]散列表只会在对ht[0]散列表进行rehash时使用。 rehashidx属性:记录rehash的进度,如果目前没有进行rehash那么值为-1。
dictType的结构(定义了一系列函数)
typedef struct dictType{ unsigned int (*hashFunction)(const void *key); // H(K)散列函数 void *(*keyDup)(void *privatedata, const void *key); // 复制Key void *(*valDup)(void *privatedata, const void *obj); // 复制Value int (*keyCompare)(void *privatdata, const void *key1 , const void *key2); // 对比Key void (*keyDestructor)(void *privatedata, void *key); // 销毁Key void (*valDestructor)(void *privatedata, void *obj); // 销毁Value }dictType;
3.1 在字典中进行查找、添加、更新、删除操作
在字典中进行查找
以客户端传递的Key作为关键字K,通过dict中的dictType的H(K)散列函数计算散列值,使用dictht[0]的sizemask属性和散列值计算索引,然后遍历索引对应的链表,如果存在Key相同的DictEntry则直接返回,否则返回NULL。
在字典中进行添加和更新
以客户端传递的Key作为关键字K,通过dict中的dictType的H(K)散列函数计算散列值,使用dictht[0]的sizemask属性和散列值计算索引,然后遍历索引对应的链表,如果存在Key相同的DictEntry则进行更新,否则创建代表Key和Value的对象,然后创建一个DictEntry并使其分别指向Key和Value的对象,最终将该DictEntry追加到链表的末尾。
在字典中进行删除(查到后进行删除)
以客户端传递的Key作为关键字K,通过dict中的dictType的H(K)散列函数计算散列值,使用dictht[0]的sizemask属性和散列值计算索引,然后遍历索引对应的链表,如果存在Key相同的DictEntry则进行删除。
3.2 散列表的扩容和缩容
由于散列表的负载因子需要维持在一个合理的范围内,因此当散列表中的元素过多时会进行扩容,过少时会进行缩容。
一旦散列表的长度发生改变,那么就要进行rehash,即对原先散列表中的元素在新的散列表中重新进行hash。
Redis中的rehash是渐进式的,并不是一次性完成,因为出于性能的考虑,如果散列表中包含上百万个节点,如果一次性完成rehash的话,那么有可能导致Redis在一定时间内无法正常对外提供服务。
在rehash进行期间,每次对字典执行查找、添加、更新、删除操作时,除了会执行相应的操作以外,还会顺带的将ht[0]散列表在rehashidx索引上的所有节点rehash到ht[1]上,然后将rehashidx属性的值加1。
渐进式Rehash的步骤
1.为字典的ht[1]散列表分配空间。
*若执行的是扩容操作,那么ht[1]的长度为第一个大于等于ht[0].used*2的2ⁿ。
*若执行的是缩容操作,那么ht[1]的长度为第一个大于等于ht[0].used的2ⁿ。
2.rehashidx属性设置为0,表示开始进行rehash。
3.在rehash进行期间,每次对字典执行查找、添加、更新、删除操作时,除了会执行相应的操作以外,还会顺带将ht[0]散列表在rehashidx索引上的所有节点rehash到ht[1]上,然后将rehashidx属性的值加1。
4.随着对字典的不断操作,最终在某个时刻,ht[0]散列表中的所有节点都会被rehash到ht[1]上,此时将rehashidx属性设置为-1,表示rehash已结束。
*在进行渐进式rehash的过程中,字典会同时使用ht[0]和ht[1]两个散列表,因此字典的查找、更新、删除操作会在两个散列表中进行,如果在ht[0]计算得到的索引指向NULL则从ht[1]中进行查找。
4.Redis提供的编码方式
Redis提供了八种编码方式,每种编码方式都有其特定的数据结构。
redis_encoding_int // 整数字符串 redis_encoding_embstr // 短字符串 redis_encoding_row // 长字符串 redis_encoding_ziplist // 压缩列表 redis_encoding_linkedlist // 链表 redis_encoding_intset // 整数集合 redis_encoding_hashtable // hashTable redis_encoding_skiplist // 跳跃表
1.INT编码方式
INT编码方式会将RedisObject中的*ptr指针直接改写成long ptr,ptr属性直接存储字面量。
2.EMBSTR编码方式
3.ROW编码方式
*EMBSTR和ROW编码方式在内存中都会创建字符串对象(SDS),区别在于EMBSTR编码方式中RedisObject和SDS共同使用同一块内存单元,Redis内存分配器只需要分配一次内存,而ROW编码方式中需要单独的为RedisObject和SDS分配内存单元。
4.ZIPLIST编码方式
压缩列表是Redis为了节约内存而开发的,它是一块顺序表(顺序存储结构,内存空间连续),一个压缩列表中可以包含多个entry节点,每个entry节点可以保存一个整数值或者字符串。
zlbytes:记录了压缩列表的大小(占4个字节)
zltail:记录了压缩列表最后一个节点距离起始位置的大小(占4个字节)
zllen:记录了压缩列节点的个数(占2个字节)
entry:压缩列表中的节点(大小由节点中存储的内容所决定)
zlend:压缩列表的结束标志(占1个字节)
如果存在一个指针P指向压缩列表的起始位置,就可以根据P+zltail得到最后一个节点的地址。
5.LINKEDLIST编码方式
Redis使用listNode结构来表示链表中的节点。
typedef struct listNode {
struct listNode *prev;
struct listNode *next;
void *value;
}listNode;
每个listNode节点分别包含指向前驱和后继节点的指针以及指向元素的指针。
Redis使用list结构来持有listNode
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;
head指针:指向链表的头节点。
tail指针:指向链表的最后一个节点。
len属性:存储链表中节点的个数。
6.INTSET编码方式
Redis使用intset结构来表示整数集合。
typedef struct inset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
}intset;
encoding属性:标识contents数组的类型,支持INTESET_ENC_INT16、INTESET_ENC_INT32、INTESET_ENC_INT64。
length属性:存储整数集合中元素的个数。
contents数组:存储整数集合中的元素(从小到大进行排序,并且保证元素不会重复)
Contents升级
当往整数集合中添加一个比当前Contents数组类型还要大的元素时,将要进行Contents的升级。
1.对Contents数组进行扩容( (length + 1) * 新类型的大小)
2.将原有的元素转换成与新元素相同的类型,然后移动到正确的位置上。
3.将新元素添加到数组当中。
4.将encoding属性修改为新元素的类型。
*contents数组不支持降级,一旦对contents数组进行了升级那么就要一直保持升级后的状态。
7.HASHTABLE编码方式
8.SKIPLIST编码方式
通过在每个节点中维护多个指向其他节点的指针,从而达到快速访问的目的。
Redis使用zskiplistNode结构来表示跳跃表中的节点.
typedef struct zskiplistNode {
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned int span;
}level[];
struct zskiplistNode *backward;
double score;
robj *obj;
}zskiplistNode
level[]数组:用于存储zskiplistLevel,每个zskiplistLevel都包含forward和span属性,其中forward属性用于指向表尾方向的其他节点,而span属性则记录了forward指针所指向的节点距离当前节点的跨度(forward指针遵循同层连接的原则)
backward属性:指向上一个节点的指针。
score属性:存储元素的分数。
obj指针:指向元素的地址(字符串对象)
每次创建一个新的跳跃表节点时,会随机生成一个介于1到32之间的值作为level数组的大小。
Redis使用zskiplist结构来持有zskiplistNode
typedef struct zskiplist {
struct zskiplistNode *header,*tail;
unsigned long length;
int level;
}zskiplist;
header指针:指向跳跃表的头节点。
tail指针:指向跳跃表的最后一个节点。
length属性:存储跳跃表中节点的个数(不包括表头节点)
level属性:跳跃表中节点level的最大值(不包括表头节点)
*跳跃表中存在表头节点,表头节点一共有32个level,即数组的大小为32。
遍历zskiplist的流程
1.通过zskiplist的header指针访问跳跃表中的头节点。
2.从下一个节点最高的level开始往下遍历,若下一个节点的最高level比当前节点的最高level要大,则从当前节点的最高level开始往下遍历。
3.当不存在下一个节点时,遍历结束。
5.Redis中的对象
Redis中一共包含五种对象,分别是字符串对象、列表对象、哈希对象、集合对象、有序集合对象,每种对象都支持多种编码方式,不同的编码方式之间使用不同的数据结构进行存储。
Redis各个对象支持的编码方式
1.字符串对象
字符串对象支持INT、EMBSTR、ROW三种编码方式。
INT编码方式
如果字符串的值是整数,同时可以使用long来进行表示,那么Redis将会使用INT编码方式。
INT编码方式会将RedisObject中的*ptr指针直接改写成long ptr,ptr属性直接存储字面量。
EMBSTR编码方式
如果字符串的值是字符,同时字符串的大小小于32个字节,那么Redis将会使用EMBSTR编码方式。
ROW编码方式
如果字符串的值是字符,同时字符串的大小大于32个字节,那么Redis将会使用ROW编码方式。
*EMBSTR和ROW编码方式在内存中都会创建字符串对象(SDS),区别在于EMBSTR编码方式中RedisObject和SDS共同使用同一块内存单元,Redis内存分配器只需要分配一次内存,而ROW编码方式需要单独的为RedisObject和SDS分配内存单元。
编码转换
如果字符串的值不再是整数或者用long无法进行表示,那么INT编码方式将会转换成ROW编码方式。
如果字符串的值其大小大于32个字节,那么EMBSTR编码方式将会转换成ROW编码方式。
*INT编码方式不能转换成EMBSTR编码方式。
字符串共享对象
Redis在启动时会初始化值为0~9999的字符串对象作为共享对象,当set一个Key其Value是在0~9999范围时,会直接使用该共享对象,DictEntry中的value指针直接指向该共享的字符串对象。
在集群模式中,Redis的每个节点启动时都会初始化值为0~9999的字符串对象作为共享对象。
在RedisV4.0以上,使用Object refcount命令不再返回共享对象实际被引用的次数,而是直接返回Integer.MAX_VALUE。
2.列表对象
列表对象支持ZIPLIST、LINKEDLIST两种编码方式。
ZIPLIST编码方式
如果列表对象保存的元素的数量少于512个,同时每个元素的大小都小于64个字节,那么Redis将会使用ZIPLIST编码方式。
LINKEDLIST编码方式
如果列表对象保存的元素的数量多于512个,或者元素的大小大于64个字节,那么Redis将会使用LINKEDLIST编码方式。
编码转换
如果列表对象保存的元素的数量多于512个,或者元素的大小大于64个字节,那么Redis将会使用LINKEDLIST编码方式。
可以通过list-max-ziplist-entries和list-max-ziplist-value参数,调整列表对象ZIPLIST编码方式中最多可以保存元素的个数以及每个元素的最大大小。
3.哈希对象
哈希对象支持ZIPLIST和HASHTABLE两种编码方式。
ZIPLIST编码方式
如果哈希对象保存的键值对的数量少于512个,同时每个键值对中的键和值的大小都小于64个字节,那么Redis将会使用ZIPLIST编码方式。
HASHTABLE编码方式
如果哈希对象保存的键值对的数量多于512个,或者键值对中的键或值的大小大于64个字节,那么Redis将会使用HASHTABLE编码方式。
编码转换
如果哈希对象保存的键值对的数量多于512个,或者键或值中的键和值的字符串的大小大于64个字节,那么Redis将会使用HASHTABLE编码方式。
可以通过hash-max-ziplist-entries和hash-max-ziplist-value参数,调整哈希对象ZIPLIST编码方式中最多可以保存元素的个数以及每个键值对中的键和值的字符串的最大大小。
4.集合对象
集合对象支持INTSET和HASHTABLE两种编码方式。
INTSET编码方式
如果集合对象保存的元素的数量少于512个,同时每个元素都是整数,那么Redis将会使用INTSET编码方式。
HASHTABLE编码方式
如果集合对象保存的元素的数量多于512个,或者元素不是整数,那么Redis将会使用HASHTABLE编码方式。
编码转换
如果集合对象保存的元素的数量多于512个,或者元素不是整数,那么Redis将会使用HASHTABLE编码方式。
可以通过set-max-intset-entries参数,调整集合对象INTSET编码方式中最多可以保存元素的个数。
5.有序集合对象
有序集合对象支持ZIPLIST和SKIPLIST两种编码方式。
ZIPLIST编码方式
如果有序集合对象保存的元素的数量少于128个,同时每个元素的大小都小于64个字节,那么Redis将会使用ZIPLIST编码方式。
SKIPLIST编码方式
如果有序集合对象保存的元素的数量多于128个,或者元素的大小大于64个字节,那么Redis将会使用SKIPLIST编码方式。
为了在有序集合中可以快速的进行查找以及范围操作,因此有序集合会同时使用HashTable和跳表两种结构来进行维护,HashTable中的键存储的是元素本身,值存储的是分数,当需要进行查找时,可以以O(1)的时间复杂度获取到元素的分数,当需要进行范围型操作时,从跳表进行操作。
编码转换
如果有序集合对象保存的元素的数量多于128个,或者元素的大小大于64个字节,那么Redis将会使用SKIPLIST编码方式。
可以通过zset-max-ziplist-entries和zset-max-ziplist-value参数,调整有序集合对象ZIPLIST编码方式中最多可以保存元素的个数以及每个元素的最大大小。
6.Redis内存分配器
Redis提供了jemalloc、libc、tcmalloc内存分配器,默认使用jemalloc,需要在编译时指定。
Jemalloc内存分配器
jemalloc内存分配器将内存划分为小、大、巨大三个范围,每个范围又包含多个大小不同的内存单元。
*DictEntry、RedisObject以及对象在初始化时,Redis内存分配器都会分配一个合适的内存大小。
*如果频繁修改value,且value的大小相差很大,那么有可能会导致已分配的内存空间不足,那么redis内存分配器需要重新为对象分配内存,然后释放掉对象之前所占用的内存(不是立即释放,此时属于内存碎片,等待Redis的内存清除策略)
7.Redis内存监控
可以使用info memory命令查看Redis内存的使用情况
used_memory:redis有效数据占用的内存大小(包括使用的虚拟内存) uesd_memory_rss:redis有效数据占用的内存大小(不包括使用的虚拟内存)、redis进程所占用的内存大小、内存碎片(与TOP命令查看的内存一直) mem_fragmentation_ratio(内存碎片率) = used_memory_rss / used_memory ,由于一般不会使用虚拟内存,同时redis进程所占用的内存相对使用的内存来说很少,因此这个比例可以认为是内存碎片率。 mem_allocator:redis内存分配器,可选jemalloc(默认)、libc、tcmalloc
*max_memory配置的是Redis有效数据最大可以使用的内存,由于存在内存碎片,因此Redis实际占用的内存大小最终一定会比max_memory要大。
关于内存碎片率
mem_fragmentation_ratio = used_memory_rss / used_memory ;
当内存碎片率 < 1时,表示redis正在使用虚拟内存,当内存碎片率严重 > 1,表示redis存在大量的内存碎片。
*内存碎片率在1~1.1之间是比较健康的状态。
产生内存碎片的原因
1.如果频繁修改value,且value的大小相差很大,那么有可能导致已分配的内存大小不足,那么redis内存分配器需要重新为对象分配内存,之前的内存空间就属于内存碎片。
2.过期和被删除的Key,先会被标记,此时属于内存碎片,然后等待Redis的内存清除策略回收(惰性或主动)
3.大key。
解决内存碎片的方法
1.重启Redis服务,会自动读取RDB文件进行数据的恢复,重新为对象分配内存。
2.Redis4.0提供了清除内存碎片的功能
#自动清除
activedefrag yes
#手动执行命令清除
memory purge
8.Redis监视器
客户端向服务器发送命令请求时,服务器除了会执行相应的命令以外,还会将关于这条命令的相关信息转发给各个监视器。
客户端可以通过执行monitor命令让自己变成一个监视器,实时接收服务器当前正在执行的命令的相关信息。