前言
字符串在 Redis 中的应用场景十分广泛,所有的键都是字符串类型,值也可能是字符串类型。
比如电商系统用 Redis 缓存商品信息,可以把商品 ID 作为键,商品信息序列化为 JSON 后作为值写入:
SET item:1001 '{"title":"苹果","price":99}'
Redis 是用 C 语言开发的,在 C 语言中,字符串可以用字符数组来表示,但是 Redis 并没有直接使用原生的字符数组,而是自定义了一个叫 Simple Dynamic String(简单动态字符串,缩写 SDS)的数据结构,它相较于原生字符数组有哪些优势呢?
字符数组
在 C 语言中,字符串可以用字符数组来表示:
char *s = "redis";
字符数组就是一块连续的物理内存,依次存放每个字符,为了标记字符串结束,会在末尾加上一个 ‘\0’ 零字节字符代表结束。
字符数组存在的问题有:
- 操作效率低下
- 缓冲区溢出风险
- 二进制不安全
操作效率低
变量 s 是个指针,指向了字符数组的起始位置,除此之外再没有其它元数据来描述这个字符串了。所以,要获取字符串的长度该怎么做呢?只能是从起始位置开始遍历,直到扫描到 ‘\0’ 字符,然后返回统计的字符数量,时间复杂度是 O(N),这是 Redis 不能接受的。
缓冲区溢出风险
C 语言的字符数组是一片连续的内存空间,而且大小也是连续的。如果向一个字符数组写入超过其容量的数据,就会导致缓冲区溢出,破坏相邻的内存区域的数据,指针操作是危险的。
二进制不安全
C 的字符数组是二进制不安全的,例如 ‘\0’ 就无法存储,因为它被作为字符串的结束标记,如下示例,字符串 s2 将被截断为 ‘red’。
int main() {
char *s1 = "redis";
char *s2 = "red\0is";
printf("%lu %s\n", strlen(s1), s1);
printf("%lu %s\n", strlen(s2), s2);
return 0;
}
// 输出
// 5 redis
// 3 red
sdshdr
为了尽量复用 C 标准库中字符串的操作函数,Redis 仍然使用字符数组来保存实际的数据。但是,为了更加高效的管理字符串,Redis 新增了头部结构,自定义了 sdshdr 数据结构。
sdshdr 的通用结构
属性 | 长度 | 说明 |
---|---|---|
len | 1/2/4/8 字节 | 已使用的长度 |
alloc | 1/2/4/8 字节 | 分配的长度 |
flags | 1 字节 | 3 Bit 表示类型,5 Bit 未使用 |
buf | - | 字符数组缓冲区 |
sdshdr 细分后有五种类型:
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__)) sdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* used */
uint32_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 | 1 字节 | 存储长度小于 32 的字符串 |
sdshdr8 | 3 字节 | 存储长度小于 2^8 的字符串 |
sdshdr16 | 5 字节 | 存储长度小于 2^16 的字符串 |
sdshdr32 | 9 字节 | 存储长度小于 2^32 的字符串 |
sdshdr64 | 17 字节 | 存储长度小于 2^64 的字符串 |
这五种类型结构一样,只是能保存的字符串长度不一样,Redis 为什么要费这么大劲,设计这么多的类型呢?
这是因为相较于 C 的原生字符数组,sds 的头部毕竟是要占用额外的内存空间,所以能省一点是一点。在大多数场景下,存储的都是短字符串,其实是没有必要用 int 存储长度的,这也体现了 Redis 节省内存的设计哲学。
除此之外,Redis 还使用专门的编译优化来节省内存空间,核心是:
__attribute__ ((__packed__))
默认情况下,编译器会按照 8 字节对齐的方式给变量分配内存,即使变量不需要 8 字节的空间,编译器也会分配 8 字节,这样就会造成浪费。而加了这个指令,意思就是告诉编译器,不要采用紧凑的方式分配内存,变量实际占用多少就分配多少,再一次体现了 Redis 节省内存的设计哲学。
问题:Redis 是怎么判断 sds 对象类型的?
sds 指针指向的并不是整个对象的起始位置,而是 buf 的起始位置,这样只要向左移一位 sds[-1] 就可以读取到 flags 进行判断了。C 的指针很危险,但不可否认,也很高效。
再来看看 sds 是如何解决 C 里面字符数组的缺陷的。
操作效率低下
sdshdr 使用单独的 len 属性来记录字符串的长度,所以 sds 获取字符串长度的时间复杂度是 O(1)。
缓冲区溢出风险
sds 会在运行时自动扩缩容,这避免了 C 字符数组操作不当导致的缓冲区溢出问题。因为头部已经有属性来记录字符串的长度和缓冲区总大小了,所以在做追加操作时,可以提前判断是否需要扩容。
二进制不安全
sds 单独用属性来记录字符串的长度,所以不需要 ‘\0’ 来做结束标记了,所以 sds 可以存储任意的二进制数据,它是二进制安全的。
除了解决 C 原生字符数组的一些问题,sds 还有一些特性:
- 自动扩容
sds 相当于是一个动态的字符数组,当缓冲区不足以容纳写入的数据量时会自动扩容,当数据减小到一定量时又会自动缩容释放内存。
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;
扩容策略:
- 新的长度小于 1MB 时按照 2 倍扩容
- 新的长度大于等于 1MB 时按照 1MB 步长扩容
- 内存预分配
sds 分配的缓冲区大小不会恰好容纳你要写入的数据,而是会预先多分配一些,这样在有新数据写入时,不必再重复申请内存空间。
- 惰性删除
sds 字符串缩小后不会立即释放内存,而是会等待留着下次扩容时继续使用,还是避免频繁申请内存,换取字符串 追加 操作的性能。