Redis内存模型

197 阅读25分钟

Redis内存模型

查看Redis内存统计
127.0.0.1:8401> info memory

# Redis分配的内存总量,包括虚拟内存(字节)
used_memory:853216
#分配的内存总量(同上,只不过是另一种形式方式)
used_memory_human:833.22K
#占操作系统的内存,不包括虚拟内存(字节)
used_memory_rss:3407872
#分配的内存总量
used_memory_rss_human:3.25M
#redis的内存消耗峰值(以字节为单位)
used_memory_peak:874072
#(同上,只不过是另一种形式方式)
used_memory_peak_human:853.59K
#使用内存达到峰值内存的百分比,(used_memory / used_memory_peak) *100%
used_memory_peak_perc:97.61%
#Redis为了维护数据集的内部机制所需的内存开销,包括所有客户端输出缓冲区、查询缓冲区、AOF重写缓冲区和主从复制的backlog。
used_memory_overhead:840942
#Redis服务器启动时消耗的内存
used_memory_startup:791144
#数据占用的内存大小,即used_memory - sed_memory_overhead
used_memory_dataset:12274
#数据占用的内存大小的百分比,100%*(used_memory_dataset/(used_memory-used_memory_startup))
used_memory_dataset_perc:19.77%
rss_overhead_bytes:-4861952
#内存碎片比例 如果小于1说明使用了虚拟内存
mem_fragmentation_ratio:4.20
#内存碎片(字节为单位)
mem_fragmentation_bytes:2595680
#redis使用的内存分配器
mem_allocator:jemalloc-5.1.0
used_memory

​ 由Redis内存分配器分配的数据内存缓冲内存的内存总量(单位是字节),包括使用的虚拟内存(即swap)

used_memory_rss

​ 记录的是由操作系统分配的Redis进程内存和Redis内存中无法再被jemalloc分配的内存碎片(单位是字节)。

used_memoryused_memory_rss****的区别:

前者是从Redis角度得到的量,后者是从操作系统角度得到的量。二者之所以有所不同,一方面是

因为内存碎片和Redis进程运行需要占用内存,使得前者可能比后者小,另一方面虚拟内存的存在,使得前者可能比后者大。

​ 由于在实际应用中,Redis的数据量会比较大,此时进程运行占用的内存与Redis数据量和内存碎片相比,都会小得多;因此used_memory_rss和used_memory的比例,便成了衡量Redis内存碎片率的参数;这个参数就是mem_fragmentation_ratio。

mem_allocator

Redis使用的内存分配器,在编译时指定;可以是 libc 、jemalloc或者tcmalloc,默认是jemalloc。

redis 内存划分

​ Redis 使用键值对存储数据,其中的值(对象)包括 5 种类型,即字符串、哈希、列表、集合、有序集合。 这部分占用的内存会统计在 used_memory 中。

​ 这 5 种类型是 Redis 对外提供的,实际上,在 Redis 内部,每种类型可能有 2 种或更多的内部编码实现。

进程

​ Redis 主进程本身运行肯定需要占用内存,如代码、常量池等等;这部分内存大约几M,在大多数生产环境中与 Redis 数据占用的内存相比可以忽略。这部分内存不是由 jemalloc 分配,因此不会统计在 used_memory 中。

缓冲内存

​ 缓冲内存包括客户端缓冲区、复制积压缓冲区、AOF 缓冲区等;其中,客户端缓冲区存储客户端连接的输入输出缓冲;复制积压缓冲区用于部分复制功能;AOF 缓冲区用于在进行 AOF 重写时,保存最近的写入命令。

​ 在了解相应功能之前,不需要知道这些缓冲的细节;这部分内存由 jemalloc 分配,因此会统计在used_memory 中。

内存碎片

​ 内存碎片是 Redis 在分配、回收物理内存过程中产生的。例如,如果对数据的更改频繁,而且数据之间的大小相差很大,可能导致 Redis 释放的空间在物理内存中并没有释放。

​ 但 Redis 又无法有效利用,这就形成了内存碎片,内存碎片不会统计在 used_memory 中

​ 内存碎片的产生与对数据进行的操作、数据的特点等都有关;此外,与使用的内存分配器也有关系:如果内存分配器设计合理,可以尽可能的减少内存碎片的产生。如果 Redis 服务器中的内存碎片已经很大,可以通过安全重启的方式减小内存碎片:因为重启之后,Redis 重新从备份文件中读取数据,在内存中进行重排,为每个数据重新选择合适的内存单元,减小内存碎片。

Redis数据存储的细节

Redis 是一个K-V NOSQL 

五种类型 都是针对K-V中的V的

下图是执行set hello world时,所涉及到的数据模型。

image-20200824175020075

  1. dictEntry:Redis是Key-Value数据库,因此对每个键值对都会有一个dictEntry,里面存储了指向Key和Value的指针;next指向下一个dictEntry,与本Key-Value无关。

  2. Key:图中右上角可见,Key(”hello”)并不是直接以字符串存储,而是存储在SDS结构中。

  3. redisObject:Value(“world”)既不是直接以字符串存储,也不是像Key一样直接存储在SDS中,而是存储在redisObject中。实际上,不论Value是5种类型的哪一种,都是通过redisObject来存储的;而redisObject中的type字段指明了Value对象的类型,ptr字段则指向对象所在的地址。不过可以看出,字符串对象虽然经过了redisObject的包装,但仍然需要通过SDS存储。

  4. jemalloc:无论是DictEntry对象,还是redisObject、SDS对象,都需要内存分配器(如

    jemalloc)分配内存进行存储。

redisObject除了type和ptr字段以外,还有其他字段图中没有给出

jemalloc

Redis在编译时便会指定内存分配器;内存分配器可以是 libc 、jemalloc或者tcmalloc,默认是jemalloc。

​ jemalloc作为Redis的默认内存分配器,在减小内存碎片方面做的相对比较好。jemalloc在64位系统中,将内存空间划分为小、大、巨大三个范围;每个范围内又划分了许多小的内存块单位;当Redis存储数据时,会选择大小最合适的内存块进行存储。

jemalloc划分的内存单元如下图所示:

image-20200824175951256

​ 需要9 16 16-9 =7

​ 这样划分:技能保证充分利用内存空间,又能保证我们的性能使用

例如,如果需要存储大小为130字节的对象,jemalloc会将其放入160字节的内存单元中。

redisObject

​ Redis对象有5种类型;无论是哪种类型,Redis都不会直接存储,而是通过redisObject对象进行存储。

​ redisObject对象非常重要,Redis对象的类型、内部编码、内存回收、共享对象等功能,都需要redisObject支持。

​ Redis中的每个对象都是由如下结构表示(列出了与保存数据有关的三个属性)

{ 
    unsigned type:4;//类型 五种对象类型 
    unsigned encoding:4;//编码 
    void *ptr;//指向底层实现数据结构的指针 
    //... 
    int refcount;//引用计数 
    //... 
    unsigned lru:24;//记录最后一次被命令程序访问的时间 
    //... 
}robj; 
type

​ type字段表示对象的类型,占4个字节;目前包括REDIS_STRING(字符串)、REDIS_LIST (列表)、REDIS_HASH(哈希)、REDIS_SET(集合)、REDIS_ZSET(有序集合)。

当我们执行type命令时,便是通过读取RedisObject的type字段获得对象的类型;如下面所示:

127.0.0.1:8401> set name gc
OK
127.0.0.1:8401> type name
string
127.0.0.1:8401> set age 18
OK
127.0.0.1:8401> type age
string
127.0.0.1:8401> sadd myage age1 age2 age3
(integer) 3
127.0.0.1:8401> type myage
set
encoding

​ encoding表示对象的内部编码,占4个字节。

​ 对于Redis支持的每种类型,都有至少两种内部编码,例如对于字符串,有int、embstr、raw三种编码。通过encoding属性,Redis可以根据不同的使用场景来为对象设置不同的编码,很大程度的提高了Redis的灵活性和效率。

prt

​ ptr指针指向具体的数据,如前面的例子中,set hello world,ptr指向包含字符串world的SDS。

refcount

​ refcount记录的是该对象被引用的次数,类型为整型。refcount的作用,主要在于对象的引用计数和内存回收。

当创建新对象时,refcount初始化为1;当有新程序使用该对象时,refcount加1;当对象不再被

一个新程序使用时,refcount减1;当refcount变为0时,对象占用的内存会被释放。

​ Redis中被多次使用的对象(refcount>1),称为共享对象。Redis为了节省内存,当有一些对象重复出现时,新的程序不会创建新的对象,而是仍然使用原来的对象。这个被重复使用的对象,就是共享对象。目前共享对象仅支持整数值的字符串对象。

共享对象的具体实现

​ Redis的共享对象目前只支持整数值的字符串对象。之所以如此,实际上是对内存和CPU(时间)的平衡:共享对象虽然会降低内存消耗,但是判断两个对象是否相等却需要消耗额外的时间。对于整数值,

​ 判断操作复杂度为O(1);对于普通字符串,判断复杂度为O(n);而对于哈希、列表、集合和有序集合,判断的复杂度为O(n^2)。

​ 虽然共享对象只能是整数值的字符串对象,但是5种类型都可能使用共享对象(如哈希、列表等的元素可以使用)。

​ 就目前的实现来说,Redis服务器在初始化时,会创建10000个字符串对象,值分别是09999的整数值;当Redis需要使用值为09999的字符串对象时,可以直接使用这些共享对象。10000这个数字可以

​ 通过调整参数REDIS_SHARED_INTEGERS(4.0中是OBJ_SHARED_INTEGERS)的值进行改变。共享对象的引用次数可以通过object refcount命令查看,如下图所示。命令执行的结果页佐证了只有0~9999之间的整数会作为共享对象。

127.0.0.1:8401> set k1 9999
OK
127.0.0.1:8401> set k2 9999
OK
127.0.0.1:8401> set k3 9999
OK
#5.0版本以后,如果对象被共享过,则会返回大于1
127.0.0.1:8401> object refcount k1
(integer) 2147483647
127.0.0.1:8401> set k4 100000
OK
#像这个value因为已经大于9999,所以不会被共享,则返回1
127.0.0.1:8401> object refcount k4
(integer) 1
127.0.0.1:8401> 
lru

​ lru记录的是对象最后一次被命令程序访问的时间,占据的比特数不同的版本有所不同(2.6版本占22字节,4.0版本占24字节)。

​ 通过对比lru时间与当前时间,可以计算某个对象的闲置时间;object idletime命令可以显示该闲置时间(单位是秒)。object idletime命令的一个特殊之处在于它不改变对象的lru值。

lru值除了通过object idletime命令打印之外,还与Redis的内存回收有关系:如果Redis打开了

maxmemory选项,且内存回收算法选择的是volatile-lru或allkeys—lru,那么当Redis内存占用

超过maxmemory指定的值时,Redis会优先选择空转时间最长的对象进行释放。

淘汰策略后对象池无效

​ 当开启了lru淘汰策略的时候,共享对象可能会存在回收失败的问题。

是因为。LRU算法需要获取对象最后被访问时间,以便淘汰最长未访问数据每个对象最后访问时间存储在redisObject对象的lru字段。对象共享意味着多个引用共享同一个redisObject,这时lru字段也会被共享,导致无法获取每个对象的最后访问时间。如果没有设置maxmemory,直到内存被用尽Redis也不会触发内存回收,所以共享对象池可以正常工作。

为什么只有整数对象池

​ 首先整数对象池复用的几率最大,其次对象共享的一个关键操作就是判断相等性,Redis之所以只有整数对象池,是因为整数比较算法时间复杂度为O(1),只保留一万个整数为了防止对象池浪费。如果是字符串判断相等性,时间复杂度变为O(n),特别是长字符串更消耗性能(浮点数在Redis内部使用字符串存储)。对于更复杂的数据结构如hash,list等,相等性判断需要O(n 2 )。对于单线程的Redis来说,这样的开销显然不合理,因此Redis只保留整数共享对象池。

SDS k-v

​ Redis是使用了SDS(Simpl Dynamic String)简单动态字符串作为默认字符串的表示。(有点类似于ArrayList)

3.2版本之前的Redis,SDS源代码:

struct sdshdr{ 

//记录buf数组中已使用字节的数量 
//等于 SDS 保存字符串的长度 
int len; 
//记录 buf 数组中未使用字节的数量 
int free; 
//字节数组,用于保存字符串 
char buf[]; 

} 

buf表示字节数组,用来存储字符串;len表示buf已使用的长度,free表示buf未使用的长度。下面是两个例子。

截屏2020-08-24 下午10.29.38

​ 通过SDS的结构可以看出,[buf数组的长度=free+len+1(其中1表示字符串结尾的空字符);所以,一个SDS结构占据的空间为:free所占长度+len所占长度+ buf数组的长度+1=4+4+字符串长度+1=字符串长度+9

3.2版本之后的Redis,SDS源代码:

typedef char *sds; 

struct __attribute__ ((__packed__)) sdshdr5 { // 对应的字符串长度小于 1<<5 32字节
	unsigned char flags; 

	char buf[]; 
};

struct __attribute__ ((__packed__)) sdshdr8 { // 对应的字符串长度小于 1<<8 256
	uint8_t len; /* used */ //目前字符创的长度 用1字节存储
  uint8_t alloc; //已经分配的总长度 用1字节存储
  unsigned char flags; //flag用3bit来标明类型,类型 后续解释,其余5bit目前没有使用 embstr raw
  char buf[]; //柔性数组,以'\0'结尾
};

struct __attribute__ ((__packed__)) sdshdr16 { // 对应的字符串长度小于 1<<16
  uint16_t len; /*已使用长度,用2字节存储*/
  uint16_t alloc; /* 总长度,用2字节存储*/ 
  unsigned char flags; /* 3 lsb of type, 5 unused bits */ 	    char buf[];
};

struct __attribute__ ((__packed__)) sdshdr32 { // 对应的字符串长度小于 1<<32
  uint32_t len; /*已使用长度,用4字节存储*/
  uint32_t alloc; /* 总长度,用4字节存储*/
  unsigned char flags;/* 低3位存储类型, 高5位预留 */
  char buf[];/*柔性数组,存放实际内容*/
};

struct __attribute__ ((__packed__)) sdshdr64 { // 对应的字符串长度小于 1<<64
  uint64_t len; /*已使用长度,用8字节存储*/
  uint64_t alloc; /* 总长度,用8字节存储*/
  unsigned char flags; /* 低3位存储类型, 高5位预留 */
  char buf[];/*柔性数组,存放实际内容*/
};

截屏2020-08-24 下午10.43.33

​ 此外,由于SDS中的buf仍然使用了C字符串(即以’\0’结尾),因此SDS可以使用C字符串库中的部分函数;但是需要注意的是,只有当SDS用来存储文本数据时才可以这样使用,在存储二进制数据时则不行(’\0’不一定是结尾)

SDS 与 C 字符串的比较

SDS 在 C 字符串的基础上加入了 free 和 len 字段,带来了很多好处:

**获取字符串长度上的时间复杂度:**SDS 是 O(1),C 字符串是 O(n)。

**缓冲区溢出:**使用 C 字符串的 API 时,如果字符串长度增加(如 strcat 操作)而忘记重新分配内存,很容易造成缓冲区的溢出。

而 SDS 由于记录了长度,相应的 API 在可能造成缓冲区溢出时会自动重新分配内存,杜绝了缓冲区溢出。

**修改字符串时内存的重分配:**对于 C 字符串,如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。

而对于 SDS,由于可以记录 len 和 free,因此解除了字符串长度和空间数组长度之间的关联,可以在此基础上进行优化。

空间预分配策略(即分配内存时比实际需要的多)使得字符串长度增大时重新分配内存的概率大大减小;惰性空间释放策略使得字符串长度减小时重新分配内存的概率大大减小。

**存取二进制数据:**SDS 可以,C 字符串不可以。因为 C 字符串以空字符作为字符串结束的标识,而对于一些二进制文件(如图片等)。

内容可能包括空字符串,因此 C 字符串无法正确存取;而 SDS 以字符串长度 len 来作为字符串结束标识,因此没有这个问题。

此外,由于 SDS 中的 buf 仍然使用了 C 字符串(即以’0’结尾),因此 SDS 可以使用 C 字符串库中的部分函数。

但是需要注意的是,只有当 SDS 用来存储文本数据时才可以这样使用,在存储二进制数据时则不行(’0’不一定是结尾)。

SDS 与 C 字符串的应用

Redis 在存储对象时,一律使用 SDS 代替 C 字符串。例如 set hello world 命令,hello 和 world 都是以 SDS 的形式存储的。

而 sadd gcage age1 age2 age3 命令,不论是键(“gcage”),还是集合中的元素(“age1”、 “member2”和“age3”),都是以 SDS 的形式存储。

除了存储对象,SDS 还用于存储各种缓冲区。只有在字符串不会改变的情况下,如打印日志时,才会使用 C 字符串。

Redis 的对象类型与内部编码

前面已经说过,Redis 支持 5 种对象类型,而每种结构都有至少两种编码。

Redis的对象类型与内存编码

Redis支持5种对象类型,而每种结构都有至少两种编码;

Redis各种对象类型支持的内部编码如下图所示(只列出重点的):

截屏2020-08-24 下午11.00.19

字符串(SDS)
  1. 概述

    ​ 字符串是最基础的类型,因为所有的键都是字符串类型,且字符串之外的其他几种复杂类型的元素也是字符串。字符串长度不能超过512MB。

  2. 内部编码

    ​ 字符串类型的内部编码有3种,它们的应用场景如下:

    • int: 8个字节的长整型。字符串值是整型时,这个值使用long整型表示。

    • embstr:<=44字节的字符串。embstr与raw都使用redisObject和sds保存数据,区别在于,embstr的使用只分配一次内存空间(因此redisObject和sds是连续的),而raw需要分配两次内存

      空间(分别为redisObject和sds分配空间)。因此与raw相比,embstr的好处在于创建时少分配一次空间,删除时少释放一次空间,以及对象的所有数据连在一起,寻找方便。而embstr的坏处也

      很明显,如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,因此redis中的embstr实现为只读。

    • raw:大于44个字节的字符串

      image-20200825164850350

3.2之后 embstr和raw进行区分的长度,是44;是因为redisObject的长度是16字节,sds的长度

是4+字符串长度;因此当字符串长度是44时,embstr的长度正好是16+4+44 =64,jemalloc正好

可以分配64字节的内存单元。

3.2 之前embstr和raw进行区分的长度,是39,因为redisObject的长度是16字节,sds的长度是

9+字符串长度;因此当字符串长度是39时,embstr的长度正好是16+9+39 =64,jemalloc正好可

以分配64字节的内存单元。

列表
  1. 概述

    列表(list)用来存储多个有序的字符串,每个字符串称为元素;

    一个列表可以存储2^32-1个元素。

    Redis中的列表支持两端插入和弹出,并可以获得指定位置(或范围)的元素,可以充当数组、队列、栈等。

  2. 内部编码

    Redis3.0之前列表的内部编码可以是压缩列表(ziplist)或双端链表(linkedlist)。选择的折中方案

    是两种数据类型的转换,但是在3.2版本之后 因为转换也是个费时且复杂的操作,引入了一种新的数据格式,结合了双向列表linkedlist和ziplist的特点,称之为quicklist。所有的节点都用quicklist存储,省去了到临界条件是的格式转换。

  3. 压缩列表

    压缩列表(ziplist)是列表键和哈希键的底层实现之一。当一个列表只包含少量列表项时,并且每个列表项时小整数值或短字符串,那么Redis会使用压缩列表来做该列表的底层实现。

    压缩列表(ziplist)是Redis为了节省内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构,一个压缩列表可以包含任意多个节点(entry),每个节点可以保存一个字节数组或者一个整数值,放到一个连续内存区。

image-20200825170429202

  • previous_entry_ength: 记录压缩列表前一个字节的长度。

  • encoding:节点的encoding保存的是节点的content的内容类型

  • content:content区域用于保存节点的内容,节点内容类型和长度由encoding决定。

    4.双向链表

双向链表(linkedlist):由一个list结构和多个listNode结构组成;

image-20200825170647501

通过图中可以看出,双端链表同时保存了表头指针和表尾指针,并且每个节点都有指向前和指向后的指针;链表中保存了列表的长度;dup、free和match为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。而链表中每个节点指向的是type为字符串的redisObject。

5.快速列表

简单的说,我们仍旧可以将其看作一个双向列表,但是列表的每个节点都是一个ziplist,其实就是linkedlist和ziplist的结合。quicklist中的每个节点ziplist都能够存储多个数据元素。

Redis3.2开始,列表采用quicklist进行编码。

image-20200825170804273

//32byte 的空间 
typedef struct quicklist { 
// 指向quicklist的头部 
quicklistNode *head;

// 指向quicklist的尾部 
quicklistNode *tail; 

// 列表中所有数据项的个数总和 
unsigned long count; 

// quicklist节点的个数,即ziplist的个数 
unsigned int len; 

// ziplist大小限定,由list-max-ziplist-size给定 
// 表示不用整个int存储fill,而是只用了其中的16位来存储 

int fill : 16; 

// 节点压缩深度设置,由list-compress-depth给定 
unsigned int compress : 16; 

} quicklist; 

typedef struct quicklistNode { 

struct quicklistNode *prev; // 指向上一个ziplist节点 

struct quicklistNode *next; // 指向下一个ziplist节点 

unsigned char *zl; // 数据指针,如果没有被压缩,就指向ziplist结构,反之指向quicklistLZF结构 

unsigned int sz; // 表示指向ziplist结构的总长度(内存占用长度) 

unsigned int count : 16; // 表示ziplist中的数据项个数 

unsigned int encoding : 2; // 编码方式,1--ziplist,2--quicklistLZF 

unsigned int container : 2; // 预留字段,存放数据的方式,1--NONE,2-- 

ziplist 

unsigned int recompress : 1; // 解压标记,当查看一个被压缩的数据时,需要暂时解压,标记此参数为1,之后再重新进行压缩 

unsigned int attempted_compress : 1; // 测试相关 

unsigned int extra : 10; // 扩展字段,暂时没用 

} quicklistNode; 
哈希(压缩列表和哈希表)
  1. 概况

    哈希(作为一种数据结构),不仅是redis对外提供的5种对象类型的一种(与字符串、列表、集合、有序结合并列),也是Redis作为Key-Value数据库所使用的数据结构。为了说明的方便,后面当使用“内层的哈希”时,代表的是redis对外提供的5种对象类型的一种;使用“外层的哈希”代指Redis作为Key-Value数据库所使用的数据结构。

​ 2.内部编码

​ 内层的哈希使用的内部编码可以是压缩列表(ziplist)和哈希表(hashtable)两种; Redis的外层的哈希则只使用了hashtable。

压缩列表前面已介绍。与哈希表相比,压缩列表用于元素个数少、元素长度小的场景;其优势在于集中存储,节省空间;同时,虽然对于元素的操作复杂度也由O(1)变为了O(n),但由于哈希中元素数量较少,因此操作的时间并没有明显劣势。

hashtable:一个hashtable由1个dict结构、2个dictht结构、1个dictEntry指针数组(称为bucket)和多个dictEntry结构组成。

正常情况下(即hashtable没有进行rehash时)各部分关系如下图所示:

image-20200825172105858

dict

一般来说,通过使用dictht和dictEntry结构,便可以实现普通哈希表的功能;但是Redis的实现中,在dictht结构的上层,还有一个dict结构。下面说明dict结构的定义及作用。

dict结构如下:

typedef struct dict{ 

dictType *type; // type里面主要记录了一系列的函数,可以说是规定了一系列的接口 
void *privdata; // privdata保存了需要传递给那些类型特定函数的可选参数 

//两张哈希表 
dictht ht[2];//便于渐进式rehash 

int trehashidx; //rehash 索引,并没有rehash时,值为 -1 

//目前正在运行的安全迭代器的数量 
int iterators; 

} dict; 

​ 3.编码转换

如前所述,Redis中内层的哈希既可能使用哈希表,也可能使用压缩列表。

只有同时满足下面两个条件时,才会使用压缩列表:

  • 哈希中元素数量小于512个;

  • 哈希中所有键值对的键和值字符串长度都小于64字节。

    下面展示了Redis内层的哈希编码转换的特点:

127.0.0.1:8401> hset gchash k1 v1
(integer) 1
127.0.0.1:8401> hset gchash k2 v2
(integer) 1
127.0.0.1:8401> hset gchash k3 v3
(integer) 1
127.0.0.1:8401> object encoding gchash
"ziplist"
#大于64字节时
127.0.0.1:8401> hset gchash k4 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
(integer) 1
127.0.0.1:8401> object encoding gchash
"hashtable"
127.0.0.1:8401> 
集合(整数集合和哈希表)
  1. 概况

    集合(set)与列表类似,都是用来保存多个字符串,但集合与列表有两点不同:集合中的元素是无序的,因此不能通过索引来操作元素;集合中的元素不能有重复。

  2. 内部编码

    集合的内部编码可以是整数集合(intset)或哈希表(hashtable)。

    哈希表前面已经讲过,这里略过不提;需要注意的是,集合在使用哈希表时,值全部被置为null。

    整数集合的结构定义如下:

    typedef struct intset{ 
    	uint32_t encoding;   // 编码方式 
    	uint32_t length;    // 集合包含的元素数量 
    	int8_t contents[]; // 保存元素的数组 
    } intset; 
    

    其中,encoding代表contents中存储内容的类型,虽然contents(存储集合中的元素)是int8_t类型,但实际上其存储的值是int16_t、int32_t或int64_t,具体的类型便是由encoding决定的;length表示元素个数。

    整数集合适用于集合所有元素都是整数且集合元素数量较小的时候,与哈希表相比,整数集合的优势在于集中存储,节省空间;同时,虽然对于元素的操作复杂度也由O(1)变为了O(n),但由于集合数量较少,因此操作的时间并没有明显劣势。

​ 3.编码转换

​ 只有同时满足下面两个条件时,集合才会使用整数集合:

  • 集合中元素数量小于512个。
  • 集合中所有元素都是整数值。

如果有一个条件不满足,则使用哈希表;且编码只可能由整数集合转化为哈希表,反方向则不可能。下面展示了集合编码转换的特点

127.0.0.1:8401> sadd gcset 100 101 102
(integer) 3
127.0.0.1:8401> object encoding gcset
"intset"
127.0.0.1:8401> sadd gcset gcsss
(integer) 1
127.0.0.1:8401> object encoding gchash
"hashtable"
127.0.0.1:8401> 
有序集合(压缩列表和跳跃表)
  1. 概况

    有序集合与集合一样,元素都不能重复;但与集合不同的是,有序集合中的元素是有顺序的。与列表使用索引下标作为排序依据不同,有序集合为每个元素设置一个分数(score)作为排序依据。

  2. 内部编码

    有序集合的内部编码可以是压缩列表(ziplist)或跳跃表(skiplist)。

    跳跃表是一种有序数据结构,通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点

    的目的。除了跳跃表,实现有序数据结构的另一种典型实现是平衡树;大多数情况下,跳跃表的效率可以和平衡树媲美,且跳跃表实现比平衡树简单很多,因此redis中选用跳跃表代替平衡树。跳跃表支持平均O(logN)、最坏O(N)的复杂点进行节点查找,并支持顺序操作。

    [Redis的跳跃表实现由zskiplistzskiplistNode两个结构组成:前者用于保存跳跃表信息(如头结点、尾节点、长度等),后者用于表示跳跃表节点。

    只有同时满足下面两个条件时,才会使用压缩列表:有序集合中元素数量小于128个;有序集合中所有成员长度都不足64****字节。如果有一个条件不满足,则使用跳跃表;且编码只可能由压缩列表转化为跳跃表,反方向则不可能。

image-20200825174503127

到19 普通鏈表 3-6-7-9-12-17-19

跳跃表 21-9-21-17-21-19

Redis 设计优化

估算Redis内存使用量

要估算redis中的数据占据的内存大小,需要对redis的内存模型有比较全面的了解,包括第一节课介绍的hashtable、sds、redisobject、各种对象类型的编码方式等。

下面以最简单的字符串类型来进行说明。

假设有90000个键值对,每个key的长度是12个字节,每个value的长度也是12个字节(且key和value都不是整数);image-20200825175224650

下面来估算这90000个键值对所占用的空间。在估算占据空间之前,首先可以判定字符串类型使用的编码方式:embstr

90000个键值对占据的内存空间主要可以分为两部分:一部分是90000个dictEntry占据的空间;一部分是键值对所需要的bucket空间。

每个dictEntry占据的空间包括:

  1. 一个dictEntry结构,24字节,jemalloc会分配32字节的内存块(64位操作系统下,一个指针8字节,一个dictEntry由三个指针组成)
  2. 一个key,12字节,所以SDS(key)需要12+4=16 个字节([SDS的长度=4+字符串长度),jemalloc会分配16字节的内存块
  3. 一个redisObject,16字节,jemalloc会分配16字节的内存块(4bit+4bit+24bit+4Byte+8Byte=16Byte)
  4. 一个value,12字节,所以SDS(value)需要12+4=16个字节([SDS的长度=4+字符串长度),jemalloc会分配16字节的内存块
  5. 综上,一个dictEntry所占据的空间需要32+16+16+16=80个字节。

bucket空间:

bucket数组的大小为大于90000的最小的2^n,是131072;每个bucket元素(bucket中存储的都是指针元素)为8字节(因为64位系统中指针大小为8字节)。

因此,可以估算出这90000个键值对占据的内存大小为:[9000080 + 1310728 = 8248576

作为对比将key和value的长度由12字节增加到13字节,则对应的SDS变为17个字节,jemalloc会分配32个字节,因此每个dictEntry占用的字节数也由80字节变为112字节。此时估算这90000个键值对占据内存大小为:90000112 + 1310728 = 11128576

优化内存占用

了解redis的内存模型,对优化redis内存占用有很大帮助。下面介绍几种优化场景和方式

  1. 利用jemalloc特性进行优化

    上一小节所讲述的90000个键值便是一个例子。由于jemalloc分配内存时数值是不连续的,因此key/value字符串变化一个字节,可能会引起占用内存很大的变动;在设计时可以利用这一点。

  2. 使用整型长整型

    如果是整型/长整型,Redis会使用int类型(8字节)存储来代替字符串,可以节省更多空间。因此在可以使用长整型/整型代替字符串的场景下,尽量使用长整型/整型。

  3. 共享对象

    利用共享对象,可以减少对象的创建(同时减少了redisObject的创建),节省内存空间。目前redis中的共享对象只包括10000个整数(0-9999);可以通过调整REDIS_SHARED_INTEGERS参数提高共享对象的个数;

例如将REDIS_SHARED_INTEGERS调整到20000,则0-19999之间的对象都可以共享

论坛网站在redis中存储了每个帖子的浏览数,而这些浏览数绝大多数分布在0-20000之间,这时候通过适当增大REDIS_SHARED_INTEGERS参数,便可以利用共享对象节省内存空间。

​ 4.缩短键值对的存储长度

​ 键值对的长度是和性能成反比的,比如我们来做一组写入数据的性能测试,执行结果如下:

image-20200825175617915

从以上数据可以看出,在 key 不变的情况下,value 值越大操作效率越慢,因为 Redis 对于同一种数据类型会使用不同的内部编码进行存储,比如字符串的内部编码就有三种:int(整数编码)、raw(优化内存分配的字符串编码)、embstr(动态字符串编码),这是因为 Redis 的作者是想通过不同编码实现效率和空间的平衡,然而数据量越大使用的内部编码就越复杂,而越是复杂的内部编码存储的性能就越低。

这还只是写入时的速度,当键值对内容较大时,还会带来另外几个问题:

  • 内容越大需要的持久化时间就越长,需要挂起的时间越长,Redis 的性能就会越低;

  • 内容越大在网络上传输的内容就越多,需要的时间就越长,整体的运行速度就越低;

  • 内容越大占用的内存就越多,就会更频繁的触发内存淘汰机制,从而给 Redis 带来了更多的运行

    负担。

因此在保证完整语义的同时,我们要尽量的缩短键值对的存储长度,必要时要对数据进行序列化和压缩再存储,以 Java 为例,序列化我们可以使用 protostuffff 或 kryo,压缩我们可以使用 snappy。