继上篇讲解了字典的内部结构 之后,本篇我们开始讲字典 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 字符串的结构定义源码
typedef char* sds;
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len;
uint8_t alloc;
unsigned char flags;
char buf[]; // sds指向buf
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len;
uint16_t alloc;
unsigned char flags;
char buf[]; // sds指向buf
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len;
uint32_t alloc;
unsigned char flags;
char buf[]; // sds指向buf
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len;
uint64_t alloc;
unsigned char flags;
char buf[]; // sds指向buf
};
#define SDS_TYPE_MASK 7
#define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T))))
// SDS_HDR指向sdshdr
// sds指向sdshdr.buf
static inline size_t sdslen(const sds s) {
unsigned char flags = s[-1]; // 注意负下标
switch(flags&SDS_TYPE_MASK) {
return SDS_HDR(8,s)->len;
case SDS_TYPE_16:
return SDS_HDR(16,s)->len;
case SDS_TYPE_32:
return SDS_HDR(32,s)->len;
case SDS_TYPE_64:
return SDS_HDR(64,s)->len;
}
return 0;
}
我们日常使用的字符串都是只读的,一般只有拿字符串当位图使用时才会对字符串进行追加和修改操作。为了避免浪费,Redis 在第一次创建 sds 字符串时,不给它分配冗余空间。在第一次追加操作之后才会分配 100% 的冗余空间。
值得注意的是,我们平时使用的字符串指针都是指向字符串内存空间的头部,但是在 Redis 里面我们使用的 sds 字符串指针指向的是字符串内存空间的脖子部位,因为 sds 字符串有自己的头部信息。
如果 sds 字符串只是作为字典的 key 而存在,那么字典里面元素的 key 会直接指向 sds。如果 字符串是作为 Redis的对象而存在,它还会包上一个通用的对象头,也就是 RedisObject。对象头的 ptr 字段会指向 sds。
typedef struct redisObject {
unsigned type:4; // 对象类型
unsigned encoding:4; // 对象编码
unsigned lru:24; // LRU时间戳
int refcount; // 引用计数
void *ptr; // 指向体结构的指针
} robj;
讲到这里,需要提一下现代计算机的结构上在 CPU 和 内存之间存在一个缓存的结构,用来协调 CPU 的高效和访存的相对缓慢的矛盾。我们平时听到的 L1 Cache、L2 Cache就是这个缓存。当 CPU 要访问内存时先在缓存里找一找有没有,如果没有就去内存里拿了之后放到缓存里,这个缓存的最小单位一般是 64 字节,也就是一次性缓存连续的 64 字节内容,这个最小单位称为「缓存行」。这样下次获取内存地址附近的数据时可以直接从缓存中拿到。
对于 Redis 的字符串对象来说,我们需要先访问 redisObject 对象头,拿到 ptr 指针,然后再访问指向的 sds 字符串。如果对象头和 sds 字符串相距较远,就会存在缓存穿透现象,性能就会打折。所以 Redis 为了优化硬件的缓存命中,它为字符串设计了一种特殊的编码结构,这种结构就是 embstr 。它将 redisObject 对象头和 sds 字符串挤在一起连续存储,可以一次性放到缓存行里,这样就可以明显提升缓存命中率。
#define OBJ_ENCODING_RAW 0 // 普通 sds 字符串
#define OBJ_ENCODING_EMBSTR 8 // embstr
但是缓存行毕竟只有 64 字节,所以它能容纳的 sds 字符串的长度也是有限的。我们计算一下 redisObject 对象头需要占用 16 字节,最短的 sds 头部需要占用 3 字节,那么剩下的只有 45 字节了,字符串还需要以 NULL 结尾,最终留给字符串的长度最大也就只有 44 字节。我们可以通过 debug object 指令观察一下对象的编码类型来验证一下这个计算是否正确。
127.0.0.1:6379> set codehole abcdefghijklmnopqrstuvwxyz012345678901234567
OK
127.0.0.1:6379> debug object codehole
Value at:0x7efed82220c0 refcount:1 encoding:embstr serializedlength:45 lru:2942469 lru_seconds_idle:4562936
127.0.0.1:6379> set codehole abcdefghijklmnopqrstuvwxyz0123456789012345678
OK
127.0.0.1:6379> debug object codehole
Value at:0x7efed82704c0 refcount:1 encoding:raw serializedlength:46 lru:2942210 lru_seconds_idle:4563172
127.0.0.1:6379> set codehole 1024
OK
127.0.0.1:6379> debug object codehole
Value at:0x7efed824cb90 refcount:2147483647 encoding:int serializedlength:3 lru:2942982 lru_seconds_idle:4562541
注意到上面的输出中出现了 encoding:int 类型的编码,这是怎么回事呢?原来 Redis 又对整型字符串做了优化,当字符串是可以用 long 类型表达的整数时,Redis 内部将会使用整型编码。注意整数在 Redis 内部的类型 type 是字符串。
#define OBJ_ENCODING_INT 1
我们再观察一遍 redisObject 对象头。
typedef struct redisObject {
unsigned type:4; // 对象类型
unsigned encoding:4; // 对象编码
unsigned lru:24; // LRU时间戳
int refcount; // 引用计数
void *ptr; // 指向体结构的指针
} robj;
当字符串内容可以用 long 整数表达时,对象头的 ptr 指针将退化为一个 long 型的整数。也就是
typedef struct redisObject {
unsigned type:4; // 对象类型
unsigned encoding:4; // 对象编码
unsigned lru:24; // LRU时间戳
int refcount; // 引用计数
long value; // 整数值
} robj;
如果这个整数太大,超出了 long 的表达范围,就会使用 sds 字符串表示,根据长短不同会分别选择 embstr 和 raw 编码类型。
我们再看一个很诡异的现象
127.0.0.1:6379> set codehole1 9999
OK
127.0.0.1:6379> set codehole2 9999
OK
127.0.0.1:6379> debug object codehole1
Value at:0x7efed826fc80 refcount:2147483647 encoding:int serializedlength:3 lru:2946566 lru_seconds_idle:4559814
127.0.0.1:6379> debug object codehole2
Value at:0x7efed826fc80 refcount:2147483647 encoding:int serializedlength:3 lru:2946566 lru_seconds_idle:4559819
127.0.0.1:6379> set codehole1 10000
OK
127.0.0.1:6379> set codehole2 10000
OK
127.0.0.1:6379> debug object codehole1
Value at:0x7efed821b080 refcount:1 encoding:int serializedlength:3 lru:2946823 lru_seconds_idle:4559610
127.0.0.1:6379> debug object codehole2
Value at:0x7efed821b160 refcount:1 encoding:int serializedlength:3 lru:2946822 lru_seconds_idle:4559613
注意 debug object 指令输出的 Value at: xxxxxxx 这个表示 redisObject 对象头的地址。为什么值为 9999 时,两个对象的地址是一样的。而变成了 10000 地址就不一样了呢?
for (j = 0; j < OBJ_SHARED_INTEGERS; j++) {
shared.integers[j] = makeObjectShared(createObject(OBJ_STRING,(void*)(long)j));
shared.integers[j]->encoding = OBJ_ENCODING_INT;
}
这是因为「小整数对象缓存」。Redis 在初始化的时候会构造 [0, 10000) 这1w个小整数对象持久放在内存里,以后凡是在这个范围内的整型字符串都会直接使用共享的小整数对象。小整数对象的引用计数字段的值恒定为 INT_MAX。在很多面向对象的语言中,都有小整数对象缓存的概念。
接下来我们仔细分析一下创建 embstr 的函数 createEmbeddedStringObject 的代码
robj *createEmbeddedStringObject(const char *ptr, size_t len) {
// redisObject对象头大小 + sds头部大小 + 字符串长度 + 1 (NULL结尾)
// redisObject对象头指针
robj *o = zmalloc(sizeof(robj)+sizeof(struct sdshdr8)+len+1);
// o+1 实际上是 o + sizeof(redisObject)
// sds头部指针
struct sdshdr8 *sh = (void*)(o+1);
o->type = OBJ_STRING;
o->encoding = OBJ_ENCODING_EMBSTR;
// sh+1 实际上是 sh + sizeof(struct sdshdr8)
// 指向 sh->buf
o->ptr = sh+1;
o->refcount = 1;
// 初始化 LRU bits
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
o->lru = (LFUGetTimeInMinutes()<<8) | LFU_INIT_VAL;
} else {
o->lru = LRU_CLOCK();
}
sh->len = len;
sh->alloc = len;
sh->flags = SDS_TYPE_8;
// 初始化字符串内容
if (ptr == SDS_NOINIT)
// 省去字符串拷贝时间
sh->buf[len] = '\0';
else if (ptr) {
// 拷贝字符串
memcpy(sh->buf,ptr,len);
sh->buf[len] = '\0';
} else {
// 全部初始化为0,也就是空字符串
memset(sh->buf,0,len+1);
}
return o;
}
我们可以看到对象头和字符串内容是通过一次zmalloc调用分配的,也就是说对象头和字符串内容是连续的分配在一起。还将 sds 字符串的 flags 设置为 SDS_TYPE_8 说明它是一个短字符串,长度可以直接用一个字节就可以表示。同时在字符串内容 buf 的尾部有 '\0'
标识,这是 C 字符串的结束标志。
本文节选之在线技术小册《Redis 深度历险》,对 Redis 感兴趣请点击「阅读原文」深入阅读《Redis 深度历险》
阅读更多深度技术文章,扫一扫上面的二维码关注微信公众号「码洞」