在数据库应用场景中,C语言传统的字符串可能存在二进制安全问题,内存越界问题。下面先来看一下这些问题发生的场景。文中展示的 Redis 源码均来自 3.0.4 版本。
二进制安全问题
因为C字符串需要以空字符'\0'表示结尾,这使得C字符串无法正确存储包含'\0'的数据。因为没有提供额外的信息来区分'\0'是结尾还是数据的一部分。虽然可以使用base64等算法对数据进行编码来解决该问题,但这通常会降低代码的效率。
内存越界
因为C字符串不记录自身长度,这导致在使用 strcat 等拼接函数时可能导致内存越界问题。同样的,当截断字符串时,因为没有保存长度,也无法安全的复用。虽然在长度变化时可以通过重新分配内存的方式解决上述问题,但是频繁的系统调用会导致代码非常低效。
简单动态字符串
Redis 构建了一种名为简单动态字符串(simple dynamic string, SDS)的抽象类型来解决上述问题。在 Redis 里面,C字符串只会作为字符串字面量用在一些无需对字符串值进行修改的地方。当一个字符串需要被修改时,Redis 会使用 SDS 来表示字符串值。
struct sdshdr {
unsigned int len; // 记录 buf 数组中已使用的字节数量
unsigned int free; // 记录 buf 数组中未使用的字节数量
char buf[]; //字节数组,用于保存具体的数据
};
Redis 中,SDS 结构的定义如上所示。可以发现,即使数据中出现了 '\0',我们也可以通过 sdshdr::len 字段高效准确的获取长度。这样就避免了二进制的安全问题。Redis 也给我们准备了获取长度的函数:
typedef char *sds;
// s 是一个 char *,指向一个 sdshdr 对象的 buf[0]。
static inline size_t sdslen(const sds s) {
// 通过减去偏移量获取对应的 sdshdr 指针。
struct sdshdr *sh = (void*)(s-(sizeof(struct sdshdr)));
// 返回长度。
return sh->len;
}
同样的,在进行字符串拼接操作时,可以预先检查 sdshdr::free 字段来避免内存越界的问题。如果当前空闲字节数量不小于待拼接字符的数量,那么就直接追加到后,否则会先扩容后再进行追加。为了避免频繁申请/释放内存的问题,Redis 制定了预分配原则和惰性释放原则。
预分配原则:
- 对SDS长度增加时,如果目标长度小于 1M,那么程序会分配与目标长度相同大小的未使用空间。
- 对SDS长度增加时,如果目标长度不小于 1M,那么程序分配 1M 未使用空间。
惰性释放原则:
- 如果对SDS长度变小,程序不会立刻将空闲内存归还给操作系统。而是提供了相应的API,让具体的使用场景来决定何时将内存归还给操作系统。比如 Redis 使用 SDS 作为接受客户端命令的缓冲区,Redis 会尝试回收长时间未用的缓冲区的空闲内存(当然还有其他的收缩规则)。
下面是 sdscatlen 函数(向 sds 尾部追加数据) 和 sdsMakeRoomFor(sds扩容函数) 。可以理解一下与分配原则的具体实现。
/* Append the specified binary-safe string pointed by 't' of 'len' bytes to the
* end of the specified sds string 's'.
*
* After the call, the passed sds string is no longer valid and all the
* references must be substituted with the new pointer returned by the call. */
sds sdscatlen(sds s, const void *t, size_t len) {
struct sdshdr *sh;
size_t curlen = sdslen(s);
// 检查是否需要分配额外内存,如需要则扩容。
s = sdsMakeRoomFor(s,len);
if (s == NULL) return NULL;
sh = (void*) (s-(sizeof(struct sdshdr)));
memcpy(s+curlen, t, len);
sh->len = curlen+len;
sh->free = sh->free-len;
s[curlen+len] = '\0';
return s;
}
/* Enlarge the free space at the end of the sds string so that the caller
* is sure that after calling this function can overwrite up to addlen
* bytes after the end of the string, plus one more byte for nul term.
*
* Note: this does not change the *length* of the sds string as returned
* by sdslen(), but only the free buffer space we have. */
sds sdsMakeRoomFor(sds s, size_t addlen) {
struct sdshdr *sh, *newsh;
// sdsavail(s) 为获取 free 字段函数。
size_t free = sdsavail(s);
size_t len, newlen;
// 无需扩容,直接返回 s
if (free >= addlen) return s;
len = sdslen(s);
sh = (void*) (s-(sizeof(struct sdshdr)));
newlen = (len+addlen);
// 预分配原则:
// 1. 如果扩容后长度小于 SDS_MAX_PREALLOC,则将长度进行翻倍。
// 2. 否则,将长度增加 SDS_MAX_PREALLOC。
// SDS_MAX_PREALLOC 为 1M。
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;
newsh = zrealloc(sh, sizeof(struct sdshdr)+newlen+1);
if (newsh == NULL) return NULL;
newsh->free = newlen - len;
return newsh->buf;
}
SDS 尾部的 '\0'
细心的读者可能注意到了,sdscatlen函数在 buf 的尾部追加了一个 '\0'。这是为了当SDS中不包含特殊字符时,可以复用一部分 C-String 的函数。为了保存 '\0',sds 对象实际占用的内存长度为 sizeof(sdshdr) + sdshdr::len + sdshdr::free + 1。
总结
- SDS 可以 O(1) 的获取字符串长度
- 通过 free 字段杜绝内存越界
- 预分配原则及惰性释放原则减少内存重分配
- 二进制安全
- 尾部 '\0' 使得SDS兼容部分C字符串函数