redis为何这么快

文章目录

  • 概述
  • 基于内存的操作
  • 高效的数据存储结构设计
  • 高效的数据结构
    • 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)

压缩列表主要目的是为了节约内存,是由一系列特殊编码的连续内存块组成的顺序型数据结构。一个压缩列表可以包含任意多个节点,每个节点可以保存一个字节数组或者一个整数值。

如下为压缩列表的结构图:
在这里插入图片描述

压缩列表记录了各组成部分的类型、长度以及用途。

如下为压缩列表各字段说明:

redis为何这么快_第1张图片

双端链表(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 对象在内存中的结构如下

redis为何这么快_第2张图片

字典

字典,又称为符号表(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)

redis为何这么快_第3张图片

当一个新的键值对要添加到字典中时,会根据键值对的键计算出哈希值和索引值,根据索引值放到对应的哈希表上,即如果索引值为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,多路指的是多个网络连接,复用指的是复用同一个线程。

redis为何这么快_第4张图片

多路I/O复用技术可以让单个线程高效的处理多个连接请求,而Redis使用用epoll作为I/O多路复用技术的实现。并且Redis自身的事件处理模型将epoll中的连接、读写、关闭都转换为事件,不在网络I/O上浪费过多的时间。

虚拟内存机制

虚拟内存机制就是当出现内存不足时暂时把不经常访问的数据(冷数据)从内存交换到磁盘中,从而腾出宝贵的内存空间用于其它需要访问的数据(热数据)。通过VM功能可以实现冷热数据分离,使热数据仍在内存中、冷数据保存到磁盘。这样就可以避免因为内存不足而造成访问速度下降的问题。

采用异步线程处理非客户端操作

对于一些非客户端请求的操作使用异步线程来处理,避免造成客户端请求的阻塞。如:主从同步,主动删除过期数据等。

参考文章

Redis为什么这么快

图解 Redis 五种数据结构底层实现

Redis之VM机制

你可能感兴趣的:(redis,redis)