Redis 中的字符串实现是一个被称为简单动态字符串(Simple Dynamic String, SDS)的抽象数据类型,它不仅是Redis字符串对象的底层实现,也是Redis内部几乎所有需要字符串表示的地方的基础数据结构。下面让我们深入了解SDS的设计与实现。
SDS(简单动态字符串)和前文讨论的Redis字符串对象是两个不同层次的概念,它们之间存在层次关系:SDS是Redis字符串对象的底层实现方式之一。
层次关系
- Redis对象层:字符串对象(StringObject)是Redis的高层抽象,是一种redisObject结构
- 编码实现层:StringObject可以使用三种不同的编码方式(INT、EMBSTR、RAW)
- 底层数据结构:当使用EMBSTR或RAW编码时,字符串内容由SDS结构存储
Redis字符串对象(StringObject)
│
├── INT编码 ─────┬─► 直接在指针字段存储整数值
│ │ redisObject {
│ │ type: REDIS_STRING,
│ │ encoding: REDIS_ENCODING_INT,
│ │ ptr: (直接存放整数值)
│ │ }
│
├── EMBSTR编码 ───┬─► 使用SDS存储字符串,且redisObject和SDS连续分配
│ │ [redisObject][sdshdr][数据...]
│ │ { }{ }{ }
│ │ 一次内存分配,连续内存布局
│
└── RAW编码 ──────┬─► 使用SDS存储字符串,但redisObject和SDS分开分配
│ redisObject ──指针──> sdshdr ──指针──> 数据
│ { } { } { }
│ 两次内存分配,分离的内存布局
SDS的数据结构
typedef char *sds;
struct sdshdr {
int len; // 字符串已使用长度
int free; // 剩余可用长度
char buf[]; // 实际数据
};
SDS的设计非常简洁但精巧:
len
字段记录字符串的实际长度free
字段记录未使用的预分配空间buf
是柔性数组,存储实际字符串内容
最有趣的是,sds
类型本身只是C字符串(char*
)的别名,它指向buf
的起始位置,这使得SDS可以被传递给C字符串函数。
内存布局
SDS在内存中的实际布局如下:
header 实际字符串内容
+------------------+-------------------------+
| len | free | buf | h e l l o \0 |
+------------------+-------------------------+
^ ^
| |
sh sds指针(s)
当我们有一个SDS指针s
时,它实际上指向的是字符数组的起始位置,而不是结构体的起始位置。要访问结构体,需要通过指针运算:(void*)(s - sizeof(struct sdshdr))
。
SDS相比C字符串的优势
1. O(1)时间复杂度获取字符串长度
size_t sdslen(const sds s) {
struct sdshdr *sh = (void*)(s - sizeof(struct sdshdr));
return sh->len;
}
与C字符串需要遍历计算长度不同,SDS可以通过读取len
字段在O(1)时间内获取长度。
2. 防止缓冲区溢出
SDS在执行字符串操作前会检查空间是否足够:
sds sdscatlen(sds s, const void *t, size_t len) {
// 确保有足够空间再进行操作
s = sdsMakeRoomFor(s, len);
if (s == NULL) return NULL;
memcpy(s+curlen, t, len);
sdssetlen(s, curlen+len);
s[curlen+len] = '\0';
return s;
}
而C语言的strcat
等函数则没有这种保护机制。
3. 减少内存重分配
空间预分配策略
当SDS需要扩容时,它不仅会分配”刚好够用”的空间,还会额外分配一些预留空间:
// sdsMakeRoomFor的核心逻辑
newlen = (len+addlen);
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2; // 小于1MB翻倍
else
newlen += SDS_MAX_PREALLOC; // 大于1MB加1MB
这大大减少了连续追加操作时的内存重分配次数。
惰性空间释放
当SDS缩短字符串时,不会立即释放多余的空间:
// sdstrim缩短字符串后
sh->free = sh->free+(sh->len-len); // 释放的空间加入free
sh->len = len; // 更新实际长度
这些空间会被保留下来以备将来使用,避免了频繁的内存分配和释放。
4. 二进制安全
C字符串以\0
字符作为结束标志,无法存储包含\0
的二进制数据。而SDS使用len
字段确定长度,可以存储任意二进制数据,包括图片、音频等包含\0
字节的数据。
5. 兼容C字符串函数
尽管SDS提供了更多的功能和安全性,它仍然保持了与C字符串函数的兼容性。SDS字符串末尾总是有一个额外的\0
字符,这使得SDS可以直接传递给printf等C函数。
SDS的生命周期
1. 创建
// 从C字符串创建
sds s = sdsnew("hello");
// 创建空字符串
sds s = sdsempty();
2. 修改
// 字符串拼接
s = sdscat(s, " world");
// 格式化追加
s = sdscatprintf(s, " %d", 2023);
// 字符串裁剪
sdstrim(s, " \t\n"); // 去除首尾空白字符
3. 释放
sdsfree(s);
空间优化策略示例
以下是一个展示SDS空间预分配和惰性释放特性的例子:
// 初始状态
sdshdr { len=0, free=0, buf="" }
// 追加"hello"后
sdshdr { len=5, free=5, buf="hello" } // 预分配了相同大小的额外空间
// 再追加" world"后(6字符)
sdshdr { len=11, free=0, buf="hello world" } // 额外空间用完了
// 再追加"!"后(需要扩容)
sdshdr { len=12, free=12, buf="hello world!" } // 新空间是数据长度的2倍
// 如果截断回"hello"
sdshdr { len=5, free=19, buf="hello" } // 注意free增加了,空间没有释放