简单动态字符串
Redis没有使用C语言传统的以空字符结尾的字符串表示,而是自己构造了一种名为简单动态字符串(simple dynamic string,SDS)的抽象类型。
除了在诸如打印日志等无需对字符串进行修改的地方使用C字符串之外,其它场合一般使用SDS。其优点:
- 使用起来更加简单;
- 二进制安全;
- 计算效率高;
- 兼容普通的C字符串函数;
SDS的定义
在sds.h/sdshdr的结构中有这么一个值:
1 | typedef char *sds; |
这里flags的低三位表示类型(是sdshdr8还是16),高五位没被使用;len记录当前字节数组长度,alloc记录当前字节数组分配的内存大小,都不包含'\0';buf保存真实字符串的值,以及结尾的'\0'。
这里使用了attribute ((packed)),它的作用是编译器取消结构在编译过程中的优化对齐,按照实际占用字节数进行对齐。我们可以打印出字节长度分别为:
1 | sizeof(struct sdshdr8) // 3->3, result of llvm |
SDS与C字符串的区别
常数复杂度获取字符串长度
这一点得益于结构体重的len字段,与C字符串不同,redis获取字符串长度的复杂度从O(N)降到O(1)。
杜绝缓冲区的溢出
C库中有一个<string.h>/strcat函数可以将两个字符串进行拼接,但C库中这个函数是假设使用者为目的字符串分配了足够多的内存,否则会产生缓冲区溢出。
与C字符串不同,SDS使用了另外的拼接函数:sdscatlen
1 | /* Append the specified binary-safe string pointed by 't' of 'len' bytes to the |
这里的关键是sdsMakeRoomFor函数,其它部分只是做一些内存拷贝和长度的重新设置。
1 | sds sdsMakeRoomFor(sds s, size_t addlen) { |
这里的关键策略是针对不同长度的字符串做不同的分配策略:
- 新字符串小于1M时,预分配两倍空间
- 新字符串大于1M时,预分配多1M的空间
另外就是不使用SDS5,将其当作SDS8来使用。
由于redis可能出现频繁修改字符串的场景,这种预分配的策略可以使得SDS将连续增长N次字符串所需要的内存重分配次数从必定N次降低为最多N次。
减少修改字符串时带来的内存重分配次数
字符串的创建
直接看源码:
1 | sds sdsnewlen(const void *init, size_t initlen) { |
这里有两个特殊的地方:一是sds都以'\0'结尾;二是sdshdr5用flag这个字段可以同时保存type和len。
惰性空间的释放
惰性空间的释放主要体现在SDS字符串的缩短操作,redis中的sdstrim提供了这样的一个操作:sdstrim
函数接受一个 SDS 和一个 C 字符串作为参数,从 SDS
左右两端分别移除所有在 C
字符串中出现过的字符(《redis设计与实现》一书有误)。源码如下
1 | sds sdstrim(sds s, const char *cset) { |
由此,可以看到sdstrim并没有释放原空间,即alloc不变,变的是len。这样后续再需要扩展的时候,len后的空间能够再被利用。
事实上,redis的确提供了释放空间的函数:
1 | /* Free an sds string. No operation is performed if 's' is NULL. */ |
二进制的安全
这里的关键是SDS是使用len属性的值而不是空字符来判断字符串是否结束,这种二进制安全的做法使得redis不仅可以保存文本数据,还可以保存任意格式的二进制数据。
兼容部分C字符串的函数
这是基于SDS遵循了C字符串以空字符串结尾的惯例,这些API都会将SDS保存的又用数据的末尾保存位空字符。