We read every piece of feedback, and take your input very seriously.
To see all available qualifiers, see our documentation.
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
本文原创,著作权归WGrape所有,未经授权,严禁转载
声明 :故事纯属虚构,旨在从实际问题切入到文章主题,请不要过分讨论故事内容和技术细节。
某中型互联网公司开拓了一个在线商城的新业务,计划一个多月后赶在双11前上线,并由小王担任技术负责人。在成立之初,小王便规划了整个业务的技术架构,其中由小程负责用户系统的设计。
这是小程第一次负责这么大的业务模块设计,在他受命之际,为了不负厚望,开始认真的规划起用户系统的设计。
在设计方案即将完成时,为了保证读性能,小程又在原有设计中增加了缓存层。经过多次考虑,他选择了如下的Redis缓存方案。
在小程最终确认设计方案后,开始投入到没日没夜的紧急开发中,虽然累,但也觉得非常值得。在一个多月后,经历了多次测试和验证的用户系统,顺利进入了上线阶段。
新业务终于在双11前一晚如期上线了。这天大家都在紧张看着监控,生怕系统出现故障,可是不幸的事情还是在意料之中发生了。
运维告知小程,从晚上10点开始,告警群中开始出现用户服务的Redis网络流量告警,短短的5分钟内总流量异常增长了10倍。小程立即打开监控,发现预热的这段时间内,用户数确实翻了几倍,但远没有10倍,现在问题来了,流量的元凶在哪里 ?
既然问题出现在Redis层,小程便开始从Redis层认真梳理和思考起来 :在底层获取用户信息的时候,使用了hgetall的方式,如果同时段调用hgetall的次数过多,再加上有些极个别用户的字段数据较多,那么可能就会导致Redis网络IO的飙升。
hgetall
小程立即开始排查Redis慢日志,从慢日志中发现有一条看似元凶的命令hgetall user:18765432耗时达到了10秒以上,通过分析发现user:18765432这个Key竟然达到了惊人的100MB+,其中有一个intro的个人介绍字段占了99%的空间。这时小程才突然晃过神来,这个字段可能有输入漏洞,导致用户绕过限制恶意上传了大量文本内容导致这个Key异常的大。
hgetall user:18765432
user:18765432
100MB+
intro
来不及排查具体原因,小程先删除了这个用户Key的intro字段,然后在后端添加了更严格的校验,堵住用户侧的漏洞,等了大概1分钟后系统终于恢复了正常。
为了系统地优化Redis性能,小程重新设计了 zip + protobuf 的缓存方案。这种方案是否可行呢,下面会进行测试实验。
zip + protobuf
zip
protobuf
首先定义一个Intro字段长度为800w长文本数据的用户,然后使用hash/json/zip+protobuf三种用户信息存储方式,分别测试数据是否可以正常写入解析和Redis的内存占用情况。
Intro
hash
json
zip+protobuf
经过以下运行发现三种方式数据均可正常写入和解析,由于篇幅限制,完整代码请见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
在实验前,本地Redis为空,默认的空间大小为used_memory_human:1.21M
used_memory_human:1.21M
如果使用Hash存储方式,占用内存为 24.22 - 1.21 = 23.01MB
24.22 - 1.21 = 23.01MB
如果使用Json存储方式,占用内存为 24.26 - 1.21 = 23.05MB
24.26 - 1.21 = 23.05MB
如果使用zip+protobuf存储方式,占用内存为 1.43 - 1.21 = 0.22MB
1.43 - 1.21 = 0.22MB
经过测试可以得出结论 :在Redis的String中存储压缩后的字节流数据,不但可以正常解析,还明显节省了大量的内存空间
因为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)
由于String与Byte[]在底层存储并无差异,所以在上面的测试代码中也能发现无论写入String数据还是字节流数据,都能正常写入和读取解析。
也就是说,在Redis中String类型对字符串和二进制两种编码都是支持的,那么它的底层是如何实现的呢 ?我们先留一个悬念。
在C语言中是没有字符串string类型的,一般只有使用字符数组char[]才能实现字符串类型,如下三种实现字符串的方式所示。虽然后面两种没有在定义时声明长度,但和第一种char [N]是等效的,因为它们的长度都是在编译时就已经固定的。
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";
有没有一种不固定长度的字符串实现方法呢?当然!如下所示的这两种写法都是定义了char *字符指针,然后在运行期间使用如malloc()/memcpy()等内存操作函数动态地扩容。
char *
malloc()/memcpy()
// 本质都是字符指针 char *s; char s[];
我们都知道Redis是使用C语言编写的,现在问题来了,Redis中的String是字符数组实现的吗 ?肯定不是!Redis不可能会在编译期间就确定所有字符串的长度,所以只能使用字符指针在运行期动态扩容的方式。
但仅仅是字符指针吗?只要我们简单翻看Redis源码,就会知道它在字符指针char[]之上又封装了一种叫做SDS的结构。这样实现的原因是什么呢 ?
SDS
在C语言中要求字符串末尾必须有\0字符(对应的二进制为0000 0000),在操作字符串时遇到\0字符才会认为字符串结束,这样就会存在以下问题
\0
0000 0000
strcpy()
strcat()
memcpy()
所以在C语言中的字符串不是二进制安全的,而Redis的String为了实现图片、音频等各种二进制数据的存储,就必须解决二进制存储问题,实现二进制安全。
同样的,由于\0字符的限制,在C语言中处理字符串时也会比较严重的性能问题
因此在底层SDS结构中分别定义了char buf[]和len这两个属性,可以在O(1)常数时间内获取字符串的长度,而且在字符串变更操作时,对buf[]数组也设计了更加高效的内存分配策略,提高字符串的操作性能。
char buf[]
len
buf[]
在src/sds.h文件中定义了SDS(Simple Dynamic Strings Header)结构和操作API,可以发现SDS并非只有1种,而是定义了sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64这五种结构。
src/sds.h
sdshdr5
sdshdr8
sdshdr16
sdshdr32
sdshdr64
从这5种结构可以看出,Redis对len和alloc这些属性都细化了uint8_t/uint16_t/uint32_t/uint64_t这4种类型,而不是直接用int代替。为了节省内存和提高性能,Redis可谓是 “无所不用其极“ !
alloc
uint8_t/uint16_t/uint32_t/uint64_t
int
buf
flags
在/src/server.h中定义了不同object(如val值)的编码,其中对String一共有3种编码,具体在后面会详细介绍。
/src/server.h
#define OBJ_ENCODING_RAW 0 /* Raw representation */ #define OBJ_ENCODING_INT 1 /* Encoded as integer */ #define OBJ_ENCODING_EMBSTR 8 /* Embedded sds string encoding */
void sdsfree(sds s);
sds sdsnewlen(const void *init, size_t initlen);
sds sdscpylen(sds s, const char *t, size_t len);
sds sdscatlen(sds s, const void *t, size_t len);
sds sdsgrowzero(sds s, size_t len);
在/src/commands.c文件中的redisCommandTable[]数组中定义了set命令的详细内容
/src/commands.c
redisCommandTable[]
其中就包括了setCommand()函数,它定义在t_string.c文件中,主要包括以下三个操作。
setCommand()
t_string.c
SET
GET
EX/PX/NX
tryObjectEncoding
RAW/INT/EMBSTR
setGenericCommand
在setGenericCommand()核心函数中先调用getExpireMillisecondsOrReply()函数生成毫秒时间戳milliseconds
setGenericCommand()
getExpireMillisecondsOrReply()
milliseconds
它是根据EX/PX的不同生成以毫秒为单位的时间,与commandTimeSnapshot()获取的当前毫秒时间戳相加而成。
EX/PX
commandTimeSnapshot()
根据flags标志判断是否要在SET前先GET,比如GETSET命令,如果是则先调用getGenericCommand()函数即GET命令的核心逻辑,用于返回数据给客户端。
GETSET
getGenericCommand()
在SET命令中,它支持NX/XX两种选项
NX/XX
NX
SET key value NX
SETNX key value
XX
在实现中,会先调用lookupKeyWrite()函数判断Key是否存在,如果满足下面两种情况,则会执行提前返回的前置处理
lookupKeyWrite()
在调用setKey()写入KV前,先根据Key是否存在、是否设置有效期等特点写入setkey_flags标志变量中,完成数据的写入,再调用setExpire()函数设置有效期。
setKey()
setkey_flags
setExpire()
同样的,我们在src/commands.c文件中可以找到GET命令对应的getCommand()函数
src/commands.c
getCommand()
在getCommand()函数中再调用核心函数getGenericCommand(),通过lookupKey()找到字典中Key对应的Val。
lookupKey()
在返回Val至客户端前,先判断Val的编码类型,如果是OBJ_ENCODING_RAW或OBJ_ENCODING_EMBSTR类型,则根据原编码返回数据,如果是OBJ_ENCODING_INT类型则转为字符串类型后返回。
OBJ_ENCODING_RAW
OBJ_ENCODING_EMBSTR
OBJ_ENCODING_INT
set
setbit
阅读完本章后,你或许还会有上述等问题,那么请关注下一篇文章 :《从实践中探究Redis原理》之String是字符数组吗(下)
The text was updated successfully, but these errors were encountered:
No branches or pull requests
目录
前言
本文原创,著作权归WGrape所有,未经授权,严禁转载
一、从一次性能优化说起
1、项目背景
某中型互联网公司开拓了一个在线商城的新业务,计划一个多月后赶在双11前上线,并由小王担任技术负责人。在成立之初,小王便规划了整个业务的技术架构,其中由小程负责用户系统的设计。
2、用户系统的设计
这是小程第一次负责这么大的业务模块设计,在他受命之际,为了不负厚望,开始认真的规划起用户系统的设计。
3、缓存方案的选择
在设计方案即将完成时,为了保证读性能,小程又在原有设计中增加了缓存层。经过多次考虑,他选择了如下的Redis缓存方案。
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项目。
(2) 实验前Redis内存
在实验前,本地Redis为空,默认的空间大小为
used_memory_human:1.21M
(3) 实验后Redis内存
如果使用Hash存储方式,占用内存为
24.22 - 1.21 = 23.01MB
如果使用Json存储方式,占用内存为
24.26 - 1.21 = 23.05MB
如果使用zip+protobuf存储方式,占用内存为
1.43 - 1.21 = 0.22MB
2、测试结论
经过测试可以得出结论 :在Redis的String中存储压缩后的字节流数据,不但可以正常解析,还明显节省了大量的内存空间
3、聊聊String与Byte[]
(1) 两种类型的差异
因为String和Byte[]在最底层的存储是一样的,都是二进制数据,所以在Go语言中我们经常看到String和Byte[]互相转换的情况。
(2) 两种数据的写入
由于String与Byte[]在底层存储并无差异,所以在上面的测试代码中也能发现无论写入String数据还是字节流数据,都能正常写入和读取解析。
也就是说,在Redis中String类型对字符串和二进制两种编码都是支持的,那么它的底层是如何实现的呢 ?我们先留一个悬念。
4、C语言中的String
(1) 编译期固定长度
在C语言中是没有字符串
string
类型的,一般只有使用字符数组char[]
才能实现字符串类型,如下三种实现字符串的方式所示。虽然后面两种没有在定义时声明长度,但和第一种char [N]
是等效的,因为它们的长度都是在编译时就已经固定的。(2) 运行期动态扩容
有没有一种不固定长度的字符串实现方法呢?当然!如下所示的这两种写法都是定义了
char *
字符指针,然后在运行期间使用如malloc()/memcpy()
等内存操作函数动态地扩容。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语言中处理字符串时也会比较严重的性能问题因此在底层
SDS
结构中分别定义了char buf[]
和len
这两个属性,可以在O(1)常数时间内获取字符串的长度,而且在字符串变更操作时,对buf[]
数组也设计了更加高效的内存分配策略,提高字符串的操作性能。四、从源码探究String的设计
1、SDS
在
src/sds.h
文件中定义了SDS(Simple Dynamic Strings Header)结构和操作API,可以发现SDS并非只有1种,而是定义了sdshdr5
、sdshdr8
、sdshdr16
、sdshdr32
、sdshdr64
这五种结构。从这5种结构可以看出,Redis对
len
和alloc
这些属性都细化了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种编码,具体在后面会详细介绍。(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增长到指定的长度,然后将不属于字符串的字节部分都置为02、Set操作过程
(1) 找到Set命令
在
/src/commands.c
文件中的redisCommandTable[]
数组中定义了set命令的详细内容其中就包括了
setCommand()
函数,它定义在t_string.c
文件中,主要包括以下三个操作。SET
和GET
命令的扩展参数部分,如EX/PX/NX
等参数值tryObjectEncoding
函数对Val进行编码,确认Val是RAW/INT/EMBSTR
3种编码中的哪一个类型setGenericCommand
函数执行SET的核心逻辑(2) 生成毫秒时间戳
在
setGenericCommand()
核心函数中先调用getExpireMillisecondsOrReply()
函数生成毫秒时间戳milliseconds
它是根据
EX/PX
的不同生成以毫秒为单位的时间,与commandTimeSnapshot()
获取的当前毫秒时间戳相加而成。(3) 如果是GETSET命令则先调用GET
根据
flags
标志判断是否要在SET前先GET,比如GETSET
命令,如果是则先调用getGenericCommand()
函数即GET
命令的核心逻辑,用于返回数据给客户端。(4) 有NX或XX选项的前置处理
在SET命令中,它支持
NX/XX
两种选项NX
:只在键不存在时,才对键进行设置操作。SET key value NX
效果等同于SETNX key value
XX
:只在键已经存在时,才对键进行设置操作在实现中,会先调用
lookupKeyWrite()
函数判断Key是否存在,如果满足下面两种情况,则会执行提前返回的前置处理NX
选项,但Key已存在XX
选项,但Key不存在(5) 写入KV值和设置有效期
在调用
setKey()
写入KV前,先根据Key是否存在、是否设置有效期等特点写入setkey_flags
标志变量中,完成数据的写入,再调用setExpire()
函数设置有效期。3、Get操作过程
同样的,我们在
src/commands.c
文件中可以找到GET
命令对应的getCommand()
函数在
getCommand()
函数中再调用核心函数getGenericCommand()
,通过lookupKey()
找到字典中Key对应的Val。在返回Val至客户端前,先判断Val的编码类型,如果是
OBJ_ENCODING_RAW
或OBJ_ENCODING_EMBSTR
类型,则根据原编码返回数据,如果是OBJ_ENCODING_INT
类型则转为字符串类型后返回。五、未完待续
SDS
在源码中的使用?SDS
在内存中的分配过程?set
命令如何写入String和二进制数据的?setbit
命令和set
命令的实现过程有何区别?阅读完本章后,你或许还会有上述等问题,那么请关注下一篇文章 :《从实践中探究Redis原理》之String是字符数组吗(下)
The text was updated successfully, but these errors were encountered: