Redis为什么那么快?

redis(Remote Dictionary Server ) 远程字典服务

开源的,使用ANSI C语言编写的,支持网络,可基于内存亦可持久化的日志型、key-value数据库,支持多种数据类型(String\list\set\zset\hash),同时也支持丰富的操作API,push/pop/add/remove/取交集/取差集合。数据存储在内存中,同时周期性的会将更新的数据写入磁盘或者吧修改的操作追加写入到日志记录文件,并且基于此实现了主从同步(master-slave)
官网地址:redis.io
作者: Salvatore Sanfilippo,来自意大利的西西里岛

快的原因

  • 基于内存的实现
  • 使用I/o多路复用,非阻塞IO
  • 单线程模型,避免了不必要的上下文切换和竞争
  • 高效的数据结构
    1. 动态字符串
    2. 双向链表
    3. 压缩列表
    4. 跳跃表
    5. hash表
    6. 整数数组
  • 根据实际的数据类型选择合理的数据编码

有多快

在这里插入图片描述

官方宣称QPS可以达到100000,参考:https://redis.io/topics/benchmarks


原因详细说一下


1. 完全基于内存实现

一般的磁盘数据库,对于数据的读写均需要与磁盘通过IO操作交互,而redis是基于内存实现,其在数据读取和写入速度上,完全吊打磁盘;
参考磁盘的调用栈
应用程序–>系统调用–>块设备接口–>I/O驱动–>bus总线–>物理磁盘

内存直接由CPU进行控制,即cpu集成的内存控制器,内存直接与cpu对接,享受cpu通信的最优带宽;因此,数据存储在内存中,读写操作不会因为磁盘的IO限制,速度自然飞快;
量化操作系统的各种IO延迟时间

在这里插入图片描述


2. 高效的数据结构

数据结构是一种数据的组织与存储方式,不同的数据结构拥有不同的特性,适用不同的应用场景;

例如mysql的innoDb存储引擎采用B+树,es的文档存储采用B树,

redis支持5种数据类型,每种数据类型均有适用的场景,同时,也更具实际存储内容的不同,每种数据类型又采用了不同的数据结构来组织存储数据,因而可以进一步来提升处理速度

5种数据类型及适用的场景
  • String :缓存,计数器,分布式锁等
  • List: 链表,队列,微博关注人时间轴列表
  • Hash: 用户信息,hash表,购物车商品信息
  • Set:去重,赞,踩,共同好友等
  • zset:访问量排行榜,点击量排行榜等
    以上的数据类型,底层使用了一种或者多种的数据结构来进行存储

在这里插入图片描述

SDS简单动态字符串

redis并未采用c语言传统的字符串表示,而是自己构建了一种名为(simple dynamic string)的抽象类型,并且用作默认的redis字符串表示,sds的数据结构如图:
命令:set name Redis

在这里插入图片描述

结构说明

free: 剩余未使用的空间
len: 已经使用的空间
buf:一个char数组,前五个保存的是redis的字符,最后一个空间保存一个空字符串‘\0’

sds的优点
  1. 获取字符串长度时间复杂度为O(1)
  2. 杜绝缓冲区溢出
    缓冲区溢出是指修改字符串内容时,未考虑实际可用的空间,导致数据溢出可用空间并对其他数据的空间产生影响;sds在修改前,会通过API判断是否空间足够,如果不足,则sds会自动扩展空间至所需大小,再执行修改操作;
  3. 减少修改时,改变字符串长度所需的内存重新分配的次数
    • 空间预分配(当修改时,所需空间长度增加): 当对sds的字符串进行修改时,如果空间不够,需要扩容时,如果len的长度小于1M,那么程序会分配len两倍长度的未使用空间,例如需要将redis修改为changbo,原来的空间是第一次分配,空间长度为5+1(\0)=6,而扩容时,会变为7*2+1=15,其中len为7,free为7,这个即预分配;如果要重新分配的所需长度大于1MB,那么会额外分配1MB的空间,例如如果要修改为10MB,则空间的大小为10MB+1MB+1byte;
    • 惰性释放空间(当修改时,所需空间减少):例如当将redis修改为cb时,长度由5变为2,但sds并不会立即释放3个长度的空间,而是采用free标记未使用的空间,若后续对该字符串的修改是增加时,如果空间小于5,则不需要重新再分配;
  4. 二进制安全
    redis不仅存储string,也可以存储二进制数据,二进制数中存储的字符是不确定的,可能存在例如‘\0’这种,在C中\0表示字符串结束,但是在sds中不是,sds的字符串结束是使用len来计算的;
redis hash字典

redis整体就是采用hash表来存储所有的键值对,无论那种数据类型,本质上也就是一个数组,每个元素叫做hash桶,桶中的元素即为entry,保存这实际具体的key和指针信息;hash表中找到数据所在桶的时间复杂度为O(1);发生hash冲突时redis采用链表来解决,同时,为了解决链表过长导致的性能下降,redis也采用两个全局hash表,并采用rehash来重新映射拷贝;具体流程如下:

  1. 开始时,采用hash1来存储数据,hash2并没有分配空间,当hash1容量增加至扩容阈值时(负载因子:0.75)触发rehash操作时,给hash2分配空间,但是hash1映射转移到hash2并不是一次性的,因为这样会造成redis阻塞,无法提供服务;采用渐进式的rehash, 每次处理请求时,从hash的第一个索引开始,将该位置数据拷贝到hash2中并进行重新hash映射,将rehash分步到多次的请求中,避免了耗时阻塞;
  2. 关于rehash的流程: https://www.cnblogs.com/meituantech/p/9376472.html

3. ziplist 压缩列表

压缩列表是List,hash,sortset,三种数据类型的底层存储结构之一,当一个列表只有少量的数据的时候,那么redis会采用压缩列表来作为键的底层实现,ziplist采用采用了一系列特殊编码的连续内存块组成的顺序型的数据结构,ziplist可以存储多个entry节点,每个节点可以存储整数或者字符串;
ziplist数据结构由以下几部分组成 zlbytes(int32) , zltail_offset(int32) ,zllength(int18),entries(T[]) , zlend(int8)

struct ziplist<T> {
    int32 zlbytes; // 整个压缩列表占用字节数
    int32 zltail_offset; // 最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
    int16 zllength; // 元素个数
    T[] entries; // 元素内容列表,挨个挨个紧凑存储
    int8 zlend; // 标志压缩列表的结束,值恒为 0xFF
}

在这里插入图片描述

该种数据结构,在查找第一个和最后一个元素时,时间复杂度为O(1),查找其他元素时,是O(N);

4. 双端队列

redis的list通常也用于队列,例如微博关注人数的时间轴,无论是fifo队列,或filo栈,双端队列对此支持均非常好;
list链表结构

typedef struct list{
listNode * head; //表头节点指针
listNode * tail;// 表尾节点指针
unsigned long len; //链表所包含的恶节点数量
void *(*dup)(void *ptr); // 节点值复制函数
void (*free)(void *ptr); //节点值释放函数
int (*match)(void *ptr,void *key); // 节点值对比函数
}
特性总结
  • 双端: 每个节点都有prev和next指针,获取一个节点的前置或后置节点的时间复杂度均是O(1).
  • 无环:表头和表尾的prev和next均指向null,链表访问到null为结束
  • 带表头和表尾指针: 如上list的数据结构,包含了表头和表尾的指针,程序获取到第一个或最后一个的时间复杂度为O(1).
  • 带链表长度计数器:程序使用list结构的len来对链表节点进行计数,程序获取链表中节点数量的时间复杂度为O(1).
  • 多态: 链表中使用void* 指针来保存节点值,同时通过list结构的dup,free,match三个属性来为节点设置类型特定函数,所以链表可以保存不同类型的值;

后续版本对列表进行了改造,使用quickList来代替zipList和LinkedList.
quickList是ziplist和linkedList的混合体,多个zipList采用双向指针来串接起来;
在这里插入图片描述

5. skiplist 跳跃表

sorted set 有序的set集合,底层采用跳跃表来实现排序功能;
跳跃表是一种有序的数据结构,在每一个节点维持多个指向其他节点的指针,从而达到快速访问的目的
跳跃表支持评论O(logN),最坏O(N) 时间复杂度的查找,跳表是在链表的基础上增加了多层索引,通过索引的跳转,实现数据的快速查找定位;其结构图如下所示;
在这里插入图片描述
在这里插入图片描述
1、什么是level k节点
含有key 个ahead(后继)指针的节点
2、每个节点要维护多少个额外后继指针?
采用抛硬币的方式,抛一次硬币指针的个数加1, 重复抛硬币直到某次结果为反面。伪代码见论文中的randomLevel() 函数
3、header指针要维护多少个指针呢
当前链表的level最大为多少,那么header就维护多少个指针

4、查找如何遍历
先沿最大的ahead 指针向后继查找,如果ahead[k] <searchKey, 将ahead[k]作为当前节点继续查找,否则检查ahead[k-1],直到k=1或者当前节点的key==查找的key
比如为了查找key=25, 那么遍历的路径为6->25

5、新增时额外的向前指针如何维护
调用随机数产生器产生level ,根据key, 查找到待插入的位置,同时update[]记录链表中其他节点需要指向当前节点指针,修改指针时,当前节点的ahead[i]=update[i],update[i]指向当前节点
比如上图中 为了插入17,先遍历链表找到待插入的位置为12之后, 那么update[2]=节点9的ahead[2], update[1]=节点12的ahead[1], 将节点17的ahead[2]=update[2], ahead[1]=update[1]; upate[2]=节点17, upate[1]=节点17

6、删除节点时额外指针维护
先根据key,查找到对应节点的位置,同时update[]数组记录链表的其他节点指向当前节点的情况, 当前节点删除后需要将update[i]更新为当前节点的ahead[i]
针对上图 为了删除key=17的节点,先遍历链表找到key=17节点位置, 并记录update[2]=节点key=9的ahead[2], update[1]=节点key=12的ahead[1]; 修改指针时,节点9的ahead[2]=节点17的ahead[2], 节点12的ahead[1]=节点17的ahead[1];

6. 整数数组(intset)

当一个集合只包含整数值的元素时,并且这个集合的元素不多,那么redis会使用整数集合作为集合键的底层实现,

typedef struct intset{
     //编码方式
     uint32_t encoding;
     //集合包含的元素数量
     uint32_t length;
     //保存元素的数组
     int8_t contents[];
}intset;

contents是存储集合元素的数组,集合每个元素都是一个数组项(item),各个项目在数组中按值的大小从小到大有序的排列并且数组中1不包含任何重复项,length属性记录了整数集合包含的元素的数量,也是contenes数组的长度;

7. 合理的数据编码

redis在底层存储时,会构建两个对象(redisObject)来表示数据库中的键值,的在redis中创建一个键值对时,至少创建两个对象,一个是键对象,一个是值对象;
redisObject的模型

typedef struct redisObject{
    //类型
   unsigned type:4;
   //编码
   unsigned encoding:4;
   //指向底层数据结构的指针
   void *ptr;
    //...
 }robj;

type: 对象的类型: STring,list ,set zset ,hash
每一种数据结构底层存储可能对应多种方式,所以涉及到了编码转换的问题;

编码转换示例

String: 存储数字采用int,如非数字采用raw编码
List: ziplist,linkedList,字符串长度小于64字节,元素个数小于512个,采用ziplist,否则采用linkedList; 该条件可配置(redis.config)

list-max-ziplist-entries 512
list-max-ziplist-value 64

Hash: hash对象的编码可以是ziplist或者hashTable.
当Hash对象同时满足以下两个条件时,hash对象采用zipList编码:

  • Hash 对象保存的所有键值对的键和值的字符串长度均小于 64 字节。
  • Hash 对象保存的键值对数量小于 512 个。

Set: set对象的编码可以是intset 或 hashtable, intset编码的对象使用整数集合为底层实现,把所有的元素都保存在一个整数集合里面。(元素为整数并且小于一定范围则使用intSet)

ZSet: 其编码对象可以使 ziplist或者skiplist,采用ziplist时,每个集合元素使用两个紧挨在一起的压缩列表来存储。压缩列表第一个节点存储元素的成员,第二个节点存储元素的分值,并且按照分值的大小来排序;
如果满足以下条件时,采用ziplist,

  • Zset 保存的元素个数小于 128。
  • Zset 元素的成员长度都小于 64 字节。
    如果不满足以上条件的任意一个时,则zipList会转换为skipList
zset-max-ziplist-entries 128
zset-max-ziplist-value 64

7.单线程模型

redis的单线程是指网络IO与键值数据的读取是由一个线程来完成,而其数据持久化,集群数据同步,异步删除等都由其他线程执行;

多线程的优势

可以提升系统吞吐量,充分利用多核cpu资源
但同样的,多核cpu带来的负面影响自然也很多,如果使用了多线程,没有良好的系统设计,可能会出现这样的场景: 前期的吞吐量会上升,但是随着线程数的进一步增加,系统的吞吐量几乎不再增加,甚至下降;主要原因是上下文切换和同步块资源竞争:

  • 在cpu运行每一个任务之前,cpu需要知道任务在何处加载并开始运行,系统需要设置或恢复cpu寄存器和程序计数器,这个成为cpu上下文,切换上下文时,需要保存寄存器即程序计数器的状态,以便下一次的唤醒,这是非常消耗资源的操作;
  • 在多线程场景下,修改共享数据时,为了保证数据的正确,需要加锁相应数据块,这会带来额外的性能开销,面临的共享资源的并发访问控制问题,因而带来代码开发的复杂度以及调试的困难;
单线程的优势
  1. 不会因为线程创建导致性能消耗
  2. 避免上下文切换引起的cpu消耗,没有多线程切换的开销;
  3. 避免了线程之间的竞争问题,比如添加锁,释放锁,死锁等,不需要考虑各种锁的问题;
  4. 代码开发更加清晰,处理逻辑简单
redis的单线程是否没有充分利用cpu资源呢

因为redis是基于内存操作的,cpu不是redis的瓶颈,redis的瓶颈可能是机器内存和网络带宽;
原文地址:https://redis.io/topics/faq

8. IO多路复用模型

redis 采用I/O多路复用技术,并发处理连接,采用了epoll+自己实现的简单的事件框架,epoll中的读,写,关闭,连接 均转化为事件,利用epoll多路复用来高效处理;

什么是io多路复用

首先了解下redis的基本的io处理流程,

  1. 与客户端建立连接 accept
  2. 从socket读取请求 recv
  3. 解析客户端发送的请求 parse
  4. 执行get指令;
  5. 响应客户端数据,将数据写回socket

在传统的阻塞io模型中,执行read,accept,recv时,会一直阻塞等待;accept和recv均会导致阻塞,例如连接建立不成功,数据读取时,数据没有到达;
在这里插入图片描述

IO多路复用

多路是指多个socket连接,而复用则是指复用一个线程。目前多路复用有三种技术,即 select , poll , epoll;其中,epoll是目前最好的多路复用技术。

其原理是:内核不是监控程序本身连接,而是监控应用程序的文件描述符。

客户端运行时,在服务器端,io多路复用程序会将消息放在队列中,然后通过事件分派器将其转发到不同的事件处理器。
内核会一直监听socket上的连接请求或者数据处理请求,一旦请求到达,则交给redis线程处理,从而实现一个redis线程处理多个连接的效果;

epoll提供了基于事件回调的处理机制,针对不同的事件的发生,调用相应的事件处理器;
因此,redis并没有阻塞在某一个连接上,而是无区分的不间断的处理事件,所以提升了其响应性能;
在这里插入图片描述

补充select,poll,epoll的区别

这三种技术均是IO多路复用的实现,均为监听fd的模式来完成事件的处理;
其不同点及优缺点如下:
select: 每建立一个连接,创建一个fd并且加到fd_set中,每次检查时,将数组从用户空间拷贝到内核空间,内核遍历fd_set,检查是否有读写操作,当遍历时,存在读写操作,则标记fd_set的mask值,当遍历完成后如果没有mask值,则暂时挂起线程,超过指定时间后再唤醒重复上述操作;如果存在mask则表示存在读写操作,然后再将fd_set复制到用户空间进行处理;
在这里插入图片描述
该操作中,时间复杂度为O(N)
fd_set监控的连接数存在上限:32位为1024,64位为2048
缺点:

  1. 单进程可以打开的fd有限制,
  2. 对socket的扫描是线性的,效率低
  3. 用户空间和内核空间的复制非常消耗资源

poll:
与select类似,采用链表解决了连接数限制以及数组复制资源消耗的问题,即每次复制只需要复制头指针,这样保证在连接数增加时的性能稳定;

epoll:
即event+poll
时间复杂度为O(1)
处理过程: 在内核空间创建一个红黑树,添加fd,并且注册回调函数epoll_ctl(),当网卡驱动有就绪时,触发回调函数,回调函数会把创建一个就绪链表,内核的检查链表是否有就绪数据,如果没有就绪数据,则挂起等待指定时间后重试,如果有,则会将fd_set拷贝到共享内存空间,用户态获取fd_set进行处理;
在这里插入图片描述
采用事件驱动的方式,内核无需轮询扫描;

epoll有两种工作方式,分别是 水平触发(LT)和边缘触发(ET)
LT模式:若就绪的事件一次没有处理完要做的事件,就会一直去处理。即就会将没有处理完的事件继续放回到就绪队列之中(即那个内核中的链表),一直进行处理。
ET模式:就绪的事件只能处理一次,若没有处理完会在下次的其它事件就绪时再进行处理。而若以后再也没有就绪的事件,那么剩余的那部分数据也会随之而丢失。
由此可见:ET模式的效率比LT模式的效率要高很多。只是如果使用ET模式,就要保证每次进行数据处理时,要将其处理完,不能造成数据丢失,这样对编写代码的人要求就比较高。
注意:ET模式只支持非阻塞的读写:为了保证数据的完整性。

总结

  1. 纯内存的操作
  2. redis存储采用全局hash表,最优时间复杂度为O(1),同时采用rehash来扩容,渐进式的扩容,避免了阻塞
  3. 使用非阻塞IO,多路复用IO,单线程轮询就绪的事件并处理,效率高
  4. 采用单线程模型,保证了每个操作的原子性,也减少了上下文切换带来的cpu消耗和资源竞争
  5. 对不同的数据类型和数据结构进行编码优化,例如压缩表ziplist,跳表skipList,字符串编码SDS,等

参考资料

redis唯快不破的秘密: https://www.toutiao.com/i6923420652095439373/
select,poll,epoll区别: https://blog.csdn.net/nanxiaotao/article/details/90612404

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值