本篇我们开始讲字典 key 的内部结构,也就是 sds 字符串。首先它不是普通字符串,而是 sds 字符串,这个 sds 的意思是「Simple Dynamic String」,它的结构很简单,它是动态的,意味着可以支持修改。不过即使是这样简单的字符串结构,在结构设计上作者可是煞费苦心。
我们知道 C语言里面的字符串是以0x\0结尾,通常就说是以 NULL 结尾。它不包含长度信息,当我们需要获取字符串长度时,需要调用 strlen(s) 来获取长度,它的时间复杂度是 O(n),如果一个字符串太长,这个函数就太浪费 CPU了。
所以 Redis 不能这么干,它需要将长度信息使用单独的字段进行存储,这就需要一个额外的字段,这个字段也要占用存储空间。在日常使用中,小字符串才是大头,它的长度信息往往只需要 1byte 存储就可以了,可以表示最大长度为 255 的字符串。如果字符串再大一些,就需要 2byte,甚至是 3byte、4byte。Redis 会为不同长度的字符串选择不同长度的字段来表示长度信息。同时 Redis 为了可以直接使用标准C语言字符串库函数,sds 的字符串内容还是以 NULL 结尾,这会额外多占用一个字节的空间。
sds 是动态字符串,它需要支持追加操作,需要能扩充容量。如果字符串放置的比较紧凑,追加时,就需要重新分配新的更大的存储空间,然后进行内容的拷贝(不严格,想想为什么)。如果追加的太频繁,内存的分配和拷贝就会消耗大量 CPU。
图片
所以 Redis 为动态字符串设计了冗余空间,追加时只要内容不是太大,是可以不必重新分配内存的,如果字符串的长度是1024,Redis 会分配2048字节的存储空间,也就是 100% 的冗余空间。这个设计非常类似于 Java 语言的 ArrayList 。不过 Redis 考虑的更加周到,当字符串的长度超过 1M 时,它的冗余空间只有 1M,避免出现太大的浪费。Redis 还限制了字符串最大长度不得超过 512M。
下面是 sds 字符串的结构定义源码
我们日常使用的字符串都是只读的,一般只有拿字符串当位图使用时才会对字符串进行追加和修改操作。为了避免浪费,Redis 在第一次创建 sds 字符串时,不给它分配冗余空间。在第一次追加操作之后才会分配 100% 的冗余空间。
图片
值得注意的是,我们平时使用的字符串指针都是指向字符串内存空间的头部,但是在 Redis 里面我们使用的 sds 字符串指针指向的是字符串内存空间的脖子部位,因为 sds 字符串有自己的头部信息。
如果 sds 字符串只是作为字典的 key 而存在,那么字典里面元素的 key 会直接指向 sds。如果 字符串是作为 Redis的对象而存在,它还会包上一个通用的对象头,也就是 RedisObject。对象头的 ptr 字段会指向 sds。
讲到这里,需要提一下现代计算机的结构上在 CPU 和 内存之间存在一个缓存的结构,用来协调 CPU 的高效和访存的相对缓慢的矛盾。我们平时听到的 L1 Cache、L2 Cache就是这个缓存。当 CPU 要访问内存时先在缓存里找一找有没有,如果没有就去内存里拿了之后放到缓存里,这个缓存的最小单位一般是 64 字节,也就是一次性缓存连续的 64 字节内容,这个最小单位称为「缓存行」。这样下次获取内存地址附近的数据时可以直接从缓存中拿到。
对于 Redis 的字符串对象来说,我们需要先访问 redisObject 对象头,拿到 ptr 指针,然后再访问指向的 sds 字符串。如果对象头和 sds 字符串相距较远,就会存在缓存穿透现象,性能就会打折。所以 Redis 为了优化硬件的缓存命中,它为字符串设计了一种特殊的编码结构,这种结构就是 embstr 。它将 redisObject 对象头和 sds 字符串挤在一起连续存储,可以一次性放到缓存行里,这样就可以明显提升缓存命中率。
object 指令观察一下对象的编码类型来验证一下这个计算是否正确。
注意到上面的输出中出现了 encoding:int 类型的编码,这是怎么回事呢?原来 Redis 又对整型字符串做了优化,当字符串是可以用 long 类型表达的整数时,Redis 内部将会使用整型编码。注意整数在 Redis 内部的类型 type 是字符串。
我们再观察一遍 redisObject 对象头。
当字符串内容可以用 long 整数表达时,对象头的 ptr 指针将退化为一个 long 型的整数。也就是
如果这个整数太大,超出了 long 的表达范围,就会使用 sds 字符串表示,根据长短不同会分别选择 embstr 和 raw 编码类型。
我们再看一个很诡异的现象
注意 debug object 指令输出的 Value at: xxxxxxx 这个表示 redisObject 对象头的地址。为什么值为 9999 时,两个对象的地址是一样的。而变成了 10000 地址就不一样了呢?
这是因为「小整数对象缓存」。Redis 在初始化的时候会构造 [0, 10000) 这1w个小整数对象持久放在内存里,以后凡是在这个范围内的整型字符串都会直接使用共享的小整数对象。小整数对象的引用计数字段的值恒定为 INT_MAX。在很多面向对象的语言中,都有小整数对象缓存的概念。
接下来我们仔细分析一下创建 embstr 的函数 createEmbeddedStringObject 的代码
我们可以看到对象头和字符串内容是通过一次zmalloc调用分配的,也就是说对象头和字符串内容是连续的分配在一起。还将 sds 字符串的 flags 设置为 SDS_TYPE_8 说明它是一个短字符串,长度可以直接用一个字节就可以表示。同时在字符串内容 buf 的尾部有 '\0' 标识,这是 C 字符串的结束标志。
欢迎工作一到五年的Java工程师朋友们加入Java架构开发:744677563
本群提供免费的学习指导 架构资料 以及免费的解答
不懂得问题都可以在本群提出来 之后还会有职业生涯规划以及面试指导