Redis 不直接使用原始 C 字符串,而是自己构建了一种字符串类型,叫做 SDS(simple dynamic string), 并将 SDS 作为 Redis 的默认字符串表示.
SDS 的定义
看一下 SDS 的定义:
// file : sds.h
struct sdshdr {
// 字符串当前长度,
//等于 buf 数组中已使用字节数
unsigned int len;
// 剩余可用长度
unsigned int free;
// 字符数组(具体存放字符串的地方)
char buf[];
};
SDS buf 数组还是遵循 C 字符串以空字符结尾的惯例,这样可以直接重用一部分 C 字符串函数库里函数
SDS 与 C 字符串的区别
常数复杂度获取字符串的长度
因为 C 字符串并不记录自身的长度信息,所以获取一个 C 字符串的长度,必须遍历整个字符串,这个操作的的复杂度为
O(N)
.
SDS 在 len 属性中记录了 SDS 的 len 属性,获取一个 SDS 长度的复杂度仅为
O(1)
杜绝缓冲区溢出
除了获取字符串长度的复杂度高之外, C 字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出.
看看字符串拼接函数 strcat
的调用:
char *strcat( char *dest, const char *src );
在执行 strcat
时假定 dest 分配了足够多的内存,可以容纳 src 字符串中的内容,而一旦这个假定不成立,就会产生缓冲区溢出.
举个例子,假设程序中有两个在内存中紧邻着的 C 字符串 s1 和 s2,执行 strcat(s1, s2)
. 如图所示:
如果在执行之前忘了给 s1 分配足够的空间,在执行之后, s1的数据将溢出到 s2 的空间之中,导致 s2 的内容被意外更改.
与 C 字符串不同, SDS 的 空间分配完全杜绝了发生缓冲区溢出的可能性,当 Redis 需要对 SDS 进行修改时,API 会首先检查 SDS 的空间是否满足修改的要求,如果不满足, API 会自动将 SDS 空间扩展至执行修改所需的大小
举个例子, Redis 里也有一个执行拼接的函数: sdscat
, 假如执行 sdscat(s," WORLD");
, 如果检查发现 s 的空间不足以拼接, sdscat 会先扩展s 的空间,在执行拼接.
拼接操作前后如图所示:
注意上图中的 SDS, sdscat 不仅进行了拼接操作,还额外分配了11 字节的未使用空间,恰好等于拼接之后的字符串长度, 这并不是巧合,它是 SDS 的空间分配策略,下面会讲到
减少修改字符串时带来的内存分配次数
对于 C 字符串,每次增长或 缩短一个 C 字符串,程序总要多保存这个 C 字符串的数组进行一次内存重分配操作:
- 如果执行的增长字符串的操作,在执行之前,程序要先通过内存重分配来扩展空间的大小,如果忘了可能会产生缓冲区溢出
- 如果执行的是缩短字符串的操作,在执行之后,程序要通过内存重分配来释放不再使用的部分空间,如果忘了,可能会产生内存泄露
内存重分配涉及到复杂的算法,并且可能需要执行系统调用.
- 在一般程序中,如果修改字符串长度的情况不太常出现,那么每次修改都执行一次内存重分配是可以接受的
- 但是 Redis 作为数据库,经常用于对于速度要求严苛,数据被频繁修改的场合,如果每次修改都执行一次内存重分配的话,光是执行内存重分配就会占去修改字符串所用时间的一大部分,若果修改频繁发生,可能会对系统性能造成影响
为了避免 C 字符串的这种缺陷, SDS 使用了未使用空间这一概念, 在 SDS 中, buf 数组的长度不一定是字符数量加上一个结束符,数组里面还包含未使用的字节,这些字节的数量由 SDS 的 free 属性记录.
通过未使用空间, SDS 实现了空间预分配和惰性空间释放两种优化策略.
空间预分配
空间预分配用于优化 SDS 的字符串增长操作: 当 SDS 的 API 对一个 SDS 进行修改,并且需要空间扩展时, 程序不仅会为 SDS 分配修改所需的空间,还会为 SDS 分配额外的未使用空间.
额外的未使用空间分配规则如下:
- 如果对 SDS 修改后,字符串的长度( len 的值) 小于 1 MB, 那么程序分配和 len 一样大小的未使用空间,此时 free=len.
在上节实例中, s=”HELLO” + ” WORLD”, len = 11,程序会分配11字节的未使用空间, SDS的 buf 数组的实际长度变成 11+11+1 = 23 字节(额外的一字节保存空字符) - 如果对 SDS 修改后, 字符串的长度大于 1 MB, 那么程序会分配1 MB 的未使用空间.举个例子,如果进行修改之后, SDS 的len 变成 10 MB, 那么程序会分配 1MB 的未使用空间(free=1 MB),SDS 的 buf数组实际长度将变为 10 MB + 1 MB + 1 byte.
通过预分配策略, Redis
可以减少连续执行字符串增长操作所需的内存重分配次数.
惰性空间释放
惰性空间释放用于优化SDS 的字符串缩短操作: 当需要缩短 SDS 保存的字符串时,程序并不立即回收缩短之后多出来的字节,而是使用 free 属性将这些字节的数量记录起来,等待将来使用.
举个例子, sdstrim 函数接受一个 SDS 和一个 C 字符串,从 SDS 字符串两侧删除在 C 中出现的字符,对于某个SDS中 buf保存着 `s = “xxabcyy”, 执行
sdsstrim(s, "xy");
再执行:
sdscat(s, "Redis");
SDS 结构变化如下图:
注意执行 sdstrim 之后 SDS 并没有释放多出来的 6 字节,而是将这6 字节作为未使用空间保留在了 SDS 里面,在之后的 sdscat 操作中不必为了拼接重新分配空间.
SDS 也提供了相应的 API, 在有需要时,真正地释放 SDS 未使用空间,不用担心惰性空间策略造成的空间浪费.
二进制安全
C 字符串中的字符必须符合某种编码 ,除了末尾之外,其他位置不能包含空字符,否则最先被读入的空字符会被误认为字符串结束标志,这些限制使得 C 字符串只能保存文本数据, 而不能保存像图片,音频,视频这样的二进制文件.
维基百科的Null-terminated string 词条给出了空字符结尾字符串的定义,说明了这种表示的来源)
数据库保存二进制数据的场景并不少见,为了确保 Redis 可以使用各种不同的场景, SDS 中的 API 都是二进制安全的,所有的 SDS API 会以处理二进制的方式来处理 buf 中数据,程序不会对其中的数据做任何假设,数据在写入时什么,在读出时就是什么样.