Redis源码学习——基础数据结构之SDS

简介: ###Redis数据结构-SDS Redis是一个开源(BSD许可),内存存储的数据结构服务器,可用作数据库,高速缓存和消息队列代理。 首先介绍下Redis的基础数据结构 —— SDS Redis没有使用传统C语言的字符串(字符数组)表示。而是自己构建了一种名为sds(Simple Dymamic String)的抽象类型,作为redis的默认字符类型。 SDS用于保存数据库中的

Redis数据结构-SDS

Redis是一个开源(BSD许可),内存存储的数据结构服务器,可用作数据库,高速缓存和消息队列代理。

首先介绍下Redis的基础数据结构 —— SDS
Redis没有使用传统C语言的字符串(字符数组)表示。而是自己构建了一种名为sds(Simple Dymamic String)的抽象类型,作为redis的默认字符类型。 SDS用于保存数据库中的字符串值,用户客户端的输入的缓冲区,AOF模块中的缓冲区都是由SDS实现的。

SDS相比于C字符串的优点:

  1. 常数复杂度获取字符串长度
  2. 缓冲区溢出
  3. 减少改变字符串长度时带来的内存重新分配
  4. 二进制安全

同时,sds也支持c字符串的部分操作函数。

SDS的数据结构:

以下是SDS的数据结构

/*
 * 保存字符串对象的结构
 */
struct sdshdr {
    
    // buf 中已占用空间的长度
    int len;

    // buf 中剩余可用空间的长度
    int free;

    // 数据空间
    char buf[];
};
// ps: 在redis 3.0中  为了更加节省内存,可用的sdshdr分成4种,len和free属性分别可以是uint8_t,uint16_t,uint32_t,uint64_t 这四种类型,会随着sds所保存的字符串长度不同,而分配为不同的sdshdr。 

获取长度;

Redis中获取字符长度的操作是
STRLEN key

C语言中的字符串并不记录自身的长度信息。 如果我们想要获取一个c字符串的长度,我们要遍历整个字符串,直到遇到代表结尾符的'/n'为止。 毫无疑问,其复杂度是O(N)。
而在sds中,我们记录了每个sds对象中所存字符串的长度。 sds提供了一系列操作sds的函数,若出现改变数组长度的草走,都会同步更新len字段,保证len字段的实时性。 这样每个STRLEN的复杂度就变成了O(1).

缓存区溢出:

C语言中提供了strcat方法,可以将strSrc字符串拼到strDest字符串尾部。 然而每次执行strcat操作时,都假设了我们已经strDest指针分配了足够多的内存。 然而一旦当分配的内存不足, 机会出现缓存区溢出。如下:

#include <stdio.h>
#include <string.h>

int main(void)
{    
    char dest[20] = "Hey ";
    for(int i = 0; i < 20; i++) {
        strcat(dest, ", Man");    
        printf("%d time, lenght is: %ld \n", i, strlen(dest));
    }
    return 0;
}

执行结果:

0 time, lenght is: 9
1 time, lenght is: 14
2 time, lenght is: 19
[1]    29751 abort      ./str

可以看到,当执行到第三次的时候,并不会由于dest已经快到其最大容量。 所以第四次strcat执行时,会出现溢出,中断程序。
而在sds中,执行sdscat操作,会判断为目标sds对象所分配的内存是否可以容纳拼接后的字符内存。 代码如下:

sds sdscat(sds s, const char *t) {
    return sdscatlen(s, t, strlen(t));
}
sds sdscatlen(sds s, const void *t, size_t len) {
    struct sdshdr *sh;
    
    // 获取原字符长度
    size_t curlen = sdslen(s);

    // 扩展空间。 若原指针分配内存不足,则重新分配内存。 返回新的地址
    // T = O(N)
    s = sdsMakeRoomFor(s,len);

    // 若内存不足直接返回
    if (s == NULL) return NULL;

    // 获取sds句柄sdshdr 的指针位置
    sh = (void*) (s-(sizeof(struct sdshdr)));
    
    // 复制将t中字符串复制到目标字符串后面
    // T = O(N)
    memcpy(s+curlen, t, len);

    // 更新属性
    sdssetlen(s, curlen+len);

    // 添加新结尾符号
    s[curlen+len] = '\0';

    // 返回新 sds
    return s;
}

可以看到, 每次执行字符串拼接操作,都会判断所分配的内存是否足够,如果不足,会重新分配内存。 其他操作,例如sdsrange(仅保留部分字符串),sdstrim(裁剪特定字符)都会有以上类似逻辑,保证每次操作都会即时释放多余内存,且不会出现内存不足。

减少改变字符串长度时带来的内存重新分配

然而通过C字符串执行会改变字符串长度的操作, 也可以通过判断字符串长度实现预分配内存。每次内存重新分配都要将原内存中的字符复制到新内存中, 复杂度是O(N),然而当我们频繁地对一个字符串进行改变长度的操作,会导致每次操作都引起一次O(N)的操作。
在sds中, 每次重新分配内存都会预留一部分作为buffer,我们可以从上文的代码中看到,重新分配内存是通过sdsMakeRoomFor函数调用。 那么我们看下sdsMakeRoomFor中,分配内存的策略:

sds sdsMakeRoomFor(sds s, size_t addlen) {
    struct sdshdr *sh, *newsh;

    // 获取s目前剩余的空间长度
    size_t free = sdsavail(s);

    size_t len, newlen;

    // s 目前的空余空间已经足够,无须再进行扩展,直接返回
    if (free >= addlen) return s;

    // 获取 s 目前已占用空间的长度
    len = sdslen(s);
    
    // 获取句柄指针位置
    sh = (void*) (s-(sizeof(struct sdshdr)));

    // 扩展后s至少需要的长度
    newlen = (len+addlen);

    // 根据新长度,为 s 分配新空间所需的大小
    if (newlen < SDS_MAX_PREALLOC)
        // 如果新长度小于 SDS_MAX_PREALLOC 
        // 那么为它分配两倍于所需长度的空间
        // 对新建的sds或者重新分配内存的sds,都会采用此策略,保留1倍的长度
        newlen *= 2;
    else
        // 否则,所保留的buffer长度为 SDS_MAX_PREALLOC
        newlen += SDS_MAX_PREALLOC;
    
    // T = O(N)
    // 分配内存,获取新的指针地址
    newsh = zrealloc(sh, sizeof(struct sdshdr)+newlen+1);

    // 若内存不足,分配失败,返回
    if (newsh == NULL) return NULL;

    // 设置剩余空间长度。
    newsh->free = newlen - len;

    // 返回 sds
    return newsh->buf;
}

redis中为了减少内存的重新分配, 使用了预留buffer的方法。 将原来n次字符串操作一定有n次O(N)复杂度的内存分配, 调整为最多有n次O(N)复杂度的内存分配。
tips: 当执行sdstrim这样减少字符串长度的操作时, 即时裁剪后多余的内存大于len的一半,sds也不会立即将多余的空间释放,而是保留下来未将来的增长操作做了优化。 且提供了sdsRemoveFreeSpace这个APi,用于我们可以手动将这样多余的内存释放。

二进制安全

二进制安全是一个主要用来处理字符串操作的编程术语。二进制安全功能本质上是把输入当作一个没有任何特殊的原生流,其在操作上应包含一个字符所能有的256种可能的值(假设为8为字符)。
由于C字符串需要通过'0'判断字符串的结尾。 所以当我们储存字符串时,需要将'0'这样的特殊字符过滤掉。 在sds中,由于我们维护了字符串的长度,所以并没有这样的顾虑。 sds符合二进制安全。

支持c字符串的部分操作函数

由于sds的api提供的入参都是sds格式,指向的都是其buf属性的指针位置。 我们以一个创建sds的api为例:

sds sdsnewlen(const void *init, size_t initlen) {

    struct sdshdr *sh;
    // do somethings to create sds...
    
    sh->len = initlen;
    sh->free = 0;
    sh->buf[initlen] = '\0';

    // 返回 buf 部分,而不是整个 sdshdr
    return (char*)sh->buf;
}

我们可以看到,sds对象,我们获取的并不是整个sdshdr指针的位置, 而是其buf属性的指针,也就是存字符串的指针位置,且sds遵守了以'0'作为一个字符串结尾的条件(但并不是通过'0'的位置判断字符串的长度)。 这样就保证了我们对一部分C字符串接口传入sds对象的指针,是可以当作char[]用的。

目录
相关文章
|
8月前
|
存储 缓存 NoSQL
【📕分布式锁通关指南 12】源码剖析redisson如何利用Redis数据结构实现Semaphore和CountDownLatch
本文解析 Redisson 如何通过 Redis 实现分布式信号量(RSemaphore)与倒数闩(RCountDownLatch),利用 Lua 脚本与原子操作保障分布式环境下的同步控制,帮助开发者更好地理解其原理与应用。
606 6
|
7月前
|
消息中间件 缓存 NoSQL
Redis各类数据结构详细介绍及其在Go语言Gin框架下实践应用
这只是利用Go语言和Gin框架与Redis交互最基础部分展示;根据具体业务需求可能需要更复杂查询、事务处理或订阅发布功能实现更多高级特性应用场景。
420 86
|
6月前
|
NoSQL 算法 Redis
【Docker】(3)学习Docker中 镜像与容器数据卷、映射关系!手把手带你安装 MySql主从同步 和 Redis三主三从集群!并且进行主从切换与扩容操作,还有分析 哈希分区 等知识点!
Union文件系统(UnionFS)是一种**分层、轻量级并且高性能的文件系统**,它支持对文件系统的修改作为一次提交来一层层的叠加,同时可以将不同目录挂载到同一个虚拟文件系统下(unite several directories into a single virtual filesystem) Union 文件系统是 Docker 镜像的基础。 镜像可以通过分层来进行继承,基于基础镜像(没有父镜像),可以制作各种具体的应用镜像。
753 6
|
7月前
|
存储 消息中间件 NoSQL
Redis数据结构:别小看这5把“瑞士军刀”,用好了性能飙升!
Redis提供5种基础数据结构及多种高级结构,如String、Hash、List、Set、ZSet,底层通过SDS、跳表等实现高效操作。灵活运用可解决缓存、计数、消息队列、排行榜等问题,结合Bitmap、HyperLogLog、GEO更可应对签到、UV统计、地理位置等场景,是高性能应用的核心利器。
|
7月前
|
存储 缓存 NoSQL
Redis基础命令与数据结构概览
Redis是一个功能强大的键值存储系统,提供了丰富的数据结构以及相应的操作命令来满足现代应用程序对于高速读写和灵活数据处理的需求。通过掌握这些基础命令,开发者能够高效地对Redis进行操作,实现数据存储和管理的高性能方案。
232 12
|
7月前
|
存储 消息中间件 NoSQL
【Redis】常用数据结构之List篇:从常用命令到典型使用场景
本文将系统探讨 Redis List 的核心特性、完整命令体系、底层存储实现以及典型实践场景,为读者构建从理论到应用的完整认知框架,助力开发者在实际业务中高效运用这一数据结构解决问题。
|
7月前
|
存储 缓存 NoSQL
【Redis】 常用数据结构之String篇:从SET/GET到INCR的超全教程
无论是需要快速缓存用户信息,还是实现高并发场景下的精准计数,深入理解String的特性与最佳实践,都是提升Redis使用效率的关键。接下来,让我们从基础命令开始,逐步揭开String数据结构的神秘面纱。
|
存储 算法
非递归实现后序遍历时,如何避免栈溢出?
后序遍历的递归实现和非递归实现各有优缺点,在实际应用中需要根据具体的问题需求、二叉树的特点以及性能和空间的限制等因素来选择合适的实现方式。
390 59
|
10月前
|
编译器 C语言 C++
栈区的非法访问导致的死循环(x64)
这段内容主要分析了一段C语言代码在VS2022中形成死循环的原因,涉及栈区内存布局和数组越界问题。代码中`arr[15]`越界访问,修改了变量`i`的值,导致`for`循环条件始终为真,形成死循环。原因是VS2022栈区从低地址到高地址分配内存,`arr`数组与`i`相邻,`arr[15]`恰好覆盖`i`的地址。而在VS2019中,栈区先分配高地址再分配低地址,因此相同代码表现不同。这说明编译器对栈区内存分配顺序的实现差异会导致程序行为不一致,需避免数组越界以确保代码健壮性。
218 0
栈区的非法访问导致的死循环(x64)
|
存储 C语言 C++
【C++数据结构——栈与队列】顺序栈的基本运算(头歌实践教学平台习题)【合集】
本关任务:编写一个程序实现顺序栈的基本运算。开始你的任务吧,祝你成功!​ 相关知识 初始化栈 销毁栈 判断栈是否为空 进栈 出栈 取栈顶元素 1.初始化栈 概念:初始化栈是为栈的使用做准备,包括分配内存空间(如果是动态分配)和设置栈的初始状态。栈有顺序栈和链式栈两种常见形式。对于顺序栈,通常需要定义一个数组来存储栈元素,并设置一个变量来记录栈顶位置;对于链式栈,需要定义节点结构,包含数据域和指针域,同时初始化栈顶指针。 示例(顺序栈): 以下是一个简单的顺序栈初始化示例,假设用C语言实现,栈中存储
884 77