String 为什么不好用了?—— 浅析 Redis 数据结构

文摘   科技   2024-02-27 12:41   浙江  

01

问题与背景

Redis 的 String 数据类型以简单、易用著称。但是,在 Redis 使用规范中,要求我们在大业务量的场景下,慎用 String 数据结构,这是为什么呢?通过我之前遇到的一个场景,经过精简,和大家共同一探究竟。


我遇到的任务是根据 insight_point_id 快速获取其所属的 insight_id。insight_point_id 与 insight_id 都是10位数字:


insight_point_id: 1101000051insight_id: 3301000051


这个任务完美契合 Redis 中 String 数据结构的 key-value 使用场景。因此,我使用 String 类型来存储数据。


在上线前的压力测试中,我模拟真实使用场景,在 Redis 中存储了一亿个 key-value 对,大约使用了6.4GB 的内存,平均一个键值对用了64字节。但是,简单分析一下上述的 key-value 记录,实际只需要16字节就可以了:insight_point_id 与 insight_id 都是10位数,我们可以用两个8字节的 Long 类型表示这两个 ID。因为8字节的 Long 类型最大可以表示2的64次方的数值,所以肯定可以表示10位数。


但是,为什么 String 类型却用了64字节呢?


02

String 的数据结构

因为 Redis 的数据类型有很多,而且,不同数据类型都有些相同的 metadata 要记录(比如最后一次访问的时间、被引用的次数等),Redis 用 RedisObject 结构体来统一记录,并用一个指针指向实际数据。


当我们使用 String 数据类型,并且存储的数据包含字符类型的时候,这个指针便指向 SDS 数据结构(Simple Dynamic String),这个数据结构也预留了8字节去描述内存的分配与使用情况,并且存储了实际 value。



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


一方面,当保存的是 Long 类型整数时,RedisObject 中的指针就直接赋值为整数数据,这样就不用额外的指针再指向整数了,节省指针的空间开销。这种方式叫做 int 编码


另一方面,当保存的是字符串数据,并且字符串小于等于44字节时,RedisObject 中的元数据、指针和 SDS 是一块连续的内存区域,这样就可以避免内存碎片。这种布局方式被称为 embstr 编码方式。


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



了解了 RedisObject 所包含的额外开销,我们就可以计算 String 类型的内存使用量了。因为10位数的 insight_point_id 和 insight_id 是 Long 类型整数,所以可以直接用 int 编码的 RedisObject 保存。每个 int 编码的 RedisObject 元数据部分占8字节,指针部分被直接赋值为8字节的整数了。此时,每个 ID 会使用16字节,加起来一共是32字节。


但是,另外的32字节去哪儿了呢?


03

Redis 全局 Hash 结构

众所周知,Redis 的顶层数据结构是一张 Hash 表,哈希表的每一项是一个 dictEntry 的结构体,用来指向一个 key-value。dictEntry 结构中有三个8字节的指针,分别指向 key、value 以及下一个 dictEntry,三个指针共24字节。


另外,Redis 底层使用 jemalloc 库进行内存分配,在分配内存时,会根据申请的字节数 N,找一个比 N 大,但是最接近 N 的2的幂次数作为分配的空间,这样可以减少频繁分配的次数。


因此,这三个指针只有24字节,实际却占用了32字节。



综上,我们总算梳理清楚“内存去哪儿了”。我们的有效信息只有16字节,使用 String 类型保存时,却需要64字节的内存空间,有48字节都没有用于保存实际的数据。我们来换算下,6.4GB 内存空间其中有 4.8GB 的内存空间都用来保存元数据了。额外的内存空间开销很大,非常的不划算。


那么,有没有更加节省内存的方法呢?


04

Redis Hash 数据类型的 ziplist 数据结构

Redis 的 Hash 数据类型使用了 ziplist 数据结构:



ziplist 表头有三个字段 zlbytes、zltail 和 zllen,分别表示列表长度、列表尾的偏移量,以及列表中的 entry 个数,表尾还有一个 zlend,表示列表结束。


ziplist 之所以能节省内存,就在于它是用一系列连续的 entry 保存数据。每个 entry 的元数据包括下面几部分:


prev_len

表示前一个 entry 的长度

1字节

len

表示自身长度

4字节

encoding

表示编码方式

1字节

content

保存实际数据

-


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


以 insight_id 为例,每个 entry 保存一个 insight_id (8字节),此时,每个 entry 占用的内存为:

prev_len+len+encoding+content=1+4+1+8=14,

实际分配16字节。


当我们用 String 类型时,一个键值对就有一个 dictEntry,要用32字节空间。采用 Hash 时,一个 key 就对应一个集合的数据,能保存的数据多了很多,但也只用了一个 dictEntry,这样就节省了内存。


这个优化方案看起来不错,但还存在一个问题:在用 Hash 类型保存 key-value 时,一个 key 对应了一个集合的数据,但是在我们的场景中,一个 insight_point_id 只对应一个 insight_id,我们该怎么用 Hash 数据类型呢?


05

二级编码方法

我们可以采用二级编码方法解决上面的问题。所谓二级编码,就是把一个单值的数据拆分成两部分,前一部分作为 Hash 集合的 key,后一部分作为 Hash 集合的 value,这样一来,我们就可以把单值数据保存到 Hash 集合中了。以 insight_point_id: 1101000060 和 insight_id: 3302000080 为例,我们可以把 insight_ponit_id 的前7位(1101000)作为 Hash 类型的键,把 insight_point_id 的最后3位(060)和 insight_id 分别作为 Hash 类型值中的 key 和 value。按照这种设计方法,我在 Redis 中插入了一组 insight_point_id,insight_id,并且用 info 命令查看了内存开销,我发现,增加一条记录后,内存占用只增加了16字节:


printf("hello world!");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,大大节省内存空间。


为什么二级编码一定要把 insight_point_id 的前7位作为 Hash 类型的键,把最后3位作为 Hash 类型值中的 key 呢?其实,二级编码方法中采用的 ID 长度是有讲究的。


Redis Hash 类型的两种底层实现结构,分别是 ziplist 和哈希表。


Hash 类型设置了用压缩列表保存数据时的两个阈值,一旦超过了阈值,Hash 类型就会用哈希表来保存数据了。


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


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


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


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


06

总结

String 数据类型通常被视为一种“万金油”,在各种场合都被广泛使用。然而,当存储的键值对数据本身占用的内存空间较小时,String 类型的元数据开销占据了主导地位。这些开销包括 RedisObject 结构、SDS 结构以及 dictEntry 结构的内存消耗。


为了应对这种情况,我们可以采用压缩列表(ziplist)来存储数据。当使用 Hash 这种集合类型来保存单一键值对数据时,我们可以使用二级编码的方法将单一值数据分割成两部分,分别作为 Hash 集合的键和值。这不仅可以减少内存开销,还能提高 Redis 的性能。


微策略 商业智能
微策略 MicroStrategy (Nasdaq: MSTR) 是企业级分析和移动应用软件行业的佼佼者。关注我们了解行业资讯、技术干货和程序员日常。
 最新文章