Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

《从实践中探究Redis原理》之String是字符数组吗(上) #271

Closed
WGrape opened this issue Nov 6, 2022 · 0 comments
Closed
Labels
Redis源码系列 Redis源码系列的学习与分享 已发布 表示本文章已发布 底层研究系列 对技术底层的研究

Comments

@WGrape
Copy link
Owner

WGrape commented Nov 6, 2022

目录

前言

本文原创,著作权归WGrape所有,未经授权,严禁转载

一、从一次性能优化说起

声明 :故事纯属虚构,旨在从实际问题切入到文章主题,请不要过分讨论故事内容和技术细节。

1、项目背景

某中型互联网公司开拓了一个在线商城的新业务,计划一个多月后赶在双11前上线,并由小王担任技术负责人。在成立之初,小王便规划了整个业务的技术架构,其中由小程负责用户系统的设计。

2、用户系统的设计

这是小程第一次负责这么大的业务模块设计,在他受命之际,为了不负厚望,开始认真的规划起用户系统的设计。

3、缓存方案的选择

在设计方案即将完成时,为了保证读性能,小程又在原有设计中增加了缓存层。经过多次考虑,他选择了如下的Redis缓存方案。

Key名称 Key类型
user:{{uid}} Hash uid:12837932
name:"Lucky"
avatar:"cdn.img.com/edadf.png"

4、项目开发和测试

在小程最终确认设计方案后,开始投入到没日没夜的紧急开发中,虽然累,但也觉得非常值得。在一个多月后,经历了多次测试和验证的用户系统,顺利进入了上线阶段。

5、紧张的时刻到了

新业务终于在双11前一晚如期上线了。这天大家都在紧张看着监控,生怕系统出现故障,可是不幸的事情还是在意料之中发生了。

运维告知小程,从晚上10点开始,告警群中开始出现用户服务的Redis网络流量告警,短短的5分钟内总流量异常增长了10倍。小程立即打开监控,发现预热的这段时间内,用户数确实翻了几倍,但远没有10倍,现在问题来了,流量的元凶在哪里 ?

6、快速排查与修复

既然问题出现在Redis层,小程便开始从Redis层认真梳理和思考起来 :在底层获取用户信息的时候,使用了hgetall的方式,如果同时段调用hgetall的次数过多,再加上有些极个别用户的字段数据较多,那么可能就会导致Redis网络IO的飙升。

小程立即开始排查Redis慢日志,从慢日志中发现有一条看似元凶的命令hgetall user:18765432耗时达到了10秒以上,通过分析发现user:18765432这个Key竟然达到了惊人的100MB+,其中有一个intro的个人介绍字段占了99%的空间。这时小程才突然晃过神来,这个字段可能有输入漏洞,导致用户绕过限制恶意上传了大量文本内容导致这个Key异常的大。

来不及排查具体原因,小程先删除了这个用户Key的intro字段,然后在后端添加了更严格的校验,堵住用户侧的漏洞,等了大概1分钟后系统终于恢复了正常。

7、重新思考和设计

为了系统地优化Redis性能,小程重新设计了 zip + protobuf 的缓存方案。这种方案是否可行呢,下面会进行测试实验。

  • zip :对用户的长文本字段进行zip压缩
  • protobuf :对用户整体结构做protobuf编码

二、场景复现

1、实验内容

首先定义一个Intro字段长度为800w长文本数据的用户,然后使用hash/json/zip+protobuf三种用户信息存储方式,分别测试数据是否可以正常写入解析和Redis的内存占用情况。

(1) 测试代码

经过以下运行发现三种方式数据均可正常写入和解析,由于篇幅限制,完整代码请见shark项目

// 通过使用三种不同的方式, 观察redis内存的变化

// hash处理方式
hashstore.WriteRedis(redisConn, user)
redisUser = hashstore.ReadRedis(redisConn, user.Uid)
fmt.Printf("%d : name=%s , address=%s , intro.length=%d\n", redisUser.Uid, redisUser.Nick, redisUser.Address, len([]rune(redisUser.Intro)))
// 12345678 : name=用户昵称 , address=用户地址 , intro.length=8000000

// JSON处理方式
jsonstore.WriteRedis(redisConn, user)
redisUser = jsonstore.ReadRedis(redisConn, user.Uid)
fmt.Printf("%d : name=%s , address=%s , intro.length=%d\n", redisUser.Uid, redisUser.Nick, redisUser.Address, len([]rune(redisUser.Intro)))
// 12345678 : name=用户昵称 , address=用户地址 , intro.length=8000000

// Zip+Protobuf处理方式
zipstore.WriteRedis(redisConn, user)
redisUser = zipstore.ReadRedis(redisConn, user.Uid)
fmt.Printf("%d : name=%s , address=%s , intro.length=%d", redisUser.Uid, redisUser.Nick, redisUser.Address, len([]rune(redisUser.Intro)))
// 12345678 : name=用户昵称 , address=用户地址 , intro.length=8000000

(2) 实验前Redis内存

在实验前,本地Redis为空,默认的空间大小为used_memory_human:1.21M

image

(3) 实验后Redis内存

如果使用Hash存储方式,占用内存为 24.22 - 1.21 = 23.01MB
image

如果使用Json存储方式,占用内存为 24.26 - 1.21 = 23.05MB
image

如果使用zip+protobuf存储方式,占用内存为 1.43 - 1.21 = 0.22MB
image

2、测试结论

测试序号 数据量级 方案 类型 内存
1 长度为800w的字符串/800w字节/8MB Hash存储 Hash 23.01MB
2 长度为800w的字符串/800w字节/8MB JSON存储 String 23.05MB
3 长度为800w的字符串/800w字节/8MB Zip+Protobuf String 0.22MB

经过测试可以得出结论 :在Redis的String中存储压缩后的字节流数据,不但可以正常解析,还明显节省了大量的内存空间

3、聊聊String与Byte[]

(1) 两种类型的差异

  • Byte[]存储最底层的二进制数据,又称为字节流数据,二进制数据
  • String是比Byte[]更高级的类型,是一种在Byte[]之上抽象出来的类型

因为String和Byte[]在最底层的存储是一样的,都是二进制数据,所以在Go语言中我们经常看到String和Byte[]互相转换的情况。

var bytes = []byte("hello world")

// [104 101 108 108 111 32 119 111 114 108 100]
fmt.Printf("%v", bytes)

// hello world
fmt.Printf("%s", bytes)

(2) 两种数据的写入

由于String与Byte[]在底层存储并无差异,所以在上面的测试代码中也能发现无论写入String数据还是字节流数据,都能正常写入和读取解析。

也就是说,在Redis中String类型对字符串和二进制两种编码都是支持的,那么它的底层是如何实现的呢 ?我们先留一个悬念。

4、C语言中的String

(1) 编译期固定长度

在C语言中是没有字符串string类型的,一般只有使用字符数组char[]才能实现字符串类型,如下三种实现字符串的方式所示。虽然后面两种没有在定义时声明长度,但和第一种char [N]是等效的,因为它们的长度都是在编译时就已经固定的。

// 本质都是字符数组
char s[12] = {'h','e','l','l','o',' ','w','o','r','l','d','\0'};
char *s = "hello world";
char s[] = "hello world";

(2) 运行期动态扩容

有没有一种不固定长度的字符串实现方法呢?当然!如下所示的这两种写法都是定义了char *字符指针,然后在运行期间使用如malloc()/memcpy()等内存操作函数动态地扩容。

// 本质都是字符指针
char *s;
char s[];

5、Redis中的String

我们都知道Redis是使用C语言编写的,现在问题来了,Redis中的String是字符数组实现的吗 ?肯定不是!Redis不可能会在编译期间就确定所有字符串的长度,所以只能使用字符指针在运行期动态扩容的方式。

但仅仅是字符指针吗?只要我们简单翻看Redis源码,就会知道它在字符指针char[]之上又封装了一种叫做SDS的结构。这样实现的原因是什么呢 ?

三、String为什么要这样设计

1、二进制安全

在C语言中要求字符串末尾必须有\0字符(对应的二进制为0000 0000),在操作字符串时遇到\0字符才会认为字符串结束,这样就会存在以下问题

  • 字符串中不能有\0这样的二进制数据
  • 在执行strcpy()strcat()memcpy()操作时有缓冲区溢出的风险

所以在C语言中的字符串不是二进制安全的,而Redis的String为了实现图片、音频等各种二进制数据的存储,就必须解决二进制存储问题,实现二进制安全。

2、高性能操作

同样的,由于\0字符的限制,在C语言中处理字符串时也会比较严重的性能问题

  • 较频繁的内存分配
  • 计算字符串长度的时间复杂度为O(N)

因此在底层SDS结构中分别定义了char buf[]len这两个属性,可以在O(1)常数时间内获取字符串的长度,而且在字符串变更操作时,对buf[]数组也设计了更加高效的内存分配策略,提高字符串的操作性能。

四、从源码探究String的设计

1、SDS

src/sds.h文件中定义了SDS(Simple Dynamic Strings Header)结构和操作API,可以发现SDS并非只有1种,而是定义了sdshdr5sdshdr8sdshdr16sdshdr32sdshdr64这五种结构。

image

从这5种结构可以看出,Redis对lenalloc这些属性都细化了uint8_t/uint16_t/uint32_t/uint64_t这4种类型,而不是直接用int代替。为了节省内存和提高性能,Redis可谓是 “无所不用其极“ !

(1) 结构定义

  • buf :缓存数组,存储实际的字符串
  • len :当前字符串的长度,即buf已经使用的长度
  • alloc :buf 分配的长度,等于buf[]的总长度-1,因为buf有包括一个/0的结束符
  • flags :只有3位有效位,因为类型的表示就是0到4,所有这个8位的flags 有5位没有被用到

(2) 3种编码

/src/server.h中定义了不同object(如val值)的编码,其中对String一共有3种编码,具体在后面会详细介绍。

#define OBJ_ENCODING_RAW 0     /* Raw representation */
#define OBJ_ENCODING_INT 1     /* Encoded as integer */
#define OBJ_ENCODING_EMBSTR 8  /* Embedded sds string encoding */

(3) 常用API

  • void sdsfree(sds s); :释放一个SDS字符串
  • sds sdsnewlen(const void *init, size_t initlen); :创建一个固定长度的新的SDS字符串
  • sds sdscpylen(sds s, const char *t, size_t len); :将固定长度的字符串t复制到字符串s中
  • sds sdscatlen(sds s, const void *t, size_t len); :将固定长度的字符串t附加到字符串s后面
  • sds sdsgrowzero(sds s, size_t len); :将SDS增长到指定的长度,然后将不属于字符串的字节部分都置为0

2、Set操作过程

(1) 找到Set命令

/src/commands.c文件中的redisCommandTable[]数组中定义了set命令的详细内容

image

其中就包括了setCommand()函数,它定义在t_string.c文件中,主要包括以下三个操作。

  • 解析String类型的SETGET命令的扩展参数部分,如EX/PX/NX等参数值
  • 调用tryObjectEncoding函数对Val进行编码,确认Val是RAW/INT/EMBSTR3种编码中的哪一个类型
  • 调用setGenericCommand函数执行SET的核心逻辑

image

(2) 生成毫秒时间戳

setGenericCommand()核心函数中先调用getExpireMillisecondsOrReply()函数生成毫秒时间戳milliseconds

image

它是根据EX/PX的不同生成以毫秒为单位的时间,与commandTimeSnapshot()获取的当前毫秒时间戳相加而成。

image

(3) 如果是GETSET命令则先调用GET

根据flags标志判断是否要在SET前先GET,比如GETSET命令,如果是则先调用getGenericCommand()函数即GET命令的核心逻辑,用于返回数据给客户端。

image

image

(4) 有NX或XX选项的前置处理

在SET命令中,它支持NX/XX两种选项

  • NX :只在键不存在时,才对键进行设置操作。SET key value NX 效果等同于 SETNX key value
  • XX :只在键已经存在时,才对键进行设置操作

image

在实现中,会先调用lookupKeyWrite()函数判断Key是否存在,如果满足下面两种情况,则会执行提前返回的前置处理

  • NX选项,但Key已存在
  • XX选项,但Key不存在

image

(5) 写入KV值和设置有效期

在调用setKey()写入KV前,先根据Key是否存在、是否设置有效期等特点写入setkey_flags标志变量中,完成数据的写入,再调用setExpire()函数设置有效期。

image

3、Get操作过程

同样的,我们在src/commands.c文件中可以找到GET命令对应的getCommand()函数

image

getCommand()函数中再调用核心函数getGenericCommand(),通过lookupKey()找到字典中Key对应的Val。

image

在返回Val至客户端前,先判断Val的编码类型,如果是OBJ_ENCODING_RAWOBJ_ENCODING_EMBSTR类型,则根据原编码返回数据,如果是OBJ_ENCODING_INT类型则转为字符串类型后返回。

image

五、未完待续

  • SDS在源码中的使用?
  • SDS在内存中的分配过程?
  • String三种编码的使用规则是什么?
  • set命令如何写入String和二进制数据的?
  • setbit命令和set命令的实现过程有何区别?

阅读完本章后,你或许还会有上述等问题,那么请关注下一篇文章 :《从实践中探究Redis原理》之String是字符数组吗(下)

@WGrape WGrape added the Redis源码系列 Redis源码系列的学习与分享 label Nov 6, 2022
@WGrape WGrape closed this as completed Nov 6, 2022
@WGrape WGrape added the 底层研究系列 对技术底层的研究 label Nov 6, 2022
@WGrape WGrape added the 已发布 表示本文章已发布 label Feb 23, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Redis源码系列 Redis源码系列的学习与分享 已发布 表示本文章已发布 底层研究系列 对技术底层的研究
Projects
None yet
Development

No branches or pull requests

1 participant