《Reids 设计与实现》第一章 简单动态字符串和链表
文章目录
一、简单动态字符串
1.简介
Redis 没有直接使用 C 语言传统的字符串表示(以空字符 ‘\0’ 结尾),而是自己构建了一种名为简单动态字符串(Simple Dynamic String,SDS)的抽象类型,并将 SDS 用作 Redis 的默认字符串
C 字符串只会作为字符串字面量,用在一些无须对字符串值进行修改的地方,比如打印日志
当 Redis 需要一个可以被修改的字符串值时,Redis 就会使用 SDS。除了用来保存数据库中的字符串值之外,SDS 还被用作缓冲区,比如 AOF 模块中的 AOF 缓冲区和客户端状态中的输入缓冲区
2.SDS 的定义
struct sdshdr{
//记录 buf 数组中已使用字节的数量
//等于 SDS 所保存字符串的长度
int len;
//记录 buf 数组中未使用字节的数量
int free;
//字节数组,用于保存字符串
char buf[];
};
空字符结尾
SDS 遵循 C 字符串以空字符结尾的惯例,保存空字符的 1 字节空间不计算在 SDS 的 len 属性内。并且,给空字符分配额外的 1 字节空间,以及添加空字符到字符串末尾等操作,都是由 SDS 函数自动完成的。遵循 C 字符串以空字符结尾的好处是,SDS 可以直接重用一部分 C 字符串函数库里的函数
3.SDS 与 C 字符串的区别
C 语言使用长度为 N+1 的字符数组来表示长度为 N 的字符串,并且字符数组的最后一个元素总是空字符 ‘\0’。这种简单的字符串表示方式不能满足 Redis 对字符串在安全性、效率以及功能方面的要求
常数复杂度获取字符串长度
很容易理解,只需要访问 SDS 的 len 属性,就可以立即知道 SDS 的长度,因此时间复杂度为常数阶。而 C 字符串并不记录自身的长度信息,所以为了获取一个 C 字符串的长度,必须遍历整个字符串,时间复杂度为线性阶
此外,设置和更新 SDS 长度的工作是由 SDS 的 API 在执行时自动完成的,使用 SDS 无须进行任何手动修改长度的工作
这确保了获取字符串长度的工作不会成为 Redis 的性能瓶颈
杜绝缓冲区溢出
C 字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出。举个例子,strcat 函数可以将 src 字符串中的内容拼接到 dest 字符串的末尾:
char *strcat(char *dest, const char *src);
因为 C 字符串不记录自身的长度,所以 strcat 假定用户在执行这个函数时,已经为 dest 分配了足够多的内存,可以容纳 src 字符串中的所有内容,而一旦这个假定不成立时,就会产生缓冲区溢出
假设程序里由两个在内存中紧邻着的 C 字符串 s1 和 s2
如果一个程序员决定通过执行:
strcat(s1, " Cluster");
将 s1 的内容修改为 “Redis Cluster”,但粗心的他却忘了在执行 strcat 之前为 s1 分配足够的空间,那么在 strcat 函数执行之后,s1 的数据将溢出到 s2 所在的空间中,导致 s2 保存的内容被意外地修改,如图所示:
与 C 字符串不同,SDS 的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当 SDS API 需要对 SDS 进行修改时,API 会先检查 SDS 的空间是否满足修改所需的要求,如果不满足的话,API 会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现缓冲区溢出问题
减少修改字符串时带来的内存重分配次数
因为 C 字符串并不记录自身的长度,所以对于一个包含了 N 个字符的 C 字符串来说,这个 C 字符串的底层实现总是一个 N+1 字符长的数组,每次增长或缩短一个 C 字符串都需要对其进行一次内存重分配。对于拼接操作(append),忘记内存重分配将导致缓冲区溢出;对于截断操作(trim),忘记释放内存将导致内存泄漏
因为内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较耗时的操作:
- 在一般程序中,如果修改字符串长度的情况不是经常出现,那么每次修改都执行一次内存重分配是可以接受的
- 但是 Redis 作为数据库,如果每次修改字符串的长度都需要执行一次内存重分配的话,那么光是执行内存重分配的时间就会占去修改字符串所用时间的一大部分,如果这种修改频繁地发生的话,可能还会对性能造成影响
为了避免 C 字符串的这种缺陷,SDS 通过未使用空间解除了字符串长度和底层数组长度之间的关联:在 SDS 中,buf 数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就由 SDS 的 free 属性记录
通过未使用空间,SDS 实现了空间预分配和惰性释放两种优化策略
空间预分配
空间预分配用于优化 SDS 的字符串增长操作:当 SDS 的 API 对一个 SDS 进行修改,并且需要对 SDS 进行空间扩展的时候,程序不仅会为 SDS 分配修改所必须要的空间,还会为 SDS 分配额外的未使用空间。其中,额外分配的未使用空间数量由以下公式决定:
- 如果对 SDS 进行修改之后,SDS 的长度(也即 len 属性的值)将小于 1MB,那么程序分配和 len 属性同样大小的未使用空间,这时 SDS len 属性的值将和 free 属性的值相同。举个例子,如果进行修改之后,SDS 的 len 将变成 13 字节,那么程序也会分配 13 字节的未使用空间,SDS 的 buf 数组的实际长度将变成 13 + 13 + 1 = 27
- 如果对 SDS 进行修改之后,SDS 的长度将大于等于 1 MB,那么程序会分配 1 MB 的未使用空间
通过这种预分配策略,SDS 将连续增长 N 次字符串所需的内存重分配次数从必定 N 次降低为最多 N 次
惰性空间释放
惰性空间释放用于优化 SDS 的字符串缩短操作:当 SDS 的 API 需要缩短 SDS 保存的字符串时,程序并不立即使用内存重分配来释放缩短后多出来的字节,而是使用 free 属性将这些字节的数量记录起来,并等待将来使用
与此同时,SDS 也提供了相应的 API,让我们可以在有需要时,真正地释放 SDS 的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费
二进制安全
C 字符串中的字符必须符合某种编码(比如 ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为字符串结尾,这些限制使得 C 字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据
虽然数据库一般用于保存文本数据,但使用数据库来保存二进制数据的场景也不少见。因此,为了确保 Reids 可以适用于各种不同的使用场景,SDS 的 API 都是二进制安全的,所有 SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf 数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是什么样的,它被读取时就是什么样
这也是我们将 SDS 的 buf 属性成为字节数组的原因 —— Redis 不是用这个数组来保存字符,而是用它来保存一系列二进制数据
总结
C 字符串与 SDS 之间的区别:
C 字符串 | SDS |
---|---|
获取字符串长的复杂度为 O(N) | 获取字符串长度的复杂度为 O(1) |
API 是不安全的,可能会造成缓冲区溢出 | API 是安全的,不会造成缓冲区溢出 |
修改字符串长度 N 次必然需要执行 N 次内存重分配 | 修改字符串长度 N 次最多需要执行 N 次内存重分配 |
只能保存文本数据 | 可以保存文本或者二进制数据 |
可以使用所有 string 库函数 | 可以使用一部分 string 库函数 |
4.SDS API
SDS 的主要操作 API:
函数 | 作用 | 时间复杂度 |
---|---|---|
sdsnew | 创建一个包含给定 C 字符串的 SDS | O(N),N 为给定 C 字符串的长度 |
sdsempty | 创建一个不包含任何内容的空 SDS | O(1) |
sdsfree | 释放给定的 SDS | O(N),N 为被释放 SDS 的长度 |
sdslen | 返回 SDS 的已使用空间字节数 | 这个值可以通过读取 SDS 的 len 属性来直接获得,复杂度为 O(1) |
sdsvail | 返回 SDS 的未使用空间字节数 | 这个值可以通过读取 SDS 的 free 属性来直接获得,复杂度为 O(1) |
sdsup | 创建一个给定 SDS 的副本 | O(N),N 为给定 SDS 的长度 |
sdsclear | 清空 SDS 保存的字符串内容 | 因为惰性空间释放策略,复杂度为 O(1) |
sdscat | 将给定 C 字符串拼接到 SDS 字符串的末尾 | O(N),N 为被拼接 C 字符串的长度 |
sdscatsds | 将给定 SDS 字符串拼接到另一个 SDS 字符串的末尾 | O(N),N 为被拼接 SDS 字符串的长度 |
sdscpy | 将给定的 C 字符串复制到 SDS 里面,覆盖 SDS 原有的字符串 | O(N),N 为被复制 C 字符串的长度 |
sdsgrowzero | 用空字符将 SDS 扩展至给定长度 | O(N) ,N 为扩展新增的字节数 |
sdsrange | 保留 SDS 给定区间内的数据,不在区间内的数据会被覆盖或清除 | O(N),N 为被保留数据的字节数 |
sdstrim | 接受一个 SDS 和一个 C 字符串作为参数,从 SDS 左右两端分别移除所有在 C 字符串中出现过的字符 | O(M*N),M 为 SDS 的长度,N 为给定 C 字符串的长度 |
sdscmp | 对比两个 SDS 字符串是否相同 | O(N),N 为两个 SDS 中较短的那个 SDS 的长度 |
5.重点回顾
- Redis 只会使用 C 字符串作为字面量,在大多数情况下,Redis 使用 SDS 作为字符串表示
- 比起 C 字符串,SDS 具有以下优点:
- 常熟复杂度获取字符串长度
- 杜绝缓冲区溢出
- 减少修改字符串长度时所需的内存重分配次数
- 二进制安全
二、链表
1.简介
链表提供了高效的节点重排能力,以及顺序性的节点访问方式,并且可以通过增删节点来灵活地调整链表的长度
作为一种常用的数据结构,链表内置在很多高级的编程语言里面,因为 Redis 使用的 C 语言并没有内置这种数据结构,所以 Redis 构建了自己的链表实现
链表在 Redis 中的应用非常广泛,比如链表键、发布与订阅、慢查询、监视器等功能都用到了链表,Redis 服务器本身还使用链表来保存多个客户端的状态信息,以及使用链表来表示构建客户端输出缓冲区
2.链表和链表节点的实现
每个链表节点使用一个 listNode 结构来表示:
typedef struct listNode{
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
}listNode;
虽然使用多个 listNode 结构就可以组成链表,但使用 list 结构来持有链表的话,操作起来会更方便:
typedef struct list{
//表头节点
listNode *head;
//表尾节点
listNode *tail;
//链表所包含的节点数量
unsigned long len;
//节点值复制函数
void *(*dup)(void *ptr);
//节点值释放函数
void (*free)(void *ptr);
//节点值对比函数
int (*match)(void *ptr, void *key);
}list;
list 结构为链表提供了表头指针 head、表尾指针 tail,以及链表长度计数器 len,而 dup、free 和 match 成员则是用于实现多态链表所需的类型特定函数:
- dup 函数用于复制链表节点所保存的值
- free 函数用于释放链表节点所保存的值
- match 函数用于对比链表节点所保存的值和另一个输入值是否相等
图 3-2 是由一个 list 结构和三个 listNode 结构组成的链表
![在这里插入图片描述](https://img-blog.csdnimg.cn/6ddb61016d084f168c959ab998d720f6.png #pic_center)
Redis 的链表实现的特性可以总结如下:
- 双端:链表节点带有 prev 和 next 指针,获取某个节点的前置节点和后置节点的复杂度都是 O(1)
- 无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问以 NULL 为终点
- 带表头指针和表尾指针:通过 list 结构的 head 指针和 tail 指针,程序获取链表的表头节点和表为节点的复杂度为 O(1)
- 带链表长度计数器:程序使用 list 结构的 len 属性来对 list 持有的链表节点进行计数,程序的获取链表中节点的数量的复杂度为 O(1)
- 多态:链表节点使用 void* 指针来保存节点值,并且可以通过 list 结构的 dup、free、match 三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值
3.链表和链表节点的 API
函数 | 作用 | 时间复杂度 |
---|---|---|
listSetDupMethod | 将给定的函数设置为链表的节点值复制函数 | O(1) |
listGetDupMethod | 返回链表当前正在使用的节点值复制函数 | 复制函数可以通过链表的 dup 属性直接获得,O(1) |
listSetFreeMethod | 将给定的函数设置为链表的节点值释放函数 | O(1) |
listGetFree | 返回链表当前正在使用的节点值释放函数 | 释放函数可以通过链表的 free 属性直接获得,O(1) |
listSetMatchMethod | 将给定的函数设置为链表的节点值对比函数 | O(1) |
listGetMatchMethod | 返回链表当前正在使用的节点值对比函数 | 对比函数可以通过链表的 match 属性直接获得,O(1) |
listLength | 返回链表的长度(包含了多少个节点) | 链表长度可以通过链表的 len 属性直接获得,O(1) |
listFirst | 返回链表的表头节点 | 表头节点可以通过链表的 head 属性直接获得,O(1) |
listLast | 返回链表的表尾节点 | 表尾节点可以通过链表的 tail 属性直接获得,O(1) |
listPrevNode | 返回给定节点的前置节点 | 前置节点可以通过节点的 prev 属性直接获得,O(1) |
listNextNode | 返回给定节点的后置节点 | 后置节点可以通过节点的 next 属性直接获得,O(1) |
listNodeValue | 返回给定节点目前正在保存的值 | 节点值可以通过节点的 value 属性直接获得,O(1) |
listCreate | 创建一个不包含任何节点的新链表 | O(1) |
listAddNodeHead | 将一个包含给定值的新节点添加到给定链表的表头 | O(1) |
listAddNodeTail | 将一个包含给定值的新节点添加到给定链表的表尾 | O(1) |
listInsertNode | 将一个包含给定值的新节点添加到给定节点的之前或者之后 | O(1) |
listSearchKey | 查找并返回链表中包含给定值的节点 | O(N),N 为链表长度 |
listIndex | 返回链表在给定索引上的节点 | O(N),N 为链表长度 |
listDelNode | 从链表中删除给定节点 | O(N),N 为链表长度 |
listRotate | 将链表的表尾节点弹出,然后被弹出的节点插入到链表的表头,成为新的表头结点 | O(1) |
listDup | 复制一个给定链表的副本 | O(N),N 为链表长度 |
listRelease | 释放给定链表,以及链表中的所有节点 | O(N),N 为链表长度 |
4.重点回顾
- 链表被广泛用于实现 Redis 的各种功能,比如列表键、发布与订阅、慢查询、监视器等
- 每个链表节点由一个 listNode 结构来表示,每个节点都有一个指向前置节点和后置节点的指针,所以 Redis 的链表实现是双端链表
- 每个链表使用一个 list 结构来表示,这个结构带有表头结点指针、表尾节点指针,以及链表长度等信息
- 因为链表表头节点的前置节点和表尾节点的后置节点都指向 NULL,所以 Redis 的链表实现是无环链表
- 通过为链表设置不同的类型特定函数,Redis 的链表可以用于保存各种不同类型的值