分享一个曾经使用Redis时遇到的需求以及解决方案
当时,要开发一个图片存储系统,要求这个系统能快速地记录图片ID,和图片在存储系统中保存时的ID(可以直接叫作图片存储对象ID)。同时,还要能够根据图片ID快速查找到图片存储对象ID。
因为图片数量巨大,所以我们就用10位数来表示图片ID和图片存储对象ID,例如,图片ID为1101000052,它在存储系统中对应的ID号是3301000052。
photo_id: 1101000052
photo_obj_id: 3301000052
可以看到,图片ID和图片存储对象ID正好一一对应,是典型的“键-单值”模式。所谓的“单值”,就是指键值对中的值就是一个值,而不是一个集合,这和String类型提供的“一个键对应一个值的数据”的保存形式刚好契合。
而且,String类型可以保存二进制字节流,就像“万金油”一样,只要把数据转成二进制字节数组,就可以保存了。
所以,当时的第一个方案就是用String保存数据。把图片ID和图片存储对象ID,分别作为键值对的key和value来保存,其中,图片存储对象ID用了String类型。
刚开始,我们保存了1亿张图片,大约用了6.4GB的内存。但是,随着图片数据量的不断增加,Redis内存使用量也在增加,结果就遇到了大内存Redis实例,因为生成RDB而导致响应变慢的问题。很显然,String类型并不是一种好的选择,我们还需要进一步,寻找能节省内存开销的数据类型方案。
在这个过程中,我深入的研究了String类型的底层结构,找到了它内存开销大的原因。对“万金油”的String类型有了全新的认知:String类型并不是适用于所有场合的,它有一个明显的短板,就是它保存数据时,所消耗的内存空间较多。
同时,我还仔细研究了,集合类型的数据结构。我发现,集合类型有非常节省内存空间的底层实现结构。但是,集合类型保存的数据模式,是一个键对应一系列值,并不适合直接保存单值的键值对。
所以,我们就使用二级编码的方法,实现了用集合类型保存单值键值对,Redis实例的内存空间消耗明显下降了。在这里,我就把在解决这个问题时,学到的经验和方法分享给你,包括String类型的内存空间消耗在哪、用什么数据结构可以节省内存,以及如何用集合类型保存单值键值对。
如果你在使用String类型时,也遇到了内存空间消耗较多的问题,就可以尝试下今天的解决方案了。
接下来,我们先来看看String类型的内存都消耗在哪里了。
为什么string类型内存开销大
在刚才的案例中,我们保存了1亿张图片的信息,用了约6.4GB的内存,一个图片ID和图片存储对象ID的记录,平均用了64字节。但问题是,一组图片ID及其存储对象ID的记录,实际只需要16字节就可以了。我们来分析一下。
图片ID和图片存储对象ID都是10位数,我们可以用两个8字节的Long类型,表示这两个ID。因为8字节的Long类型,最大可以表示2的64次方的数值,所以肯定可以表示10位数。但是,为什么String类型却用了64字节呢?
其实,除了记录实际数据,String类型还需要额外的内存空间,记录数据长度、空间使用等信息,这些信息也叫作元数据。当实际保存的数据较小时,元数据的空间开销就显得比较大了,有点“喧宾夺主”的意思。
那么,String类型具体是怎么保存数据的呢?我来解释一下。
当你保存64位有符号整数时,String类型会把它保存为一个8字节的Long类型整数,这种保存方式通常也叫作 int 编码方式。但是,当你保存的数据中包含字符时,String类型就会用简单动态字符串(Simple Dynamic String,SDS)结构体来保存,如下图所示
- buf:表示字节数组,保存实际数据。为了表示字节数组的结束,Redis会自动在数组最后加一个“\0”,这就会额外占用1个字节的开销。
- len:占4个字节,表示buf的已用长度。
- alloc:也占个4字节,表示buf的实际分配长度,一般大于len。
可以看到,在SDS(简单动态字符串)中,buf保存实际数据,而len和alloc本身其实是SDS(简单动态字符串)结构体的额外开销。另外,对于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结构。这种布局方式被称为raw编码模式。
知道了RedisObject所包含的额外元数据开销,现在,我们就可以计算String类型的内存使用量了。
因为10位数的图片ID和图片存储对象ID是Long类型整数,所以可以直接用int编码的RedisObject保存。每个int编码的RedisObject元数据部分占8字节,指针部分被直接赋值为8字节的整数了。此时,每个ID会使用16字节,加起来一共是32字节。但是,另外的32字节去哪儿了呢?
Redis会使用一个全局哈希表,保存所有键值对,哈希表的每一项是一个dictEntry的结构体,用来指向一个键值对。如下图所示
dictEntry结构中有三个8字节的指针,分别指向key、value以及下一个dictEntry,三个指针共24字节。但是,这三个指针只有24字节,为什么会占用了32字节呢?这就要提到Redis使用的内存分配库jemalloc了。jemalloc在分配内存时,会根据我们申请的字节数N,找一个比N大,但是最接近N的2的幂次数作为分配的空间,这样可以减少频繁分配的次数。
举个例子。如果你申请6字节空间,jemalloc实际会分配8字节空间;如果你申请24字节空间,jemalloc则会分配32字节。所以,在我们刚刚说的场景里,dictEntry结构就占用了32字节。
好了,到这儿,你应该就能理解,为什么用String类型保存图片ID和图片存储对象ID时需要用64个字节了。
你看,明明有效信息只有16字节使用String类型保存时,却需要64字节的内存空间,有48字节都没有用于保存实际的数据。我们来换算下,如果要保存的图片有1亿张,那么1亿条的图片ID记录就需要6.4GB内存空间,其中有4.8GB的内存空间都用来保存元数据了,额外的内存空间开销很大。那么,有没有更加节省内存的方法呢?
用什么数据结构可以节省内存?
Redis有一种底层数据结构,叫压缩列表(ziplist),这是一种非常节省内存的结构。
再来说下压缩列表的构成。表头有三个字段zlbytes、zltail和zllen,分别表示列表长度、列表尾的偏移量,以及列表中的entry个数。压缩列表尾还有一个zlend,表示列表结束。
压缩列表之所以能节省内存,就在于它是用一系列连续的entry保存数据。每个entry的元数据包括下面几部分。
- prev_len,表示前一个entry的长度。prev_len有两种取值情况:1字节或5字节。取值1字节时,表示上一个entry的长度小于254字节。虽然1字节的值能表示的数值范围是0到255,但是压缩列表中zlend的取值默认是255,因此,就默认用255表示整个压缩列表的结束,其他表示长度的地方就不能再用255这个值了。所以,当上一个entry长度小于254字节时,prev_len取值为1字节,否则,就取值为5字节。
- len:表示自身长度,4字节;
- encoding:表示编码方式,1字节;
- content:保存实际数据。
这些entry会挨个儿放置在内存中,不需要再用额外的指针进行连接,这样就可以节省指针所占用的空间。
我们以保存图片存储对象ID为例,来分析一下压缩列表是如何节省内存空间的。
每个entry保存一个图片存储对象ID(8字节),此时,每个entry的prev_len只需要1个字节就行,因为每个entry的前一个entry长度都只有8字节,小于254字节。这样一来,一个图片的存储对象ID所占用的内存大小是14字节(1+4+1+8=14),实际分配16字节。
Redis基于压缩列表实现了List、Hash和Sorted Set这样的集合类型,这样做的最大好处就是节省了dictEntry的开销。当你用String类型时,一个键值对就有一个dictEntry,要用32字节空间。但采用集合类型时,一个key就对应一个集合的数据,能保存的数据多了很多,但也只用了一个dictEntry,这样就节省了内存。
这个方案听起来很好,但还存在一个问题:在用集合类型保存键值对时,一个键对应了一个集合的数据,但是在我们的场景中,一个图片ID只对应一个图片的存储对象ID,我们该怎么用集合类型呢?
换句话说,在一个键对应一个值(也就是单值键值对)的情况下,我们该怎么用集合类型来保存这种单值键值对呢?
如何用集合类型保存单值的键值对?
在保存单值的键值对时,可以采用基于Hash类型的二级编码方法。这里说的二级编码,就是把一个单值的数据拆分成两部分,前一部分作为Hash集合的key,后一部分作为Hash集合的value。这样一来,我们就可以把单值数据保存到Hash集合中了
以图片ID 1101000060和图片存储对象ID 3302000080为例,我们可以把图片ID的前7位(1101000)作为Hash类型的键,把图片ID的最后3位(060)和图片存储对象ID分别作为Hash类型值中的key和value。
按照这种设计方法,我在Redis中插入了一组图片ID及其存储对象ID的记录,并且用info命令查看了内存开销,我发现,增加一条记录后,内存占用只增加了16字节,如下所示:
127.0.0.1:6379> info memory
# Memory
used_memory:1039120
127.0.0.1:6379> hset 1101000 060 3302000080
(integer) 1
127.0.0.1:6379> info memory
# Memory
used_memory:1039136
在使用String类型时,每个记录需要消耗64字节,这种方式却只用了16字节,所使用的内存空间是原来的1/4,满足了我们节省内存空间的需求。
不过,你可能也会有疑惑:“二级编码一定要把图片ID的前7位作为Hash类型的键,把最后3位作为Hash类型值中的key吗?”其实,二级编码方法中采用的ID长度是有讲究的。
那么,Hash类型底层结构什么时候使用压缩列表,什么时候使用哈希表呢?其实,Hash类型设置了用压缩列表保存数据时的两个阈值,一旦超过了阈值,Hash类型就会用哈希表来保存数据了。
如下所示
- hash-max-ziplist-entries:表示用压缩列表保存时哈希集合中的最大元素个数。
- hash-max-ziplist-value:表示用压缩列表保存时哈希集合中单个元素的最大长度。
如果我们往Hash集合中写入的元素个数超过了hash-max-ziplist-entries,或者写入的单个元素大小超过了hash-max-ziplist-value,Redis就会自动把Hash类型的实现结构,由压缩列表转为哈希表。
一旦从压缩列表转为了哈希表,Hash类型就会一直用哈希表进行保存,而不会再转回压缩列表了。在节省内存空间方面,哈希表就没有压缩列表那么高效了。
为了能充分使用压缩列表的精简内存布局,我们一般要控制保存在Hash集合中的元素个数。所以,在刚才的二级编码中,我们只用图片ID最后3位作为Hash集合的key,也就保证了Hash集合的元素个数不超过1000。同时,我们把hash-max-ziplist-entries设置为1000,这样一来,Hash集合就可以一直使用压缩列表来节省内存空间了。