Redis的线程模型
一、描述
Redis是一个高性能的NoSql数据存储工具,在高并发的系统设计中,Redis基本都是必不可少的,是我们提升系统性能的一大利器。深入理解Redis高性能的原理还是很重要,当然Redis的高性能设计是一个系统性的工程,涉及到很多内容,本文重点关注Redis的IO模型,以及基于IO模型的线程模型。
我们从IO的起源开始,讲述了阻塞IO、非阻塞IO、多路复用IO。基于多路复用IO,我们也梳理了几种不同的Reactor模型,并分析了几种Reactor模型的优缺点。基于Reactor模型我们开始了Redis的IO模型和线程模型的分析,并总结出Redis线程模型的优点、缺点,以及后续的Redis多线程模型方案。本文的重点是对Redis线程模型设计思想的梳理,捋顺了设计思想,就是一通百通的事了。
二、网络IO模型
我们常说的网络IO模型,主要包含阻塞IO、非阻塞IO、多路复用IO、信号驱动IO、异步IO,本文重点关注跟Redis相关的内容,所以我们重点分析阻塞IO、非阻塞IO、多路复用IO,帮助大家后续更好的理解Redis网络模型。
2.1 阻塞IO
我们经常说的阻塞IO其实分为两种,一种是单线程阻塞,一种是多线程阻塞。这里面其实有两个概念,阻塞和线程。
阻塞:指调用结果返回之前,当前线程会被挂起,调用线程只有在得到结果之后才会返回;
线程:系统调用的线程个数。
像建立连接、读、写都涉及到系统调用,本身是一个阻塞的操作。
-
2.1.1 单线程阻塞
服务端单线程来处理,当客户端请求来临时,服务端用主线程来处理连接、读取、写入等操作。
比如下例代码:
import java.net.Socket; public class BioTest { public static void main(String[] args) throws IOException { ServerSocket server=new ServerSocket(8081); while(true) { Socket socket=server.accept(); System.out.println("accept port:"+socket.getPort()); BufferedReader in=new BufferedReader(new InputStreamReader(socket.getInputStream())); String inData=null; try { while ((inData = in.readLine()) != null) { System.out.println("client port:"+socket.getPort()); System.out.println("input data:"+inData); if("close".equals(inData)) { socket.close(); } } } catch (IOException e) { e.printStackTrace(); } finally { try { socket.close(); } catch (IOException e) { e.printStackTrace(); } } } } }
我们准备用两个客户端同时发起连接请求、来模拟单线程阻塞模式的现象。同时发起连接,通过服务端日志,我们发现此时服务端只接受了其中一个连接,主线程被阻塞在上一个连接的read方法上。
要是我把第一个链接关闭后,按理来说主线程要返回,然后第二个客户端要被接受。
从日志中发现,在第一个连接被关闭后,第二个连接的请求被处理了,也就是说第二个连接请求在排队,直到主线程被唤醒,才能接收下一个请求,符合我们的预期。
这是因为什么呢?主要原因在于 accept、read、write三个函数都是阻塞的,主线程在系统调用的时候,线程是被阻塞的,其他客户端的连接无法被响应
在这种情况中,服务器每次只能处理一个连接请求,CPU没有充分利用,性能比较低。
-
2.1.2 多线程阻塞
package net.io.bio; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.net.ServerSocket; import java.net.Socket; public class BioTest { public static void main(String[] args) throws IOException { final ServerSocket server=new ServerSocket(8081); while(true) { new Thread(new Runnable() { public void run() { Socket socket=null; try { socket = server.accept(); System.out.println("accept port:"+socket.getPort()); BufferedReader in=new BufferedReader(new InputStreamReader(socket.getInputStream())); String inData=null; while ((inData = in.readLine()) != null) { System.out.println("client port:"+socket.getPort()); System.out.println("input data:"+inData); if("close".equals(inData)) { socket.close(); } } } catch (IOException e) { e.printStackTrace(); } finally { } } }).start(); } } }
然后一样的发起两个请求
-
两个请求都能接受,很明显 服务端新增了两个线程来处理客户端的连接请求。
但是这种模型就没有弊端嘛?也是有的。要是客户端连接很多的时候,服务端会创建大量的线程来处理请求。线程是很耗资源的,创建、上下文切换等,这种就会导致资源的大量消耗。
2.2 非阻塞
如果我们把所有的Socket都放到队列里,只用一个线程来轮训所有的Socket的状态,如果准备好了就把它拿出来,是不是就减少了服务端的线程数呢?
package net.io.bio; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.net.ServerSocket; import java.net.Socket; import java.net.SocketTimeoutException; import java.util.ArrayList; import java.util.List; import org.apache.commons.collections4.CollectionUtils; public class NioTest { public static void main(String[] args) throws IOException { final ServerSocket server=new ServerSocket(8082); server.setSoTimeout(1000); List<Socket> sock ets=new ArrayList<Socket>(); while (true) { Socket socket = null; try { socket = server.accept(); socket.setSoTimeout(500); sockets.add(socket); System.out.println("accept client port:"+socket.getPort()); } catch (SocketTimeoutException e) { System.out.println("accept timeout"); } //模拟非阻塞:轮询已连接的socket,每个socket等待10MS,有数据就处理,无数据就返回,继续轮询 if(CollectionUtils.isNotEmpty(sockets)) { for(Socket socketTemp:sockets ) { try { BufferedReader in=new BufferedReader(new InputStreamReader(socketTemp.getInputStream())); String inData=null; while ((inData = in.readLine()) != null) { System.out.println("input data client port:"+socketTemp.getPort()); System.out.println("input data client port:"+socketTemp.getPort() +"data:"+inData); if("close".equals(inData)) { socketTemp.close(); } } } catch (SocketTimeoutException e) { System.out.println("input client loop"+socketTemp.getPort()); } } } } } }
发起2个客户端连接,线程就会开始轮询两个连接中是否有数据。当轮询线程的时候发现有数据了时候就会开始处理。
服务端专门有一个线程来负责轮询所有的Socket,来确认操作系统是否完成了相关事件,如果有则返回处理,如果无则继续轮询,那么就会出现:CPU的空转、系统调用(每次轮询到涉及到一次系统调用,通过内核命令来确认数据是否准备好),造成资源的浪费,那有没有一种机制,来解决这个问题呢?
2.3 IO多路复用
import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.SelectionKey; import java.nio.channels.Selector; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.nio.charset.Charset; import java.util.Iterator; import java.util.Set; public class NioServer { private static Charset charset = Charset.forName("UTF-8"); public static void main(String[] args) { try { Selector selector = Selector.open(); ServerSocketChannel chanel = ServerSocketChannel.open(); chanel.bind(new InetSocketAddress(8083)); chanel.configureBlocking(false); chanel.register(selector, SelectionKey.OP_ACCEPT); while (true){ int select = selector.select(); if(select == 0){ System.out.println("select loop"); continue; } System.out.println("os data ok"); Set<SelectionKey> selectionKeys = selector.selectedKeys(); Iterator<SelectionKey> iterator = selectionKeys.iterator(); while (iterator.hasNext()){ SelectionKey selectionKey = iterator.next(); if(selectionKey.isAcceptable()){ ServerSocketChannel server = (ServerSocketChannel)selectionKey.channel(); SocketChannel client = server.accept(); client.configureBlocking(false); client.register(selector, SelectionKey.OP_READ); //继续可以接收连接事件 selectionKey.interestOps(SelectionKey.OP_ACCEPT); }else if(selectionKey.isReadable()){ //得到SocketChannel SocketChannel client = (SocketChannel)selectionKey.channel(); //定义缓冲区 ByteBuffer buffer = ByteBuffer.allocate(1024); StringBuilder content = new StringBuilder(); while (client.read(buffer) > 0){ buffer.flip(); content.append(charset.decode(buffer)); } System.out.println("client port:"+client.getRemoteAddress().toString()+",input data: "+content.toString()); //清空缓冲区 buffer.clear(); } iterator.remove(); } } } catch (Exception e) { e.printStackTrace(); } } }
创建2个客户端进行连接,会发现跟多线程阻塞一样,同时被创建被读写请求。
当然操作系统的多路复用有好几种实现方式,我们经常使用的select(),epoll模式这里不做过多的解释,有兴趣的可以查看相关文档,IO的发展后面还有异步、事件等模式,我们在这里不过多的赘述,我们更多的是为了解释Redis线程模式的发展。
三、NIO线程模型
Redis 采用的是IO多路复用模式。
-
Reactor:类似NIO编程中的Selector,负责I/O事件的派发;
-
Acceptor:NIO中接收到事件后,处理连接的那个分支逻辑;
-
Handler:消息读写处理等操作类。
3.1 单Reactor单线程
处理流程
Reactor监听连接事件、Socket事件,当有连接事件过来时交给Acceptor处理,当有Socket事件过来时交个对应的Handler处理。
优点
-
模型比较简单,所有的处理过程都在一个连接里;
-
实现上比较容易,模块功能也比较解耦,Reactor负责多路复用和事件分发处理,Acceptor负责连接事件处理,Handler负责Scoket读写事件处理。
缺点
-
只有一个线程,连接处理和业务处理共用一个线程,无法充分利用CPU多核的优势。
-
在流量不是特别大、业务处理比较快的时候系统可以有很好的表现,当流量比较大、读写事件比较耗时情况下,容易导致系统出现性能瓶颈。
既然业务处理逻辑可能会影响系统瓶颈,那我们是不是可以把业务处理逻辑单拎出来,交给线程池来处理,一方面减小对主线程的影响,另一方面利用CPU多核的优势。
3.2 单Reactor多线程
处理方式跟 单Reactor单线程 没什么不一样,只是将业务逻辑交给线程池处理。
处理流程
-
Reactor监听连接事件、Socket事件,当有连接事件过来时交给Acceptor处理,当有Socket事件过来时交个对应的Handler处理。
-
Handler完成读事件后,包装成一个任务对象,交给线程池来处理,把业务处理逻辑交给其他线程来处理。
优点
-
让主线程专注于通用事件的处理(连接、读、写),从设计上进一步解耦;
-
利用CPU多核的优势。
缺点
-
貌似这种模型已经很完美了,我们再思考下,如果客户端很多、流量特别大的时候,通用事件的处理(读、写)也可能会成为主线程的瓶颈,因为每次读、写操作都涉及系统调用。
那针对这种缺点,客户端很多,流量很大的情况下,怎么办?
3.3 多Reactor多线程
这种模型相对单Reactor多线程模型,只是将Scoket的读写处理从mainReactor中拎出来,交给subReactor线程来处理。
处理流程
-
mainReactor主线程负责连接事件的监听和处理,当Acceptor处理完连接过程后,主线程将连接分配给subReactor;
-
subReactor负责mainReactor分配过来的Socket的监听和处理,当有Socket事件过来时交个对应的Handler处理;
-
Handler完成读事件后,包装成一个任务对象,交给线程池来处理,把业务处理逻辑交给其他线程来处理。
优点
-
让主线程专注于连接事件的处理,子线程专注于读写事件吹,从设计上进一步解耦;
-
利用CPU多核的优势。
缺点
-
实现上会比较复杂,在极度追求单机性能的场景中可以考虑使用
四、Redis的线程模型
4.1 描述
Redis服务器中有两类事件,文件事件和时间事件。
-
文件事件:在这里可以把文件理解为Socket相关的事件,比如连接、读、写等;
-
时间时间:可以理解为定时任务事件,比如一些定期的RDB持久化操作。
IO多路复用负责各事件的监听(连接、读、写等),当有事件发生时,将对应事件放入队列中,由事件分发器根据事件类型来进行分发;
如果是连接事件,则分发至连接应答处理器;GET、SET等redis命令分发至命令请求处理器。
命令处理完后产生命令回复事件,再由事件队列,到事件分发器,到命令回复处理器,回复客户端响应。
4.2 客户端和服务端的交互流程
4.3.1 连接流程
连接过程
-
Redis服务端主线程监听固定端口,并将连接事件绑定连接应答处理器。
-
客户端发起连接后,连接事件被触发,IO多路复用程序将连接事件包装好后丢人事件队列,然后由事件分发处理器分发给连接应答处理器。
-
连接应答处理器创建client对象以及Socket对象,我们这里关注Socket对象,并产生ae_readable事件,和命令处理器关联,标识后续该Socket对可读事件感兴趣,也就是开始接收客户端的命令操作。
-
当前过程都是由一个主线程负责处理。
4.2.2 命令执行流程
SET命令执行过程
-
客户端发起SET命令,IO多路复用程序监听到该事件后(读事件),将数据包装成事件丢到事件队列中(事件在上个流程中绑定了命令请求处理器);
-
事件分发处理器根据事件类型,将事件分发给对应的命令请求处理器;
-
命令请求处理器,读取Socket中的数据,执行命令,然后产生ae_writable事件,并绑定命令回复处理器;
-
IO多路复用程序监听到写事件后,将数据包装成事件丢到事件队列中,事件分发处理器根据事件类型分发至命令回复处理器;
-
命令回复处理器,将数据写入Socket中返回给客户端。
4.3 优缺点
Redis采用的是单线程Reactor模型,但是没有充分利用CPU特性,要等待主线程处理完在接受下一次请求,为啥Redis会采用这个模式呢
Redis本身的特性
-
命令执行基于内存操作,业务处理逻辑比较快,所以命令处理这一块单线程来做也能维持一个很高的性能。
弊端 Reactor单线程模型的缺点也同样在Redis中来体现,唯一不同的地方就在于业务逻辑处理(命令执行)这块不是系统瓶颈点。
随着流量的上涨,IO操作的的耗时会越来越明显(read操作,内核中读数据到应用程序。write操作,应用程序中的数据到内核),当达到一定阀值时系统的瓶颈就体现出来了。
4.4 Redis多线程模式
Redis的多线程模型跟”多Reactor多线程模型“、“单Reactor多线程模型有点区别”,但同时用了两种Reactor模型的思想,具体如下;
-
Redis的多线程模型是将IO操作多线程化,本身逻辑处理过程(命令执行过程)依旧是单线程,借助了单Reactor思想,实现上又有所区分。
-
将IO操作多线程化,又跟单Reactor衍生出多Reactor的思想一致,都是将IO操作从主线程中拎出来。
命令执行大致流程
-
客户端发送请求命令,触发读就绪事件,服务端主线程将Socket(为了简化理解成本,统一用Socket来代表连接)放入一个队列,主线程不负责读;
-
IO 线程通过Socket读取客户端的请求命令,主线程忙轮询,等待所有 I/O 线程完成读取任务,IO线程只负责读不负责执行命令;
-
主线程一次性执行所有命令,执行过程和单线程一样,然后需要返回的连接放入另外一个队列中,有IO线程来负责写出(主线程也会写);
-
主线程忙轮询,等待所有 I/O 线程完成写出任务。