当前位置:首页 > 科技  > 软件

Redis中万金油的String,为什么不好用了?

来源: 责编: 时间:2023-10-24 09:00:07 217观看
导读今天,我们先了解下 String 类型的内存空间消耗问题,以及选择节省内存开销的数据类型的解决方案。我想和你分享一个之前我面临的需求案例。曾经,我们面临着一个任务,要创建一个高效的图片存储系统,要求这个系统能够快速记录

今天,我们先了解下 String 类型的内存空间消耗问题,以及选择节省内存开销的数据类型的解决方案。5xA28资讯网——每日最新资讯28at.com

我想和你分享一个之前我面临的需求案例。5xA28资讯网——每日最新资讯28at.com

曾经,我们面临着一个任务,要创建一个高效的图片存储系统,要求这个系统能够快速记录图片 ID 和图片在存储系统中的唯一标识(我们称之为图片存储对象 ID)。此外,还需要能够通过图片 ID 快速检索到相应的图片存储对象 ID。5xA28资讯网——每日最新资讯28at.com

考虑到图片数量庞大,我们决定使用 10 位数字来表示图片 ID 和图片存储对象 ID。举个例子,图片 ID 可能是 1101000051,对应的存储对象 ID 则是 3301000051。5xA28资讯网——每日最新资讯28at.com

photo_id: 11010000515xA28资讯网——每日最新资讯28at.com

photo_obj_id: 33010000515xA28资讯网——每日最新资讯28at.com

这个案例很明显地展现了“键 - 单值”模式。在这种模式中,每个键值对中的值都是一个单一的值,而不是一个值的集合,与 String 类型的数据存储方式完美契合。5xA28资讯网——每日最新资讯28at.com

另外,String 类型的数据可以保存二进制字节流,这使得它非常灵活,只需将数据转换成二进制字节数组,就可以轻松地进行存储。5xA28资讯网——每日最新资讯28at.com

因此,我们的初始解决方案是使用 String 类型来存储数据。我们将图片 ID 和图片存储对象 ID 分别用作键值对中的键和值,其中图片存储对象 ID 使用了 String 类型。5xA28资讯网——每日最新资讯28at.com

最初,我们成功地存储了一亿张图片,大约使用了 6.4GB 的内存。但是,随着图片数据不断增加,我们开始遇到了问题,Redis 实例的内存使用量不断上升,导致生成 RDB 文件时出现延迟的情况。显然,String 类型并不是一个适合大规模数据存储的理想选择,因此我们需要寻找更为节省内存开销的数据类型解决方案。5xA28资讯网——每日最新资讯28at.com

在这个过程中,我深入研究了 String 类型的底层结构,找出了它内存开销较大的原因。这让我对这个“通用型”的 String 数据类型有了新的认识,它并不适用于所有情况,尤其在内存空间消耗方面存在明显短板。5xA28资讯网——每日最新资讯28at.com

与此同时,我还仔细研究了集合类型的数据结构,发现它们具有非常高效的内存管理结构。但是,集合类型的数据结构通常用于保存一键多值的数据,不太适用于直接存储单一键对应的单一值。因此,我们采用了二级编码的方法,成功地使用集合类型来存储单一键值对。这种改变显著降低了 Redis 实例的内存开销。5xA28资讯网——每日最新资讯28at.com

在本篇文章中,我将与你分享我在解决这一问题过程中所获得的经验和方法,包括 String 类型的内存开销问题,可节省内存的数据结构选择,以及如何使用集合类型来存储单一键值对。如果你在使用 String 类型时也遇到了内存开销较大的问题,那么今天的解决方案可能会对你有所帮助。5xA28资讯网——每日最新资讯28at.com

接下来,我们先来看看 String 类型的内存都消耗在哪里了。5xA28资讯网——每日最新资讯28at.com

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

在刚才的案例中,我们保存了 1 亿张图片的信息,用了约 6.4GB 的内存,一个图片 ID 和图片存储对象 ID 的记录平均用了 64 字节。5xA28资讯网——每日最新资讯28at.com

但问题是,一组图片 ID 及其存储对象 ID 的记录,实际只需要 16 字节就可以了。5xA28资讯网——每日最新资讯28at.com

我们来分析一下。图片 ID 和图片存储对象 ID 都是 10 位数,我们可以用两个 8 字节的 Long 类型表示这两个 ID。因为 8 字节的 Long 类型最大可以表示 2 的 64 次方的数值,所以肯定可以表示 10 位数。但是,为什么 String 类型却用了 64 字节呢?5xA28资讯网——每日最新资讯28at.com

其实,除了记录实际数据,String 类型还需要额外的内存空间记录数据长度、空间使用等信息,这些信息也叫作元数据。当实际保存的数据较小时,元数据的空间开销就显得比较大了,有点“喧宾夺主”的意思。5xA28资讯网——每日最新资讯28at.com

那么,String 类型具体是怎么保存数据的呢?我来解释一下。5xA28资讯网——每日最新资讯28at.com

当你保存 64 位有符号整数时,String 类型会把它保存为一个 8 字节的 Long 类型整数,这种保存方式通常也叫作 int 编码方式。5xA28资讯网——每日最新资讯28at.com

但是,当你保存的数据中包含字符时,String 类型就会用简单动态字符串(Simple Dynamic String,SDS)结构体来保存,如下图所示:5xA28资讯网——每日最新资讯28at.com

图片图片5xA28资讯网——每日最新资讯28at.com

buf:字节数组,保存实际数据。为了表示字节数组的结束,Redis 会自动在数组最后加一个“/0”,这就会额外占用 1 个字节的开销。5xA28资讯网——每日最新资讯28at.com

len:占 4 个字节,表示 buf 的已用长度。5xA28资讯网——每日最新资讯28at.com

alloc:也占个 4 字节,表示 buf 的实际分配长度,一般大于 len。5xA28资讯网——每日最新资讯28at.com

可以看到,在 SDS 中,buf 保存实际数据,而 len 和 alloc 本身其实是 SDS 结构体的额外开销。5xA28资讯网——每日最新资讯28at.com

另外,对于 String 类型来说,除了 SDS 的额外开销,还有一个来自于 RedisObject 结构体的开销。5xA28资讯网——每日最新资讯28at.com

因为 Redis 的数据类型有很多,而且,不同数据类型都有些相同的元数据要记录(比如最后一次访问的时间、被引用的次数等),所以,Redis 会用一个 RedisObject 结构体来统一记录这些元数据,同时指向实际数据。5xA28资讯网——每日最新资讯28at.com

一个 RedisObject 包含了 8 字节的元数据和一个 8 字节指针,这个指针再进一步指向具体数据类型的实际数据所在,例如指向 String 类型的 SDS 结构所在的内存地址,可以看一下下面的示意图。关于 RedisObject 的具体结构细节,我会在后面的课程中详细介绍,现在你只要了解它的基本结构和元数据开销就行了。5xA28资讯网——每日最新资讯28at.com

图片图片5xA28资讯网——每日最新资讯28at.com

为了节省内存空间,Redis 还对 Long 类型整数和 SDS 的内存布局做了专门的设计。5xA28资讯网——每日最新资讯28at.com

一方面,当保存的是 Long 类型整数时,RedisObject 中的指针就直接赋值为整数数据了,这样就不用额外的指针再指向整数了,节省了指针的空间开销。5xA28资讯网——每日最新资讯28at.com

另一方面,当保存的是字符串数据,并且字符串小于等于 44 字节时,RedisObject 中的元数据、指针和 SDS 是一块连续的内存区域,这样就可以避免内存碎片。这种布局方式也被称为 embstr 编码方式。5xA28资讯网——每日最新资讯28at.com

当然,当字符串大于 44 字节时,SDS 的数据量就开始变多了,Redis 就不再把 SDS 和 RedisObject 布局在一起了,而是会给 SDS 分配独立的空间,并用指针指向 SDS 结构。这种布局方式被称为 raw 编码模式。5xA28资讯网——每日最新资讯28at.com

为了帮助你理解 int、embstr 和 raw 这三种编码模式,我画了一张示意图,如下所示:5xA28资讯网——每日最新资讯28at.com

图片图片5xA28资讯网——每日最新资讯28at.com

好了,知道了 RedisObject 所包含的额外元数据开销,现在,我们就可以计算 String 类型的内存使用量了。5xA28资讯网——每日最新资讯28at.com

因为 10 位数的图片 ID 和图片存储对象 ID 是 Long 类型整数,所以可以直接用 int 编码的 RedisObject 保存。每个 int 编码的 RedisObject 元数据部分占 8 字节,指针部分被直接赋值为 8 字节的整数了。此时,每个 ID 会使用 16 字节,加起来一共是 32 字节。但是,另外的 32 字节去哪儿了呢?5xA28资讯网——每日最新资讯28at.com

Redis 会使用一个全局哈希表保存所有键值对,哈希表的每一项是一个 dictEntry 的结构体,用来指向一个键值对。dictEntry 结构中有三个 8 字节的指针,分别指向 key、value 以及下一个 dictEntry,三个指针共 24 字节,如下图所示:5xA28资讯网——每日最新资讯28at.com

图片图片5xA28资讯网——每日最新资讯28at.com

但是,这三个指针只有 24 字节,为什么会占用了 32 字节呢?这就要提到 Redis 使用的内存分配库 jemalloc 了。5xA28资讯网——每日最新资讯28at.com

jemalloc 在分配内存时,会根据我们申请的字节数 N,找一个比 N 大,但是最接近 N 的 2 的幂次数作为分配的空间,这样可以减少频繁分配的次数。5xA28资讯网——每日最新资讯28at.com

举个例子。如果你申请 6 字节空间,jemalloc 实际会分配 8 字节空间;如果你申请 24 字节空间,jemalloc 则会分配 32 字节。所以,在我们刚刚说的场景里,dictEntry 结构就占用了 32 字节。5xA28资讯网——每日最新资讯28at.com

好了,到这儿,你应该就能理解,为什么用 String 类型保存图片 ID 和图片存储对象 ID 时需要用 64 个字节了。5xA28资讯网——每日最新资讯28at.com

你看,明明有效信息只有 16 字节,使用 String 类型保存时,却需要 64 字节的内存空间,有 48 字节都没有用于保存实际的数据。我们来换算下,如果要保存的图片有 1 亿张,那么 1 亿条的图片 ID 记录就需要 6.4GB 内存空间,其中有 4.8GB 的内存空间都用来保存元数据了,额外的内存空间开销很大。那么,有没有更加节省内存的方法呢?5xA28资讯网——每日最新资讯28at.com

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

Redis 有一种底层数据结构,叫压缩列表(ziplist),这是一种非常节省内存的结构。5xA28资讯网——每日最新资讯28at.com

我们先回顾下压缩列表的构成。表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量,以及列表中的 entry 个数。压缩列表尾还有一个 zlend,表示列表结束。5xA28资讯网——每日最新资讯28at.com

图片图片5xA28资讯网——每日最新资讯28at.com

压缩列表之所以能节省内存,就在于它是用一系列连续的 entry 保存数据。每个 entry 的元数据包括下面几部分。5xA28资讯网——每日最新资讯28at.com

prev_len,表示前一个 entry 的长度。prev_len 有两种取值情况:1 字节或 5 字节。取值 1 字节时,表示上一个 entry 的长度小于 254 字节。虽然 1 字节的值能表示的数值范围是 0 到 255,但是压缩列表中 zlend 的取值默认是 255,因此,就默认用 255 表示整个压缩列表的结束,其他表示长度的地方就不能再用 255 这个值了。所以,当上一个 entry 长度小于 254 字节时,prev_len 取值为 1 字节,否则,就取值为 5 字节。5xA28资讯网——每日最新资讯28at.com

len:表示自身长度,4 字节;5xA28资讯网——每日最新资讯28at.com

encoding:表示编码方式,1 字节;5xA28资讯网——每日最新资讯28at.com

content:保存实际数据。5xA28资讯网——每日最新资讯28at.com

这些 entry 会挨个儿放置在内存中,不需要再用额外的指针进行连接,这样就可以节省指针所占用的空间。5xA28资讯网——每日最新资讯28at.com

我们以保存图片存储对象 ID 为例,来分析一下压缩列表是如何节省内存空间的。5xA28资讯网——每日最新资讯28at.com

每个 entry 保存一个图片存储对象 ID(8 字节),此时,每个 entry 的 prev_len 只需要 1 个字节就行,因为每个 entry 的前一个 entry 长度都只有 8 字节,小于 254 字节。这样一来,一个图片的存储对象 ID 所占用的内存大小是 14 字节(1+4+1+8=14),实际分配 16 字节。5xA28资讯网——每日最新资讯28at.com

Redis 基于压缩列表实现了 List、Hash 和 Sorted Set 这样的集合类型,这样做的最大好处就是节省了 dictEntry 的开销。当你用 String 类型时,一个键值对就有一个 dictEntry,要用 32 字节空间。但采用集合类型时,一个 key 就对应一个集合的数据,能保存的数据多了很多,但也只用了一个 dictEntry,这样就节省了内存。5xA28资讯网——每日最新资讯28at.com

这个方案听起来很好,但还存在一个问题:在用集合类型保存键值对时,一个键对应了一个集合的数据,但是在我们的场景中,一个图片 ID 只对应一个图片的存储对象 ID,我们该怎么用集合类型呢?换句话说,在一个键对应一个值(也就是单值键值对)的情况下,我们该怎么用集合类型来保存这种单值键值对呢?5xA28资讯网——每日最新资讯28at.com

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

在保存单值的键值对时,可以采用基于 Hash 类型的二级编码方法。这里说的二级编码,就是把一个单值的数据拆分成两部分,前一部分作为 Hash 集合的 key,后一部分作为 Hash 集合的 value,这样一来,我们就可以把单值数据保存到 Hash 集合中了。5xA28资讯网——每日最新资讯28at.com

以图片 ID 1101000060 和图片存储对象 ID 3302000080 为例,我们可以把图片 ID 的前 7 位(1101000)作为 Hash 类型的键,把图片 ID 的最后 3 位(060)和图片存储对象 ID 分别作为 Hash 类型值中的 key 和 value。5xA28资讯网——每日最新资讯28at.com

按照这种设计方法,我在 Redis 中插入了一组图片 ID 及其存储对象 ID 的记录,并且用 info 命令查看了内存开销,我发现,增加一条记录后,内存占用只增加了 16 字节,如下所示:5xA28资讯网——每日最新资讯28at.com

127.0.0.1:6379> info memory# Memoryused_memory:1039120127.0.0.1:6379> hset 1101000 060 3302000080(integer) 1127.0.0.1:6379> info memory# Memoryused_memory:1039136

在使用 String 类型时,每个记录需要消耗 64 字节,这种方式却只用了 16 字节,所使用的内存空间是原来的 1/4,满足了我们节省内存空间的需求。5xA28资讯网——每日最新资讯28at.com

不过,你可能也会有疑惑:“二级编码一定要把图片 ID 的前 7 位作为 Hash 类型的键,把最后 3 位作为 Hash 类型值中的 key 吗?”其实,二级编码方法中采用的 ID 长度是有讲究的5xA28资讯网——每日最新资讯28at.com

Redis Hash 类型的两种底层实现结构,分别是压缩列表和哈希表。5xA28资讯网——每日最新资讯28at.com

那么,Hash 类型底层结构什么时候使用压缩列表,什么时候使用哈希表呢?其实,Hash 类型设置了用压缩列表保存数据时的两个阈值,一旦超过了阈值,Hash 类型就会用哈希表来保存数据了。5xA28资讯网——每日最新资讯28at.com

这两个阈值分别对应以下两个配置项:5xA28资讯网——每日最新资讯28at.com

hash-max-ziplist-entries:表示用压缩列表保存时哈希集合中的最大元素个数。5xA28资讯网——每日最新资讯28at.com

hash-max-ziplist-value:表示用压缩列表保存时哈希集合中单个元素的最大长度。5xA28资讯网——每日最新资讯28at.com

如果我们往 Hash 集合中写入的元素个数超过了 hash-max-ziplist-entries,或者写入的单个元素大小超过了 hash-max-ziplist-value,Redis 就会自动把 Hash 类型的实现结构由压缩列表转为哈希表。5xA28资讯网——每日最新资讯28at.com

一旦从压缩列表转为了哈希表,Hash 类型就会一直用哈希表进行保存,而不会再转回压缩列表了。在节省内存空间方面,哈希表就没有压缩列表那么高效了。5xA28资讯网——每日最新资讯28at.com

为了能充分使用压缩列表的精简内存布局,我们一般要控制保存在 Hash 集合中的元素个数。所以,在刚才的二级编码中,我们只用图片 ID 最后 3 位作为 Hash 集合的 key,也就保证了 Hash 集合的元素个数不超过 1000,同时,我们把 hash-max-ziplist-entries 设置为 1000,这样一来,Hash 集合就可以一直使用压缩列表来节省内存空间了。5xA28资讯网——每日最新资讯28at.com

小结

在这篇文章中,我们将颠覆以往对 String 数据类型的传统认知。以前,String 被视为一种“万金油”,在各种场合都被广泛使用。然而,当存储的键值对数据本身占用的内存空间较小时,String 类型的元数据开销占据了主导地位。这些开销包括 RedisObject 结构、SDS 结构以及dictEntry 结构的内存消耗。5xA28资讯网——每日最新资讯28at.com

为了应对这种情况,我们可以采用压缩列表(ziplist)来存储数据。当然,当使用 Hash 这种集合类型来保存单一键值对数据时,我们需要将单一值数据分割成两部分,分别作为 Hash 集合的键和值。就像之前案例中使用了二级编码来表示图片 ID那样,我们鼓励你将这一方法应用到你的具体场景中。这不仅可以减少内存开销,还能提高 Redis 的性能。希望这个解决方案对你的应用有所帮助。5xA28资讯网——每日最新资讯28at.com

本文链接:http://www.28at.com/showinfo-26-14711-0.htmlRedis中万金油的String,为什么不好用了?

声明:本网页内容旨在传播知识,若有侵权等问题请及时与本网联系,我们将在第一时间删除处理。邮件:2376512515@qq.com

上一篇: 阿里二面:消息队列的事务消息可以用 TCC 模式实现吗?

下一篇: 如何从单体架构迁移到微服务架构:挑战和最佳实践

标签:
  • 热门焦点
Top