Redis源码阅读1-SDS

简单动态字符串(SDS)

简单动态字符串(Simple Dynamic Strings,SDS)是Redis的基本数据结构之一,用于存储字符串 和整型数据 。SDS兼容C语言标准字符串处理函数 ,且在此基础上保证了二进制安全。本章将详细讲解SDS的实现,为读者理解Redis的原理和各种命令的实现打下基础。

什么是二进制安全?字符串的二进制安全是指,字符串能够存储和处理任意二进制数据。C语言中的字符串就不是二进制安全的,因为它要求以'\0'作为字符串的结尾,那么0本身就无法作为字符串的数据来存储。

SDS的数据结构

在Redis中,自定了SDS来代替C语言字符串,其通过显式记录字符串的长度来保证二进制安全。

c 复制代码
struct sds {
    int len;      // buf 中已占用字节数
    int free;     // buf 中剩余可用字节数
    char buf[];   // 柔性数组存储实际数据
};

什么是柔性数组?柔性数组成员(flexible array member),只能被放在结构体的末尾。包含柔性数组成员的结构体,通过malloc函数为柔性数组动态分配内存,即该数组的大小取决于你为该结构体分配了多少内存。

在上面简化版本的SDS结构体定义中,使用两个int类型的变量len和free来分别记录当前buf中,使用了多少字节和剩余多少字节。看起来似乎没啥问题,对吧。

但是!!!Redis还想进一步压榨存储空间,根据存储的字符串长度的不同,设计了五种不同的sds结构体:

  • 首先需要一个flag字段来标识当前SDS采用的是五个SDS结构体中的哪一个,至少需要3个bits,因此使用一个unsigned char来存储。
  • len和alloc字段分别采用uint8_t、uint16_t、uint32_t和uint64_t类型,来存储不同长度的字符串的总容量和占用情况
  • 其中有一个例外,sdshdr5没有len和alloc字段。该SDS结构体用于存储长度为1~31的字符串,其长度使用flags中空闲的5个bits来表示,如下图所示(榨干了...):
c 复制代码
/* SDS类型 */
#define SDS_TYPE_5  0
#define SDS_TYPE_8  1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4

/* 五种SDS结构体头部 */
struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* used */
    uint8_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* used */
    uint16_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* used */
    uint32_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* used */
    uint64_t alloc; /* excluding the header and null terminator */
    unsigned char flags; /* 3 lsb of type, 5 unused bits */
    char buf[];
};

在继续介绍SDS的操作之前,需要提一嘴,SDS指针指向的是buf,而不是结构体的首地址:

这样做的目的是让SDS字符串能够兼容C语言字符串,并且我们可以通过查看sds指针之前flags字段来快速找到SDS结构体的首地址。

SDS的操作

获取sds字符串的长度:

  • 先通过sds[-1]获取到flags的值
  • 然后将使用与SDS_TYPE_MASK(0b0111)按位求与,得到s的类型
  • 每种类型从SDS结构体首地址到buf字段的距离都是固定的,因此可以很方便地找到SDS结构体首地址,返回长度即可
  • 注意SDS_TYPE_5进行了特殊操作,因为其长度存储在flags字段的高5位
c 复制代码
static inline size_t sdslen(const sds s) {
    unsigned char flags = s[-1];
    switch(flags&SDS_TYPE_MASK) {
        case SDS_TYPE_5:
            return SDS_TYPE_5_LEN(flags);
        case SDS_TYPE_8:
            return SDS_HDR(8,s)->len;
        case SDS_TYPE_16:
            return SDS_HDR(16,s)->len;
        case SDS_TYPE_32:
            return SDS_HDR(32,s)->len;
        case SDS_TYPE_64:
            return SDS_HDR(64,s)->len;
    }
    return 0;
}

创建 sds 字符串:

  • 参数init,值为NULL,将分配的内存初始化为0;值为SDS_NOINIT就不进行初始化
  • 参数initlen,即要创建的字符串的长度
  • 通过sdsReqType宏函数可以获得长度为initlen的字符串对应的类型
  • 通过sdsHdrSize宏函数可以获得类型对应的首部长度
  • 分配内存,大小为 首部长度 + 字符串长度 + 1(为了兼容C字符串,存储末尾位置'\0')
  • 之后就是根据SDS类型,设置各个字段,不多叙
  • 值得注意的是,最后返回的是指向buf的指针s,而不是SDS结构体的首地址(还是为了兼容C字符串)
c 复制代码
sds sdsnewlen(const void *init, size_t initlen) {
    void *sh;
    sds s;
    char type = sdsReqType(initlen);    /* 根据字符串长度获取SDS类型 */
    if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
    int hdrlen = sdsHdrSize(type);         /* 根据类型获取首部的长度 */
    unsigned char *fp; /* flags pointer. */

    sh = s_malloc(hdrlen+initlen+1);  /* 分配内存,+1是为'\0'留位置,兼容C字符串 */
    if (init==SDS_NOINIT)
        init = NULL;
    else if (!init)
        memset(sh, 0, hdrlen+initlen+1);
    
    if (sh == NULL) return NULL;
    s = (char*)sh+hdrlen;           /* s指向buf */
    fp = ((unsigned char*)s)-1;     /* fp指向flags */
    switch(type) {
        case SDS_TYPE_5: {          /* sdshdr5类型,将长度放在flags中 */
            *fp = type | (initlen << SDS_TYPE_BITS);
            break;
        }
        case SDS_TYPE_8: {
            SDS_HDR_VAR(8,s);       /* SDS_HDR_VAR用于根据类型的到首部的首地址 */
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_16: {
            SDS_HDR_VAR(16,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_32: {
            SDS_HDR_VAR(32,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_64: {
            SDS_HDR_VAR(64,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
    }
    if (initlen && init)
        memcpy(s, init, initlen);
    s[initlen] = '\0';
    return s;        /* 返回的是s,而不是首部的首地址 */ 
}

释放 sds 字符串:

  • 老套路,先通过sds[-1]获取到flags的值
  • 然后将其使用sdsHdrSize()函数,根据flags中的类型信息(第3位)获取对应的SDS首部长度
  • 最后通过char*指针的减法运算,得到SDS结构体的首地址,调用s_free释放掉该SDS字符串
c 复制代码
void sdsfree(sds s) {
    if (s == NULL) return;
    s_free((char*)s-sdsHdrSize(s[-1]));
}

static inline int sdsHdrSize(char type) {
    switch(type&SDS_TYPE_MASK) {
        case SDS_TYPE_5:
            return sizeof(struct sdshdr5);
        case SDS_TYPE_8:
            return sizeof(struct sdshdr8);
        case SDS_TYPE_16:
            return sizeof(struct sdshdr16);
        case SDS_TYPE_32:
            return sizeof(struct sdshdr32);
        case SDS_TYPE_64:
            return sizeof(struct sdshdr64);
    }
    return 0;
}

拼接 SDS 字符串:

  • 委托给sdscatlen()实现拼接
  • 在sdscatlen()中,首先确保源字符串的内存足够存储拼接结果,然后进行拷贝、设置len字段等
c 复制代码
sds sdscatsds(sds s, const sds t) {
    return sdscatlen(s, t, sdslen(t));
}

sds sdscatlen(sds s, const void *t, size_t len) {
    size_t curlen = sdslen(s);

    s = sdsMakeRoomFor(s,len);
    if (s == NULL) return NULL;
    memcpy(s+curlen, t, len);
    sdssetlen(s, curlen+len);
    s[curlen+len] = '\0';
    return s;
}

扩容策略:

  • 简单的按需扩容,不会预分配更多的内存。
  • 新旧SDS类型相同时,无需修改其他字段,使用realloc就地分配所需内存
  • 新旧SDS类型不同时,则重新分配+拷贝,并重新设置flags中的类型信息
c 复制代码
sds sdsMakeRoomFor(sds s, size_t addlen) {
    void *sh, *newsh;
    size_t avail = sdsavail(s);  /* alloc - len = 剩余的容量 */
    size_t len, newlen;
    char type, oldtype = s[-1] & SDS_TYPE_MASK;
    int hdrlen;

    /* Return ASAP if there is enough space left. */
    if (avail >= addlen) return s;

    len = sdslen(s);
    sh = (char*)s-sdsHdrSize(oldtype);
    newlen = (len+addlen);
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;

    type = sdsReqType(newlen);

    /* Don't use type 5: the user is appending to the string and type 5 is
     * not able to remember empty space, so sdsMakeRoomFor() must be called
     * at every appending operation. */
    if (type == SDS_TYPE_5) type = SDS_TYPE_8;

    hdrlen = sdsHdrSize(type);
    if (oldtype==type) {            /* 新旧SDS类型不变,只需稍微增加一点内存 */
        newsh = s_realloc(sh, hdrlen+newlen+1);
        if (newsh == NULL) return NULL;
        s = (char*)newsh+hdrlen;
    } else {                        /* 新旧SDS类型改变,需要修改flags中的类型信息 */
        /* Since the header size changes, need to move the string forward,
         * and can't use 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;
        s[-1] = type;
        sdssetlen(s, len);
    }
    sdssetalloc(s, newlen);
    return s;
}

理解了SDS的基本结构和操作方法,其余的SDS操作函数就不用多介绍了,大差不差的。

核心问题

  1. SDS字符串如何实现二进制安全?

显式地使用alloc和len字段记录当前SDS字符串的容量和长度,支持任何数据存储在SDS中。

  1. SDS字符串是如何兼容C语言的?

这就是SDS比较有趣的地方,一个sds指针(char*)指向的是柔性数组buf,而不是SDS结构体首地址,并且在SDS字符串的末尾处还额外添加了一个\0字符。因此将SDS字符串完全可以被当作C字符串传给C字符串库中的函数进行处理。

  1. sdshdr5的特殊之处是什么?

为了最大程度地压榨内存,使用sdshdr5存储长度为[1, 32)的字符串,该结构体不包含alloc和len字段,而是利用flags字段中剩余的5个bits来表示字符串的长度。

相关推荐
IT大白鼠3 天前
Redis 系列 · 第 01 篇——认知入门:Redis 是什么
redis·nosql
彧azz3 天前
Linux 环境下 Redis 学习总结:数据类型、持久化、锁、事务、主从与缓存问题
linux·redis·笔记·学习·面试
lbb 小魔仙3 天前
OpenClaw + cpolar 实战:远程 NAS、分享小游戏、RDP,再配置公网 AI 入口
数据库·人工智能·redis·oracle·prometheus
夕除3 天前
redis--018
redis
两锭子3 天前
Redis基础
redis
烟沙九洲3 天前
Redis 缓存穿透、缓存击穿、缓存雪崩
数据库·redis
字节探索3 天前
Redis 只会当缓存用?这 10 大实战场景,让你的系统快到飞起
redis·后端
于樱花森上飞舞3 天前
【Redis】哨兵详解
java·开发语言·数据库·redis
Rain的Java大神之路3 天前
如何快速上传10G文件
java·spring boot·redis·后端·mysql·spring cloud·面试
Wang's Blog3 天前
Java 接入Redis: 通用命令与键管理
java·服务器·redis