如果面试官问你,单线程的Redis为什么那么快,你可能脱口而出,因为单线程,避免上下文切换;因为基于内存,比硬盘读写快很多;因为采用的是多路复用网络模型。不管你是否真的理解了,这个回答足以应付一半以上的面试官了,但是如果可以再进行补充就更好了:因为Redis对各种数据结构进行了精心的设计,比如String采用的是SDS,比如list采用的是ziplist,quicklist等等,可能这样的回答就比较出彩了,至少可以说出部分面试者不太清楚的事情。今天我们就来看看Redis中最常用的String数据结构的奥秘。
从位操作说起
bitmap的应用场景很多,比如大名鼎鼎的布隆过滤器(之前的博客有介绍过:《大白话布隆过滤器》),比如统计指定用户在一年内任意日期内的登录情况,统计任意日期内,所有用户的登录情况等等,都可以用bitmap来实现(之前的博客也有介绍过:《有点长的博客:Redis不是只有get set那么简单》),所以好好看看bitmap还是很有必要的,不过本篇博客不打算详细介绍bitmap,只是通过bitmap引出我们今天的话题,而bitmap的核心就是位操作。
如果我们要往Redis塞入一个value为“hello”的key,这个所有人都会:
test:1>set key hello
"OK"
test:1>get key
"hello"
如果我们要利用位操作实现这个需求呢?什么,我没听错把,位操作也可以实现这个需求吗?当然可以,因为在Redis中,String就是用byte数组来存储的。
什么,你不信?那请继续看下去。
要用位操作实现这个需求,我们要获得“hello”的ascii码,接着计算出二进制:
比如,“h”的ascii码是104,二进制是1101000:
"e"的ascii码是65,二进制是101,二进制是1100101:
然后形成如下的位图:
下面就需要利用位操作来进行设置:
test:1>setbit s 1 1
"0"
test:1>setbit s 2 1
"0"
test:1>setbit s 4 1
"0"
test:1>setbit s 10 1
"0"
test:1>setbit s 13 1
"0"
test:1>setbit s 9 1
"0"
test:1>setbit s 15 1
"0"
setbit的顺序可以随意调整,只要最终得到的位图是如上形式的就OK了。(我这里就调整了下seitbit的顺序,好吧,我承认其实我是打错了,又懒得再去打一遍,反正最终形成的位图是一样的)。
然后我们get一下:
test:1>get s
"he"
很神奇,有木有,这也说明了在Redis的底层,String就是一个数组。
SDS
不管在什么编程语言、存储引擎中,String都是应用最广泛的,而在不同的编程语言、存储引擎中,String可能有不同的实现,在Redis中,String的底层就是SDS,它的全称是Simple Dynamic String。
Redis是C语言开发的,C语言是没有现成的字符串类型的,而是用字符数组来表示字符串,Redis为什么不直接这么做呢,而要“别出心裁”的自己构建SDS数据结构来实现字符串呢?
我们先来这个SDS是个什么鬼:
struct sdshdr {
int len;
int free;
char buf[];
};
SDS的定义比较简单,只有3个字段,而且从字面上就可以看出是什么意思:
- len:存储字符串的实际长度
- free:存储剩余(空闲)的空间
- buf[]:存储实际数据
我们可以看到,其实SDS结构也包含了字节数组,但是不同的是,新增了两个字段。
为了方便起见,下面的文章把直接使用字节数组来表示字符串称为C语言的字符串,但是大家要明白,C语言是没有现成的字符串类型的。
下面我们来看下SDS和C语言的字符串有什么区别:
- 求字符串长度
C语言的字符串,求字符串的长度只能遍历,时间复杂度是O(N),单线程的Redis表示鸭梨山大,但是现在引入了一个字段来存储字符串的实际长度,时间复杂度瞬间降低成了O(1)。 - 二进制安全
在C语言中,读取字符串遵循的是“遇零则止”,即,读取字符串,当读取到“\0”,就认为已经读到了结尾,哪怕后面还有字符串也不会读取了,像图片、音频等二进制数据,经常会穿插“\0”在其中,好端端的图片、音频就毁了…但是现在有了一个字段来存储字符串的实际长度,读取字符串的时候,先看下这个字符串的长度是多少,然后往后读多少位就可以了。 - 缓冲区溢出
字符串拼接是开发中常见的操作,C语言的字符串是不记录字符串长度的,一旦我们调用了拼接函数,而没有提前计算好内存,就会产生缓冲区溢出的情况,但是现在引入了free字段,来记录剩余的空间,做拼接操作之前,先去看下还有多少剩余空间,如果够,那就放心的做拼接操作,不够,就进行扩容。 - 减少内存重分配次数
- 空间预分配:当对字符串进行拼接操作的时候,Redis会很贴心的分配一定的剩余空间,这块剩余空间现在看起来是有点浪费,但是我们如果继续拼接,这块剩余空间的作用就出来了。
- 惰性空间释放:当我们做了字符串缩减的操作,Redis并不会马上回收空间,因为你可能即将又要做字符串的拼接操作,如果你再次操作,还是没有用到这部分空间,Redis也会去回收这部分空间。
扩容策略
字符串小于1M,采用的是加倍扩容的策略,也就是多分配100%的剩余空间,当大于1M,每次扩容,只会多分配1M的剩余空间。
最大长度
Redis 规定字符串的长度不得超过 512M 字节。
embstr raw
Redis的字符串有两种存储方式,一种是embstr,一种是raw,当长度<=44,采用embstr 来存储:
set codebear abcdefghijklmnopqrstuvwxyz012345678912345678
"OK"
debug object codebear
"Value at:0x7f4050476880 refcount:1 encoding:embstr serializedlength:45 lru:1999016 lru_seconds_idle:36"
当长度>44,改用raw来存储:
set codebear abcdefghijklmnopqrstuvwxyz0123456789123456781
"OK"
debug object codebear
"Value at:0x7f404ac30100 refcount:1 encoding:raw serializedlength:46 lru:1999188 lru_seconds_idle:3"
网上也有一些博客说是以39为分界线,为什么会有两种答案呢?继续看下去就明白了。
我们先来看看Redis的对象头,查看:
#define LRU_BITS 24
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
} robj;
Redis对象头占 4bit+4bit+24bit+4byte+8byte(指针,在64 bit system下,占8byte)=32bit+12byte=4byte+12byte=16byte。
再来看看这两种存储形式有什么区别:
embstr的存储形式比较紧凑,Redis的对象头和SDS对象存在一起(连续)。
一般来说,在raw的存储形式下,Redis的对象头和SDS对象不存在一起(不连续)。
我们可以简单的理解为,一块内存的大小为64byte。
好了,前置内容介绍完毕了,我们来看看Redis3.0版本的SDS的定义,查看:
struct sdshdr {
unsigned int len;
unsigned int free;
char buf[];
};
Redis对象头占了16byte,SDS对象的len和free又占了8byte,64-16-8=40,同时保存的字符串会以\0结尾,又占用了1byte,所以实际存储的字符串只能<=39位,所以在低版本的Redis下,embstr、raw的分界线为39。
再来看看Redis5.0版本的SDS的定义,查看:
可以看到变化很大,为什么要做那么大的改变?更节省内存,当字符串长度比较小的时候,会用
sdshdr8来存储,len和alloc共占用2byte,flags占用1byte,\0结尾占用1byte,一共是4byte,64byte-16byte(对象头)-4byte=44byte,所以在高版本的Redis下,embstr、raw的分界线为44。
怎么样,没想到吧,我们Redis经常使用的String竟然牵扯到那么多东西,而这些东西就可以区分平庸开发和优秀开发,成为一个优秀的开发,要学习的东西还有很多很多。