Redis内存模型、底层数据结构、内存优化
工欲善其事必先利其器,在说明Redis内存之前首先说明如何统计Redis使用内存的情况。
在客户端通过redis-cli连接服务器后(后面如无特殊说明,客户端一律使用redis-cli),通过info命令可以查看内存使用情况:
127.0.0.1:6379> info memory
# Memory
#Redis分配的内存总量,包括虚拟内存(字节)
used_memory:853464
#占操作系统的内存,不包括虚拟内存(字节)
used_memory_rss:12247040
#内存碎片比例 如果小于0说明使用了虚拟内存
mem_fragmentation_ratio:15.07
#Redis使用的内存分配器
mem_allocator:jemalloc-5.1.0
其中,info命令可以显示redis服务器的许多信息,包括服务器基本信息、CPU、内存、持久化、客户端连接信息等等;memory是参数,表示只显示内存相关的信息。
返回结果中比较重要的几个说明如下:
Redis分配器分配的内存总量(单位是字节)
,包括使用的虚拟内存(即swap);Redis分配器后面会介绍。used_memory_human只是显示更友好Redis进程占据操作系统的内存(单位是字节)
,与top及ps命令看到的值是一致的;除了分配器分配的内存之外,used_memory_rss还包括进程运行本身需要的内存、内存碎片
等,但是不包括虚拟内存。
内存碎片比率
,该值是used_memory_rss / used_memory
的比值。该值越大,内存碎片比例越大
当这种情况出现时,应该及时排查,如果内存不足应该及时处理,如增加Redis节点、增加Redis服务器的内存、优化应用等
。mem_fragmentation_ratio在1.03左右是比较健康的状态
(对于jemalloc来说);上面截图中的mem_fragmentation_ratio值很大,是因为还没有向Redis中存入数据,Redis进程本身运行的内存使得used_memory_rss 比used_memory大得多。默认是jemalloc
;截图中使用的便是默认的jemalloc。Redis作为内存数据库,在内存中存储的内容主要是数据(键值对);通过前面的叙述可以知道,除了数据以外,Redis的其他部分也会占用内存。
Redis的内存占用主要可以划分为以下几个部分:
作为数据库,数据是最主要的部分;这部分占用的内存会统计在used_memory中
。
默认是jemalloc内存分配器负责分配
。
Redis使用键值对存储数据,其中的值(对象)包括5种类型,即字符串、哈希、列表、集合、有序集合。这5种类型是Redis对外提供的,实际上,在Redis内部,每种类型可能有2种或更多的内部编码实现;此外,Redis在存储对象时,并不是直接将数据扔进内存,而是会对对象进行各种包装:如redisObject、SDS等;这篇文章后面将重点介绍Redis中数据存储的细节。
Redis主进程本身运行肯定需要占用内存,如代码、常量池等等;这部分内存大约几兆,在大多数生产环境中与Redis数据占用的内存相比可以忽略。这部分内存不是由jemalloc分配,因此不会统计在used_memory中
。
补充说明:除了主进程外,Redis创建的子进程运行也会占用内存,如Redis执行AOF、RDB重写时创建的子进程。当然,这部分内存不属于Redis进程,也不会统计在used_memory和used_memory_rss中。
缓冲内存包括客户端缓冲区、复制积压缓冲区、AOF缓冲区等;
在了解相应功能之前,不需要知道这些缓冲的细节;这部分内存由jemalloc分配,因此会统计在used_memory中。
内存碎片是Redis在分配、回收物理内存过程中产生的。
例如,如果对数据的更改频繁,而且数据之间的大小相差很大,可能导致redis释放的空间在物理内存中并没有释放,但redis又无法有效利用,这就形成了内存碎片。内存碎片不会统计在used_memory中。
内存碎片的产生与对数据进行的操作、数据的特点等都有关;此外,与使用的内存分配器也有关系:如果内存分配器设计合理,可以尽可能的减少内存碎片的产生。后面将要说到的jemalloc便在控制内存碎片方面做的很好。
如果Redis服务器中的内存碎片已经很大,可以通过安全重启的方式减小内存碎片:因为重启之后,Redis重新从备份文件中读取数据,在内存中进行重排,为每个数据重新选择合适的内存单元,减小内存碎片。
Redis是一个K-V NoSQL非关系型数据库。
Redis有五种数据类型,分别string类、hash类型、list类型、set类型、zset类型。这五种类型,都是针对K-V中的V进行设计的。
关于Redis数据存储的细节,涉及到内存分配器(如jemalloc)、简单动态字符串(SDS)、5种对象类型及内部编码、redisObject
。在讲述具体内容之前,先说明一下这几个概念之间的关系。
下图是执行set hello world时,所涉及到的数据模型。
存储在SDS结构
中。存储在redisObject中
。实际上,不论Value是5种类型的哪一种,都是通过redisObject来存储的;而redisObject中的type字段指明了Value对象的类型,ptr字段则指向对象所在的地址。不过可以看出,字符串对象虽然经过了redisObject的包装,但仍然需要通过SDS存储。无论是DictEntry对象,还是redisObject、SDS对象,都需要内存分配器(如jemalloc)分配内存进行存储
。以DictEntry对象为例,有3个指针组成,在64位机器下占24个字节,jemalloc会为它分配32字节大小(2的n次方
)的内存单元。下面来分别介绍jemalloc、redisObject、SDS、对象类型及内部编码。
Redis在编译时便会指定内存分配器;内存分配器可以是 libc 、jemalloc或者tcmalloc,
默认是jemalloc
。
jemalloc作为Redis的默认内存分配器,在减小内存碎片方面做的相对比较好。jemalloc在64位系统中,将内存空间划分为小、大、巨大
三个范围;每个范围内又划分了许多小的内存块单位;当Redis存储数据时,会选择大小最合适的内存块进行存储。
例如,如果需要存储大小为130字节的对象,jemalloc会将其放入160字节的内存单元中。
前面说到,Redis对象有5种类型;无论是哪种类型,Redis都不会直接存储,而是通过redisObject对象进行存储。
redisObject对象非常重要,Redis对象的类型、内部编码、内存回收、共享对象
等功能,都需要redisObject支持,下面将通过redisObject的结构来说明它是如何起作用的。
redisObject的定义如下(列出了与保存数据有关的三个属性):
redisObject的每个字段的含义和作用如下:
(1)type
(2)encoding
encoding表示对象的内部编码,占4个比特
。通过encoding属性,Redis可以根据不同的使用场景来为对象设置不同的编码,大大提高了Redis的灵活性和效率
。以列表对象为例,有压缩列表和双端链表两种编码方式;如果列表中的元素较少,Redis倾向于使用压缩列表进行存储,因为压缩列表占用内存更少,而且比双端链表可以更快载入;当列表对象元素较多时,压缩列表就会转化为更适合存储大量元素的双端链表。object encoding
命令,可以查看对象采用的编码方式,如下图所示:(3)lru
lru记录的是对象最后一次被命令程序访问的时间
,占据的比特数不同的版本有所不同(如4.0版本占24比特,2.6版本占22比特)。闲置时间
;object idletime命令可以显示该闲置时间(单位是秒)。object idletime
命令的一个特殊之处在于它不改变对象的lru值。lru值除了通过object idletime命令打印之外,还与
Redis的内存回收
有关系:如果Redis打开了maxmemory
选项,且内存回收算法选择的是volatile-lru
或allkeys—lru
,那么当Redis内存占用超过maxmemory指定的值时,Redis会优先选择空转时间最长的对象进行释放。
(4)refcount
refcount记录的是该对象被引用的次数,类型为整型
。refcount的作用,主要在于对象的引用计数和内存回收。当创建新对象时,refcount初始化为1;当有新程序使用该对象时,refcount加1;当对象不再被一个新程序使用时,refcount减1;当refcount变为0时,对象占用的内存会被释放。Redis中被多次使用的对象(refcount>1),称为共享对象
。Redis为了节省内存,当有一些对象重复出现时,新的程序不会创建新的对象,而是仍然使用原来的对象。这个被重复使用的对象,就是共享对象。 目前共享对象仅支持整数值的字符串对象。
Redis的共享对象目前只支持整数值的字符串对象。
之所以如此,实际上是对内存和CPU(时间)的平衡:共享对象虽然会降低内存消耗,但是判断两个对象是否相等却需要消耗额外的时间。对于整数值,判断操作复杂度为O(1);对于普通字符串,判断复杂度为O(n);而对于哈希、列表、集合和有序集合,判断的复杂度为O(n^2)。Redis服务器在初始化时,会创建10000个字符串对象,值分别是0~9999的整数值
;当Redis需要使用值为0~9999的字符串对象时,可以直接使用这些共享对象。10000这个数字可以通过调整参数REDIS_SHARED_INTEGERS(4.0中是OBJ_SHARED_INTEGERS)的值进行改变
。object refcount
命令查看,如下图所示。命令执行的结果页佐证了只有0~9999之间的整数会作为共享对象。(5)ptr
ptr指针指向具体的数据,如前面的例子中,set hello world,
ptr指向包含字符串world的SDS
。
(6)总结
4bit+4bit+24bit+4Byte(refcount是int,占4个字节)+8Byte(一个指针8个字节)=16Byte。
Redis没有直接使用C字符串(即以空字符’\0’结尾的字符数组)作为默认的字符串表示,而是使用了SDS。SDS是简单动态字符串(Simple Dynamic String)
的缩写。
SDS是整个Redis数据存储很重要的角色,因为所有的key都是字符串,都是SDS存储的。
(1)SDS结构
(2)SDS与C字符串的比较
获取字符串长度
:SDS是O(1),C字符串是O(n)缓冲区溢出
:使用C字符串的API时,如果字符串长度增加(如strcat操作)而忘记重新分配内存,很容易造成缓冲区的溢出;而SDS由于记录了长度,相应的API在可能造成缓冲区溢出时会自动重新分配内存,杜绝了缓冲区溢出。修改字符串时内存的重分配
:对于C字符串,如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。而对于SDS,由于可以记录len和free,因此解除了字符串长度和空间数组长度之间的关联,可以在此基础上进行优化:空间预分配策略
(即分配内存时比实际需要的多)使得字符串长度增大时重新分配内存的概率大大减小;惰性空间释放策略
使得字符串长度减小时重新分配内存的概率大大减小。存取二进制数据
:SDS可以,C字符串不可以。因为C字符串以空字符作为字符串结束的标识,而对于一些二进制文件(如图片等),内容可能包括空字符串,因此C字符串无法正确存取;而SDS以字符串长度len来作为字符串结束标识,因此没有这个问题。(3)SDS与C字符串的应用
Redis在存储对象时,一律使用SDS代替C字符串
。例如set hello world命令,hello和world都是以SDS的形式存储的。
而sadd myset member1 member2 member3
命令,不论是键(”myset”),还是集合中的元素(”member1”、 ”member2”和”member3”),都是以SDS的形式存储。
除了存储对象,SDS还用于存储各种缓冲区。
只有在字符串不会改变的情况下,如打印日志时,才会使用C字符串。
前面已经说过,Redis支持5种对象类型,而每种结构都有至少两种编码;
这样做的好处在于:
一方面接口与实现分离,当需要增加或改变内部编码时,用户使用不受影响;
另一方面可以根据不同的应用场景切换内部编码,提高效率。
Redis各种对象类型支持的内部编码如下图所示(只列出重点的):
关于Redis内部编码的转换,都符合以下规律:编码转换在Redis写入数据时完成,且转换过程不可逆,只能从小内存编码向大内存编码转换。
8个字节的长整型
。字符串值是整型时,这个值使用long整型表示。<=44字节的字符串
。embstr与raw都使用redisObject和sds保存数据,区别在于,embstr的使用只分配一次内存空间(因此redisObject和sds是连续的),而raw需要分配两次内存空间(分别为redisObject和sds分配空间)
。因此与raw相比,embstr的好处在于创建时少分配一次空间,删除时少释放一次空间,以及对象的所有数据连在一起,寻找方便。而embstr的坏处也很明显,如果字符串的长度增加需要重新分配内存时,整个redisObject和sds都需要重新分配空间,因此redis中的embstr实现为只读
。大于44个字节的字符串
Redis中的列表
支持两端插入和弹出,并可以获得指定位置(或范围)的元素,可以充当数组、队列、栈
等。压缩列表(ziplist)
或双端链表(linkedlist)
。
压缩列表
:
列表中元素数量小于512个
;列表中所有字符串对象都不足64字节
。双端列表
;且编码只可能由压缩列表转化为双端链表,反方向则不可能
。内层的哈希
”时,代表的是redis对外提供的5对象类型的一种;使用“外层的哈希
”代指Redis作为KeyValue数据库所使用的数据结构。内层的哈希
使用的内部编码可以是压缩列表(ziplist)和哈希表(hashtable)
两种;Redis的外层的哈希则只使用了hashtable
。压缩列表用于元素个数少、元素长度小的场景
;其优势在于集中存储,节省空间;同时,虽然对于元素的操作复杂度也由O(1)变为了O(n),但由于哈希中元素数量较少,因此操作的时间并没有明显劣势。1个dict结构、2个dictht结构、1个dictEntry指针数组(称为bucket)和多个dictEntry结构组成
。rehash
时)各部分关系如下图所示:bucket是一个数组,数组的每个元素都是指向dictEntry结构的指针
。redis中bucket数组的大小计算规则如下:大于dictEntry的最小的2^n;例如,如果有1000个dictEntry,那么bucket大小为1024;如果有1500个dictEntry,则bucket大小为2048。
一般来说,通过使用dictht和dictEntry结构,便可以实现普通哈希表的功能;但是Redis的实现中,在dictht结构的上层,还有一个dict结构。
下面说明dict结构的定义及作用。ht属性和trehashidx属性则用于rehash
,即当哈希表需要扩展或收缩时使用。ht是一个包含两个项的数组,每项都指向一个dictht结构,这也是Redis的哈希会有1个dict、2个dictht结构的原因
。通常情况下,所有的数据都是存在放dict的ht[0]中,ht[1]只在rehash的时候使用。dict进行rehash操作的时候,将ht[0]中的所有数据rehash到ht[1]中。然后将ht[1]赋值给ht[0],并清空ht[1]。因此,Redis中的哈希之所以在dictht和dictEntry结构之外还有一个dict结构,一方面是为了适应不同类型的键值对,另一方面是为了rehash。
元素数量小于512个
;所有键值对的键和值字符串长度都小于64字节
。如果有一个条件不满足,则使用哈希表;且编码只可能由压缩列表转化为哈希表,反方向则不可能
。集合(set)与列表类似,都是用来保存多个字符串
,但集合与列表有两点不同:集合中的元素是无序的,因此不能通过索引来操作元素;集合中的元素不能有重复。一个集合中最多可以存储2^32-1个元素;除了支持常规的增删改查,Redis还支持多个集合取交集、并集、差集。
整数集合(intset)
或哈希表(hashtable)
。其中,encoding代表contents中存储内容的类型,虽然contents(存储集合中的元素)是int8_t类型,但实际上其存储的值是int16_t、int32_t或int64_t,具体的类型便是由encoding决定的;length表示元素个数。
元素数量小于512个
;所有元素都是整数值
。由整数集合转化为哈希表,反方向则不可能
。整数集合适用于集合所有元素都是整数且集合元素数量较小的时候,与哈希表相比,整数集合的优势在于集中存储,节省空间;同时,虽然对于元素的操作复杂度也由O(1)变为了O(n),但由于集合数量较少,因此操作的时间并没有明显劣势。
压缩列表(ziplist)
或跳跃表(skiplist)
。集合中元素数量小于128个
;集合中所有成员长度都不足64字节
。且编码只可能由压缩列表转化为跳跃表,反方向则不可能
。Redis的跳跃表实现由zskiplist和zskiplistNode两个结构组成:前者用于保存跳跃表信息(如头结点、尾节点、长度等),后者用于表示跳跃表节点
。了解Redis的内存模型之后,下面通过几个例子说明其应用。内存优化首先要估算内存使用量。
下面以最简单的字符串类型来进行说明。
假设有90000
个键值对,每个key的长度是7个字节
,每个value的长度也是7个字节
(且key和value都不是整数,说明不会使用共享对象
);
下面来估算这90000个键值对所占用的空间。在估算占据空间之前,首先可以判定字符串类型使用的编码方式:embstr
。
90000个键值对占据的内存空间主要可以分为两部分:一部分是90000个dictEntry占据的空间;一部分是键值对所需要的bucket空间
。
每个dictEntry占据的空间包括:
1) 一个dictEntry结构,24字节
,jemalloc会分配32字节
的内存块(64位操作系统下,一个指针8字节,一个dictEntry由三个指针组成
)
2) 一个key,7字节,所以SDS(key)
需要7+9=16个字节(SDS的长度=free+len+9
,free和len就是字符串长度加数组空余空间,因为一个字符一个字节,9是两个指针加一个’\0’的字符大小),jemalloc会分配16字节
的内存块(int占4个字节,一个字符一个字节
)
3) 一个redisObject,16字节,jemalloc会分配16字节的内存块(4bit+4bit+24bit+4Byte(refcount,int占4个字节)+8Byte(指针)=16Byte
)
4) 一个value,7字节,所以SDS(value)
需要7+9=16个字节(SDS的长度=free+len+9),jemalloc会分配16字节
的内存块
5) 综上,一个dictEntry所占据的空间需要32+16+16+16=80
个字节。
bucket空间(外hash数组空间):
bucket数组的大小为大于90000的最小的2^n
,是131072
;每个bucket元素(bucket中存储的都是指针元素
)为8字节(因为64位系统中指针大小为8字节)。
因此,可以估算出这90000个键值对占据的内存大小为:90000*80 + 131072*8 = 8248576
。
作为对比将key和value的长度由7字节增加到8字节
则对应的SDS变为17个字节,jemalloc会分配32个字节,因此每个dictEntry占用的字节数也由80字节变为112字节。此时估算这90000个键值对占据内存大小为:90000*112 + 131072*8 =11128576
。
了解redis的内存模型,对优化redis内存占用有很大帮助。下面介绍几种优化场景。
例如,如果key的长度如果是8个字节,则SDS为17字节,jemalloc分配32字节;此时将key长度缩减为7个字节,则SDS为16字节,jemalloc分配16字节;则每个key所占用的空间都可以缩小一半。
不是整型,则会使用embstr或者raw,他们都使用redisObject和sds保存数据的。除了字符本身大小,redisObject和sds的数据结构也会占用一定的大小。
例如将REDIS_SHARED_INTEGERS调整到20000,则0-19999之间的对象都可以共享。
考虑这样一种场景:论坛网站在redis中存储了每个帖子的浏览数,而这些浏览数绝大多数分布在0-20000之间,这时候通过适当增大REDIS_SHARED_INTEGERS参数,便可以利用共享对象节省内存空间。
内存碎片多
,内存浪费严重;这时便可以考虑重启redis服务
,在内存中对数据进行重排,减少内存碎片。内存碎片率小于1
,说明redis内存不足
,部分数据使用了虚拟内存(即swap);由于虚拟内存的存取速度比物理内存差很多(2-3个数量级),此时redis的访问速度可能会变得很慢。因此必须设法增大物理内存
(可以增加服务器节点数量,或提高单机内存),或减少redis中的数据
。maxmemory-policy
),当内存达到一定量后,根据不同的优先级对内存进行回收。