Redis数据结构为什么既省内存又高效?(一)
底层存储
当其他人问你Redis是如何用单线程来实现每秒10w+的QPS,你会如何回答呢?
- 使用IO多路复用
- 非CPU密集型任务
- 纯内存操作
- 巧妙的数据结构
我们今天就来盘盘Redis数据结构到底有多巧妙!
「Redis所有的数据结构都是在内存占用和执行效率之间找一个比较好的均衡点,不一味的节省内存,也不一味的提高执行效率」
Redis底层就是一个大map,key是字符串,value可能是字符串,哈希,列表等。
如何记录这个value的类型呢?我们定义一个类用一个type字段表示类型的种类不就行了?
在Redis中这个对象就是redisObject(在C语言中对象叫结构体哈)
「Redis中的每个对象底层的数据结构都是redisObject结构体」
可以看到除了type属性外,还有其他属性,那么其他属性有什么作用呢?
「type:对应于type命令,记录redis的对象类型」
> setbit login 1 1
0
> type login
string
> set cache a
OK
> type cache
string
> xadd mystream * msg1 value1 msg2 value2
1643183760501-0
> type mystream
stream
> rpush numbers 1 2 3
3
> type numbers
list
「encoding:对应于object encoding命令,记录了对象使用的底层数据结构。不同的场景同一个对象可能使用不同的底层编码」
来看一下上面对象的编码
> object encoding numbers
quicklist
> object encoding cache
embstr
> object encoding login
raw
「每种对象类型,有可能使用了多种编码类型,具体的对应关系如下」
这个图在后面的内容中,会多次出现为的就是加深大家的记忆
「Redis并没有为每种对象类型固定一种编码实现,而是在不同场景下使用不同的编码,在内存占用和执行效率之间做一个比较好的均衡」
ptr:指向底层数据结构实现的指针,这些数据结构由对象的encoding属性决定
当我们在Redis中创建一个键值对时,至少会创建2个对象。一个对象用于键值对中的键(键对象),一个对象用于键值对中的值(值对象)
当执行如下命令时,msg为一个对象,hello world为一个对象
127.0.0.1:6379> set msg "hello world"
OK
接着我们来看每种数据类型的底层数据结构
string
3.0版本及以前
首先总结了一下Redis中出现的数据类型,及其占用的字节数,便于我们后面分析
是不是发现自己不认识uint8_t,uint16_t等,这是个什么类型?学c语言的时候没学过啊。
发现不认识的数据类型,一猜就是用typedef重命名了,全局搜一下,果然是
在Redis3.0版本及以前字符串的数据结构如下所示
struct sdshdr {
// buf数组中已使用字符的数量
unsigned int len;
// buf数组中未使用字符的数量
unsigned int free;
// 字符数组,用来保存字符串
char buf[];
};
free:free为0,表示未使用的空间 len:这个sds保存了5字节长的字符串 buf:char类型的数组,前5个字节是字符串,后一个字节是\0,表示结尾,\0不计入总长度
当要存的字符串变大或者变小的时候,会造成频繁的内存分配,进而影响性能。所以Redis通过「空间预分配」和「惰性空间释放」策略来避免内存的频繁分配
「空间预分配」
当sds的内容变大时,程序不仅会为sds分配修改所需要的空间,还会为sds分配额外的未使用的空间。就是多分配一点,省得一会又分配
「惰性空间释放」
当sds内容变小时,程序并不会释放缩短后剩余的空间,只是修改free属性,将未使用字符数量记录下来,等以后使用
目前看起来使用效率已经很高了,但是变态的redis还是进行了优化。「能用位存储变量的值绝不用基本数据类型,能用字节数少的基本数据类型,绝不用字节数多的数据类型」
3.0版本以后
如果让你优化上述的结构,你会如何优化呢?
当字符串很小的时候,我们还得额外的使用8个字节(len和free各占4个字节),感觉有点太浪费了。「元数据比实际要存储的数据都大」
我们是否可以根据字符串的长度,来决定len和free占用的字节数呢?比如短字符串len和free的长度为1字节就够了。长字符串,用2字节或4字节,更长的字符串,用8字节。于是提供了5种类型的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[];
};
根据字符串的长度还使用不同的数据结构来存储,如果标识不同的数据结构呢?就来加一个flags字段把。其他的属性和之前版本的类似
「sdshdr8和sdshdr16属性看着还比较正常,sdshdr5怎么少了len和alloc这2个属性了?」
「在sdshdr5中将类型放到了flags的前3个字节中(3个字节能保存6种类型,所以3个字节足够了),后5个字节用来保存字符的长度。因为sdshdr5取消了alloc字段,因此也不会进行空间预分配」
这还不够,sds在减少内存分配,减少内存碎片的目标上还做了其他努力,当字符串是long类型的整数时,直接用整数来保存这个字符串
当字符串的长度小于等于44字节时,redisObject和sds一起分配内存。当字符串大于44字节时,才对redisObject分配一次内存,对sds分配一次内存
「为什么以44字节为界限?」
redisObject:16个字节 SDS:sdshdr8(3个字节)+ SDS 字符数组(N字节 + \0结束符 1个字节)
Redis规定嵌入式字符串最大以64字节存储,所以N=64-16-3-1=44
「为什么嵌入式字符串最大以64字节存储?」
因为在x86体系下,一般的缓存行大小是63字节,redis能一次加载完成
文章转自公众号:Java识堂