本文作者: Pushy

本文链接: http://pushy.site/2019/12/21/redis-sds/

版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!

1. 什么是 SDS

众所周知,在 Redis 的五种数据解构中,最简单的就是字符串:

redis> set msg "Hello World"

而 Redis 并没有直接使用 C 语言传统的字符串表示,而是自己构建了一个名为简单动态字符串(Simple dynamic string,即 SDS)的抽象数据结构。

执行上面的 Redis 命令,在 Server 的数据库中将创建一个键值对,即:

  • 键为 “msg” 的 SDS;
  • 值为 “Hello World” 的 SDS。

我们再来看下 SDS 的定义,在 Redis 的源码目录 sds.h 头文件中,定义了 SDS 的结构体:

struct sdshdr {
// 记录 buf 数组中当前已使用的字节数量
unsigned int len;
// 记录 buf 数组中空闲空间长度
unsigned int free;
// 字节数组
char buf[]; };

可以看到,SDS 通过 lenfree 属性值来描述字节数组 buf 当前的存储状况,这样在之后的扩展和其他操作中有很大的作用,还能以 O(1) 的复杂度获取到字符串的长度(我们知道,C 自带的字符串本身并不记录长度信息,只能遍历整个字符串统计)

那么为什么 Redis 要自己实现一套字符串数据解构呢?下面慢慢来研究!

2. SDS 的优势

杜绝缓冲区溢出

除了获取字符串长度的复杂度为较高之外,C 字符串不记录自身长度信息带来的另一个问题就是容易造成内存溢出。举个例子,通过 C 内置的 strcat 方法将字符串 motto 追加到 s1 字符串后边:

void wrong_strcat() {
char *s1, *s2; s1 = malloc(5 * sizeof(char));
strcpy(s1, "Hello");
s2 = malloc(5 * sizeof(char));
strcpy(s2, "World"); char *motto = " To be or not to be, this is a question.";
s1 = strcat(s1, motto); printf("s1 = %s \n", s1);
printf("s2 = %s \n", s2);
} // s1 = Hello To be or not to be, this is a question.
// s2 = s a question.

但是输出却出乎意料,我们只想修改 s1 字符串的值,而 s2 字符串也被修改了。这是因为 strcat 方法假定用户在执行前已经为 s1 分配了足够的内存,可以容纳 motto 字符串中的内容。而一旦这个假设不成立,就会产生缓冲区溢出

通过 Debug 我们看到,s1 变量内存的初始位置为 94458843619936 (10进制), s2 初始位置为 94458843619968,是一段相邻的内存块:

所以一旦通过 strcat 追加到 s1 的字符串 motto 的长度大于 s1 到 s2 的内存地址间隔时,将会修改到 s2 变量的值。而正确的做法应该是在 strcat 之前为 s1 重新调整内存大小,这样就不会修改 s2 变量的值了:

void correct_strcat() {
char *s1, *s2; s1 = malloc(5 * sizeof(char));
strcpy(s1, "Hello");
s2 = malloc(5 * sizeof(char));
strcpy(s2, "World"); char *motto = " To be or not to be, this is a question.";
// 为 s1 变量扩展内存,扩展的内存大小为 motto * sizeof(char) + 空字符结尾(1)
s1 = realloc(s1, (strlen(motto) * sizeof(char)) + 1);
s1 = strcat(s1, motto); printf("s1 = %s \n", s1);
printf("s2 = %s \n", s2);
} // s1 = Hello To be or not to be, this is a question.
// s2 = World

可以看到,扩容后的 s1 变量内存地址起始位置变为了 94806242149024(十进制),s2 起始地址为 94806242148992。这时候 s1 与 s2 内存地址的间隔大小已经足够 motto 字符串的存放了:

而与 C 字符串不同, SDS 的空间分配策略完全杜绝了发生缓冲区溢出的可能性,具体的实现在 sds.c 中。通过阅读源码,我们可以明白之所以 SDS 能杜绝缓冲区溢出是因为再调用 sdsMakeRoomFor 时,会检查 SDS 的空间是否满足修改所需的要求(即 free >= addlen 条件),如果满足 Redis 将会将 SDS 的空间扩展至执行所需的大小,在执行实际的 concat 操作,这样就避免了溢出发生:

// 与 C 语言 string.h/strcat 功能类似,其将一个 C 字符串追加到 sds
sds sdscat(sds s, const char *t) {
return sdscatlen(s, t, strlen(t));
} sds sdscatlen(sds s, const char *t, size_t len) {
struct sdshdr *sh;
size_t curlen = sdslen(s); // 获取 sds 的 len 属性值 s = sdsMakeRoomFor(s, len);
if (s == NULL) return NULL;
// 将 sds 转换为 sdshdr,下边会介绍
sh = (void *) (s - sizeof(struct sdshdr));
// 将字符串 t 复制到以 s+curlen 开始的内存地址空间
memcpy(s + curlen, t, len);
sh->len = curlen + len; // concat后的长度 = 原先的长度 + len
sh->free = sh->free - len; // concat后的free = 原来 free 空间大小 - len
s[curlen + len] = '\0'; // 与 C 字符串一样,都是以空字符 \0 结尾
return s;
} // 确保有足够的空间容纳加入的 C 字符串, 并且还会分配额外的未使用空间
// 这样就杜绝了发生缓冲区溢出的可能性
sds sdsMakeRoomFor(sds s, size_t addlen) {
struct sdshdr *sh, *newsh;
size_t free = sdsavail(s); // 当前 free 空间大小
size_t len, newlen; if (free >= addlen) {
/* 如果空余空间足够容纳加入的 C 字符串大小, 则直接返回, 否则将执行下边的代码进行扩展 buf 字节数组 */
return s;
}
len = sdslen(s); // 当前已使用的字节数量
sh = (void *) (s - (sizeof(struct sdshdr)));
newlen = (len + addlen); // 拼接后新的字节长度 if (newlen < SDS_MAX_PREALLOC)
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;
newsh = realloc(sh, sizeof(struct sdshdr) + newlen + 1);
if (newsh == NULL) return NULL; // 申请内存失败 /* 新的 sds 的空余空间 = 新的大小 - 拼接的 C 字符串大小 */
newsh->free = newlen - len;
return newsh->buf;
}

另外,在看源码时我对 sh = (void *) (s - sizeof(struct sdshdr)); 一脸懵逼,如果不懂可以看:Redis(一)之 struct sdshdr sh = (void) (s-(sizeof(struct sdshdr)))讲解

减少修改字符带来的内存重分配次数

对于包含 N 个字符的 C 字符串来说,底层总是由 N+1 个连续内存的数组来实现。由于存在这种关系,因此每次修改时,程序都需要对这个 C 字符串数组进行一次内存重分配操作:

  • 如果是拼接操作:扩展底层数组的大小,防止出现缓冲区溢出(前面提到的);
  • 如果是截断操作:需要释放不使用的内存空间,防止出现内存泄漏

Redis 作为频繁被访问修改的数据库,为了减少修改字符带来的内存重分配的性能影响,SDS 也变得非常需要。因为在 SDS 中,buf 数组的长度不一定就是字符串数量 + 1,可以包含未使用的字符,通过 free 属性值记录。通过未使用空间,SDS 实现了以下两种优化策略:

Ⅰ、空间预分配

空间预分配用于优化 SDS 增长的操作:当对 SDS 进行修改时,并且需要对 SDS 进行空间扩展时,Redis 不仅会为 SDS 分配修改所必须的空间,还会对 SDS 分配额外的未使用空间

在前面的 sdsMakeRoomFor 方法可以看到,额外分配的未使用空间数量存在两种策略:

  • SDS 小于 SDS_MAX_PREALLOC:这时 len 属性值将会和 free 属性相等;
  • SDS 大于等于 SDS_MAX_PREALLOC:直接分配 SDS_MAX_PREALLOC 大小。
sds sdsMakeRoomFor(sds s, const char *t, size_t len) {
...
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;
newsh = realloc(sh, sizeof(struct sdshdr) + newlen + 1);
if (newsh == NULL) return NULL;
newsh->free = newlen - len;
return newsh->buf;
}

通过空间预分配策略,Redis 可以减少连续执行字符串增长操作所需的内存重分配次数。

Ⅱ、惰性空间释放

惰性空间释放用于优化 SDS 字符串缩短操作,当需要缩短 SDS 保存的字符串时,Redis 并不立即使用内存重分配来回收缩短多出来的字节,而是使用 free 属性将这些字节记录起来,并等待来使用

举个例子,可以看到执行完 sdstrim 并没有立即回收释放多出来的 22 字节的空间,而是通过 free 变量值保存起来。当执行 sdscat 时,先前所释放的 22 字节的空间足够容纳追加的 C 字符串 11 字节的大小,也就不需要再进行内存空间扩展重分配了。

#include "src/sds.h"

int main() {
// sds{len = 32, free = 0, buf = "AA...AA.a.aa.aHelloWorld :::"}
s = sdsnew("AA...AA.a.aa.aHelloWorld :::");
// sds{len = 10, free = 22, buf = "HelloWorld"}
s = sdstrim(s, "Aa. :");
// sds{len = 21, free = 11, buf = "HelloWorld! I'm Redis"}
s = sdscat(s, "! I'm Redis");
return 0;
}

通过惰性空间释放策略,SDS 避免了缩短字符串时所需内存重分配操作,并会将来可能增长操作提供优化。与此同时,SDS 也有相应的 API 真正地释放 SDS 的未使用空间。

二进制安全

C 字符串必须符合某种编码,并且除了字符串的末尾之外,字符串不能包含空字符(\0),否则会被误认为字符串的末尾。这些限制导致不能保存图片、音频等这种二进制数据。

但是 Redis 就可以存储二进制数据,原因是因为 SDS 是使用 len 属性值而不是空字符来判断字符串是否结束的。

兼容部分 C 字符串函数

我们发现, SDS 的字节数组有和 C 字符串相似的地方,例如也是以 \0 结尾(但是不是以这个标志作为字符串的结束)。这就使得 SDS 可以重用 <string.h> 库定义的函数:

#include <stdio.h>
#include <strings.h>
#include "src/sds.h" int main() {
s = sdsnew("Cat");
// 根据字符集比较大小
int ret = strcasecmp(s, "Dog");
printf("%d", ret);
return 0;
}

3. 总结

看完 Redis 的 SDS 的实现,终于知道 Redis 只所以快,肯定和 epoll 的网络 I/O 模型分不开,但是也和底层优化的简单数据结构分不开。

SDS 精妙之处在于通过 len 和 free 属性值来协调字节数组的扩展和伸缩,带来了较比 C 字符串太多的性能更好的优点。什么叫牛逼?这就叫牛逼!

Redis 动态字符串 SDS 源码解析的更多相关文章

  1. Redis学习之SDS源码分析

    一.SDS的简单介绍 SDS:简单动态字符串(simple dynamic string) 1)SDS是Redis默认的字符表示,比如包含字符串值的键值对都是在底层由SDS实现的 2)SDS用来保存数 ...

  2. [源码解析] 并行分布式任务队列 Celery 之 消费动态流程

    [源码解析] 并行分布式任务队列 Celery 之 消费动态流程 目录 [源码解析] 并行分布式任务队列 Celery 之 消费动态流程 0x00 摘要 0x01 来由 0x02 逻辑 in komb ...

  3. [源码解析] Pytorch 如何实现后向传播 (3)---- 引擎动态逻辑

    [源码解析] Pytorch 如何实现后向传播 (3)---- 引擎动态逻辑 目录 [源码解析] Pytorch 如何实现后向传播 (3)---- 引擎动态逻辑 0x00 摘要 0x01 前文回顾 0 ...

  4. [源码解析] TensorFlow 分布式环境(6) --- Master 动态逻辑

    [源码解析] TensorFlow 分布式环境(6) --- Master 动态逻辑 目录 [源码解析] TensorFlow 分布式环境(6) --- Master 动态逻辑 1. GrpcSess ...

  5. [源码解析] TensorFlow 分布式环境(7) --- Worker 动态逻辑

    [源码解析] TensorFlow 分布式环境(7) --- Worker 动态逻辑 目录 [源码解析] TensorFlow 分布式环境(7) --- Worker 动态逻辑 1. 概述 1.1 温 ...

  6. redis 5.0.7 源码阅读——动态字符串sds

    redis中动态字符串sds相关的文件为:sds.h与sds.c 一.数据结构 redis中定义了自己的数据类型"sds",用于描述 char*,与一些数据结构 typedef c ...

  7. Redis系列(十):数据结构Set源码解析和SADD、SINTER、SDIFF、SUNION、SPOP命令

    1.介绍 Hash是以K->V形式存储,而Set则是K存储,空间节省了很多 Redis中Set是String类型的无序集合:集合成员是唯一的. 这就意味着集合中不能出现重复的数据.可根据应用场景 ...

  8. .Net Core缓存组件(Redis)源码解析

    上一篇文章已经介绍了MemoryCache,MemoryCache存储的数据类型是Object,也说了Redis支持五中数据类型的存储,但是微软的Redis缓存组件只实现了Hash类型的存储.在分析源 ...

  9. Redis系列(九):数据结构Hash源码解析和HSET、HGET命令

    2.源码解析 1.相关命令如下: {"hset",hsetCommand,,"wmF",,NULL,,,,,}, {"hsetnx",hse ...

随机推荐

  1. React学习之路之创建项目

    React 开发环境准备 IDE工具 visual studio code 开发环境 开发环境需要安装nodejs和npm,nodejs工具包含了npm. nodejs下载官网:https://nod ...

  2. c# asp.net core取当月第一天和最后一天及删除最后一个字符的多种方法

    当月第一天0时0分0秒 DateTime.Now.AddDays( - DateTime.Now.Day).Date 当月最后一天23时59分59秒 DateTime.Now.AddDays( - D ...

  3. SVN服务端安装和仓库的创建

    1.安装SVN服务端 双击运行: 点击[next] 勾上复选框,点击[next] 使用默认选项,点击[next] 点击[Standard Edition]建议端口号不用443,因为Vmware占用了, ...

  4. python 库 PrettyTabble 使用与错误

    参考链接:http://zetcode.com/python/prettytable/ PrettyTable能在python中生成ASCII 表,可以使用他控制表的很多方面,包括文本对齐.表的边框. ...

  5. Python笔记:装饰器

    装饰器        1.特点:装饰器的作用就是为已存在的对象添加额外的功能,特点在于不用改变原先的代码即可扩展功能: 2.使用:装饰器其实也是一个函数,加上@符号后放在另一个函数“头上”就实现了装饰 ...

  6. 构造命题公式的真值表--biaobiao88

    对给出的任意一个命题公式(不超过四个命题变元),使学生会用C语言的程序编程表示出来,并且能够计算它在各组真值指派下所应有的真值,画出其真值表. #include<iostream> usi ...

  7. CSP复赛day2模拟题

    没错,我又爆零了.....先让我自闭一分钟.....so 当你忘记努力的时候,现实会用一记响亮的耳光告诉你东西南北在哪. 好了,现在重归正题: 全国信息学奥林匹克联赛(NOIP2014) 复赛模拟题 ...

  8. topshelf注册服务

    1.需要从nutget上获取toshelf配置 2.代码 using Common.Logging; using Quartz; using Quartz.Impl; using System; us ...

  9. C++中的Mat, const Mat, Mat &,Mat &, const Mat &的区别

    Mat, copy传递,不会改变外部变量的Mat. Mat &, reference传递,函数内部修改将会改变外部. const Mat, copy传递,在函数内,不会被修改,也不会影响到外部 ...

  10. 简单实现Shiro单点登录(自定义Token令牌)

    1. MVC Controller 映射 sso 方法. /** * 单点登录(如已经登录,则直接跳转) * @param userCode 登录用户编码 * @param token 登录令牌,令牌 ...