假设,我们用String 类型存储一个键值对,他们的长度都是10位数,其实用两个8字节的Long类型表示就可以了。Long最大可以表示2^64次方,表示10为数字绝对没问题。

但是Redis中的String 类型表示这一对 key 和 val 却用了64字节,这是因为String类型实际上还需要额外的内存空间记录数据长度,空间使用,等信息。如果用String存储比较小的数据,额外的空间就显得比较大。

当我们保存64位有符号整数时,String类型会把它保存为一个8字节的Long类型整数,这种保存也叫 int 编码。如果保存的数据中包含字符串时,String 类型就会用 简单动态字符串(SDS)来保存。其结构如下图所示:

image-20231222104741805
  • buf:字节数组,保存实际的数据,为了表示数据结束,会在数组后加一个”\0”,这就会额外占用1个字节的开销。
  • len:占4个字节,表示buf已使用的长度。
  • alloc:占4个字节,表示buf的实际分配长度,一般大于len。

对于String,除了SDS的额外开销,还有一个来自于RedisObject结构体的开销。

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

一个 RedisObject 包含了 8 字节的元数据和一个 8 字节指针,这个指针再进一步指向具体数据类型的实际数据所在:

image-20231222105622386

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

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

另一方面,当保存的是字符串数据,并且字符串小于等于44时,RedisObject 中的元数据、指针和 SDS 是一块连续的内存区域,这样就可以避免内存碎片,这种布局被称为embstr编码。当字符串大于44字节时,Redis就不再把SDS和RedisObject布局在一起,而是给SDS分配单独空间,并用指针指向SDS结构,这种被称为raw编码

image-20231222111057985

至此,十位数的key和十位数的val是Long类型整数,可以直接用int编码的RedisObject保存。每个int编码的RedisObject元数据部分占8字节,指针部分被直接赋值为8字节整数。此时,每个ID会使用16字节,加起来一共是32字节。与64字节还差32字节。

其实,Redis会使用一个全局哈希表保存所有键值对,哈希表每一项是一个dictEntry的结构体,用来指向一个键值对,三个指针一共24字节,如下图所示:

image-20231222111723672

但是这样也只有24字节,还差了8字节。这里涉及到Redis使用的内存分配库jemalloc,该函数在分配内存时,会根据我们申请的字节数N,找一个比N大,但是最接近N的2的幂次数作为分配的空间,这样可以减少频繁分配次数。

如何节省内存?

Redis 有一种底层数据结构,叫压缩列表(ziplist)。压缩列表表头有三个字段,zlbytes、zltail 和 zllen,分别表示列表长度,列表尾的偏移量以及列表中的entry个数。压缩列表尾还有一个 zlend,表示列表结束。

image-20231222112339962

每个entry又有以下及部分:

  • prev_len,表示前一个 entry 的长度。prev_len 有两种取值情况:1 字节或 5 字节。取值 1 字节时,表示上一个 entry 的长度小于 254 字节,取5时表示大于255字节。
  • len:表示自身长度,4字节。
  • encoding:表示编码方式,1字节。
  • content:保存实际数据。

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

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