Redis核心技术与实战【学习笔记】 - 5.万金油的String带来的内存空间开销问题及解决办法

前言

这篇文章,了解下 String 类型的内存消耗问题,以及选择节省内存开销的数据类型的解决方案。

先分析一个案例:开发一个文件存储系统,要求这个系统能快速记录文件 ID 和图片在存储系统中保存的文件存储对象 ID。同时,系统可根据文件 ID 快速查找到图片存储对象 ID。

因文件数量巨大,所以采用 10 位数字来表示文件 ID文件存储对象 ID的关系。如,文件 ID 为 1111100021 ,它在存储系统中的ID 为 3333300021。

文件 ID文件存储对象 ID 是典型的“键 - 单值”模式,这和 String 的类型提供的保存形式刚好契合。而且,String 类型可以保存二进制字节流,只要把 ID 转成二进制字节数组就可以保存了。

所以,我们就把文件 ID文件存储对象 ID 分别作为 key 和 value 保存,其中文件存储对象 ID 用来 String 类型。

保存一亿个文件,大约用了 6.4GB 内存。随着文件数量不断增加, Redis 的内存使用量也在增加,结果就遇到了大内存而响应变慢的问题。我们需要寻找能节省内存开销的数据方案。


为什么 String 类型内存开销大?

刚刚的案例中,保存 1 亿个文件的信息,用了余额 6.4GB 的内存,一个 ID 记录平均用了 64 字节的数据。但是,一个 ID 其实只需要 8 字节就可以了。

ID 只需要 8 字节的 Long 类型(最大值 9223372036854775807,即 19 位整数)就可以表示。

所以一组文件 ID文件存储对象 ID 用 16 字节就可以了,为什么 String 类型确用了 64 字节呢

其实,除了记录实际数据,String 类型还需要额外的内存空间记录数据长度、空间使用等信息,这些信息也叫元数据。当实际保存的数据较小时,元数据的空间开销就显得比较大,有点“宣宾夺主”的意思。

  • 当保存 64 位有符号整数时,String 类型会把它保存为一个 8 字节类型整数,这种保存方式也叫 int 编码方式

  • 当保存的数据中包含字符时,String 就会使用简单动态字符串(Simple Dynamic String, SDS)结构来保存,如下图所示:
    Redis核心技术与实战【学习笔记】 - 5.万金油的String带来的内存空间开销问题及解决办法_第1张图片

    • buf:字节数组,保存实际数据。在字节数组结尾处,Redis 会自动在数组最后加一个 \0,这也会额外占用 1 个字节的开销。
    • len:4 字节,表示 buf 的长度。
    • alloc:4 字节,表示 buf 的实际分配长度,一般大于 len。

可以看到,在 SDS 中,除了 buf 保存实际的数据,len、alloc本身其实是 SDS 结构的额外开销。

另外,对于 String 来说,除了 SDS 的开销外,还有一个来自 RedisObject 结构体的开销,它用来记录,不同数据类型相同的元数据(比如最后一次访问时间、被引用的次数等),所以 Redis 用 RedisObject 结构体来统一记录这些元数据,同时指向实际数据。

一个 RedisObject 包含 8 字节元数据和 8 字节指针,这个指针再进一步指向具体数据类型实际数据存放地址,例如指向 String 类型的 SDS 结构所在的内存地址。
Redis核心技术与实战【学习笔记】 - 5.万金油的String带来的内存空间开销问题及解决办法_第2张图片

为了节省内存,Redis 还对 Long 类型整数和 SDS 的内存布局做了专门的设计:

  • 一方面,当保存的是 Long 类型证书时,RedisObject 的指针就直接复制为整数数据,这样就节省了指针的空间开销。
  • 另一方面,当保存的是字符串,且长度小于 44 时,RedisObject 中的元数据、指针和 SDS 是一块连续的内存区域,这样就可以避免内存碎片。这种布局方式也叫作 embstr 编码方式
    当然,当字符串大于 44 字节时, SDS 的数据量就变多了,Redis 就不再把 SDS 和 RedisObject 布局在一起了,而是会给 SDS 分配独立的空间,并用指针只是 SDS 结构。这种布局方式叫做 raw 编码模式

下图为 int、embstr、raw 这三种编码模式的示意图
Redis核心技术与实战【学习笔记】 - 5.万金油的String带来的内存空间开销问题及解决办法_第3张图片

现在我们来计算下文件 ID 的内存使用量了
因为 文件 ID文件存储对象 ID 都是 Long 类型整数,所以直接用 int 编码 的 RedisObject 保存,即 RedisObject 的元数据占 8 个字节,指针部分直接复制为 8 字节的整数了。此时,每个 ID 会使用 16 个字节,加起来共 32 字节。另外的 32 字节去哪儿了?

我们在之前讲过,Redis 会用一个全局的哈希表保存所有的键值对,哈希表的每一项是一个 dictEntity 的结构体,用来指向一个键值对。dictEntity 结构中共有三个 8 字节的指针,分别指向 key、value、下一个 dictEntity,共24字节,如下图所示:
Redis核心技术与实战【学习笔记】 - 5.万金油的String带来的内存空间开销问题及解决办法_第4张图片
但是这三个指针只有 24 字节,为什么会占用 32 字节呢?这是因为 Redis 的 jemalloc 在分配内存的时候,会根据我们申请的字节数 N,找一个比 N 大,但是接近 N 的 2 的幂次数作为分配的空间,这样可以减少频繁分配的次数。

例如,假如你申请 6 个字节的空间,jemalloc 实际会分配 8 字节空间;如果你申请 24 字节空间,jemalloc 则会分配 32 字节空间。

好了,到这你就可以明白为什么明明信息只有 16 字节,使用 String 保存时,确需要 64 字节的内存空间,那么 1 亿条文件 ID 急需要 6.4 GB 内存空间了, 其中有 4.8GB 的内存空间都用来保存元数据了,额外的内存空间开销很大。

用什么数据结构可以节省内存?

Redis 有一种底层数据结构,叫做压缩列表(ziplist),这是一种非常节省内存的结构。

压缩列表的构成:表头有三个字段 zlbytes、zltail、zllen,分别表示压缩列表长度、列表尾偏移量、entry 的个数。压缩列表还有一个 zlend 表示列表结束。
Redis核心技术与实战【学习笔记】 - 5.万金油的String带来的内存空间开销问题及解决办法_第5张图片
压缩列表之所有节省内存,就在于它用一系列联系的 entry 保存数据。每个 entry 的元数据包含如下几部分:

  • prev_len,表示前一个 entry 的长度。prev_len 有两种取值情况: 1 字节或 5 字节。取值 1字节时,表示上一个 entry 的长度小于 254 字节。虽然 1 字节的值能表示的数值范围是 0~255,但是 255 是表示 zlend了,所以 上一个 entry 的长度小于 254 字节时 prev_len 为 1 字节,否则,就取值 为 5 字节。
  • len:表示自身长度, 4 字节。
  • encoding:表示编码方式 1字节。
  • content:保存实际的数据。

这些 entry 会挨个儿存放在内存中,不需要在用额外的指针进行连接,这样就可以节省指针所占用的空间。

我们以保存文件存储对象 ID 为例,分析下压缩列表是如何节省内存空间的。

每个 entry 保存一个 文件存储对象 ID (8 字节),此时,每个 entry 的 prev_len 只需 1 个字节就行(因为长度是 8 字节,小于 254 字节)。这样一来,一个文件存储对象 ID 所占用的大小是 14 字节(1+4+1+8),Redis 的 jemalloc 实际分配 16 字节。

另外,Redis 基于压缩列表实现了 List、Hash 和 Sorted Set 这样的集合类型,这样做的最大好处就是节省了 dictEntry 的开销。当你用 String 类型是,一个键值对就有一个 32 字节的 dictEntry 。但是采用集合类型时,一个 key 就对应一个集合的数据,能保存的数据就多了很多,并且只需一个 dictEntry 。

这个方案看起来不错,但还存在一个问题:在用集合类型保存键值对时,一个键对应了一个集合的数据,但是在我们的场景中,一个文件 ID 只对应一个 文件存储对象 ID ,我们该怎么用集合类型来保存这种单键值对的数据呢?

如何用集合类型保存单值的键值对

可以采用基于 Hash 类型的二级编码方法,也就是把一个单值的数据拆分成两部分,前一部分作为 Hash 集合的 key,后一部分作为 Hash 集合的 Value,这样一来就可以把单值数据保存到 Hash 集合中了。

文件 ID 1111100021 和 文件存储对象 ID 3333300021 为例:

  1. 我们可以把文件 ID 的前 7 位(1111100)作为 Hash 表的键
  2. 文件 ID 的后 3 位(021)作为 Hash 表中的字段 field
  3. 文件存储对象 ID (3333300021)作为 Hash 表中的值 value。

按照这种设计方法,在 Redis 中插入了一组文件 ID 及其文件存储对象 ID 的记录,并且用 info 查看了内存的开销,发现,增加一条记录后,内存占用只增加了 16 字节。

你可能也会有疑惑:“二级编码一定要把图片 ID 的前 7 位作为 Hash 类型的键,把最后 3 位作为 Hash 类型值中的 key 吗?”。 其实,二级编码方法中采用的 ID 长度是有讲究的

其实 Hash 类型底层结构什么时候使用压缩列表,什么时候使用哈希表呢? 其实,Hash 类型设置了用压缩列表保存数据时的两个阈值,一旦超过阈值,Hash 类学就会用 Hash 表来保存数据了。

这两个阈值分别对应下面两个配置项:

  • hash-max-ziplist-entries:表示用压缩列表保存时哈希集合中的最大元素个数。
  • hash-max-ziplist-value:表示用压缩列表保存时哈希集合中单个元素的最大长度。

如果,我们往 Hash 集合中写入的元素个数超过了 hash-max-ziplist-entries,或者写入的单个元素的大小超过了 hash-max-ziplist-value,Redis 就会自动把 Hash 类型的实现结构由压缩列表转为 哈希表。

为了能充分使用压缩列表的精简内存布局,我们一般要控制保存在 Hash 集合中的元素个数。所以,在上面的二级编码中,我们只用文件 ID 最后 3 位作为 Hash 表中的字段,这也就保证了哈希表的元素格式不超过 1000,同时我们把 hash-max-ziplist-entries 设置为 1000,这样一来, Hash 集合就可以一致使用压缩列表来节省内存空间了。

你可能感兴趣的:(Redis核心技术学习,redis,redis,String,redis,节省内存空间方法)