文章目录
一、redis到底有多快?
官方文档:https://redis.io/docs/management/optimization/benchmarks/
我们使用redis自带的benchmark脚本测试:
D:\Redis-x64-3.2.100>redis-benchmark -t set, lpush -n 100000 -q
====== lpush -n 100000 -q ======
100000 requests completed in 0.89 seconds
50 parallel clients
3 bytes payload
keep alive: 1
99.90% <= 1 milliseconds
99.95% <= 5 milliseconds
99.96% <= 6 milliseconds
100.00% <= 6 milliseconds
111856.82 requests per second
我们发现,每秒可以执行11万多次set、lpush命令。
D:\Redis-x64-3.2.100>redis-benchmark -n 100000 -q script load "redis.call('set', 'lua','666')"
script load redis.call('set', 'lua','666'): 105485.23 requests per second
执行Lua脚本也能达到每秒10万多次,按照这个测试结果,redis的10万qps还是比较准确的,在高性能服务器上性能还能更强。
二、redis为什么这么快
总结起来主要是三点:
1、纯内存结构
2、请求处理单线程
3、多路复用机制
1、内存存储
KV结构的内存数据库,时间复杂度为O(1)。
(1)虚拟存储器(虚拟内存Virtual Memory)
计算机里面的内存我们叫做主内存,硬盘叫做辅存。
主存可看做一个很长的数组,一个字节一个单元,每个字节都有一个唯一的地址,这个地址叫做物理地址(Physical Address)。
早期的计算机中,如果CPU需要内存,使用物理寻址,直接访问主存储器。
看起来是挺合乎情理的,但是这种方式有几个弊端:
1、一般的操作系统都是多用户多任务的,所有的进程共享主存。如果每个进程都独立占一块物理地址空间,主存很快就会被用完。我们希望在不同的时刻,不同的进程可以共用同一快物理地址空间。
2、如果所有进程都是直接访问物理内存,那么一个进程就可以修改其他进程的内存数据,导致物理地址空间被破坏,程序运行就会出现异常。
咋办呢?对于物理内存的使用,应该有一个角色来协调和指挥。
在CPU和主存之间增加一个中间层。CPU不再使用物理地址访问主存,而是访问一个虚拟地址,由这个中间层把地址转换成物理地址,最终获得数据。这个中间层叫做MMU(Memory Management Unit),内存管理单元。
具体的操作如下所示:
我们访问MMU就跟访问物理内存一样,所以把虚拟出来的地址叫做虚拟内存(Virtual Memory)。
在每一个进程开始创建的时候,都会分配一段虚拟地址,然后通过虚拟地址和物理地址的映射来获取真实数据,这样进程就不会直接接触到物理地址,甚至不知道自己调用的哪块物理地址的数据。
目前,大多数操作系统都使用了虚拟内存,如windows系统的虚拟内存、Linux系统的交换空间等等。Windows的虚拟内存(pagefile.sys)是磁盘空间的一部分。
在32位的系统上,虚拟地址空间大小是2 ^ 32 = 4G。在64位系统上,最大虚拟地址空间理论上是2 ^ 64 = 1024 * 1024 TB,实际上没有用到64位,因为用不到那么大的空间,而且会造成很大的系统开销。Linux一般用低48位来表示虚拟地址空间,也就是2 ^ 48=256TB。
cat /proc/cpuinfo
address sizes : 42 bits physical, 48 bits virtual
实际的物理内存可能远远小于虚拟内存的大小。
总结:引入虚拟内存的作用:
- 1、通过把同一块物理内存映射到不同的虚拟地址空间实现内存共享
- 2、对物理内存进行隔离,不同的进程操作互不影响
- 3、虚拟内存可以提供更大的地址空间,并且地址空间是连续的,使得程序编写、链接更加简单。
(2)用户空间和内核空间
Linux/GNU的虚拟内存又进一步划分成了两块:一部分是内核空间(Kernel-space),一部分是用户空间(User-space)。
在Linux系统中,虚拟地址布局如下:
这两块空间的区别是什么呢?
进程的用户空间存放的是用户程序的代码和数据,内核空间中存放的是内核代码和数据。不管是内核空间还是用户空间,它们都处于虚拟内存空间中,都是对物理地址的映射。
当进程运行在内核空间时就处于内核态,而进程运行在用户空间时就处于用户态。
进程在内核空间可以访问受保护的内存空间,也可以访问底层硬件设备。也就是可以执行任意命令,调用系统的一切资源。在用户空间只能执行简单的运算,不能直接调用系统资源,必须通过系统接口(又称system call),才能向内核发出指令。
所以,这样划分的目的是为了避免用户进程直接操作内核,保证内核安全。
top命令:
us表示CPU消耗在User space的时间百分比;
sy表示CPU消耗在Kernel space的时间百分比。
2、单线程
按照正常思路来讲,要实现这么高的并发性能,多线程理论上来说比单线程的性能要好很多,为什么Redis要用单线程?
这里说的单线程其实是指处理客户端的请求是单线程的,可以把它叫做主线程。从4.0版本之后,还引入了一些线程处理其他的事情,比如清理脏数据、无用连接的释放、大key的删除等等。
把处理请求的主线程设置为单线程有什么好处呢?
- 没有创建线程、销毁线程带来的消耗
- 避免了上下文切换导致的CPU消耗
- 避免了线程之间带来的锁竞争问题
Redis使用单线程确实有很多好处,但是不会白白浪费了多核CPU资源吗?
官方是这样解释的:
在Redis中单线程已经够用了,CPU不是redis的瓶颈。Redis的瓶颈最有可能是机器内存或者网络带宽。既然单线程容易实现,又不需要处理线程并发的问题,那就顺理成章地采用单线程的方案了。
注意,因为请求处理是单线程的,不要在生产环境运行长命令,比如keys、flushall、flushdb,否则会导致请求被阻塞。
(1)进程切换(上下文切换)
多任务操作系统是怎么实现运行远大于CPU数量的任务个数的?当然,这些任务实际上并不是真的在同时运行,而是因为系统通过时间片分片算法,在很短的时间内,将CPU轮流分配给它们,造成多任务同时运行的错觉。
在这个交替运行的过程里面,为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,以及恢复以前挂起的某个进程的执行。这种行为被称为进程切换。
什么叫上下文(Context)?
在每个任务运行前,CPU都需要知道任务从哪里加载、又从哪里开始运行。也就是说,需要系统事先帮它设置好CPU寄存器和程序计数器(Program Counter),这个叫做CPU的上下文。
而保存下来的上下文,会存储在系统内核
中,并在任务重新调度执行时再次加载进来。这样就能保证任务原来的状态不受影响,让任务看起来还是连续运行。
在切换上下文的时候,需要完成一系列的工作,这是一个很消耗资源的操作。
(2)进程的阻塞
正在运行的进程由于提出系统服务请求(如IO操作),但因为某种原因未得到操作系统的立即响应,该进程只能把自己变成阻塞状态,等待相应的事件出现后才被唤醒。
进程在阻塞状态不占用CPU资源。
(3)文件描述符 FD
Linux系统将所有设备都当做文件来处理,而Linux用文件描述符来标识每个文件对象。
文件描述符(File Descriptor)是内核为了高效管理已被打开的文件所创建的索引,用于指向被打开的文件,所有执行IO操作的系统调用都通过文件描述符。
文件描述符是一个简单的非负整数,用以表明每个被进程打开的文件。Linux系统里面有三个标准文件描述符:0,标准输入(键盘);1,标准输出(显示器);2,标准错误输出(显示器)。
3、同步非阻塞IO
Redis使用了同步非阻塞IO,多路复用机制处理并发连接。
(1)传统IO数据拷贝
以读操作为例:
当应用程序执行read系统调用读取文件描述符(FD)的时候,如果这块数据已经存在于用户进程的页内存中,就直接从内存中读取数据。如果数据不存在,则先将数据从磁盘加载数据到内核缓冲区中,再从内核缓冲区拷贝到用户进程的页内存中。(两次拷贝,两次user和kernel的上下文切换)。
IO阻塞到底阻塞在哪里?一目了然。
(2)Blocking IO
当使用read或write对某个文件描述符进行过读写时,如果当前FD不可读,系统就不会对其他的操作做出响应。从硬件设备复制数据到内核缓冲区是阻塞的,从内核缓冲区拷贝到用户空间,也是阻塞的,直到copy complete,内核返回结果,用户进程才解除block的状态。
为了解决阻塞的问题,我们有几个思路:
1、在服务端创建多个线程或者使用线程池——但是在高并发的情况下需要的线程会很多,系统无法承受,而且创建和释放线程都需要消耗资源。
2、由请求方定期轮询,在数据准备完毕后再从内核缓存缓冲期复制数据到用户空间(非阻塞式IO),这种方式会存在一定的延迟。
(3)IO多路复用(IO Multiplexing)
能不能用一个线程处理多个客户端请求?答案是肯定的。
IO指的就是网络IO,多路指的多个TCP连接(Socket或Channel),复用指的是复用一个或多个线程。
它的基本原理就是不再由应用程序自己监视连接,而是由内核替应用程序监视文件描述符。
客户端在操作的时候,会产生具有不同事件类型的socket。在服务端,IO多路复用程序(IO Multiplexing Module)会把消息放入队列中,然后通过文件事件分派器(File event Dispatcher),转发到不同的事件处理器中。
多路复用有很多的实现,以select为例,当用户进程调用了多路复用器,进程会被阻塞。内核会监视多路复用器负责的所有socket,当任何一个socket的数据准备好了,多路复用器就会返回。这时候用户进程再调用read操作,把数据从内核缓冲期拷贝到用户空间。
所以,IO多路复用的特点是通过一种机制让一个进程能同时等待多个文件描述符,而这些文件描述符其中的任意一个进入读就绪(readable)状态,select()函数就可以返回。
多路复用需要操作系统的支持。Redis的多路复用,提供了select,epoll,evport,kqueue几种选择,在编译的时候来选择一种。源码ae.c:
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
#ifdef HAVE_EPOLL
#include "ae_epoll.c"
#else
#ifdef HAVE_KQUEUE
#include "ae_kqueue.c"
#else
#include "ae_select.c"
#endif
#endif
#endif
evport是Solaris系统内核提供支持的;
epoll是Linux系统内核提供支持的;
kqueue是Mac系统提供支持的;
select是POSIX提供支持的,一般的操作系统都有支撑(保底方案);
源码:ae_evport.c、ae_epoll.c、ae_kqueue.c、ae_select.c
总结一下:
Redis抽象了一套AE事件模型,将IO事件和时间时间融入一起,同时借助多路复用机制的回调特性(Linux上用epoll),似的IO读写都是非阻塞的,实现高性能的网络处理能力。
我们一直在说的Redis新版本多线程的 特性,意思并不是服务端接收客户端请求变成多线程的了,它还是单线程的。
严格意义上来说,Redis从4.0之后就引入了多线程用来处理一些耗时长的工作和后台工作,那不然的话,如果真的只有一个线程,那些耗时的操作肯定会导致客户端请求被阻塞。我们这里说的多线程,确切的说,叫做多线程IO。
(4)多线程IO
回到多路复用的图,服务端的数据返回给客户端,需要从内核空间copy数据到用户空间,然后回写到socket(write调用),这个过程使非常耗时的。所以多线程IO指的就是把结果写到socket的这个环境是多线程的。处理请求依然是单线程的,所以不存在线程并发安全问题。
(5)select和epoll的区别
select:进程可以通过把一个或者多个 fd 传递给 select 系统调用,进程会阻塞在 select 操作上,这样 select 可以帮我们检测多个 fd 是否处于就绪状态。
这个模式有二个缺点
1.由于他能够同时监听多个文件描述符,假如说有 1000 个,这个时候如果其中一个 fd 处于就绪状态了,那么当前进程需要线性轮询所有的 fd,也就是监听的 fd 越多,性能开销越大。
2.同时,select 在单个进程中能打开的 fd 是有限制的,默认是 1024,对于那些需要支持单机上万的 TCP 连接来说确实有点少。
epoll:linux 还提供了 epoll 的系统调用,epoll 是基于事件驱动方式来代替顺序扫描,因此性能相对来说更高,主要原理是,当被监听的 fd 中,有 fd 就绪时,会告知当前进程具体哪一个 fd 就绪,那么当前进程只需要去从指定的 fd 上读取数据即可。
另外,epoll 所能支持的 fd 上线是操作系统的最大文件句柄,这个数字要远远大于 1024。
【由于 epoll 能够通过事件告知应用进程哪个 fd 是可读的,所以我们也称这种 IO 为异步非阻塞 IO,当然它是伪异步的,因为它还需要去把数据从内核同步复制到用户空间中,真正的异步非阻塞,应该是数据已经完全准备好了,我只需要从用户空间读就行】