redis 版本:5.0

本文代码在Redis源码中的位置:redis/src/sds.c、redis/src/sds.h

源码整体结构

src:核心实现代码,用 C 语言编写

tests:单元测试代码,用 Tcl 实现

deps:所有依赖库

字符串存储结构

Redis 将字符串的实现称为 sds(simple dynamic string)。为了提高存储空间的利用率,Redis 对不同长度的字符串,采用不同的数据结构。

以下是长度小于32的字符串的存储结构:

struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags;
char buf[];
};

其中,flags:高5位指示字符串真实长度,低3位指示数据类型;buf:柔性数组,指向字符串存储地址。

:柔性数组是在 C99 及以上标准才支持,其目的是为了在结构体中能够“动态”地设定数组的长度。但需要注意的是,对结构体进行 sizeof 时,柔性数组的大小不被计算在内。因此在对结构体分配大小时,需要注意加上柔性数组的大小。柔性数组必须被声明在结构体的最后,其起始地址与上一字段的末尾地址相连。

参考:https://en.wikipedia.org/wiki/Flexible_array_member

其他长度的字符串的存储结构与其类似,结构分别如下:

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[];
};

其中,len 表示字符串实际长度;alloc 表示申请的字符数组的长度。

可以看出,Redis 最长能存储长度大约为 \(1.84* 10^{19}\) (\(2^{64}-1\)) 的字符串。

:在声明结构体时,struct __attribute__ ((__packed__)) 语法用来告诉编译器采用紧凑的方式存储数据,即1字节对齐,而不是默认的所有变量大小的最小公倍数做字节对齐。

sdshdr32 为例,若采用1字节对齐,lenallocflags 一共占9个字节(4+4+1),而默认会使用4字节对齐,这样 flags 也会占用4个字节,一共占用12个字节。

采用 packed 的好处是明显的,一来可以减少数据的大小,提高空间利用率;二来这为地址计算带来了方便:无论哪种类型(除 sdshdr5 外),使用 buff[-1] 即能获取到 flag

字符串基本操作

创建

sdsnewlen 方法负责创建字符串,代码如下:

sds sdsnewlen(const void *init, size_t initlen) {
void *sh;
sds s;
char type = sdsReqType(initlen);
if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
int hdrlen = sdsHdrSize(type);
unsigned char *fp; sh = s_malloc(hdrlen+initlen+1);
if (init==SDS_NOINIT)
init = NULL;
else if (!init)
memset(sh, 0, hdrlen+initlen+1);
if (sh == NULL) return NULL;
s = (char*)sh+hdrlen;
fp = ((unsigned char*)s)-1;
switch(type) {
case SDS_TYPE_5: {
*fp = type | (initlen << SDS_TYPE_BITS);
break;
}
case SDS_TYPE_8: {
SDS_HDR_VAR(8,s);
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;
}

以上代码比较简单,但有几点需要说明:

  • 从第5行代码可以看出,对于空字符串,Redis 将其做为 sdshdr8 类型而不是 sdshdr5 类型。其给出的原因是:用户很有可能会在空字符串后追加新的字符串,因此用一个存储长度适中的结构。
  • sh 指针指向对应结构体的起始地址,其长度为结构体大小 + 待存储字符串的大小 + 1。最后加1是为了在末尾追加一个 \0 符。
  • s 指针即为以上提到的柔性数组起始地址。fp 指针地址正好在 s 指针指向地址的前一字节,指向 flags
  • 第19行代码中,initlen << SDS_TYPE_BITSinitlen 左移3位,正好印证之前提到的 flags 使用高5位存储字符串长度。之后或上 type ,即把低3位置为 type 的值(因为之前左移3位后,低3位值均为0,这样或运算的值就是 type)。
  • SDS_HDR_VAR 宏定义为 #define SDS_HDR_VAR(T,s) struct sdshdr##T *sh = (void*)((s)-(sizeof(struct sdshdr##T))); 。其中的 ## 表示字符连接的意思。以23行代码为例,该行代码在经编译器预处理后,变为 struct sdshdr8 *sh = (void*)((s)-(sizeof(struct sdshdr8))),语义为sh = s - sizeof(sdshdr8)暂时还没弄明白这里为啥要重算一下 sh。按理说第15行代码 s = (char*)sh+hdrlen; 中,s 正是由 sh +sizeof(sdshdr8)得到。
  • 注意,最终返回给外部的是 s 指针而不是结构体指针。

其中的sdsReqType 方法用于根据待存储字符串长度选择合适的存储类型,代码如下:

static inline char sdsReqType(size_t string_size) {
if (string_size < 1<<5)
return SDS_TYPE_5;
if (string_size < 1<<8)
return SDS_TYPE_8;
if (string_size < 1<<16)
return SDS_TYPE_16;
#if (LONG_MAX == LLONG_MAX)
if (string_size < 1ll<<32)
return SDS_TYPE_32;
return SDS_TYPE_64;
#else
return SDS_TYPE_32;
#endif
}

LONG_MAX 表示 long 类型整数最大值,与编译器有关。通常64位编译器值为 \(2^{63} - 1\),32位编译器为 \(2^{32}-1\)。LLONG_MAX 表示 long long 类型整数最大值,通常32位和64位编译器值均为 \(2^{63} - 1\)。

参考:

:static inline 关键字是在建议编译器,以类似于宏定义的方式对待该函数,即在编译阶段,直接将该函数的相关指令插入到调用该函数的地方。这样做可减少函数调用的开销。

参考:https://zhuanlan.zhihu.com/p/132726037

sdsHdrSize 函数用于计算对应存储类型的大小,代码如下:

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_TYPE_MASK 的值为7,二进制即为:0000 0111type 和它做与运算,正好得出自己的后3位的值,即类型值。

删除

删除字符串的方法有两种,一种是真正释放了内存,代码如下:

void sdsfree(sds s) {
if (s == NULL) return;
s_free((char*)s-sdsHdrSize(s[-1]));
}

其中 s[-1] 之前也提到过,就是 flags 的值。而 s-sdsHdrSize(s[-1]) 则得到了 sdshdr 结构体的起始地址。

另一种则只是将长度置为0,并没有真正释放内存,这么做的目的当然是为了下次存储字符串时,无需重新申请内存,直接再用即可。

void sdsclear(sds s) {
sdssetlen(s, 0);
s[0] = '\0';
}

其中,sdssetlen 函数用来设置字符串长度,代码如下:

static inline void sdssetlen(sds s, size_t newlen) {
unsigned char flags = s[-1];
switch(flags&SDS_TYPE_MASK) {
case SDS_TYPE_5:
{
unsigned char *fp = ((unsigned char*)s)-1;
*fp = SDS_TYPE_5 | (newlen << SDS_TYPE_BITS);
}
break;
case SDS_TYPE_8:
SDS_HDR(8,s)->len = newlen;
break;
case SDS_TYPE_16:
SDS_HDR(16,s)->len = newlen;
break;
case SDS_TYPE_32:
SDS_HDR(32,s)->len = newlen;
break;
case SDS_TYPE_64:
SDS_HDR(64,s)->len = newlen;
break;
}
}

追加(cat)

追加操作用于在一个字符串后面添加另一字符串,其代码如下:

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

连接后,s 将指向连接后的字符串。

sdscatlen 函数逻辑也很简单:扩容(若需要) -> 复制追加内容 -> 修改长度。代码如下:

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;
}

其中 sdsMakeRoomFor 为扩容函数,根据剩余可用空间大小和待追加的字符串长度决定是否扩容。它保证了其返回的指针指向的内存区域,一定能容纳追加的字符串(若内部的内存申请成功的话)。代码如下:

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; /* 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) {
newsh = s_realloc(sh, hdrlen+newlen+1);
if (newsh == NULL) return NULL;
s = (char*)newsh+hdrlen;
} else {
/* 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;
}

其基本逻辑为:

  • 若剩余容量足够容纳待追加的字符串,则无需扩容(第9行)。
  • 否则,算出追加后的字符串的长度 newlen(第13行)。第13行~第17行代码还对 newlen 做了调整。暂时不知道为啥要这么做
  • 之后根据 newlen 决定使用什么类型进行存储。若类型还和之前一样,则继续沿用之前的内存结构,但需要使用 s_realloc 扩大之前的内存空间(第28行);否则则需要重新申请一块新的内存(第34行),将之前的数据复制到新内存中(第36行)。

连接(join)

连接操作是将一个字符串数组的所有元素串联在一起,若给定了分隔符,各元素之间还需插入分隔符。例如,若字符串数组 s = ["a", "b", "c"] ,分隔符 sep = ",",则 join(s, sep) 的结果为:a,b,c。其代码如下:

sds sdsjoin(char **argv, int argc, char *sep) {
sds join = sdsempty();
int j; for (j = 0; j < argc; j++) {
join = sdscat(join, argv[j]);
if (j != argc-1) join = sdscat(join,sep);
}
return join;
}

代码很简单,就不多做说明了。其中,sdsempty() 是构造一个空串,即:sdsnewlen("",0)

分割参数

sdssplitargs 函数用于将一条命令按参数分割成一个字符串数组,代码如下:

sds *sdssplitargs(const char *line, int *argc) {
const char *p = line;
char *current = NULL;
char **vector = NULL; *argc = 0;
while(1) {
/* skip blanks */
while(*p && isspace(*p)) p++;
if (*p) {
/* get a token */
int inq=0; /* set to 1 if we are in "quotes" */
int insq=0; /* set to 1 if we are in 'single quotes' */
int done=0; if (current == NULL) current = sdsempty();
while(!done) {
if (inq) {
if (*p == '\\' && *(p+1) == 'x' &&
is_hex_digit(*(p+2)) &&
is_hex_digit(*(p+3)))
{
unsigned char byte; byte = (hex_digit_to_int(*(p+2))*16)+
hex_digit_to_int(*(p+3));
current = sdscatlen(current,(char*)&byte,1);
p += 3;
} else if (*p == '\\' && *(p+1)) {
char c; p++;
switch(*p) {
case 'n': c = '\n'; break;
case 'r': c = '\r'; break;
case 't': c = '\t'; break;
case 'b': c = '\b'; break;
case 'a': c = '\a'; break;
default: c = *p; break;
}
current = sdscatlen(current,&c,1);
} else if (*p == '"') {
/* closing quote must be followed by a space or
* nothing at all. */
if (*(p+1) && !isspace(*(p+1))) goto err;
done=1;
} else if (!*p) {
/* unterminated quotes */
goto err;
} else {
current = sdscatlen(current,p,1);
}
} else if (insq) {
if (*p == '\\' && *(p+1) == '\'') {
p++;
current = sdscatlen(current,"'",1);
} else if (*p == '\'') {
/* closing quote must be followed by a space or
* nothing at all. */
if (*(p+1) && !isspace(*(p+1))) goto err;
done=1;
} else if (!*p) {
/* unterminated quotes */
goto err;
} else {
current = sdscatlen(current,p,1);
}
} else {
switch(*p) {
case ' ':
case '\n':
case '\r':
case '\t':
case '\0':
done=1;
break;
case '"':
inq=1;
break;
case '\'':
insq=1;
break;
default:
current = sdscatlen(current,p,1);
break;
}
}
if (*p) p++;
}
/* add the token to the vector */
vector = s_realloc(vector,((*argc)+1)*sizeof(char*));
vector[*argc] = current;
(*argc)++;
current = NULL;
} else {
/* Even on empty input string return something not NULL. */
if (vector == NULL) vector = s_malloc(sizeof(void*));
return vector;
}
} err:
while((*argc)--)
sdsfree(vector[*argc]);
s_free(vector);
if (current) sdsfree(current);
*argc = 0;
return NULL;
}

以上代码总体上是在对字符串 line 逐个字符进行遍历,指针 p 做为游标指向当前访问的字符。

主体上,代码由两层 while 循环构成。外层 while 循环(第7行)条件总是成立,因此该函数要么出错结束(第102行~末尾),返回 NULL,要么从第98行成功结束,此时的条件是 p 指针正好指向字符串末尾的 \0,返回分割出的参数数组 vector。而内层 while 循环执行结束后(第91行),会识别出一个参数,并会将该参数添加到 vector 数组中(第92行)。

再来看内层 while 循环是如何识别一个参数的。这段代码(第17行到89行)总体结构是 if...else if ... else ...。进入前两分支的条件是处于双引号模式或单引号模式下,否则进入 else 分支。每轮循环结束,游标 p 向后挪动一位(第88行)。

双、单引号模式由 inqinsq 变量指示,前者表示当前访问的字符在单引号内(即之前已访问了起始单引号,但还未访问到闭合单引号),后者则表示是在双引号内。进入这两个模式的时机当然是遇到了双引号(第77行)或单引号(第80行)。

在非双、单引号模式下,若遇到一般字符,都会将该字符追加到 current 字符串中(current 在遍历开始是空串);若遇到 空格'\n''\r''\t''\0',内层循环都将结束,并将 current 做为识别到的一个参数。

在双、单引号模式下,若是遇到 "\n""\r""\t""\b""\a"(注意,这些都是两个字符而不是一个),会将其做为单字符 '\n''\r''\t''\b''\a' 加入 current 中(第41行);若字符串遍历完成都没遇到闭合的双、单引号,则会报错(第49行和第64行);闭合的双、单引号后面不是空格(若字符串后面还有其他字符),也会报错(第44行和第60行)。

另外,在双引号模式下,会将格式类似于 "\x41"的子串,做为16进制数,转成字符类型后追加到 current 中(第19行~第28行)。该例中,"\x41" 将做为字母 A

以上便是 Redis 字符串操作最主要的函数,还有一些比如字符串大小写转换,比较等函数实现均非常简单,这里不再赘述,有兴趣可以去看看源码。

参考

Redis源码学习(1)──字符串的更多相关文章

  1. Redis源码学习:字符串

    Redis源码学习:字符串 1.初识SDS 1.1 SDS定义 Redis定义了一个叫做sdshdr(SDS or simple dynamic string)的数据结构.SDS不仅用于 保存字符串, ...

  2. Redis源码学习:Lua脚本

    Redis源码学习:Lua脚本 1.Sublime Text配置 我是在Win7下,用Sublime Text + Cygwin开发的,配置方法请参考<Sublime Text 3下C/C++开 ...

  3. 柔性数组(Redis源码学习)

    柔性数组(Redis源码学习) 1. 问题背景 在阅读Redis源码中的字符串有如下结构,在sizeof(struct sdshdr)得到结果为8,在后续内存申请和计算中也用到.其实在工作中有遇到过这 ...

  4. redis源码学习之slowlog

    目录 背景 环境说明 redis执行命令流程 记录slowlog源码分析 制造一条slowlog slowlog分析 1.slowlog如何开启 2.slowlog数量限制 3.slowlog中的耗时 ...

  5. __sync_fetch_and_add函数(Redis源码学习)

    __sync_fetch_and_add函数(Redis源码学习) 在学习redis-3.0源码中的sds文件时,看到里面有如下的C代码,之前从未接触过,所以为了全面学习redis源码,追根溯源,学习 ...

  6. redis源码学习之lua执行原理

    聊聊redis执行lua原理 从一次面试场景说起   "看你简历上写的精通redis" "额,还可以啦" "那你说说redis执行lua脚本的原理&q ...

  7. redis源码学习之工作流程初探

    目录 背景 环境准备 下载redis源码 下载Visual Studio Visual Studio打开redis源码 启动过程分析 调用关系图 事件循环分析 工作模型 代码分析 动画演示 网络模块 ...

  8. Redis源码阅读-sds字符串源码阅读

    redis使用sds代替char *字符串, 其定义如下: typedef char *sds; struct sdshdr { unsigned int len; unsigned int free ...

  9. redis源码学习_简单动态字符串

    SDS相比传统C语言的字符串有以下好处: (1)空间预分配和惰性释放,这就可以减少内存重新分配的次数 (2)O(1)的时间复杂度获取字符串的长度 (3)二进制安全 主要总结一下sds.c和sds.h中 ...

  10. Redis源码学习-Master&Slave的命令交互

    0. 写在前面 Version Redis2.2.2 Redis中可以支持主从结构,本文主要从master和slave的心跳机制出发(PING),分析redis的命令行交互. 在Redis中,serv ...

随机推荐

  1. Centos中报错apt Command not Found

    先说结论: 在centos下用yum install xxxyum和apt-get的区别: 一般来说著名的linux系统基本上分两大类: RedHat系列:Redhat.Centos.Fedora等 ...

  2. 使用OpenWrt实现IPv6 DDNS

    OpenWrt 增加 crontab 任务 在/root/crontab/ 目录下, 创建脚本 ddns.sh #!/bin/sh # 远程php脚本的URL地址 SERVICE_URL=http:/ ...

  3. 使用JS快速读取TXT文件

    1 前言 最近有个需求,需要使用JS快速读取外部大数据文件(60w条记录的表).笔者尝试过使用JS读取Excel文件,但是跑了十几分钟仍未出结果,后来笔者尝试将原数据保存为TXT文件,再从TXT文件中 ...

  4. 学习go语言编程之并发编程

    并发基础 并发包含如下几种主流的实现模型: 多进程 多线程 基于回到的非阻塞/异步IO 协程 协程 与传统的系统级线程和进程相比,协程最大的优势在于"轻量级",可以轻松创建上百万个 ...

  5. MyBaits查询MySQL日期类型结果相差8个小时

    问题描述 在Java项目中使用MyBatis作为ORM框架,但是查询出的MySQL日期类型字段值总是比数据库表里的值多8个小时. 具体说明: MySQL数据库表字段类型为timestamp,映射的Ja ...

  6. gitee配置SSH公钥

    第一步,找个地方打开"git bash",然后输入生成ssh公钥的命令: ssh-keygen -t rsa -C 'your-email' 然后敲四次回车生成公钥: 第二步,输入 ...

  7. ubuntu18.04下安装MySQL5.7

    更新源 sudo apt update 安装mysql sudo apt install mysql-server 使用sudo mysql进入数据设置root账户的密码和权限 sudo mysql ...

  8. 【Azure Developer】在微软云中国区,如何使用Microsoft GraphAPI连接到B2C Tenant

    问题描述 如题所述,当在中国区使用Microsoft GraphAPI连接B2C Tenant时候,如何来设置中国区的Endpoint呢?在GitHub的示例中,并没有示例介绍如何连接中国区.如 问题 ...

  9. 计算机网络-IP地址

    目录 子网划分 定长子网划分 子网划分的方法 子网掩码 可变长子网划分 无类别编址 网络前缀 路由聚合 特殊用途的IP地址 专用网络地址 链路本地地址 运营商级NAT共享地址 用于文档的测试网络地址 ...

  10. Spring事务(六)-只读事务

    @Transactional(readOnly=true)就可以把事务方法设置成只读事务.设置了只读事务,事务从开始到结束,将看不见其他事务所提交的数据.这在某种程度上解决了事务并发的问题.一个方法内 ...