Redis没有直接使用C语言传统的字符串表示(以空字符结尾的字符数组),而是自己构建了一种名为简单动态字符串(simple dynamic string, SDS)的抽象类型,并将SDS用作Redis的默认字符串表示。
SDS的定义如下:
struct sdshdr {
// 记录 buf 数组中已使用字节的数量
// 等于 SDS 所保存字符串的长度
int len;
// 记录 buf 数组中未使用字节的数量
int free;
// 字节数组, 用于保存字符串
char buf[];
};
SDS 与 C 字符串的区别
- 常数复杂度获取字符串长度
因为 C 字符串不记录自身长度信息,所以为了获取一个 C 字符串的长度,程序必须遍历整个字符串, 对每个字符进行计数,直到遇到代表结尾的空字符为止,这个操作的复杂度为O(N). 和 C 字符串不同, 因为 SDS 在 len 属性 中记录了 SDS 本身的长度,所以获取一个 SDS 长度的复杂度仅为O(1).
通过使用SDS而不是C字符串,Redis将获取字符串长度所需的复杂度从O(N)降到了O(1),这确保获取字符串长度的工作不会成为Redis的性能瓶颈。
- 杜绝缓冲区溢出
除了获取字符串长度复杂度高之外, C字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出。例如在执行<string.h> / strcat 函数:char *strcat(char *dest, const char *src) 时, 若没有为dest分配足够多的内存, 就会产生缓冲区溢出。例如有如下在内存中紧邻着的 C 字符串 S1 和 S2, 如果执行 strcat(s1, " Cluster");
原意是将 s1 的内容修改为 "Redis Cluster", 但由于没有给 S1 分配足够的空间, 导致 S2 的内容被意外的修改,如下图,即S1的内容溢出到S2所在的位置上。
与 C 字符串不同, SDS 的空间分配策略完全杜绝了发生缓冲区溢出的可能性。当 SDS API 需要对 SDS 进行修改时, API 会先检查 SDS 的空间是否满足修改所需的要求, 如果不满足,API 会自动将 SDS 的空间扩展至执行修改所需要的大小,然后才执行实际的修改操作。
- 减少修改字符串时带来的内存重分配次数
由于C字符串不记录自身长度, 所以每次增长或缩短一个 C 字符串时, 程序总要对保存这个 C 字符串的数组进行一次内存重分配操作:
如果是增长字符串的操作,程序需要先通过内存重分配来扩展底层数组的空间大小,如果忘了这一步就会出现如上所说的缓冲区溢出。
如果是缩短字符串的操作,程序需要通过内存重分配来释放字符串不再使用的那部分空间,如果忘了这一步就会产生内存泄漏。
在一般程序中, 如果修改字符串长度的情况不太长出现,那么每次修改都执行一次内存重分配是可以接受的。
但是 Redis 作为数据库,经常被用于速度要求严苛、数据被频繁修改的场合。如果每次修改字符串长度都要执行一次内存重分配的话,可能对性能造成影响。
为了避免 C 字符串这种缺陷, SDS 通过未使用空间解除了字符串长度和地产数组长度之间的关联:在 SDS 中, buf 数组可以包含未使用的字节, 由 SDS 的 free 属性记录。通过未使用空间, SDS 实现了 空间预分配 和 惰性空间释放 两种优化策略。
1、空间预分配
空间预分配用于优化 SDS 的字符串增长操作:当 SDS 的 API 对一个 SDS 进行修改,并且需要对 SDS 进行空间扩展的时候, 程序不仅会为 SDS 分配修改所需要的空间,还会为 SDS 分配额外的未使用空间。修改后 SDS(len 属性) 的长度小于 1M 时, free值设置和len值相同, 大于 1M 时,free 值设置为 1M.
2、惰性空间释放
当 SDS 的 API 需要缩短 SDS保存的字符串时,程序并不立即使用内存重分配来回收多出来的字节,而是使用 free 属性将这些字节的数量记录起来并等待将来使用。于此同时,SDS 也提供了相应的 API,让我们在需要的时候真正的释放 SDS 未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。
- 二进制安全
C 字符串的字符必须符合某种编码(比如 ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则会被误认为字符串结尾。这使得 C 字符串只能保存文本数据,而不能保存图片、音频、视频、压缩文件这样的二进制文件。例如如下字符串,C 字符串只会识别到 Redis, 而忽略后面的内容。
虽然数据库一般用于保存文本数据,但也有一些情况是需要数据库来保存二进制数据的。因此,为了确保 Redis 可以适用于各种不同的使用场景, SDS 的 API 都是二进制安全的。由于 SDS 使用 len 值记录长度,所以不会造成遇见空字符提前结束的情况。
- 兼容部分 C 字符串函数
虽然 SDS 的 API 都是二进制安全的, 但他们一样遵循 C 字符串以空字符为结尾的惯例;这是为了让那些保存文本数据的 SDS 可以重用一部分 <string.h> 库定义的函数。这样 Redis 就不用重复编写 SDS 的相关函数了。
- 小结
C 字符串 | SDS |
获取字符串长度的复杂度为 O(N) | 获取字符串长度的复杂度为O(1) |
API 是不安全的,可能会造成缓冲区溢出 | API 是安全的,不会造成缓冲区溢出 |
修改字符串长度N次必然执行N次内存重分配 | 修改字符串长度N次最多执行N次内存重分配 |
只能保存文本数据 | 可以保存文本数据或者二进制数据 |
可以使用所有 <string.h> 库中的函数 | 可以使用一部分 <string.h> 库中的函数 |
- 相关指令
127.0.0.1:6379> set name "zhangsan" // 设置值, 键值对存储, name 为键, zhangsan 为值, 均为 SDS 类型
OK
127.0.0.1:6379> get name // 获得值
"zhangsan"
127.0.0.1:6379> exists name // 判断某一个 值 是否存在
(integer) 1
127.0.0.1:6379> append name "!" // 追加某一个值
(integer) 9
127.0.0.1:6379> get name
"zhangsan!"
127.0.0.1:6379> strlen name // 获得某个值的长度
(integer) 9
127.0.0.1:6379> keys * // 获得当前所有的 键
1) "name"
127.0.0.1:6379> set views 0
OK
127.0.0.1:6379> get views
"0"
127.0.0.1:6379> incr views // 自增 1
(integer) 1
127.0.0.1:6379> incr views
(integer) 2
127.0.0.1:6379> get views
"2"
127.0.0.1:6379> decr views // 自减 1
(integer) 1
127.0.0.1:6379> get views
"1"
127.0.0.1:6379> incrby views 10 // 增加任意值
(integer) 11
127.0.0.1:6379> get views
"11"
127.0.0.1:6379> decrby views 10 // 减少任意值
(integer) 1
127.0.0.1:6379> get views
"1"
127.0.0.1:6379> set key "hello, linux"
OK
127.0.0.1:6379> get key
"hello, linux"
127.0.0.1:6379> getrange key 0 5 // 截取字符串 闭区间
"hello,"
127.0.0.1:6379> getrange key 0 -1 // 获得全部的字符串
"hello, linux"
127.0.0.1:6379> setrange key 7 unix // 修改指定偏移量位置的值
(integer) 12
127.0.0.1:6379> get key
"hello, unixx"
127.0.0.1:6379> setex key1 10 10sdead //设置过期时间
OK
127.0.0.1:6379> ttl key1
(integer) -2
127.0.0.1:6379> setnx key1 "hello" // 如果不存在 再设置
(integer) 1
127.0.0.1:6379> setnx key1 "hello"
(integer) 0
127.0.0.1:6379> mset k1 v1 k2 v2 k3 v3 // 同时设置多个值
OK
127.0.0.1:6379> mget k1 k2 k3 // 同时获得多个值
1) "v1"
2) "v2"
3) "v3"
127.0.0.1:6379> msetnx k4 v4 k1 v1 // msetnx 是个原子性的操作
(integer) 0
127.0.0.1:6379> get k4
(nil)
127.0.0.1:6379> set name zhangsan
OK
127.0.0.1:6379> getset name lisi // 先 get 再 set, 若不存在, 返回nil
"zhangsan"
127.0.0.1:6379> get name
"lisi"