曾经看到这样一个案例,一个团队需要开发一个图片存储系统,需要快速记录图片ID和图片存储对象ID,还需要能够使用图片ID快速查找图片存储对象ID。我们假设用10位数字来表示图片ID和图片存储对象ID。比如图片ID是1101021043,对应的图片存储对象ID是2301010051。可以看到图片ID和图片存储ID是完全一一对应的,是典型的key-value形式,所以首先想到的是直接使用String类型来存储数据。将图片ID和图片存储ID分别保存为键值对的键和值。但是,随着存储数据量的增加,Redis的内存占用也快速上升。导致大内存Redis实例由于RDB的产生而导致响应变慢。显然String类型不是一个好的选择,那么有什么办法可以减少内存消耗呢?String类型的数据结构首先我们要明白为什么String在保存数据的时候会占用很大的内存空间。在刚才的例子中,由于图片ID和图片存储对象ID都是10位数字,所以我们可以用两个8字节的Long类型来表示这两个ID。因此,一组图片ID及其存储对象ID记录实际上只需要16个字节。但是通过Redis内存分析,一组图片ID和它们的存储对象ID占用了64个字节,那么String类型为什么要用64个字节呢?实际上,String类型除了记录实际数据外,还需要额外的内存空间来记录数据的长度、空间使用信息等,这些信息也称为元数据。当实际保存的数据较少时,元数据的空间开销比较大。我们先来看看String类型是如何保存数据的。当你保存一个64位有符号整数时,String类型会将其保存为一个8字节的Long类型整数,通常称为int编码。但是,当您保存的数据包含字符时,String类型将以简单的动态字符串结构(SDS)保存。如下图所示:len:4个字节,表示buf使用的长度。alloc:4字节,表示buf分配的长度,一般大于len。buf:字节数组,保存实际数据。Redis为了表示数组的结束,会自动在数组的末尾加上一个“\0”。可以看出,在SDS结构中,除了存储实际数据的buf外,还有len和alloc的额外元数据开销。另外,对于String类型,除了SDS的额外开销之外,还有一个叫做RedisObject结构的开销。因为Redis有很多数据类型,不同的数据类型有相同的元数据来记录(比如上次访问时间),Redis会使用一个叫做RedisObject的结构来统一记录这些元数据。一个RedisObject包含一个8字节的元数据和一个8字节的指针,指向具体数据的位置,比如String类型的SDS结构所在的内存地址。如下图所示:Redis为了节省内存空间,专门设计了Long型整型和SDS的内存布局。一方面,在保存Long类型整型时,直接将RedisObject中的指针赋值给整型数据,这样就不需要额外的指针指向整型,节省了指针的空间开销。另一方面,当保存字符串数据且字符串小于等于44字节时,RedisObject中的元数据、指针和SDS是一块连续的内存区域,这样可以避免内存碎片。这种布局也称为embstr编码。当字符串大于44字节时,SDS中的数据量开始增加,Redis不再将SDS和RedisObject排列在一起,而是为SDS分配独立的空间,并使用指针指向SDS结构体。这种布局称为原始编码模式。如下图所示:下面我们来计算一对图片ID和图片存储对象ID的内存占用。由于10位图片ID和图片存储对象ID都是Long型整型,所以可以直接用int编码的RedisObject存储。对应的RedisObject元数据部分占用8字节,指针部分直接赋值8字节整数。此时每个ID将使用16个字节,加起来一共是32个字节。但是其他32个字节去了哪里?由于Redis使用一个全局哈希表来存储所有的键值对,所以哈希表中的每一项都是一个dictEntity结构来指向一个键值对。dictEntity由三个8字节的指针组成,分别指向key、value和下一个dictEntity。如下所示。由于Redis使用的内存分配库是jemalloc,所以jemalloc在分配内存时,会根据请求的字节数N,寻找一个大于N且最接近N的2的幂作为分配空间。所以申请一个24-bytedictEntity实际上会分配32个字节。至此,你应该明白为什么保存图片ID和图片存储对象ID的String类型会占用64个字节了。一条有效信息只有16个字节,但以String类型存储时,占用了64个字节的内存空间,其中48个字节用于存储元数据信息。这是对内存空间的巨大浪费吗?那么有没有更节省内存的方式呢?使用压缩列表来节省内存Redis有一个叫做压缩列表的结构,它非常节省内存。我们先回顾一下压缩列表的组成。表头中有zlbytes、zllen和zltail三个字段,分别表示链表的长度、链表尾部的偏移量和链表的条目数。compressedlist表末尾有一个zlend,表示列表结束。如下所示。由于压缩列表使用一系列的表项来保存数据,这些表项会一个一个的放入内存中,不需要使用额外的指针进行连接,从而节省了指针占用的空间。每个条目由以下部分组成。pre_len:表示前一个条目的长度。prev_len有两个值:1个字节或5个字节。当最后一个条目的长度小于254字节时,prev_len的值为1字节,否则为5字节。len:表示自身的长度,占4个字节。encoding:表示编码方式,占用1个字节。内容:保存实际数据。假设我们使用entry来保存图片存储对象ID(占8个字节),此时每个entry的prev_len占1个字节,因为每个entry的前一个entry的长度小于264字节。这样一个图片对象ID占用的内存大小为14(1+4+1+8)字节,实际会分配16字节。Redis基于压缩列表实现了List、Hash和SortedSet集合类型。这样做最大的好处就是省去了dictEntity的内存开销。对于String类型,一个键值对有一个dictEntity,占用32个字节。对于集合类型,一个key对应很多数据,但只占用一个dictEntity,节省内存空间。如何使用集合类型存储单值键值对数据在存储单值键值对数据时,我们可以使用基于Hash类型的二次编码方式。这里所说的二次编码是指将单值数据拆分成两部分,第一部分作为Hash的key,后一部分作为Hash的值。以图片ID为1101021043,对应图片存储对象ID为2301010051为例,我们使用图片ID的前7位(1101021)作为Hash类型的key,后3位(043)和图片存储对象的ID2301010051作为Hash类型的key和value。按照这个设计,在Redis中插入一条记录只占用16个字节,所以比起使用String类型占用64个字节,节省了很多空间。最后再思考另一个问题,为什么图片ID的前7位要作为Hash类型的key,而后3位要作为Hash类型的key。在Redis的存储结构中,我们介绍了Redis的Hash类型的两种底层实现结构,压缩列表和哈希表。Hash类型为在压缩列表中存储数据设置了两个阈值。一旦超过阈值,Hash类型将使用哈希表来存储数据。这两个阈值分别对应以下两个配置项:hash-max-ziplist-entries:表示使用压缩列表保存时哈希集中的最大元素个数。hash-max-ziplist-value:表示保存在压缩列表中时哈希集合中单个元素的最大长度。哈希表在节省内存空间方面不如压缩列表有效。我们只使用后3位作为Hash类型的key,这样可以保证hash集合中的元素个数不会超过1000。同时我们设置hash-max-ziplist-entries=1000来保证Hash类型的底层是一种压缩列表的数据结构。
