文章目录
一.背景
当我们要存储“单值”数据时(例如0123456789:0987654321,可以看作一串id对应着另一串id)这样的数据,我们第一个想到的就是是使用String类型来进行存储。这样我们就可以根据键快速地找到值了。我们平常也会这么思考这样来存储,“键-单值”这是最直观最容易想地。
但是当存储大量地数据时,这种使用String的存储方式就会存在弊端。
当我们存入这种键值对的数量达到1亿时,会大约占用6.4GB的内存,而这么大的内存在进行RDB时,会因fork而阻塞主线程。为什么会占据6,4GB这么大的内存呢?
我们存入的键值的内容都long类型的整数,一个键值对大概占用16字节,通过计算,一亿个键值对占用内存大概是 1G,但是为什么在Redis中会有6G多呢?
String被看作是万金油,但是并不是试用于所有场合,它有一个明显的短板,就是保存数据时所消耗的内存空间较多。
这就不得不看看redis底层String的数据结构了。
二.String的底层数据结构
上面的存储案例中,键值对理想情况是占用16字节,但是实际上键值对平均占用了64字节。
String 类型还需要额外的内存空间记录数据长度、空间使用等信息,这些信息也叫作元数据。当实际保存的数据较小时,元数据的空间开销就显得比较大了,有点“喧宾夺主”的意思。
1.String怎么进行数据保存的:
当你保存 64 位有符号整数时,String 类型会把它保存为一个 8 字节的 Long 类型整数,这种保存方式通常也叫作 int 编码方式
。
当你保存的数据中包含字符时,String 类型就会用简单动态字符串(Simple Dynamic String,SDS)
结构体来保存。
在 SDS 中,buf 保存实际数据,而 len 和 alloc 本身其实是 SDS 结构体的额外开销。
对于 String 类型来说,除了 SDS 的额外开销,还有一个来自于 RedisObject 结构体的开销。
RedisObject
因为 Redis 的数据类型有很多,而且,不同数据类型都有些相同的元数据要记录(比如最后一次访问的时间、被引用的次数等),所以,Redis 会用一个 RedisObject 结构体来统一记录这些元数据,同时指向实际数据。
RedisObject中元数据占8字节,指针会指向数据实体,比如指向SDS。
为了节省内存空间,Redis 还对 Long 类型整数和 SDS 的内存布局做了专门的设计。
int编码
如果存储的是Long类型数据,RedisObject 中的指针就直接赋值为整数数据了,这样就不用额外的指针再指向整数了,节省了指针的空间开销。(也即是前面讲到的int编码)
embstr编码
当保存的是字符串数据,并且字符串小于等于 44 字节时,RedisObject 中的元数据、指针和 SDS 是一块连续的内存区域,这样就可以避免内存碎片。这种布局方式也被称为 embstr 编码方式。
raw编码
当字符串大于 44 字节时,SDS 的数据量就开始变多了,Redis 就不再把 SDS 和RedisObject 布局在一起了,而是会给 SDS 分配独立的空间,并用指针指向 SDS 结构。这种布局方式被称为 raw 编码模式。
根据上面的String存储方式,我们可以看出我们存储是int编码类型,每个键值对也就占了32字节,那么求它(64-32)=32字节跑哪儿去了?
其实在redis中会维护一个全局哈希表用于保存所有的键值对,哈希表的每一项是一个 dictEntry 的结构体,用来指向一个键值对。dictEntry 结构中有三个 8 字节的指针,分别指向 key、value 以及下一个 dictEntry。
jemalloc分配
虽然这里dictEntry只占了24字节,但是根据Redis使用的内存分配库jemalloc,jemalloc 在分配内存时,会根据我们申的字节数 N,找一个比 N 大,但是最接近 N 的2 的幂次数作为分配的空间,这样可以减少频繁分配的次数。所以在这里dictEntry虽然只占了24字节,但是实际上会占用32字节。
通过上面就能成功解释用String存储的键值对占用64字节的原因了。
理论上只需占用16字节,但是实际上却扩大了4倍,在存储大量的数据下,会因为RDB严重阻塞主线程。
2.String缺点
在保存的键值对本身占用的内存空间不大时,String 类型的元数据开销就占据主导了,这里面包括了
RedisObject 结构、SDS 结构、dictEntry 结构的内存开销
三.解决String占用大量内存的方法
那有什么方法可以节省内存空间呢?
1.压缩列表 ziplist
在Redis底层有一个数据结构叫做压缩列表,我们来看看这个数据结构:
表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度
、列表尾的偏移量
,以及列表中的 entry 个数
。压缩列表尾还有一个 zlend,表示列表结束
。
压缩列表之所以能节省内存,就在于它是用一系列连续的 entry 保存数据,这样全局哈希表就可以减少dicEntity的使用量了。每个 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就只需要花费(1+4+1+8=14在根据jemalloc)16字节。
Redis 基于压缩列表实现了 List、Hash 和 Sorted Set 这样的集合类型,这样做的最大好处就是节省了 dictEntry 的开销。当你用 String 类型时,一个键值对就有一个 dictEntry,要用 32 字节空间。但采用集合类型时,一个 key 就对应一个集合的数据,能保存的数据多了很多,但也只用了一个 dictEntry,这样就节省了内存。
2.需要解决的问题
这个方案听起来很好,但还存在一个问题:在用集合类型保存键值对时,一个键对应了一个集合的数据,但是在我们的场景中,一个ID 只对应另一个 ID,我们该怎么用集合类型呢?换句话说,在一个键对应一个值(也就是单值键值对)的情况下,我们该怎么用集合类型来保存这种单值键值对呢?
3.二级编码
在保存单值的键值对时,可以采用基于 Hash 类型的二级编码方法
。这里说的二级编码,就是把一个单值的数据拆分成两部分,前一部分作为 Hash 集合的 key,后一部分作为Hash 集合的 value,这样一来,我们就可以把单值数据保存到 Hash 集合中了。
以(key)ID 1101000060 和 (value)ID 3302000080 为例,我们可以把图片 ID 的前7 位(1101000)作为 Hash 类型的键,把图片 ID 的最后 3 位(060)和图片存储对象ID 分别作为 Hash 类型值中的 key 和 value。
在使用 String 类型时,每个记录需要消耗 64 字节,这种方式却只用了 16 字节,所使用的内存空间是原来的 1/4,满足了我们节省内存空间的需求。
4.注意关于压缩列表升级为哈希表问题
那么,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 集合就可以一直使用压缩列表来节省内存空间了。