深入理解redis——Redis快的原因和IO多路复用深度解析

1.Redis是单线程的还是多线程的?

2.Redis性能很快的原因

3.Redis的瓶颈在哪里

4.I/O多路复用模型理论

5.I/O多路复用模型JAVA验证

6.Redis如何处理并发客户端链接

7.Linux内核函数select, poll, epoll解析

8.总结

1.Redis是单线程的还是多线程的?

当我们在学习和使用redis的时候,大家口耳相传的,说的都是redis是单线程的。其实这种说法并不严谨,Redis的版本有非常多,3.x、4.x、6.x,版本不同架构也是不同的,不限定版本问是否单线程不太严谨。
1)版本3.x,最早版本,也就是大家口口相传的redis是单线程。
2)版本4.x,严格意义上来说也不是单线程,而是负责客户端处理请求的线程是单线程,但是开始加了点多线程的东西(异步删除
3)最新版本的6.0.x后,告别了刻板印象中的单线程,而采用了一种全新的多线程来解决问题。

image.png

所以我们可以得出,Redis 6.0版本以后,对于整个redis来说,是多线程的。

Redis是单线程到多线程的演变:

在以前,Redis的网络IO和键值对的读写都是由一个线程来完成的,Redis的请求时包括获取(Socket读),解析,执行,内容返回(socket写)等都是由一个顺序串行的主线程处理,这就是所谓的单线程

image.png

但此时,Redis的瓶颈就出现了
I/O的读写本身是阻塞的,比如当socket中有数据的时候,Redis会通过调用先将数据从内核态空间拷贝到用户态空间,再交给Redis调用,而这个拷贝的过程就是阻塞的,当数据量越大时拷贝所需要的时间就越多,而这些操作都是基于单线程完成的。

在Redis6.0中新增加了多线程的功能来提高I/O读写性能,他的主要实现思路是将主线程的IO读写任务拆分给一组独立的线程去执行,这样就可以使多个socket的读写并行化了,采用I/O多路复用技术可以让单个线程高效处理多个连接请求(尽量减少网络IO的时间消耗),将最耗时的socket读取,请求解析,写入单独外包出去,剩下的命令执行任然是由主线程串行执行和内存的数据交互。

image.png

结合上图可知,网络IO操作就变成多线程化了,其他核心部分仍然是线程安全的,是个不错的折中办法。

流程图:

image.png

流程简述:
1)主线程负责接收建立链接请求,获取socket放入全局等待读取队列
2)主线程处理完读事件后,将这些连接分配给这些IO线程
3)主线程阻塞等待IO线程读取socket完毕
4)IO线程组收集各个socket输入进来的命令,收集完毕
5)主线解除阻塞,程执行命令,执行完毕后输出结果数据
6)主线程阻塞等待 IO 线程将数据回写 socket 完毕
7)解除绑定,清空等待队列

2.Redis性能很快的原因

1)基于内存操作,Redis的所有数据都存在内存中,因此所有的运算都是内存级别的,所以他的性能比较高。
2)数据结构简单:Redis的数据结构都是专门设计的,而这些简单的数据结构的查找和操作的时间大部分复杂度都是O(1),因此性能比较强,可以参考我之前写过的这篇文章。深入理解redis——redis经典五种数据类型及底层实现
3)多路复用和非阻塞I/O,Redis使用I/O多路复用来监听多个socket链接客户端,这样就可以使用一个线程链接来处理多个请求,减少线程切换带来的开销,同时也避免了I/O阻塞操作。
4)主线程为单线程,避免上下文切换,因为是单线程模型,因此避免了不必要的上下文切换和多线程竞争(比如锁),这就省去了多线程切换带来的时间和性能上的消耗,而且单线程不会导致死锁问题的发生。

3.Redis的瓶颈在哪里?

说了这么多redis的优点,redis的性能瓶颈到底在哪里

从cpu上看:
1)redis是基于内存的,因此减少了cpu将数据从磁盘复制到内存的时间
2)redis是单线程的,因此减少了多线程切换和恢复上下文的时间
3)redis是单线程的,因此多核cpu和单核cpu对于redis来说没有太大影响,单个线程的执行使用一个cpu即可

综上所述,redis并没有太多受到cpu的限制,所以cpu大概率不会成为redis的瓶颈。

内存大小和网络IO才有可能是redis的瓶颈。

内存大小我们很好理解,那么为什么说网络IO是Redis的瓶颈呢

瓶颈一网络带宽瓶颈

在Redis中,命令的请求和数据的响应都是通过网络带宽传输的,而每个请求和响应都是一个二进制数据包,所以要传输的数量还是比较大的。如果请求和响应大到一定程度,就会一定程度造成请求和响应的阻塞。

瓶颈二连接瓶颈

在Redis中,所有客户端都是通过socket连接到服务器的,而每个连接都需要一个线程处理。在早起的Redis版本中,当有大量连接的时候,服务器可能就会因为繁忙而出现请求暂停,因为处理连接的线程可能会被全部耗尽。

所以Redis使用了I/O多路复用模型,来优化redis。

4.I/O多路复用模型理论
在学习IO多路复用之前,我们先明白下面的几个词语的概念:

1)同步:调用者要一直等待调用结果的通知后,才能继续执行,类似于串行执行程序,执行完毕后返回。

2)异步:指被调用者先返回应答让调用者先回去,然后计算调用结果,计算完成后,再以回调的方式通知给调用方。

同步,异步的侧重点,在于被调用者,重点在于获得调用结果的通知方式上。

3)阻塞:调用方一直在等,且其它别的事情都不做,当前线程会被挂起,啥都不干

4)非阻塞:调用在发出去之后,调用方先去干别的事情,不会阻塞当前线程,而会立刻返回。

阻塞,非阻塞的侧重点,在于调用者在等待消息的时候的行为调用者能否干其它的事。

以上的IO可以组合成4种组合方式:同步阻塞,同步非阻塞,异步阻塞,异步非阻塞

同时也就衍生出了unix网络编程中的五种模型:

image.png

接下来我们用代码进行验证

5.I/O多路复用模型JAVA验证

BIO(阻塞IO):

我们来看这样一段代码:

假设我们现在有一个服务器,监听6379端口,让别人来链接,我们用这样的方式来写代码:

public class RedisServer
{
    public static void main(String[] args) throws IOException
    {
        byte[] bytes = new byte[1024];
        //监听6379端口
        ServerSocket serverSocket = new ServerSocket(6379);
        while(true)
        {
            System.out.println("模拟RedisServer启动-----111 等待连接");
            Socket socket = serverSocket.accept();
            System.out.println("-----222 成功连接");
            System.out.println();
        }
    }
}

客户端:

public class RedisClient01
{
    public static void main(String[] args) throws IOException
    {
        System.out.println("------RedisClient01 start");
        Socket socket = new Socket("127.0.0.1", 6379);

    }
}

上面这个模型存在很大的问题,如果客户端与服务端建立了链接,如果上面这个链接的客户端迟迟不发数据,线程就会一直阻塞在read()方法上,这样其它客户端也不能进行链接,也就是一个服务器只能处理一个客户端,对客户很不友好,我们需要升级:

利用多线程:
只要连接了一个socket,操作系统就会分配一个线程来处理,这样read()方法堵在每个具体线程上而不阻塞主线程。

public class RedisServerBIOMultiThread
{
    public static void main(String[] args) throws IOException
    {
        ServerSocket serverSocket = new ServerSocket(6379);

        while(true)
        {
            Socket socket = serverSocket.accept();//阻塞1 ,等待客户端连接
            //每次都开一个线程
            new Thread(() -> {
                try {
                    InputStream inputStream = socket.getInputStream();
                    int length = -1;
                    byte[] bytes = new byte[1024];
                    System.out.println("-----333 等待读取");
                    while((length = inputStream.read(bytes)) != -1)//阻塞2 ,等待客户端发送数据
                    {
                        System.out.println("-----444 成功读取"+new String(bytes,0,length));
                        System.out.println("====================");
                        System.out.println();
                    }
                    inputStream.close();
                    socket.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            },Thread.currentThread().getName()).start();

            System.out.println(Thread.currentThread().getName());

        }
    }
}

存在的问题:
每来一个客户端,就要开辟一个线程,在操作系统中用户态不能直接开辟线程,需要调用内核来创建一个线程,其中还涉及到用户态的切换(上下文的切换),十分消耗资源。

解决办法:
1)使用线程池,在链接少的情况下可以使用,但是在用户量大的情况下,你不知道线程池要多大,太大了内存可能不够,也不可行。
2)NIO方式,这就引出了我们的NIO

NIO(非阻塞IO):
在NIO模式中,一切都是非阻塞的:
accept()方法是非阻塞的,如果没有客户端连接,就返回error
read()方法是非阻塞的,如果read()方法读取不到数据就返回error,如果读取到数据时只阻塞read()方法读数据的时间

在NIO模式中,只有一个进程:
当一个客户端与服务器进行链接,这个socket就会加入到一个数组中,隔一段时间遍历一次,这样一个线程就能处理多个客户端的链接和数据了。

public class RedisServerNIO
{
    //将所有socket加入到这个数组中
    static ArrayList socketList = new ArrayList<>();
    static ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

    public static void main(String[] args) throws IOException
    {
        System.out.println("---------RedisServerNIO 启动等待中......");
        ServerSocketChannel serverSocket = ServerSocketChannel.open();
        serverSocket.bind(new InetSocketAddress("127.0.0.1",6379));
        serverSocket.configureBlocking(false);//设置为非阻塞模式

        while (true)
        {
            //遍历所有数组
            for (SocketChannel element : socketList)
            {
                int read = element.read(byteBuffer);
                if(read > 0)
                {
                    System.out.println("-----读取数据: "+read);
                    byteBuffer.flip();
                    byte[] bytes = new byte[read];
                    byteBuffer.get(bytes);
                    System.out.println(new String(bytes));
                    byteBuffer.clear();
                }
            }

            SocketChannel socketChannel = serverSocket.accept();
            if(socketChannel != null)
            {
                System.out.println("-----成功连接: ");
                socketChannel.configureBlocking(false);//设置为非阻塞模式
                socketList.add(socketChannel);
                System.out.println("-----socketList size: "+socketList.size());
            }
        }
    }
}

NIO成功解决了BIO需要开启多线程的问题,NIO中一个线程就能解决多个socket,但还是存在两个问题:
问题一:对cpu不友好
如果客户端非常多的话,每次遍历都要非常多的socket,很多都是无效遍历

问题二:线程上下文切换
这个遍历是在用户态进行的,用户态判断socket是否有数据还是调用内核的read()方法实现的,这就会涉及到用户态和内核态的切换,每遍历一个就要切换一次,开销非常大。

优点不会阻塞在内核的等待数据过程,每次发起的IO请求可以立刻返回,不用阻塞等待,实时性好。

缺点会不断询问内核,占用大量cpu时间,资源利用率低。

改进:让linux内核搞定上述需求,我们将一批文件描述符通过一次系统调用传给内核,由内核层去遍历,才能真正解决这个问题,IO多路复用应运而生就是将上述工作直接放进Linux内核,不再两态转换。

6.Redis如何处理并发客户端链接

我们先来看看redis是如何处理这么多客户端连接的

企业微信截图_16461881318128.png

Redis利用linux内核函数(下文会讲解)来实现IO多路复用,将连接信息和事件放到队列中,队列再放到事件派发器,事件派发器把事件分给事件处理器。

因为Redis主线程是跑在单线程里面的,所有操作都得按顺序执行,但是由于读写操作等待用户的数据输出都是阻塞的,IO在一般的情况下不能直接返回,这回导致某一文件的IO阻塞导致整个进程无法对其它客户端提供服务,而IO多路复用就是解决这个问题的

所谓的I/O多路复用,就是通过一种机制,让一个线程可以监视多个链接描述符,一旦某个描述符就绪(一般都是读就绪或者写就绪),就能够通知程序进行相应的读写操作。这种机制的使用需要select 、 poll 、 epoll 来配合。多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。

它的概念是:多个Socket链接复用一根网线,这个功能是在内核+驱动层实现的。

IO多路复用,也叫I/O multiplexing, 这里面的 multiplexing指的其实是在单个线程通过记录跟踪每一个sock(I/O流)的状态来同时管理多个IO流,目的是尽量多的提高服务器的吞吐能力。

image.png

image.png

7.Linux内核函数select, poll, epoll解析

select, poll, epoll 都是I/O多路复用的具体的实现。

select方法:

Linux官网源码:
image.png

select函数监视的文件描述符分3类,分别是readfds、writefds和exceptfds,将用户传入的数组拷贝到内核空间,调用select函数后会进行阻塞,直到有描述符就绪(有数据 可读,可写或者有except)或者超时(timeout指定等待时间,如果立即返回设为null即可),函数返回

当select函数返回后,可以通过遍历fdset,来找到就绪的描述符。

image.png

从代码中可以看出,select系统调用后,返回了一个位置后的&ret,这样用户态只需要很简单地进行二进制比较,就能很快知道哪些socket需要read数据,有效提高了效率。

优点:
select其实就是把NIO中用户态要遍历的fd数组(我们的每一个socket链接,安装进ArrayList里面的那个)拷贝到了内核态,让内核态来遍历,因为用户态判断socket是否有数据还是要调用内核态的,所有拷贝到内核态后,这样遍历判断的时候就不用一直用用户态和内核态频繁切换了。

缺点:
1)bitmap最大1024位,一个进程最多只能处理1024个客户端
2)&rset不可重用,每次socket有数据就相应的位会被置位
3)文件描述符数组拷贝到了内核态,任然有开销。select调用需要传入fd数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗资源是惊人的。(可优化为不复制)
4)select并没有通知用户态哪一个socket用户有数据,仍需要O(n)的遍历。select仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)

我们自己模拟写的是,RedisServerNIO.java,只不过将它内核化了。

poll方法:

看一下linux内核源码
image.png

image.png

优点:

1)poll使用pollfd数组来代替select中的bitmap,数组中没有1024的限制,去掉了select只能监听1024个文件描述符的限制。

2)当pollfds数组中有事件发生,相应revents置置为1,遍历的时候又置位回零,实现了pollfd数组的重用

缺点:
1、pollfds数组拷贝到了内核态,仍然有开销
2、poll并没有通知用户态哪一个socket有数据,仍然需要O(n)的遍历

epoll方法:

epoll操作过程需要三个接口:

epoll_create:创建一个 epoll 句柄
image.png

epoll_ctl:向内核添加、修改或删除要监控的文件描述符
image.png

epoll_wait:类似发起了select() 调用
image.png

image.png

epoll 是只轮询那些真正发出了事件的流,并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。 采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗),且 Redis 在内存中操作数据的速度非常快,也就是说内存内的操作不会成为影响Redis性能的瓶颈

为什么redis一定要部署在Linux机器上才能发挥出该性能?
因为只有linux有epoll函数,其它系统会自动降级成select函数。

image.png

三个方法对比:

select poll epoll
操作方式 遍历 遍历 回调
数据结构 bitmap 数组 红黑树
最大连接数 1024(x86)或者2048(x64) 无上限 无上限
最大支持文件描述符数 有最大值限制 65535 65535
fd拷贝 每次调用,都需要把fd结合从用户态拷贝到内核态 每次调用,都需要把fd结合从用户态拷贝到内核态 只有首次调用的时候拷贝
工作效率 每次都要遍历所有文件描述符,时间复杂度O(n) 每次都要遍历所有文件描述符,时间复杂度O(n) 每次只用遍历需要遍历的文件描述符,时间复杂度O(1)

8.总结

Redis快的一部分原因在于多路复用,多路复用快的原因是系统提供了这样的调用,使得原来的while循环里的多次系统调用变成了,一次系统调用+内核层遍历这些文件描述符。

image.png

你可能感兴趣的:(redis缓存)