序言
我们经常使用Redis基本数据类型中的String,那Redis是在底层是如何存储String类型的数据的呢,以及修改字符串时,字符串所占用内存是变化的呢,本文将围绕这些信息展示。
1. 简单动态字符串
我们知道Redis主要是使用C语言进行编写的,但是Redis并没有使用C语言的字符串,而是自己构建了一个名为简单动态字符串(simple dynamic string,SDS)的抽象类型,并将 SDS 用作 Redis 的默认字符串表示。
在Redis里面,C字符串只会作为字符串字面量用在一些无需对字符串值进行修改的地方,比如打印日志
redisLog(REDIS_WARNING,"Redis is now ready to exit, bye bye ...");
当Redis需要的不仅仅是一个字符串字面量,而是一个可以被修改的字符串值时,Redis就会使用SDS来表示字符串值。 举个例子,如果我们执行命令SET msg "hello world" 那么Redis将在数据库中创建一个新的键值对,
- 键是一个字符串对象,对象的的底层实现是一个保存字符串"msg"的SDS。
- 值也是一个字符串对象,对象的底层是一个保存着字符串"hello world"的SDS。
除了用来保存数据库的字符串值之外,SDS 还被用作缓存区(buffer)。AOF模块中的AOF缓冲区,以及客户端状态中输入缓冲区,都是由SDS实现的。接下将对SDS进行介绍,解释为什么Redis实现SDS而不是C字符串。
2. SDS的定义
SDS的结构如下:
struct sdshdr {
// 记录buf数组中已使用字节的数量
// 等于SDS所保存字符串的长度
int len;
// 记录buf数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[]
}
假设Redis中存储了一个键值对,键值对的值为"Redis",那么其结构应该是下图所示:
- free 属性的值为0,表示这个SDS没有分配任何未使用空间
- len 属性的值为5,表示这个SDS保存了一个五字节长的字符串
- buf 属性是一个 char 类型的数组,数组的前五个字节分别保存了 'R'、'e'、'd'、'i'、's'五个字符,而最后一个字节则保存了空字符 '\0'
SDS 遵循 C 字符串以空字符结尾的惯例,保存空字符的1字节空间不计算在 SDS 的 len 属性中,并且为空字符分配额外的1字节空间,以及添加空字符到字符串末尾等操作,都是由 SDS 函数自动完成的。SDS 遵循以空字符来结尾的好处是可以复用 C 字符串的函数库中的函数。
3. SDS 和 C 字符串的区别
在 C 语言当中的字符串并不会记录自身的长度信息,所以为了获取一个 C 字符串的长度,程序必须遍历整个字符串,对遍历的每个字符串进行计算,这个操作算术复杂度为O(n)。
而在 Redis 中,由于每一个 SDS 都在 len 属性中记录了本身的长度的信息,所以获取一个 SDS 的长度复杂度仅为O(1)。
除了获取字符串长度的复杂度高以外,C 字符串不记录自身的长度信息带来了另外一个问题就是容易造成缓冲区溢出。比如,我们在 C 语言当中对一个字符串执行追加操作,如果该字符串的字节数据没有去扩展的话,也就是无法容纳拼接的字符串,那么就会造成缓存区溢出。SDS 是如何解决这个问题的呢?当 SDS API 需要对 SDS 进行修改时, API 会先检查 SDS 的空间是否满足修改所需的要求,如果不满足的话, API 会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作。
由于 C 字符串去不记录自身的长度信息,所以每次对 C 字符的改变都会产生一次内存重分配的操作
- 如程序执行的是增加操作,比如拼接操作,那么在执行这个操作之前,程序就需要通过内存重分配来扩展底层数组的大小。
- 如果执行的是缩短操作,比如截断,那么在执行这个操作之前,程序就需要通过内存重分配来缩短底层数组的大小。
而对于 Redis 这种对速度要求严苛、数据被频繁修改的场景下,每次执行内存重分配会大大的影响性能。所以 SDS 通过未使用空间解除了字符串长度和底层数组长度之间的关联:在 SDS 中,buf 数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就由 SDS 的 free 属性记录。通过未使用空间, SDS 实现了空间预分配和惰性空间释放两种优化策略。
4. 空间预分配和惰性空间释放
空间预分配优化 SDS 的字符串增长操作:当 SDS 的 API 对一个 SDS 进行修改,并且需要对 SDS 进行空间扩展的时候,程序不仅会为 SDS 分配修改所必须要的空间,还会为 SDS 分配额外的未使用空间。其中,额外分配的未使用空间数量由以下公式决定:
- 如果 SDS 进行修改之后,SDS 的长度(len)小于1MB,那么程序就会分配和 len 属性大小相同的未使用空间,这时 SDS len 属性的值将和 free 属性的值相同
- 如果对 SDS 进行修改后,SDS 的长度大于等于1MB,那么程序会分配1MB的未使用空间。 通过空间预分配策略,Redis 可以减少连续执行字符串增长操作所需的内存重分配次数,从原来的N次降低为至多N次。
惰性空间释放用于优化 SDS 的字符串缩短操作:当 SDS 的 API 需要缩短 SDS 保存的字符串时,程序并不立即使用内存重分配来回收缩短多的字节,而是使用 free 属性来将这些字节的数量记录起来,并等待将来使用。
5. 二进制安全
C 字符串中的字符必须符合某种编码(比如 ASCII)并且除了字符串末尾以外,字符串里面不能包含空字符,否则最先被程序读入的空字符会被认为是字符串结尾,这也意味着 C 字符串只能保存文本数据,而不能保存图片、音频、视频这样的二进制数据。
虽然一般的数据库用于保存文本数据,但使用数据库来保存二进制数据的场景也不少见,因此,为了确保 Redis 可以用于保存各种不同的使用场景,SDS 的 API 都是二进制安全的,所以的 SDS API 都会以处理二进制的方式来处理 SDS 存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤,数据存入时是什么样,取出来就是什么样。
6. String类型的常用命令
| 命令 | 功能 | 语法 | 示例 |
|---|---|---|---|
| SET | 设置指定键的值,若键已存在则覆盖旧值 | SET key value [EX seconds] [PX milliseconds] [NXXX] | SET mykey "Hello World";SET mykey "New Value" EX 10;SET mykey "NX Test" NX |
| GET | 获取指定键的值 | GET key | GET mykey |
| MSET | 同时设置多个键值对 | MSET key1 value1 key2 value2... | MSET key1 "value1" key2 "value2" |
| MGET | 获取多个指定键的值 | MGET key1 key2... | MGET key1 key2 |
| INCR | 键存储的值自增 1,若键不存在先初始化为 0 再自增 | INCR key | 假设键counter不存在,INCR counter后值为 1;若counter值为 5,执行后变为 6 |
| DECR | 键存储的值自减 1,若键不存在先初始化为 0 再自减 | DECR key | 假设键number值为 3,DECR number后值为 2 |
| INCRBY | 将键存储的值增加指定整数增量 | INCRBY key increment | 假设score值为 10,INCRBY score 5 后值为 15 |
| DECRBY | 将键存储的值减少指定整数减量 | DECRBY key decrement | 假设键quantity值为 20,DECRBY quantity 3后值为 17 |
| APPEND | 将指定值追加到键原有值末尾,若键不存在先初始化为空字符串再追加 | APPEND key value | 假设键message值为Hello,APPEND message " World"后值为Hello World;message不存在,APPEND message "First Message"后值为First Message |
总结
本文对Redis中的基本数据类型String进行介绍,分析了其在 Redis 中的存放方式,为什么使用这种存储方式,它有什么优点,最后列出了String基本数据类型的常用命令。