深挖 Redis 6.0 源码—— SDS

本文涉及的产品
云数据库 Redis 版,社区版 2GB
推荐场景:
搭建游戏排行榜
简介: SDS(Simple Dynamic Strings, 简单动态字符串)是 Redis 的一种基本数据结构,主要是用于存储字符串和整数。 这篇文章里,我们就来探讨一下 Redis SDS 这种数据结构的底层实现原理。

本文精选自 Doocs 开源社区旗下“源码猎人”项目,作者杨立滨。


项目将会持续更新,欢迎 Star 关注。


项目地址:https://github.com/doocs/source-code-hunter


SDS(Simple Dynamic Strings, 简单动态字符串)是 Redis 的一种基本数据结构,主要是用于存储字符串和整数。 这篇文章里,我们就来探讨一下 Redis SDS 这种数据结构的底层实现原理。


学习之前,首先我们要明确,Redis 是一个使用 C 语言编写的键值对存储系统


前置思考


我们首先考虑一个问题,如何实现一个二进制安全的字符串?


在 C 语言中,\0 表示字符串结束,如果字符串中本身就包含 \0 字符,那么字符串就会在 \0 处被截断,即非二进制安全;若通过使用一个 len 属性,来判断字符串是否结束,就可以保证读写字符串时不受到 \0 的影响,则是二进制安全。同时 len 属性也能保证在 O(1) 时间内获取字符串的长度。


Redis 3.2 以前的 SDS 实现


在 Redis 3.2 版本以前,SDS 的结构如下:


struct sdshdr {    unsigned int len;    unsigned int free;    char buf[];};


其中,buf 表示数据空间,用于存储字符串;len 表示 buf 中已占用的字节数,也即字符串长度;free 表示 buf 中剩余可用字节数。


字段 len 和 free 各占 4 字节,紧接着存放字符串。


这样做有以下几个好处:


用单独的变量 len 和 free,可以方便地获取字符串长度和剩余空间

内容存储在动态数组 buf 中,SDS 对上层暴露的指针指向 buf,而不是指向结构体 SDS。因此,上层可以像读取 C 字符串一样读取 SDS 的内容,兼容 C 语言处理字符串的各种函数,同时也能通过 buf 地址的偏移,方便地获取其他变量;

读写字符串不依赖于 \0,保证二进制安全


但其实以上的设计是存在一些问题的,对于不同长度的字符串,是否有必要使用 len 和 free 这 2 个 4 字节的变量?4 字节的 len,可表示的字符串长度为 2^32,而在实际应用中,存放于 Redis 中的字符串往往没有这么长,因此,空间的使用上能否进一步压缩?


那么接下来,我们就来看看最新的 Redis 是如何根据字符串的长度,使用不同的数据结构进行存储的


Redis SDS 最新实现


在 Redis 3.2 版本之后(v3.2 - v6.0),Redis 将 SDS 划分为 5 种类型:


sdshdr5:长度小于 1 字节sdshdr8:长度 1 字节sdshdr16:长度 2 字节sdshdr32:长度 4 字节sdshdr64:长度 8 字节


Redis 增加了一个 flags 字段来标识类型,用一个字节(8 位)来存储。


其中:前 3 位表示字符串的类型;剩余 5 位,可以用来存储长度小于 32 的短字符串。


struct __attribute__ ((__packed__)) sdshdr5 {    unsigned char flags; /* 前3位存储类型,后5位存储长度 */    char buf[]; /* 动态数组,存放字符串 */};


而对于长度大于 31 的字符串,仅仅靠 flags 的后 5 位来存储长度明显是不够的,需要用另外的变量来存储。sdshdr8、sdshdr16、sdshdr32、sdshdr64 的数据结构定义如下,其中 len 表示已使用的长度,alloc 表示总长度,buf 存储实际内容,而 flags 的前 3 位依然存储类型,后 5 位则预留。


struct __attribute__ ((__packed__)) sdshdr8 {    uint8_t len; /* 已使用长度,1字节 */    uint8_t alloc; /* 总长度,1字节 */    unsigned char flags; /* 前3位存储类型,后5位预留 */    char buf[];};struct __attribute__ ((__packed__)) sdshdr16 {    uint16_t len; /* 已使用长度,2字节 */    uint16_t alloc; /* 总长度,2字节 */    unsigned char flags; /* 前3位存储类型,后5位预留 */    char buf[];};struct __attribute__ ((__packed__)) sdshdr32 {    uint32_t len; /* 已使用长度,4字节 */    uint32_t alloc; /* 总长度,4字节 */    unsigned char flags; /* 前3位存储类型,后5位预留 */    char buf[];};struct __attribute__ ((__packed__)) sdshdr64 {    uint64_t len; /* 已使用长度,8字节 */    uint64_t alloc; /* 总长度,8字节 */    unsigned char flags; /* 前3位存储类型,后5位预留 */    char buf[];};


注意,一般情况下,结构体会按照所有变量大小的最小公倍数做字节对齐,而用 packed 修饰后,结构体则变为 1 字节对齐。这样做的好处有二:一是节省内存,比如 sdshdr32 可节约 3 个字节;二是 SDS 返回给上层的是指向 buf 的指针,此时按 1 字节对齐,所以可在创建 SDS 后,通过 (char*)sh+hdrlen 得到 buf 指针地址,也可以通过 buf[-1] 找到 flags


以上,Redis 根据字符串长度的不同,选择对应的数据结构进行存储。接下来,我们就来看看 Redis 字符串的相关 API 实现。


SDS API 实现


1. 创建字符串


sds sdsnewlen(const void *init, size_t initlen) {    void *sh;    sds s;    // 根据字符串长度计算相应类型    char type = sdsReqType(initlen);    // 如果创建的是""字符串,强转为SDS_TYPE_8    if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;    // 根据类型计算头部所需长度(头部包含 len、alloc、flags)    int hdrlen = sdsHdrSize(type);    // 指向flags的指针    unsigned char *fp;    // 创建字符串,+1是因为 `\0` 结束符    sh = s_malloc(hdrlen+initlen+1);    if (sh == NULL) return NULL;    if (init==SDS_NOINIT)        init = NULL;    else if (!init)        memset(sh, 0, hdrlen+initlen+1);    // s指向buf    s = (char*)sh+hdrlen;    // s减1得到flags    fp = ((unsigned char*)s)-1;    ...    // 在s末尾添加\0结束符    s[initlen] = '\0';    // 返回指向buf的指针s    return s;}


创建 SDS 的大致流程是这样的:首先根据字符串长度计算得到 type,根据 type 计算头部所需长度,然后动态分配内存空间。


注意:


1.创建空字符串时,SDS_TYPE_5 被强制转换为 SDS_TYPE_8(原因是创建空字符串后,内容可能会频繁更新而引发扩容操作,故直接创建为 sdshdr8)

2.长度计算有 +1 操作,因为结束符 \0 会占用一个长度的空间。

3.返回的是指向 buf 的指针 s。


2. 清空字符串


SDS 提供了两种清空字符串的方法。


一种是通过 s 偏移得到结构体的地址,然后调用 s_free 直接释放内存。


void sdsfree(sds s) {    if (s == NULL) return;    // s减去头部的大小得到结构体的地址    s_free((char*)s-sdsHdrSize(s[-1]));}


另一种是通过重置 len 属性值而达到清空字符串的目的,本质上 buf 并没有被真正清除,新的数据会直接覆盖 buf 中原有的数据,无需申请新的内存空间。


void sdsclear(sds s) {    // 将len属性置为0    sdssetlen(s, 0);    s[0] = '\0';}


3. 拼接字符串


SDS 拼接字符串的实现如下:


sds sdscatsds(sds s, const sds t) {    return sdscatlen(s, t, sdslen(t));}


可以看到 sdscatsds 内部调用的是 sdscatlen


sdscatlen 内部的实现相对复杂一些,由于拼接字符串可能涉及 SDS 的扩容,因此 sdscatlen 内部调用 sdsMakeRoomFor 对拼接的字符串做检查:若无需扩容,直接返回 s;若需要扩容,则返回扩容好的新字符串 s。


sds sdscatlen(sds s, const void *t, size_t len) {    // 计算当前字符串长度    size_t curlen = sdslen(s);    // 确保s的剩余空间足以拼接上t    s = sdsMakeRoomFor(s,len);    if (s == NULL) return NULL;    // 拼接s、t    memcpy(s+curlen, t, len);    // 更新s的len属性    sdssetlen(s, curlen+len);    // s末尾添加\0结束符    s[curlen+len] = '\0';    return s;}


SDS 的扩容策略是这样的:


1.若 SDS 中剩余空闲长度 avail 大于或等于新增内容的长度 addlen,无需扩容。

2.若 SDS 中剩余空闲长度 avail 小于或等于 addlen,则分情况讨论:新增后总长度 len+addlen < 1MB 的,按新长度的 2 倍扩容;新增后总长度 len+addlen >= 1MB 的,按新长度加上 1MB 扩容。


sds sdsMakeRoomFor(sds s, size_t addlen) {    void *sh, *newsh;    // 当前剩余长度    size_t avail = sdsavail(s);    size_t len, newlen;    char type, oldtype = s[-1] & SDS_TYPE_MASK;    int hdrlen;    /* 剩余长度>=新增字符串长度,直接返回 */    if (avail >= addlen) return s;    // 计算当前字符串长度len    len = sdslen(s);    sh = (char*)s-sdsHdrSize(oldtype);    // 计算新长度    newlen = (len+addlen);    // 新长度<1MB,按新长度的2倍扩容    if (newlen < SDS_MAX_PREALLOC)        newlen *= 2;    // 否则按新长度+1MB扩容    else        newlen += SDS_MAX_PREALLOC;    // 计算新长度所属类型    type = sdsReqType(newlen);    /* type5不支持扩容,强转为type8 */    if (type == SDS_TYPE_5) type = SDS_TYPE_8;    hdrlen = sdsHdrSize(type);    if (oldtype==type) {        // 类型没变,直接通过realloc扩大动态数组即可。        newsh = s_realloc(sh, hdrlen+newlen+1);        if (newsh == NULL) return NULL;        s = (char*)newsh+hdrlen;    } else {        // 类型改变了,则说明头部长度也发生了变化,不进行realloc操作,而是直接重新开辟内存        newsh = s_malloc(hdrlen+newlen+1);        if (newsh == NULL) return NULL;        // 原内存拷贝到新的内存地址上        memcpy((char*)newsh+hdrlen, s, len+1);        // 释放原先空间        s_free(sh);        s = (char*)newsh+hdrlen;        // 为flags赋值        s[-1] = type;        // 为len属性赋值        sdssetlen(s, len);    }    // 为alloc属性赋值    sdssetalloc(s, newlen);    return s;}


总结


1.SDS 返回的是指向 buf 的指针,兼容了 C 语言操作字符串的函数,读取内容时,通过 len 属性来限制读取的长度,不受 \0 影响,从而保证二进制安全;2.Redis 根据字符串长度的不同,定义了多种数据结构,包括:sdshdr5/sdshdr8/sdshdr16/sdshdr32/sdshdr64。3.SDS 在设计字符串修改出会调用 sdsMakeRoomFor 函数进行检查,根据不同情况进行扩容。


全文完!


希望本文对大家有所帮助。如果感觉本文有帮助,有劳转发或点一下“在看”!让更多人收获知识!


相关实践学习
基于Redis实现在线游戏积分排行榜
本场景将介绍如何基于Redis数据库实现在线游戏中的游戏玩家积分排行榜功能。
云数据库 Redis 版使用教程
云数据库Redis版是兼容Redis协议标准的、提供持久化的内存数据库服务,基于高可靠双机热备架构及可无缝扩展的集群架构,满足高读写性能场景及容量需弹性变配的业务需求。 产品详情:https://www.aliyun.com/product/kvstore &nbsp; &nbsp; ------------------------------------------------------------------------- 阿里云数据库体验:数据库上云实战 开发者云会免费提供一台带自建MySQL的源数据库&nbsp;ECS 实例和一台目标数据库&nbsp;RDS实例。跟着指引,您可以一步步实现将ECS自建数据库迁移到目标数据库RDS。 点击下方链接,领取免费ECS&amp;RDS资源,30分钟完成数据库上云实战!https://developer.aliyun.com/adc/scenario/51eefbd1894e42f6bb9acacadd3f9121?spm=a2c6h.13788135.J_3257954370.9.4ba85f24utseFl
目录
相关文章
|
1月前
|
NoSQL 安全 Linux
Redis 字符串:SDS
Redis 字符串:SDS
37 0
|
3月前
|
存储 NoSQL 算法
Redis系列-13.Redis经典五大类型源码及底层实现(一)(上)
Redis系列-13.Redis经典五大类型源码及底层实现(一)
75 0
|
3月前
|
存储 NoSQL Redis
redis源码学习
redis源码学习
|
27天前
|
存储 NoSQL 算法
【Redis技术进阶之路】「底层源码解析」揭秘高效存储模型与数据结构底层实现(字典)(二)
【Redis技术进阶之路】「底层源码解析」揭秘高效存储模型与数据结构底层实现(字典)
43 0
|
3月前
|
NoSQL 网络协议 Linux
Redis单线程源码深入解析
Redis单线程源码深入解析
|
8天前
|
人工智能 前端开发 Java
Java语言开发的AI智慧导诊系统源码springboot+redis 3D互联网智导诊系统源码
智慧导诊解决盲目就诊问题,减轻分诊工作压力。降低挂错号比例,优化就诊流程,有效提高线上线下医疗机构接诊效率。可通过人体画像选择症状部位,了解对应病症信息和推荐就医科室。
146 10
|
27天前
|
存储 NoSQL Redis
作者推荐 |【Redis技术进阶之路】「原理系列开篇」揭秘高效存储模型与数据结构底层实现(SDS)(三)
作者推荐 |【Redis技术进阶之路】「原理系列开篇」揭秘高效存储模型与数据结构底层实现(SDS)
29 0
|
1月前
|
存储 NoSQL 网络协议
读懂Redis源码,我总结了这7点心得
读懂Redis源码,我总结了这7点心得
|
3月前
|
存储 NoSQL 关系型数据库
图解 Redis String 底层数据结构 SDS 与计数器实战
图解 Redis String 底层数据结构 SDS 与计数器实战
45 0
|
3月前
|
缓存 NoSQL 关系型数据库
Redis 7.0 源码调试环境搭建与源码导读技巧
Redis 7.0 源码调试环境搭建与源码导读技巧
53 0

热门文章

最新文章