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

Posted by WGrape的博客 on November 6, 2022

文章内容更新请以 WGrape GitHub博客 : 《从实践中探究Redis原理》之String是字符数组吗(上) 为准

前言

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

一、从一次性能优化说起

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

1、项目背景

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

2、用户系统的设计

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

3、缓存方案的选择

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

Key名称 Key类型
user: 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命令的实现过程有何区别?

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