文章目录
- 概述
- 基于内存的操作
- 高效的数据存储结构设计
- 高效的数据结构
- string
- 底层实现
- SDS
- 字符串长度处理
- 杜绝缓冲区溢出
- 减少内存重新分配的次数
- 空间预分配
- 惰性空间释放
- list
- 底层实现
- 压缩列表(zipList)
- 双端链表(linkList)
- hsah
- 底层实现
- ziplist
- 字典
- set
- 底层实现
- zset
- 底层实现
- ziplist
- 跳跃表
- 跳跃表的定义
- 跳跃表底层实现
- 合理的数据编码
- 合理的线程模型
- 单线程模型:避免了上下文切换
- I/O 多路复用
- 定义
- 虚拟内存机制
- 采用异步线程处理非客户端操作
- 参考文章
我们都知道Redis很快,它QPS可达10万(每秒请求数)。Redis为什么这么快呢?接下来我们具体说明下。
概述
首先我们把redis快的原因先列举出来,然后具体分析
- 基于内存的操作
- 高效的数据存储结构设计
- 高效的数据结构
- 合理的数据编码
- 合理的线程模型
- 虚拟内存机制
基于内存的操作
我们都知道内存读写是比磁盘读写快很多的。Redis是基于内存存储实现的数据库,相对于数据存在磁盘的数据库,就省去磁盘到内核态,内核态到用户态之间的IO消耗。MySQL等磁盘数据库,需要建立索引来加快查询效率,而Redis数据存放在内存,直接操作内存,所以就很快。
高效的数据存储结构设计
上面讲到redis是基于内存的数据存储,但是如果仅仅把数据放在内存,不进行合理的存储设计依然可能会导致数据存储效率低下。redis对于数据的存储采用K-V存储,它使用一张全局的哈希表来保存所有的键值对。每张哈希表由多个哈希桶组成,每个哈希桶中的entry元素保存了key和value指针,其中*key指向了实际的键,*value指向了实际的值。这个java程序员可以把它暂时理解为java的hashMap数据结构就比较好理解了。哈希表的查询效率很高,查询的时间复杂度为O(1)。
但是和java的hashMap一样,哈希表也存在hash冲突问题,这个在redis中是如何解决的呢?reids采用链式哈希来解决这个问题,也就是java的hashMap(1.8版本之前)中的链表。但是如果仅仅采用链式哈希来解决hesh冲突,那么当出现链表过长时依然会导致查询效率降低,所以redis又采用了扩容的方式来解决(又与jdk1.8之前的hashMap一样)。
但是扩容时redis如何支持读写数据安全呢?redis在扩容时进行rehash,也就是增加哈希桶,减少冲突。为了rehash更高效,Redis还默认使用了两个全局哈希表,一个用于当前使用,称为主哈希表,一个用于扩容,称为备用哈希表。
高效的数据结构
我们知道redis存在5种常见数据结构,string、list、set、hash、zset。但是这5种数据结构底层使用的数据结构又是如何的呢?redis是使用c开发出来的,那么对于redis的这5种常见数据结构是不是也是直接来源于c提供的数据结构呢?下面我们提供一张网图来直接说明下这5种数据结构底层使用的数据结构。
string
底层实现
redis的string数据结构底层使用的是==简单动态字符串(SDS)==来实现的。这是一种用于存储二进制数据的一种结构, 具有动态扩容的特点. 其实现位于src/sds.h与src/sds.c中。
SDS
SDS对象代码
struct sdshdr { //SDS简单动态字符串
int len; //记录buf中已使用的空间
int free; // buf中空闲空间长度
char buf[]; //存储的实际内容
}
可以看到该对象包含3个属性,len用于存储字符串的长度,free用于存储buff[]的剩余空间,buff[]用于存储字符串。
那么为何不直接使用C的字符串呢?因为C的字符串无法直接获取字符串长度,每次修改字符串都会导致内存重新分配。所以redis自己扩展了string的底层数据结构。
所以对于SDS有如下优点:
字符串长度处理
在C语言中,要获取字符串的长度,需要从头开始遍历,复杂度为O(n);在Redis中, 已经有一个len字段记录当前字符串的长度啦,直接获取即可,时间复杂度为O(1)。
杜绝缓冲区溢出
C 字符串不记录自身长度带来的另一个问题是, 很容易造成缓存区溢出。比如使用字符串拼接函数(stract)的时候,很容易覆盖掉字符数组原有的数据。
与 C 字符串不同,SDS 的空间分配策略完全杜绝了发生缓存区溢出的可能性。当 SDS 进行字符串扩充时,首先会检查当前的字节数组的长度是否足够。如果不够的话,会先进行自动扩容,然后再进行字符串操作。
减少内存重新分配的次数
在C语言中,修改一个字符串,需要重新分配内存,修改越频繁,内存分配就越频繁,而分配内存是会消耗性能的。对于redis这种高频被操作的数据库显然不适合直接使用C的字符串数据结构,而在Redis中,SDS提供了两种优化策略:空间预分配和惰性空间释放。
空间预分配
当SDS简单动态字符串修改和空间扩充时,除了分配必需的内存空间,还会额外分配未使用的空间。分配规则是酱紫的:
-
SDS修改后,len的长度小于1M,那么将额外分配与len相同长度的未使用空间。比如len=100,重新分配后,buf 的实际长度会变为100(已使用空间)+100(额外空间)+1(空字符)=201。
-
SDS修改后, len长度大于1M,那么程序将分配1M的未使用空间。
惰性空间释放
当SDS缩短时,不是回收多余的内存空间,而是用free记录下多余的空间。后续再有修改操作,直接使用free中的空间,减少内存分配。SDS 也提供直接释放未使用空间的 API,在需要的时候,也能真正的释放掉多余的空间。
list
底层实现
列表对象的编码可以是 linkedlist 或者 ziplist,对应的底层数据结构是双端链表和压缩列表。
默认情况下,当列表对象保存的所有字符串元素的长度都小于 64 字节,且元素个数小于 512 个时,列表对象采用的是 ziplist 编码,否则使用 linkedlist 编码。可以通过配置文件修改该上限值。
压缩列表(zipList)
压缩列表主要目的是为了节约内存,是由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或者一个整数值。
如下为压缩列表的结构图:
压缩列表记录了各组成部分的类型、长度以及用途。
如下为压缩列表各字段说明:
双端链表(linkList)
双端链表是一种非常常见的数据结构,提供了高效的节点重排能力以及顺序访问方式。在 Redis 中,每个链表节点使用 listNode 结构表示,多个 listNode 通过 prev 和 next 指针组成双端链表。为了操作起来比较方便,Redis 使用了 list 结构持有链表。list 结构为链表提供了表头指针 head、表尾指针 tail,以及链表长度计数器 len,而 dup、free 和 match 成员则是实现多态链表所需类型的特定函数。
Redis 双端链表实现的特征总结如下:
-
双端 :链表节点带有 prev 和 next 指针,获取某个节点的前置节点和后置节点的复杂度都是 O(n) ;
-
无环 :表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问以 NULL 为终点;
-
带表头指针和表尾指针 :通过 list 结构的 head 指针和 tail 指针,程序获取链表的表头节点和表尾节点的复杂度为 O(1) ;
-
带链表长度计数器 :程序使用 list 结构的 len 属性来对 list 持有的节点进行计数,程序获取链表中节点数量的复杂度为 O(1) ;
-
多态 :链表节点使用 void* 指针来保存节点值,可以保存各种不同类型的值。
hsah
底层实现
哈希对象的编码可以是 ziplist(具体说明见list压缩列表) 或者 hashtable。当hash存储的数据大小或者个数没超过限制时使用的是ziplist,否则使用的是hashtable。
ziplist
ziplist 底层使用的是压缩列表实现,上文已经详细介绍了压缩列表的实现原理。每当有新的键值对要加入哈希对象时,先把保存了键的节点推入压缩列表表尾,然后再将保存了值的节点推入压缩列表表尾。
如果此时使用 ziplist 编码,那么该 Hash 对象在内存中的结构如下
字典
字典,又称为符号表(symbol table)、关联数组(associative array)或映射(map),是一种用于保存键值对(key-value pair)的抽象数据结构。字典中的每一个键都是唯一的,可以通过键查找与之关联的值,并对其修改或删除。
Redis的键值对存储就是用字典实现的,散列(Hash)的底层实现之一也是字典。一个哈希表里面可以有多个哈希表节点,每个哈希表节点中保存了字典中的一个键值对。
字典的结构
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值,等于size-1
unsigned long sizemask;
// 哈希表已有节点的数量
unsigned long used;
}
哈希表是由数组table组成,table中每个元素都是指向dict.h/dictEntry结构的指针,哈希表节点的定义如下
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
// 指向下一个哈希表节点,形成链表
struct dictEntry *next;
}
其中key是我们的键;v是键值,可以是一个指针,也可以是整数或浮点数;next属性是指向下一个哈希表节点的指针,可以让多个哈希值相同的键值对形成链表,解决键冲突问题。
字典结构,dict.h/dict
typedef struct dict {
// 和类型相关的处理函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引,当rehash不再进行时,值为-1
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
// 迭代器数量
unsigned long iterators; /* number of iterators currently running */
}
type属性和privdata属性是针对不同类型的键值对,用于创建多类型的字典,type是指向dictType结构的指针,privdata则保存需要传给类型特定函数的可选参数,关于dictType结构和类型特定函数可以看下面代码
typedef struct dictType {
// 计算哈希值的行数
uint64_t (*hashFunction)(const void *key);
// 复制键的函数
void *(*keyDup)(void *privdata, const void *key);
// 复制值的函数
void *(*valDup)(void *privdata, const void *obj);
// 对比键的函数
int (*keyCompare)(void *privdata, const void *key1, const void *key2);
// 销毁键的函数
void (*keyDestructor)(void *privdata, void *key);
// 销毁值的函数
void (*valDestructor)(void *privdata, void *obj);
}
dict的ht属性是两个元素的数组,包含两个dictht哈希表,一般字典只使用ht[0]哈希表,ht[1]哈希表会在对ht[0]哈希表进行rehash(重哈希)的时候使用,即当哈希表的键值对数量超过负载数量过多的时候,会将键值对迁移到ht[1]上。
rehashidx也是跟rehash相关的,rehash的操作不是瞬间完成的,rehashidx记录着rehash的进度,如果目前没有在进行rehash,它的值为-1
结合上面的几个结构,我们来看一下字典的结构图(没有在进行rehash)
当一个新的键值对要添加到字典中时,会根据键值对的键计算出哈希值和索引值,根据索引值放到对应的哈希表上,即如果索引值为0,则放到ht[0]哈希表上。当有两个或多个的键分配到了哈希表数组上的同一个索引时,就发生了键冲突的问题,哈希表使用链地址法来解决,即使用哈希表节点的next指针,将同一个索引上的多个节点连接起来。当哈希表的键值对太多或太少,就需要对哈希表进行扩展和收缩,通过rehash(重新散列)来执行。
set
底层实现
set底层使用的是hashtable来实现的,具体可以查看上面字典的具体说明。
zset
底层实现
zset的编码可以是 ziplist(具体说明见list压缩列表) 或者 跳跃表。当hash存储的数据大小或者个数没超过限制时使用的是ziplist,否则使用的是跳跃表。
ziplist
详情见list的压缩列表的说明
跳跃表
跳跃表的定义
一个普通的单链表查询一个元素的时间复杂度为O(N),即便该单链表是有序的。使用跳跃表(SkipList)是来解决查找问题的,它是一种有序的数据结构,不属于平衡树结构,也不属于Hash结构,它通过在每个节点维持多个指向其他节点的指针,而达到快速访问节点的目的。
跳跃表其实可以把它理解为多层的链表,它有如下的性质:
- 多层的结构组成,每层是一个有序的链表
- 最底层(level 1)的链表包含所有的元素
- 跳跃表的查找次数近似于层数,时间复杂度为O(logn),插入、删除也为 O(logn)
- 跳跃表是一种随机化的数据结构(通过抛硬币来决定层数)
- 元素插入时存在于哪些层也是通过抛硬币来决定的
- 跳跃表的删除很简单,只要先找到要删除的节点,然后顺藤摸瓜删除每一层相同的节点就好了
- 跳跃表维持结构平衡的成本是比较低的,完全是依靠随机,相比二叉查找树,在多次插入删除后,需要Rebalance来重新调整结构平衡
跳跃表底层实现
Redis的跳跃表实现是由redis.h/zskiplistNode和redis.h/zskiplist(3.2版本之后redis.h改为了server.h)两个结构定义,zskiplistNode定义跳跃表的节点,zskiplist保存跳跃表节点的相关信息。
typedef struct zskiplistNode {
// 成员对象 (robj *obj;)
sds ele;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
// 跨度实际上是用来计算元素排名(rank)的,在查找某个节点的过程中,将沿途访过的所有层的跨度累积起来,得到的结果就是目标节点在跳跃表中的排位
unsigned long span;
} level[];
}
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
}
zskiplistNode结构
- level数组(层):每次创建一个新的跳表节点都会根据幂次定律计算出level数组的大小,也就是次层的高度,每一层带有两个属性-前进指针和跨度,前进指针用于访问表尾方向的其他指针;跨度用于记录当前节点与前进指针所指节点的距离(指向的为NULL,阔度为0)
- backward(后退指针):指向当前节点的前一个节点
- score(分值):用来排序,如果分值相同看成员变量在字典序大小排序
- obj或ele:成员对象是一个指针,指向一个字符串对象,里面保存着一个sds;在跳表中各个节点的成员对象必须唯一,分值可以相同
zskiplist结构
- header、tail表头节点和表尾节点
- length表中节点的数量
- level表中层数最大的节点的层数
合理的数据编码
Redis支持多种数据基本类型,每种基本类型对应不同的数据结构,每种数据结构对应不一样的编码。为了提高性能,Redis设计者总结出,数据结构最适合的编码搭配。
Redis是使用对象(redisObject)来表示数据库中的键值,当我们在 Redis 中创建一个键值对时,至少创建两个对象,一个对象是用做键值对的键对象,另一个是键值对的值对象。
typedef struct redisObject{
//类型
unsigned type:4;
//编码
unsigned encoding:4;
//指向底层数据结构的指针
void *ptr;
//...
}
redisObject中,type 对应的是对象类型,包含String对象、List对象、Hash对象、Set对象、zset对象。encoding 对应的是编码。
- String:如果存储数字的话,是用int类型的编码;如果存储非数字,小于等于39字节的字符串,是embstr;大于39个字节,则是raw编码。
- List:如果列表的元素个数小于512个,列表每个元素的值都小于64字节(默认),使用ziplist编码,否则使用linkedlist编码
- Hash:哈希类型元素个数小于512个,所有值小于64字节的话,使用ziplist编码,否则使用hashtable编码。
- Set:如果集合中的元素都是整数且元素个数小于512个,使用intset编码,否则使用hashtable编码。
- Zset:当有序集合的元素个数小于128个,每个元素的值小于64字节时,使用ziplist编码,否则使用skiplist(跳跃表)编码
合理的线程模型
单线程模型:避免了上下文切换
Redis是单线程的,其实是指Redis的网络IO和键值对读写是由一个线程来完成的。但Redis的其他功能,比如持久化、异步删除、集群数据同步等等,实际是由额外的线程执行的。
Redis的单线程模型,避免了CPU不必要的上下文切换和竞争锁的消耗。也正因为是单线程,如果某个命令执行耗时过长的命令(如hgetall命令),会造成阻塞。Redis是面向快速执行场景的内存数据库,所以要慎用如lrange和smembers、hgetall等复杂度高的命令。
I/O 多路复用
定义
IO多路复用其实就是一种同步IO模型,它实现了一个线程可以监视多个文件句柄;一旦某个文件句柄就绪,就能够通知应用程序进行相应的读写操作;而没有文件句柄就绪时,就会阻塞应用程序,交出cpu。IO指的是网络IO,多路指的是多个网络连接,复用指的是复用同一个线程。
多路I/O复用技术可以让单个线程高效的处理多个连接请求,而Redis使用用epoll作为I/O多路复用技术的实现。并且Redis自身的事件处理模型将epoll中的连接、读写、关闭都转换为事件,不在网络I/O上浪费过多的时间。
虚拟内存机制
虚拟内存机制就是当出现内存不足时暂时把不经常访问的数据(冷数据)从内存交换到磁盘中,从而腾出宝贵的内存空间用于其它需要访问的数据(热数据)。通过VM功能可以实现冷热数据分离,使热数据仍在内存中、冷数据保存到磁盘。这样就可以避免因为内存不足而造成访问速度下降的问题。
采用异步线程处理非客户端操作
对于一些非客户端请求的操作使用异步线程来处理,避免造成客户端请求的阻塞。如:主从同步,主动删除过期数据等。
参考文章
Redis为什么这么快
图解 Redis 五种数据结构底层实现
Redis之VM机制