希望大家都可以去真的阅读源码,去了解第一手知识。而不是只去看各个公众号抄来抄去的几篇文章,有的甚至还是错的。
^_^
感慨一下,最近面试的时候怎么那么多人说 mysql 中的幻读问题是使用 mvcc 解决的?
sds 字符串:名字叫简单动态字符串,但是他可一点也不 simple 。如果你只知道他可以动态扩容,那可就差的太远啦。
动态对象头
首先,sds 对象头定义以及字符串基本函数的实现都在 sds.h 头文件中。一共定义了 5 种 sds 头格式,如下 (太多了 不一一贴出来了):
/* Note: sdshdr5 is never used, we just access the flags byte directly.
* However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
......
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
复制代码
其中,根据注释来看 sdshdr5 是没有被使用的,我们忽略。
另外几种结构体类型,仅有表示长度的 len 和记录已分配内存的 alloc 的类型长度不同。
取消字节对齐
再着,注意到了结构体上的 __attribute__ ((__packed__)) 修饰,该关键字的作用是取消字节对齐。
为了验证,我写了如下一个小代码:
struct packed_test_a
{
char i;
uint32_t j;
};
struct __attribute__ ((__packed__)) packed_test_b
{
char i;
uint32_t j;
};
...
struct packed_test_a a;
struct packed_test_b b;
a.i = 'a';
b.i = 'b';
a.j = 1u;
b.j = 2u;
printf("%d \n",sizeof(a)); //5
printf("%d \n",sizeof(b)); //8
复制代码
只是看 sizeof 还是不够,我们再使用 gdb 调试一下:
1 先将断点打在第一个 printf 的位置,使用 r 命令运行至断点处 (第 37 行)。
Thread 2 hit Breakpoint 1, main (argc=1, argv=0x7ffeefbff7d8)
at packed_test.c:37
37 printf("%d \n",sizeof(a));
复制代码
2 查看变量 a 处的内存,使用 x/24xb &a
0x7ffeefbff798: 0x61 0x01 0x00 0x00 0x00 0x00 0x00 0x00
...
复制代码
3 查看变量 b 处的内存,使用 x/24xb &b
0x7ffeefbff790: 0x62 0x00 0x00 0x00 0x02 0x00 0x00 0x00
...
复制代码
这样,我们可以很明显的看出,在添加了 __attribute__ ((__packed__)) 修饰的变量 a 上,数据a.i = 0x61 与 a.j = 0x01 在内存中是连续存储的。
而,变量 b 中,b.i = 0x62 与 b.j = 0x02 中间被填充了 3 个字节的 0x00数据用于对齐。
根据长度选择合适的对象头
创建
先看一下创建 sds 字符串的函数:sdsnewlen。
函数签名如下:
sds sdsnewlen(const void *init, size_t initlen)
复制代码
- 第一步,使用
s_malloc分配足够的内存。即对象头长度 + 字符串初始长度 + 1(C语言字符串结束符占位)。
sh = s_malloc(hdrlen+initlen+1);
复制代码
-
第二步,初始化分配的内存,使用
memset批量设零值。 -
第三步,设置变量指针
s,fp。
s = (char*)sh+hdrlen;
fp = ((unsigned char*)s)-1;
复制代码

这里,剧透一下:*fp 指向的是 *s-1 的位置。其中 *s 指向的是数据区的起始地址,结合存储结构来看的话,*s-1 其实就是 sds.flags。
- 第四步,根据类型设置对象头属性:
switch(type) {
...
case SDS_TYPE_8: {
SDS_HDR_VAR(8,s);
sh->len = initlen;
sh->alloc = initlen;
*fp = type;
break;
}
...
}
复制代码
- 第五步,数据区设置初始值
memcpy(s, init, initlen);
复制代码
- 最后,收尾工作
s[initlen] = '\0';
return s;
复制代码
注意:这里实际返回的指针为 *s 。也就是 sds 字符串数据区的起始地址。
扩容
扩容这里我们直接看 sdsMakeRoomFor 函数。
当其小于 1MB 时,每次容量直接 *2 ,否则每次扩容 1MB。嗯 就是出自这里。
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;
复制代码
随后,会判断原对象头与扩容后的对象头是否一致,来决定是直接扩,还是…换头?
hdrlen = sdsHdrSize(type);
if (oldtype==type) {
newsh = s_realloc(sh, hdrlen+newlen+1);
if (newsh == NULL) return NULL;
s = (char*)newsh+hdrlen;
} else {
/* Since the header size changes, need to move the string forward,
* and can't use realloc */
newsh = s_malloc(hdrlen+newlen+1);
if (newsh == NULL) return NULL;
memcpy((char*)newsh+hdrlen, s, len+1);
s_free(sh);
s = (char*)newsh+hdrlen;
s[-1] = type;
sdssetlen(s, len);
}
复制代码
多亏了 redis 的单线程模型,代码也极其简单:分配足够的长度,并将原内存拷贝并释放即可。
缩容
和扩容是一样的,函数见 sdsRemoveFreeSpace。
值得注意的是,缩容函数是把 sds 中尚未分配的空间还回去。字符串本身的值并不会发生变化。
/* If the type is the same, or at least a large enough type is still
* required, we just realloc(), letting the allocator to do the copy
* only if really needed. Otherwise if the change is huge, we manually
* reallocate the string to use the different header type. */
if (oldtype==type || type > SDS_TYPE_8) {
newsh = s_realloc(sh, oldhdrlen+len+1);
if (newsh == NULL) return NULL;
s = (char*)newsh+oldhdrlen;
} else {
newsh = s_malloc(hdrlen+len+1);
if (newsh == NULL) return NULL;
memcpy((char*)newsh+hdrlen, s, len+1);
s_free(sh);
s = (char*)newsh+hdrlen;
s[-1] = type;
sdssetlen(s, len);
}
复制代码
原类型与目标类型相同,或原类型在 sdshdr8 之上时,并不做类型的替换,只是重新使用 s_realloc 重新分配一个恰好的容量使用。
有没有注意到代码中存在一个 s[-1] ? 数组下标还能是负数?很奇怪对吧 稍等~
存储结构
看了 sds 字符串的结构体定义,我们就可以将其在内存中的存储结构画出来(我们以sdshdr8为例):

除 sdshdr5 之外,其余 4 种的存储结构都是一样的。
注意一下,正是因为上面在结构体上加了 __attribute__ ((__packed__)) 修饰,这里才可以确定 len,alloc,flags,*buf 这些属性在内存中一定是紧凑连接的。
代码中的那些’骚’操作
负数数组下标
如果仔细看代码的话,会发现存在着大量这样的写法:
unsigned char flags = s[-1];
复制代码
在某些语言中,数组下标为负数时表示的是访问倒数第几个元素。但是,作为鄙视链的顶端,C 语言可就不一样了~
C 语言中,数组其实就是一个指针,s[-1] 的效果其实和 *s-1 是一样的。
所以,这样我们就可以知道,s[-1] 访问的就是结构体中的 flags 属性。
那么,为什么要这样使用呢?
因为已经看过了创建 sds 的函数。我们知道创建完成后,暴露给外界的指针指向的仅仅是数据区的位置,也就是*buf这个属性。
那么,先看这个问题:为什么直接返回的只有数据区地址,而不是整个 sds 头部地址
*buf就是一个原生的 C 语言数组,stl 中提供的所有字符串相关函数都可以直接对其使用。sds头是动态的,仅仅一个sds头部地址的话,因为不知道当前数据是怎么分布的,无法访问任何属性。
这样,如果我们返回的指针指向 flags 与 *buf 中间的地址。
因为我们知道flags是定长的,前移一个 char 的位置就可以完整的取到整个 flags 数据。而一但有了 flags 我们就可以知道对象头中的数据分布,进而来访问其他 len,alloc 属性。
编译器预处理宏
现在让我们看一下在已经有 *s 指针以及flags后,如何取到头中的其他字段。
如何还是继续使用负数下标:-(1+1),-(1+1+1) 这样,那真的是完全没有可读性。
我们看一下取 len 字段的方法:
static inline size_t sdslen(const sds s) {
unsigned char flags = s[-1];
switch(flags&SDS_TYPE_MASK) {
case SDS_TYPE_5:
return SDS_TYPE_5_LEN(flags);
case SDS_TYPE_8:
return SDS_HDR(8,s)->len;
...
case SDS_TYPE_64:
return SDS_HDR(64,s)->len;
}
return 0;
}
复制代码
可以看到,针对不同的类型(除SDS_TYPE_5),都使用了同一个SDS_HDR宏,只是第一个参数不一样。
来看一下他的宏定义:
#define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T))))
复制代码
只有短短一行代码,其中有一个这样的符号 ##:也是属于预处理宏的一种,两个井号被称为连接符,会将两个 token 连接为一个。
举例来说,SDS_HDR(8,s) 在被编译器预处理后,就会变成下面这样:
((struct sdshdr8 *)((s)-(sizeof(struct sdshdr8))))
复制代码
是不是很神奇?宏参数也变成了代码本身的一部分。
那么,我们再看一下这段代码做了什么:
首先,后半部分:参数 s 为数据区起始指针,将指针减去 sizeof(struct sdshdr8) ,回想下 sds 的存储结构:那不就是将指针移动到 sds 的头部位置吗~
然后,前半部分:就是一个强制类型转换,因为我们已经得到了 sds 的头部指针,那么只需要强制转换一下,后面就可以任意发挥啦。
总结
简单动态字符串:作为 redis 中出镜率最高的一种数据结构,他可是一点都不简单。不仅仅是长度实现了动态扩容,为了把空间使用到极致,就连头部也做到了动态扩容。
并且,头部中还额外存储了当前字符串的长度,好处也很明显:虽然 *buf 指向的是一个标准 C 语言字符串,但对其使用 strlen 函数求长度也不得不遍历整个字符串。
如果额外储存的话,就完全不一样了。
而且,也多亏了 redis 的单线程,让本来很复杂的操作,变得异常简单。




















![[桜井宁宁]COS和泉纱雾超可爱写真福利集-一一网](https://www.proyy.com/skycj/data/images/2020-12-13/4d3cf227a85d7e79f5d6b4efb6bde3e8.jpg)

![[桜井宁宁] 爆乳奶牛少女cos写真-一一网](https://www.proyy.com/skycj/data/images/2020-12-13/d40483e126fcf567894e89c65eaca655.jpg)