【Redis基本数据结构】动态字符串实现

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). 如图所示:

《【Redis基本数据结构】动态字符串实现》

如果在执行之前忘了给 s1 分配足够的空间,在执行之后, s1的数据将溢出到 s2 的空间之中,导致 s2 的内容被意外更改.

与 C 字符串不同, SDS 的 空间分配完全杜绝了发生缓冲区溢出的可能性,当 Redis 需要对 SDS 进行修改时,API 会首先检查 SDS 的空间是否满足修改的要求,如果不满足, API 会自动将 SDS 空间扩展至执行修改所需的大小

举个例子, Redis 里也有一个执行拼接的函数: sdscat, 假如执行 sdscat(s," WORLD");, 如果检查发现 s 的空间不足以拼接, sdscat 会先扩展s 的空间,在执行拼接.
拼接操作前后如图所示:

《【Redis基本数据结构】动态字符串实现》

注意上图中的 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 结构变化如下图:
《【Redis基本数据结构】动态字符串实现》

注意执行 sdstrim 之后 SDS 并没有释放多出来的 6 字节,而是将这6 字节作为未使用空间保留在了 SDS 里面,在之后的 sdscat 操作中不必为了拼接重新分配空间.

SDS 也提供了相应的 API, 在有需要时,真正地释放 SDS 未使用空间,不用担心惰性空间策略造成的空间浪费.

二进制安全

C 字符串中的字符必须符合某种编码 ,除了末尾之外,其他位置不能包含空字符,否则最先被读入的空字符会被误认为字符串结束标志,这些限制使得 C 字符串只能保存文本数据, 而不能保存像图片,音频,视频这样的二进制文件.

维基百科的Null-terminated string 词条给出了空字符结尾字符串的定义,说明了这种表示的来源)

数据库保存二进制数据的场景并不少见,为了确保 Redis 可以使用各种不同的场景, SDS 中的 API 都是二进制安全的,所有的 SDS API 会以处理二进制的方式来处理 buf 中数据,程序不会对其中的数据做任何假设,数据在写入时什么,在读出时就是什么样.

我的博客: http://ygmyth.github.io

    原文作者:ygmyth
    原文地址: https://segmentfault.com/a/1190000004847669
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞