目录
1.完全基于内存实现
Redis将数据存储在内存中,内存直接与CPU对接,读写操作不会因为磁盘的IO速度而受到限制。
2.高效的数据结构
Redis数据库
不同数据类型使用不同的数据结构来提高速度,而Redis整体就是一个哈希表来保存所有的key-value。
哈希表,本质就是一个数组,每个元素被叫做哈希桶,不管什么数据类型,每个桶里面的entry保存着实际具体值的指针。
整个数据库就是一个全局哈希表,而哈希表的时间复杂度是 O(1),只需要计算每个键的哈希值,便知道对应的哈希桶位置,定位桶里面的entry找到对应数据,这个也是Redis快的原因之一。
当写入 Redis 的数据越来越多的时候,哈希冲突不可避免,会出现不同的key计算出一样的哈希值。
Redis 通过链式哈希解决冲突:也就是同一个桶里面的元素使用链表保存。但是当链表过长就会导致查找性能变差,Redis 为了追求快,使用了两个全局哈希表。用rehash操作,增加现有的哈希桶数量,减少哈希冲突。
开始默认使用hash表1保存键值对数据,哈希表2此刻没有分配空间。当数据越来多触发rehash操作,则执行以下操作:
①给hash表2分配更大的空间;
②将hash表1的数据重新映射拷贝到hash表2中;
③释放hash表1的空间。
将hash表1的数据重新映射到hash表2的过程中并不是一次性的,这样会造成Redis阻塞,无法提供服务。而是采用了渐进式rehash,每次处理客户端请求的时候,先从hash表1中第一个索引开始,将这个位置的 所有数据拷贝到hash表2中,就这样将rehash 分散到多次请求过程中,避免耗时阻塞。
不同数据类型对应的数据结构
SDS简单动态字符
C语言中要获取字符串长度,要从头开始遍历,直到遇到「\0」为止,而SDS则是如下结构:
free用于标记buf中空闲空间长度 | len记录buf空间中已使用空间长度 | buf存储实际内容 |
SDS动态字符的优点如下:
时间复杂度
C语言获取字符串长度的时间复杂度为O(n),而SDS中len保存字符串长度,因此时间复杂度为O(1);
空间预分配
SDS被修改后,程序不仅会为SDS分配所需要的必须空间,还会分配额外的未使用空间,而数组没这个功能;
惰性空间释放
当对SDS进行缩短操作时,程序并不会回收多余的内存空间,而是使用free字段将这些字节数量记录下来不释放,后面如果需要 append操作,则直接使用free中未使用的空间,减少了内存的分配;
二进制安全
Redis还可以存储二进制数据,由于二进制数据并不是规则的字符串格式,其中会包含一些特殊的字符如'\0',在 C 中遇到'\0'则表示字符串的结束,但在SDS中,标志字符串结束的是len属性,而不是'\0'字符;
zipList压缩列表
压缩列表是List、hash、zset三种数据类型底层实现之一。
当一个列表只有少量数据的时候,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表键的底层实现。
ziplist是由一系列特殊编码的连续内存块组成的顺序型的数据结构,ziplist中可以包含多个entry节点,每个节点可以存放整数或者字符串。
zlbytes表示列表占用字节数 | zltail 表示列表尾的偏移量 | zllen表示列表中的entry 个数 | entry1 | entryN | zlend表示列表结束 |
如果定位第一个元素和最后一个元素,则复杂度是 O(1)。而查找其他元素时复杂度就是O(N);
linkedlist双端列表
不管是先进先出的队列,还是先进后出的栈,双端列表都很好的支持这些特性。
①双端:链表节点带有prev和next指针,获取某个节点的前置节点和后置节点的复杂度都是O(1)。
②无环:表头节点的prev指针和表尾节点的next指针都指向 NULL,对链表的访问以NULL为终点。
③带表头指针和表尾指针:通过list结构的head指针和tail指针,程序获取链表的表头节点和表尾节点的复杂度为O(1)。
④带链表长度计数器:程序使用list结构的len属性来对list 持有的链表节点进行计数,程序获取链表中节点数量的复杂度为O(1)。
⑤多态:链表节点使用void* 指针来保存节点值,并且可以通过list结构的dup、free、match三个属性为节点值设置类型特定函数,所以链表可以用于保存各种不同类型的值。
quicklist
quicklist代替了ziplist、linkedlist。
quicklist是ziplist、linkedlist混合体,它将linkedlist按段切分,每一段都使用ziplist,ziplist之间使用双向指针串接起来;
skipList跳跃表
skiplist是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。
跳跃表支持平均 O(logN)、最坏 O(N)复杂度的节点查找,跳表在链表的基础上,增加了多层级索引,通过索引位置的几个跳转,实现数据的快速定位,如下图所示:
整数数组intset
当一个集合Set只包含整数值元素,并且这个集合的元素数量不多时,Redis 就会使用整数集合作为集合键的底层实现。
Intset底层是一个数组,整数集合的每个元素都是该数组的一个数组项,各个项在数组中按值从小到大有序地排列,并且数组中不包含任何重复项。length属性记录了整数集合包含的元素数量,即是该数组的长度。
redisObject
Redis使用对象(redisObject)来表示key-value中的key值,当在Redis中创建一个键值对时,至少创建2个对象:一个是用做键值对的键对象,另一个是键值对的值对象;
RedisObject的type字段表示对象的类型,包含字符串对象、列表对象、哈希对象、集合对象、有序集合对象。
对于每一种数据类型来说,底层的支持可能是多种数据结构,什么时候使用哪种数据结构,这就涉及到了编码转化的问题。
①String存储数字的话,采用int类型的编码,如果是非数字的话,采用raw编码;
②List对象的编码可以是ziplist或linkedlist,字符串长度<64字节且元素个数<512使用ziplist编码,否则转化为linkedlist 编码;
通过redis.conf 文件中的list-max-ziplist-entries 512、list-max-ziplist-value 64来修改配置;
③Hash对象的编码可以是ziplist或hashtable。当Hash对象同时满足以下2个条件时,采用ziplist编码,否则就是hashtable 编码:
Hash 对象保存的所有键值对的键和值的字符串长度均小于 64 字节;
Hash 对象保存的键值对数量小于 512 个。
④Set对象的编码可以是intset或hashtable,intset编码的对象使用整数集合作为底层实现,把所有元素都保存在一个整数集合里面。保存元素为整数且元素个数小于一定范围使用intset编码,任意条件不满足,则使用hashtable编码;
⑤Zset对象的编码可以是ziplist或zkiplist,当采用ziplist编码存储时,每个集合元素使用两个紧挨在一起的压缩列表来存储。
Ziplist压缩列表第一个节点存储元素的成员,第二个节点存储元素的分值,并且按分值大小从小到大有序排列。
当 Zset 对象同时满足一下两个条件时,采用ziplist编码,如果不满足以上条件的任意一个,ziplist 就会转化为zkiplist编码:
Zset 保存的元素个数小于128。
Zset 元素的成员长度都小于64字节。
这2个限制可以在redis.conf中进行修改:zset-max-ziplist-entries 128、zset-max-ziplist-value 64
3.单线程模型
Redis的单线程指的是 Redis 的网络IO以及键值对指令读写是由一个线程来执行的。 对于Redis的持久化、集群数据同步、异步删除等都是其他线程执行。
单线程优点如下:
①不会因为线程创建导致的性能消耗;
②避免上下文切换引起的 CPU 消耗,没有多线程切换的开销;
③避免了线程之间的竞争问题;
④代码更清晰,处理逻辑简单;
4.I/O多路复用模型
采用了epoll + 自己实现的简单的事件框架。epoll中的读、写、关闭、连接都转化成了事件,然后利用epoll的多路复用特性,绝不在IO上浪费一点时间。
阻塞的原因由于使用传统阻塞 IO ,也就是在执行 read、accept 、recv 等网络操作会一直阻塞等待。如下图所示:
多路指的是多个socket连接,复用指的是复用一个线程。
多路复用主要有三种技术:select,poll,epoll。epoll是最新的也是目前最好的多路复用技术。
它的基本原理是,内核不是监视应用程序本身的连接,而是监视应用程序的文件描述符。当客户端运行时,它将生成具有不同事件类型的套接字。在服务器端,I O多路复用程序会将消息放入队列,然后通过文件事件分派器将其转发到不同的事件处理器。
简单来说:Redis 单线程情况下,内核会一直监听socket上的连接请求或者数据请求,一旦有请求到达就交给Redis线程处理,这就实现了一个Redis线程处理多个IO流的效果。
select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的事件处理器。所以 Redis一直在处理事件,提升 Redis的响应性能。
Redis 线程不会阻塞在某一个特定的监听或已连接套接字上,也就是说,不会阻塞在某一个特定的客户端请求处理上。正因为此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。
5.扩展:IO多路复用技术是什么?
5.1 简介
多路指的是多个socket连接,复用指的是复用一个线程。多路复用主要有三种技术:select,poll,epoll,其中的epoll是最新的也是目前最好的多路复用技术;采用多路I/O复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗);
5.2 何谓“非阻塞IO”?
当我们通过socket来读写时,默认是阻塞的,当socket.read的时候,要传递一个参数n,表示最多读取n个字节后返回给调用程序,如果一个字节都没有,则调用者线程就会一直等待/阻塞,直到新的数据到来或者socket连接关闭,read方法才执行结束,返回结果,然后调用者线程才能继续走后面的代码;(这里的read,指的是读取返回结果)
socket.write方法写入时,如果操作系统为socket分配的write缓冲池已经满了的话,则write一直等待,等到有缓冲区中有空间空闲出来,因此,在write等待过程中,write方法没有返回,而调用者线程也是一直在等待/阻塞;(这里的write,指的是发送请求数据)
既然read/write都会等待,进而导致调用者线程也顺带着等待,影响调用者线程的后续执行,为此,非阻塞IO在socket套接字对象上提供了一个选项Non_Blocking,当启用该选项时,读写方法均不会阻塞,read能读多少就读多少,write能写多少就写多少,至于能读多少取决于内核为套接字分配的读缓冲区内部的数据字节数,同样的,能写多少也是取决于内核为套接字分配的写缓冲区的空闲空间字节数,read和write方法都是通过返回值来告诉调用者线程实际读写的字节数;
因此,调用者线程,借助非阻塞IO,在socket.read/write时,不必再阻塞等待,读写可以瞬间完成,返回结果,然后调用者线程继续执行后续代码;
5.3 事件轮询(多路复用)与非阻塞IO缺点
非阻塞IO有个逻辑缺陷,就是调用者线程如何知道read时,把该读的数据都read完,write时,写的数据都写完了,通俗来讲就是完整的read、write数据呢?
事件轮询API就是来解决这个问题的,这里最简单的事件轮询API是操作系统提供给用户的select函数,该函数参数是读写描述符列表read_fds和write_fds,而返回结果则是对应的可读可写事件,同时该函数还有一个timeout参数,如果没有任何事件到来,那么最多等待timeout时间,这段时间内select函数一直处于阻塞状态,一旦期间有任何事件到来,则立即返回,如果等待timeout时间后还是没有任何事件到来,select函数也会立即返回;
当调用者线程通过select函数拿到事件后,调用者线程继续挨个处理相应的事件,等处理完毕后,调用者线程继续调用select函数,于是调用者线程进入一个死循环,这个死循环称为事件循环,一个循环叫做一个周期;
客户端会为每个socket套接字建立一个读写文件描述符,select函数同时处理多个socket通道描述符的读写事件,这类系统调用称为多路复用API。(相当于select函数一次性把多个Socket读写文件描述符给监控了)
select逐渐不再使用,其被epoll(linux系统)、kqueue(Macosx系统)取代;
至于服务端Server,其server.socket.read是调用accept接受客户端Client的新连接,至于新连接什么时候过来,Server也是通过select函数的读事件来知晓;
5.4 读写文件描述符
read events, wr 工 te events = select(read fds, write fds, timeout) |
5.5 Redis如何使用多路复用?
Redis会为每个客户端socket套接字关联2个队列:
①与客户端请求关联的指令队列:客户端Client发送的指令通过队列来排队进行顺序处理,先到先执行;
②与客户端响应关联的晌应队列:Redis服务器通过响应队列来将指令的返回结果回复给客户端。如果队列为空,那么意昧着连接暂时处于空闲状态,不需要去获取写事件,可以将当前的客户端描述符从write_fds里面移出来。等到队列有数据了,再将描述符放进去,避免select系统调用后立即返回一个写事件,而事件没什么数据可以写,出现这种情况的线程会令 CPU消耗飘升;
5.6 select函数的timeout参数
Redis的定时任务会记录在一个被称为“最小堆”的数据结构中。在这个堆中,最快要执行的任务排在堆的最上方。在每个循环周期里, Redis都会对最小堆里面已经到时间点的任务进行处理;
待处理完毕后,将最快要执行的任务还需要的时间记录下来,这个时间就是select系统调用的timeout参数。因为Redis知道未来 timeout时间内,没有其他定时任务需要处理,所以可以安心睡眠 timeout时间。