Redis内存模型

 

摘抄并用于自查笔记

 

  我们使用Redis时,会接触Redis的5种数据类型:字符串、哈希、列表、集合、有序集合。丰富的类型是Redis相对于Memcached等的一大优势。

  了解Redis内存模型,可以:

  1) 估算Redis内存使用量,根据需要合理的评估Redis的内存使用量,选择合适的机器配置,在满足需求的情况下节约成本

  2) 优化内存。根据实际情况选择合适的数据类型和编码,更好的利用Redis内存  

  3)分析解决问题。当Redis出现阻塞、内存占用等问题时,尽快发现导致问题的原因,便于分析解决问题。

  本文主要介绍redis占用内存的情况及如何查询、不同的对象类型在内存中的编码方式、内存分配器(jemalloc)、简单动态字符串(SDS)、RedisObject等。

 

一、Redis内存统计

  在客户端通过redis-cli连接服务器后,通过info命令可以查看内存使用情况:

  info memory

  

  其中info命令可以显示redis服务器的很多信息,包括服务器基本信息、CPU、内存、持久化、客户端连接信息等。memory是参数,表示显示内存相关的信息。

   返回结果中几个比较重要的参数说明:

  1)used_memory:即redis分配器分配的内存总量(单位字节),包括使用的虚拟内存(即swap);userd_memory_human是换算下别的单位,更加友好。

  2)used_memory_rss:即redis进程占据操作系统的内存(单位是字节),与top命令及ps命令看到的值是一致的;除了分配器分配的内存外,used_memory_rss还包括进程运行本身需要的内存、内存碎片等,但是不包括虚拟内存。

  因此,used_memory和used_memory_rss,前者是从redis角度得到的量,后者是从操作系统得到的量。二者之所以有不同,一方面是内存碎片和redis进程运行需要占用内存,使得前者可能比后者小,另一方面虚拟内存的存在,使得前者可能比后者大。

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

  3)mem_fragmentation_ration:即内存碎片比率,该值是used_memory_rss / used_memory 的比值。

  mem_fragmentation_ratio一般大于1,且该值越大,内存碎片比例越大。如果

  mem_fragmentation_ratio < 1,说明redis使用了虚拟内存,由于虚拟内存的媒介是磁盘,比内存速度要慢很多,当这种情况出现时,应该及时排查,如果内存不足应该及时处理,如增加redis节点、增加redis服务器的内存、优化应用等。

  一般来说,mem_fragmentation_ratio 在1.03左右是比较健康的状态(对于jemalloc来说);如果mem_fragmentation_ratio值很大,有一种可能是因为还没有向redis中存入数据,redis进程本身运行的内存使得used_memory_rss比used_memory大得多。

  4)mem_allocator:即redis使用的内存分配器,在编译时指定,可以是libc、jemalloc或者tcmalloc,默认是jemalloc。

  

二、Redis内存划分

  redis作为内存数据库,在内存中存储的内容主要是数据(键值对)。通过前面的叙述可知,除了数据以外,redis的其他部分也会占用内存

  redis内存占用主要可以划分为以下几个部分:

  1. 数据

  作为数据库,数据是最主要的部分,这部分占用的内存会统计在used_memory中。redis使用键值对存储数据,其中值包括五种类型:字符串、哈希、列表、集合、有序集合。

  这5种类型是redis对外提供的。实际上,在redis内部,每种类型可能有2种或者更多的内部编码实现。此外,redis在存储对象时,并不是直接将数据扔进内存,而是会对对象进行各种包装:如RedisObject、SDS等。

  

  2. 进程本身运行需要的内存

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

  除了主进程,redis创建的的子进程也会占用内存,如redis执行AOF、RDB重写时创建的子进程。当然这部分内存不属于redis进程,也不会统计在used_memory和used_memory_rss中。

 

  3. 缓冲内存

  缓冲内存包括:

      客户端缓冲区:存储客户端连接的输入输出缓冲;

      复制积压缓冲区:用于主从复制功能(复制积压缓冲区是由主服务器维护的一个固定长度先进先出队列,默认大小为1MB。当主服务器进行命令传播时,它不仅会将写命令发给送所有从服务器,还会将写命令入队到复制积压缓冲区里面,如果从服务器的复制偏移量offset之后的数据仍在复制积压缓冲区里面,那么主服务器与从服务器执行部分重同步,如果从服务器的复制偏移量offset之后的数据不在复制积压缓冲区里面,那么主服务器和从服务器执行完整重同步);

      AOF缓冲区:用于在进行AOF重写时,保存最近的写入命令。

    这部分内存由jemalloc分配,因此会统计在used_memory中。

 

  4. 内存碎片

  内存碎片是redis在分配、回收物理内存过程中产生的。例如,如果对数据更改频繁,而且数据间的大小相差很大,可能导致redis释放的空间在物理内存中并没有释放,但是redis又无法有效利用,这就形成了内存碎片。内存碎片不会统计在used_memory中。

  内存碎片的产生与对数据进行的操作、数据的特点等都有关。此外,与使用的内存分配器也有关系——如果内存分配器设计合理,可以尽可能的减少内存碎片的产生。后面将要说到的jemalloc便在控制内存碎片方面做得很好。

  如果redis服务器中的内存碎片已经很大,可以通过安全重启的方式减小内存碎片。因为重启之后,redis重新从备份文件中读取数据,在内存中进行重排,为每个数据重新选择合适的内存单元,减小内存碎片。

 

 

三、Redis数据存储的细节

 

  1. 概述

  关于redis数据的存储细节,涉及到内存分配器(如jemalloc)、简单动态字符串(SDS)、5种对象类型及内部编码、RedisObject。在讲述具体内容之前,先说明一个这几个概念之间的关系。

  

  

 

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

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

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

  实际,RedisObject除了type和ptr字段外,还有其他字段图中没有给出,如用于指定对象内部编码的字段。

  4)jemalloc:无论是DictEntry对象,还是RedisObject、SDS对象,都需要内存分配器(如jemalloc)分配内存进行存储。以DictEntry对象为例,有3个指针组成,在64位机器下占24个字节,jemalloc会为它分配32字节大小的内存单元。

  

  2. jemalloc

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

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

  

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

 

  3. RedisObject

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

  RedisObject对象非常重要,redis对象的类型、内部编码、内存回收、共享对象等功能,都需要RedisObject支持,下面将通过RedisObject的结构来说明它是如何起作用的。

  RedisObject的定义如下:

  typedef struct redisObject{

  unsigned type:4;

  unsigned encoding:4;

  unsigned lru:REDIS_LRU_BITS;

  int refcount;

  void *ptr;

  } robj;

 

  RedisObject的每个字段的含义和作用如下:

  1)type字段表示对象的类型,占4个bit;目前包括REDIS_STRING(字符串)、REDIS_LIST(列表)、REDIS_HASH(哈希)、REDIS_SET(集合)、REDIS_ZSET(有序集合)。当我们执行type命令时,是通过读取RedisObject的type字段获得对象的类型。

  set mystring helloword

  type mystring   --->  string

  sadd myset member1 member2 member3

  type myset   ---> set

 

  2)encoding

  encoding表示对象的内部编码,占4个bit

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

  以列表为例,有压缩列表和双端链表两种编码方式,如果列表中的元素较少,redis倾向于使用压缩列表进行存储,因为压缩列表占用内存更少,而且比双端链表可以更快载入;当列表对象元素较多时,压缩列表就会转化为更为合适存储大量元素的双端链表。

  通过object encoding 命令,可以查看对象采用的编码方式,如下图所示:

  

 

  3)lru

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

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

   

  lru值除了通过object idletime命令打印之外,还与redis的内存回收有关:如果redis打开了maxmemory选项,且内存回收算法选择的是volatile-lru或allkeys-lru,那么当redis内存占用超过maxmemory指定值时,redis会优先选择空转时间最长的对象进行释放。(redis.conf文件中可以设置maxmemory选项对redis所能使用的最大内存做限制,并通过maxmemory_policy占用内存超过maxmemory之后的行为做定制:volatile-lru:最近最少使用算法,从设置了过期时间的键中选择空转时间最长的键值对清除掉,allkeys-lru:最近最少使用算法,从所有键中选择空转时间最长的键值对清除)

 

  4)refcount

  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个字符串对象,值分别是0-9999的整数值;当redis需要使用值为0-9999的字符串对象时,可以直接使用这些共享对象。10000这个数字可以通过调整参数OBJ_SHARED_INTEGERS的值进行改变。

  共享对象的引用次数可以通过object refcount命令查看

  

 

  5)ptr

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

 

  6)总结

  综上所述,RedisObject的结构与对象类型、编码、内存回收、共享对象都有关系;一个RedisObject对象的大小为16字节

  4bit + 4bit + 24bit + 4Byte + 8Byte = 16Byte(type + encoding + lru + 共享对象 + ptr)

 

 

  4. SDS

  redis没有直接使用c字符串作为默认的字符串表示,而是使用了SDS。SDS是简单动态字符串(Simple Dynamic String)的缩写。

 

  1)SDS结构

  SDS的结构如下:

  struct sdshdr{

  int len;

  int free

  char buf[];

  };

  其中,buf表示字节数组,用来存储字符串;len表示buf已使用的长度,free表示buf未使用的长度。

  

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

   

  2)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用来存储文本数据时才可以这样使用,在存储二进制数据时则不行('' 不一定是结尾)。

 

  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的对象类型与内部编码

 

  前面已经说过,Redis支持5种类型,而每种结构都至少两种编码。这样做的好处在于:一方面接口与实现分离,当需要增加或改变内部编码时,用户使用不受影响,另一方面可以根据不同的应用场景切换内部编码,提高效率。

  redis各种对象类型支持的内部编码如下图所示。

  

  关于Redis内部编码的转换,都符合以下规律:编码转换在redis写入数据时完成,且转换过程不可逆,只能从小内存编码向大内存编码转换。

 

  1. 字符串

  1)概况

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

 

  2)内部编码

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

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

  embstr:<=44字节的字符串。embstr与raw都使用RedisObject和SDS保存数据。区别在于:embstr的使用只分配一次内存空间(因此RedisObject和SDS是连续的),raw需要分配两次内存空间(分别为RedisObject和SDS分配空间)。因此与raw相比,embstr的好处在于创建时少分配一次空间、删除时少释放一次空间、对象的所有数据连载一起,寻找方便。而embstr的坏处也很明显:如果字符串的长度增加需要分配内存时,整个RedisObject和SDS都需要重新分配空间,因此Redis中的embstr实现为只读。

  注:len 和 free 记录的sds长度和空闲空闲,之前使用unsigned int 8字节比较浪费,因为他可以表示很大的范围,而因为embstr本身是针对短字符串设计,长度浪费故有修改。3.0之前版本的 sdshdr = unsigned int * 2 = 4 * 2 = 8 ,现在版本的使用 sdshdr8 = uint8_t * 2 + char = 1 * 2 +1 = 3。节约了5个字符。39+5=44(16 + 4 + 44 = 64,4 = 3+1 这个1字节是字符串结尾的空字符串,上面有说)。这样jemalloc正好可以分配64字节的内存单元。

  raw:大于44字字节的字符串。

  

  3)编码转换

  当int数据不再是整数,或大小超过了long的范围,自动转化为raw。

  而对于embstr,由于其实现是只读的,因此在对embstr对象进行修改时,都会先转化为raw再进行修改,因此,只要是修改embstr对象,修改后的对象一定是raw的,无论是否达到39字节。示例如下:

  

 

  2. 列表

  1)概况

  列表(list)用来存储多个有序的字符串,每个字符串称为元素;一个列表可以存储2^32-1个元素。Redis中的列表支持两端插入和弹出,并可以获得指定位置(或范围)的元素,可以充当数组、队列、栈等。

 

  2)内部编码

  列表的内部编码可以是压缩列表(ziplist)或双端链表(linkedlist)。

  双端链表:由一个list结构和多个listNode结构组成,典型结构如下:

  

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

   压缩列表:压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块(而不是像双端链表一样每个节点是指针)组成的顺序型数据结构。

  与双端链表相比,压缩列表可以节省内存空间,但是进行修改或者增删改操作时,复杂度较高,因此当节点数量较少时,可以使用压缩列表,但是节点数量多时,还是使用双端链表合适。

  压缩列表不仅用于实现列表,也用于实现哈希、有序列表,使用非常广泛。

 

  3)编码转换

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

  列表中的元素小于512个;

  列表中所有字符串对象都不足64字节。

  如果有一个条件不满足,则使用双端列表,且编码只可能由压缩列表转化为双端链表,反向则不可能。

  下图展示了列表编码转换的特点:

  

  其中,单个 字符串不能超过64字节,是为了便于统一分配每个节点的长度。这里的64字节指的是字符串的长度,不包括SDS结构,因为压缩列表使用连续、定长内存块存储字符串,不需要SDS结构指明长度。后面提到压缩列表,也会强调长度不超过64字节,原理与这里类似。

  

   3. 哈希

  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时,各部分关系如下图所示:

     dictEntry:

    dictEntry结构用于保存键值对,结构定义如下:

    typedef struct dictEntry{

      void *key;

      union{

        void *val;

        uint64_tu64;

        int64_ts64;

      }v;

      struct dictEntry *next;

    }dictEntry;

  其中,各属性的功能如下:

  key:键值对中的键;

  val :键值对中的值,使用union(共用体)实现,存储的内容既可能是一个指向值的指针,也可能是64位整型,或无符号64位整型;

  next:指向下一个dictEntry,用于解决哈希冲突问题

  在64位系统中,一个dictEntry对象占24个字节(key/value/next各占8字节)

   bucket:一个数组,数组的每个元素都是指向dictEntry结构的指针。Redis中的bucket数组的大小计算规则如下:大于dictEntry的,最小2^n。例如有1000个dictEntry,那么bucket大小为1024,如果有1500个dictEntry,则bucket大小为2048。

  

  dictht:dictht结构如下:

  typedef struct dictht{

    dictEntry **table;

    unsigned long size;

    unsigned long sizemask;

    unsigned long used;

  }dictht;

  其中,各个属性的功能说明如下:

  table属性是一个指针,指向bucket;

  size属性记录了哈希表的大小,即bucket的大小;

  used记录了已使用的dictEntry的数量;

  sizemask属性的值总是size-1,这个属性和哈希值一起决定一个键在table中存储的位置。

 

  dict:

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

  

  dict结构如下:

  typedef struct dict{

    dictType *type;

    void *privdata;

    dictht ht[2];

    int trehashidx;

  }dict;

  其中,type属性和privdata属性是为了适应不同类型的键值对,用于创建多态字典(如何适应)。

  ht属性和trehashidx属性则用于rehash,即当哈希表需要扩展或收缩时使用。

  ht是一个包含两个项的数组,每项都指向一个dictht结构,这也是Redis的哈希会有1个dict、2个dictht结构的原因。通常情况下,所有的数据都是存放在ht[0]中,ht[1]只在rehash的时候使用。dict进行rehash操作的时候,将ht[0]中的所有数据rehash到ht[1]中,然后将ht[1]赋值给ht[0],并清空ht[1]。

  因此,Redis中的哈希之所以在dictht和dictEntry结构之外还有一个dict结构,一方面是为了适应不同类型的键值对,另一方面是为了rehash。

 

  3)编码转换

  Redis中内层哈希既可能使用哈希表,也可能使用压缩列表。

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

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

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

  如果有一个条件不满足,则使用哈希表,且编码只可能由压缩表转化为哈希表,反向则不可能。

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

  

 

   4. 集合

  1)概况

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

  一个集合中最多可以存储2^32-1个元素,除了支持常规的增删改查,Redis还支持多个集合取交集、并集、差集。

  

  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个;

  集合中所有元素都是整数值。

   如果有一个条件不满足,则使用哈希表;且编码只可能由整数集合转化为哈希表,反向则不可能。

  下图展示了集合编码转换的特点:

  

  

 

  5. 有序集合

  1)概况

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

  

  2)内部编码

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

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

  除了跳跃表,实现有序数据结构的另一种典型实现是平衡树;大多情况下,跳跃表的效率可以和平衡树相媲美,且跳跃表的实现比平衡树简单很多,因此Redis中选用跳跃表代替平衡树。

  跳跃表支持平均o(logN)、最坏o(N)的复杂度进行节点查找,并支持顺序操作。Redis的跳跃表实现由zskiplist和zskiplistNode两个结构组成:前者用于保存跳跃表信息(如头结点、尾节点、长度等),后者用于表示跳跃节点。

 

  3)编码转换

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

  有序集合中的元素小于128个;

  有序集合中所有成员长度都不足64字节。 

  如果有一个条件不满足,则使用跳跃表;且编码只能由压缩表转化为跳跃表,反向则不可能。

  下图展示了有序集合编码转换的特点:

  

 

 

 

 五、应用举例

 

  1. 估算Redis内存使用量

  要估算Redis中的数据占据内存的大小,需要对Redis的内存模型有比较全面的了解,包括hashtable、SDS、RedisObject、各种对象类型的编码方式等。

  假设90000个键值对,每个key长度是7字节,每个value长度也是7字节(key和value都不是整数)。

  在估算占据空间前,首先可以判定字符串类型使用的编码方式:embstr。90000个键值对占据的内存空间主要分2部分,一部分是90000个dictEntry占据的空间,一部分是键值对所需要的bucket空间。

  每个dictEntry占据的空间包括:

  一个dictEntry,24字节,jemalloc会分配32字节的内存块

  一个key,7字节,所以SDS(key)需要7+9=16字节,jemalloc会分配16字节的内存块。

  一个RedisObject,16字节,jemalloc会分配16字节内存块。

  一个value,7字节,所以SDS(value)需要7+9=16字节,jemalloc会分配16字节的内存块。

  综上,一个dictEntry需要 32+16+16+16=80个字节。

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

   因此,可以估算出这90000个键值对占据的内存大小为:90000*8 + 131072*8 = 8248576。

 

  对于字符串类型之外的其他类型,对内存占用的估算方法是类似的,需要结合具体类型的编码方式来确定。

 

 

   2. 优化内存占用

  了解Redis的内存模型,对优化Redis内存占用有很大帮助。

  1)利用jemalloc特性进行优化

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

  例如,如果key的长度是8字节,则SDS为17字节,jemalloc分配32字节,此时将key长度缩减为7字节,则SDS为16字节,jemalloc分配16字节,则每个key所占用的空间都可以缩小一半。

 

  2)使用整型/长整型

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

 

  3)共享对象

  利用共享对象,可以减少对象的创建(同时减少了RedisObject的创建),节省内存空间。

  目前Redis中的共享对象只包括10000个整数(0-9999),可以通过调整REDIS_SHARED_INTEGER参数提高共享对象的个数。例如将REDIS_SHARED_INTEGERS调整到20000,则0-19999之间的对象都可以共享。

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

 

  4)避免过度设计

  需要注意的是,无论是哪种场景优化,都需要考虑内存空间与设计复杂度的权衡;而设计复杂度会影响到代码的复杂度、可维护性。

  如果数据量较小,那么为了节省内存而使得代码的开发、维护变得更加困难并不划算;以90000个键值对为例,实际上节省的内存空间只有几MB。但是如果数据量有几千万设置上亿,考了内存的优化就比较有必要了。

  

  3. 关注内存碎片率

  内存碎片率是一个重要的参数,对Redis内存的优化有重要意义。

  如果内存碎片率过高(jemalloc在1.03左右比较正常),说明内存多,内存浪费严重。这时便可以考虑重启Redis服务,在内存中对数据进行重排,减少内存碎片。

  如果内存碎片率小于1,说明Redis内存不足,部分数据使用了虚拟内存(即swap);由于虚拟内存的存取速度比物理内存差很多(2-3个数量级),此时,Redis的访问速度会变得很慢。因此必须设法增大物理内存(可以增加服务器节点数量,或提高单机内存),或减少Redis中的数据。

  要减少Redis中的 数据,除了选用合适的数据类型、利用共享对象等,还有一点是要设置合理的数据回收策略(maxmemory-policy),当内存达到一定量后,根据不同的优先级对内存进行回收。

  

posted @ 2019-08-06 17:52  停不下的时光  阅读(249)  评论(0编辑  收藏  举报