前 言
目前公司一些工具会远程调用一些API,这些API调用有两个比较显著特点。
1、消耗时间比较长,无论是报表调用的API,还是 backend ws API 单次调用平均达到20 s 左右。
2、返回来的数据有时也会比较大,我见过单次调用返回的数据有可能有3MB 左右。
基于上述特点,很显然有优化的空间,当然这个优化的方法很多,比如目前公司在服务器端采用诸如 k8s 这种按需扩容方案,是能够大大提升整个远程API的调用效率,又比如对服务器端代码进行优化等等。
但由于本人日常工作中只接触API 在客户端的调用,所以本文探讨会在以下前提下进行探讨:
1、基于不改变API服务器代码。
2、只讨论客户端发出请求,服务器端返回数据这一段,对于客户端后续获取数据的处理不在本文探讨范围。
3、客户端远程调用 100次 API。
4、服务器端对于每次调用响应时间为 10s 。
5、目前API 服务器,我是使用自己搭建的http 服务器来测试。
传统调用模式
单线程调用模式:
public static void singleThread() { try { Date startDate = new Date(); for(int i=0;i<100;i++) { String apiUrl = "http://127.0.0.1:6668/"; HttpClient1.sendPost(apiUrl, ""); } Date endDate = new Date(); long interval = (endDate.getTime() - startDate.getTime())/1000; SimpleDateFormat timeFomat=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:ms"); System.out.println("singleThread startDate="+timeFomat.format(startDate)+" endDate="+timeFomat.format(endDate)+" diff="+interval+"秒"); }catch(Exception ex) { ex.printStackTrace(System.out); } }
单线程模式总耗时 = 100次 *10s = 1000s 约等于 17分钟。
多线程调用模式(Future模式+线程池【3个线程】):
1 public static void multiThread() throws InterruptedException, ExecutionException { 2 Date startDate = new Date(); 3 int count = 100; 4 5 List<FutureTask<String>> lstFuture = new ArrayList<FutureTask<String>>(); 6 ExecutorService es = Executors.newFixedThreadPool(3); 7 8 for (int i = 0; i < count; i++) { 9 FutureTask<String> future = new FutureTask<String>(new Task()); 10 es.submit(future);// 异步阻塞,调用后会立即返回【非阻塞】在这里体现 11 lstFuture.add(future); 12 } 13 14 for(int i=0;i<count;i++) { 15 lstFuture.get(i).get();// 等到所有的结果返回,【同步】在这里体现 16 } 17 18 Date endDate = new Date(); 19 long interval = (endDate.getTime() - startDate.getTime())/1000; 20 SimpleDateFormat timeFomat=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:ms"); 21 System.out.println("multiThread Task total count="+count+ " startDate="+timeFomat.format(startDate)+" endDate="+timeFomat.format(endDate)+" diff="+interval+"秒"); 22 System.exit(-1); 23 } 24 25
该模式由于启用了3个线程同时进行,所以是单线程总花费时间的三分之一,单线程总耗时1000s, 则多线程模式总耗时 = 1000s /3 = 333s 约等于 6分钟。
NIO 调用模式
以上是基于公司目前所广泛使用的远程API方式,接下来会基于NIO 方式来探讨客户端对远程API调用。为了方便较系统来探讨NIO 技术,会按照以下提纲进行编排。
1、网络基础知识概览
2、NIO 核心讲述
3、基于HttpAsyncClients 实现API远程调用
4、基于Netty 4 实现API远程调用
网络基础知识概览
在常见的web 应用程序里面,都是基于不同的协议来实现,例如我们常用的邮件发送,是基于SMTP,FTP工具上传当然就是基于FTP协议,浏览器是基于HTTP协议等等。
无论是哪种协议,最终都会被抽象成Socket 来进行网络操作,如下图所示。为什么要在这里强调Socket 是因为NIO 是基于Socket 一些列操作的抽象。
Socket 常处理事件:
OP_CONNECT 事件:主要是客户端连接服务端时候触发。
OP_ACCEPT 事件:主要客户端发出连接请求后,与服务器端3次握手成功后,触发事件。这个时候往往意味着客户与服务器链路已经完成连接成功,可以进行下一步通信。
OP_READ 事件:无论是客户端还是服务器端,只要Socket 检测到缓冲区有可以读写的数据就会触发。
OP_WRITE 事件:主要是检测内核缓冲区是否已经满,如果没有满则代表缓冲区还可以继续写入数据,会触发该事件,否则不会触发。
NIO核心讲述
NIO 有说是non-blocking IO 缩写,也有说是New IO 缩写,在这里不纠结这个问题,按照我的理解NIO 核心思想就是 "多路复用"。为了说清楚 多路复用 我们先看一个传统的HTTP 服务器架构实现。
从图中看出传统的HTTP服务器模型,最大的特点就是每一个客户端(Socket)连接 在服务器端都开启一个新的线程(ps:在实际中用线程池实现),这种感觉就好像餐厅为每个新客户都配置一个服务员,
实际是没有必要。因为服务员(类似线程)一般只会在客户 点菜、传菜、结账(类似Socket 事件)需要提供服务。其他大部分事件是不需要服务员。所以NIO 就是一个线程(服务员)可以同时负责成千上百个
Socket(客户)。 所谓的“多路复用”就体现在这里 多路其实指多个客户端(Socket),复用 指的是重复使用相同的线程来负责多个客户端(Socket)事件监听。如下图所示,使用NIO模型,一个线程就可以
负责成千上百个客户,相比较传统的HTTP模型一个线程负责一个Socket相比,是能极大提升服务器响应效率这也是各大电商所广泛采用的模型。
以下是非常经典简单的NIO模型核心代码实现
1 SelectionKey acceptKey = ssc.register(selector, SelectionKey.OP_ACCEPT);// 注册OP_ACCEPT事件 2 for (;;) { // 循环遍历各个Socket事件 3 selector.select(); 4 Set readyKeys = selector.selectedKeys(); 5 Iterator i = readyKeys.iterator(); 6 long e=0; 7 while (i.hasNext()) { // 捕获到某些Socket事件 8 SelectionKey sk = (SelectionKey) i.next(); 9 i.remove(); 10 11 if (sk.isAcceptable()) { // 处理 OP_ACCEPT事件 12 doAccept(sk); 13 } 14 else if (sk.isValid() && sk.isReadable()) {//处理 OP_READ事件 15 if(!time_stat.containsKey(((SocketChannel)sk.channel()).socket())) 16 time_stat.put(((SocketChannel)sk.channel()).socket(), 17 System.currentTimeMillis()); 18 doRead(sk); 19 } 20 else if (sk.isValid() && sk.isWritable()) {//处理 OP_WRITE事件 21 doWrite(sk); 22 e=System.currentTimeMillis(); 23 long b=time_stat.remove(((SocketChannel)sk.channel()).socket()); 24 System.out.println("spend:"+(e-b)+"ms"); 25 } 26 }
以上为了讲述方便,只是列举了最简单的NIO实现模型,实际上诸如比较成熟的NIO实现框架,例如后面说的HttpAsyncClients 和 Netty 4.0 模型如下图所示,由于篇幅关系在这里就不对这个模型展开讲,
后续如果有需要会补充上来。初步计划放在 《基于NIO实现公司API Http服务器》 这篇文章来说。
基于HttpAsyncClients 实现API远程调用
异步阻塞模型,这个实现原理实际上是 Future模式+NIO+线程(ps:实际上是包含了两种线程,一种是专门用来处理OP_ACCEPT 事件,一种是专门用来处理OP_READ 和 OP_WRITE 事件)具体调用代码如下:
另外这种方式耗时为341秒左右。要比传统多出10秒左右,为什么用这种方式反而更慢? 这个统一在后面回答。
1 public static void main(String[] args){ 2 int count = 100; 3 Date startDate = new Date(); 4 try { 5 RequestConfig requestConfig = RequestConfig.custom() 6 .build(); 7 8 IOReactorConfig ioReactorConfig = IOReactorConfig.custom(). 9 setIoThreadCount(1)// 设定Woker 线程池数量,系统默认是CPU核数 10 .setSoKeepAlive(true) 11 .build(); 12 13 //设置连接池大小 14 ConnectingIOReactor ioReactor=null; 15 try { 16 ioReactor = new DefaultConnectingIOReactor(ioReactorConfig); 17 } catch (IOReactorException e) { 18 e.printStackTrace(); 19 } 20 PoolingNHttpClientConnectionManager connManager = new PoolingNHttpClientConnectionManager(ioReactor); 21 connManager.setMaxTotal(150);//最大连接数设置15 22 connManager.setDefaultMaxPerRoute(3);//每次默认发送3个连接,也就是一次性发送3个socket连接。 23 24 final CloseableHttpAsyncClient client = HttpAsyncClients.custom(). 25 setConnectionManager(connManager) 26 .setDefaultRequestConfig(requestConfig) 27 .build(); 28 client.start(); 29 30 List<Future<HttpResponse>> respList = new LinkedList<Future<HttpResponse>>(); 31 final HttpGet request = new HttpGet("http://127.0.0.1:6668/"); 32 request.setHeader("Connection", "close"); 33 for (int i = 0; i < count; i++) { 34 respList.add(client.execute(request, null)); // 使用Future模式提交请求 35 } 36 37 int index = 0; 38 BufferedInputStream bufferedInput = null; 39 byte[] buffer = new byte[1024]; 40 41 for (Future<HttpResponse> response : respList) { 42 index = index+1; 43 HttpResponse resp = response.get();// 等待任务完成 44 InputStream input = resp.getEntity().getContent(); 45 bufferedInput = new BufferedInputStream(input); 46 int bytesRead = 0; 47 while ((bytesRead = bufferedInput.read(buffer)) != -1) { 48 String chunk = new String(buffer, 0, bytesRead); 49 System.out.println("index="+index+" "+chunk); 50 } 51 } 52 53 client.close(); 54 }catch(Exception ex) { 55 ex.printStackTrace(System.err); 56 }finally{ 57 Date endDate = new Date(); 58 long interval = (endDate.getTime() - startDate.getTime())/1000; 59 SimpleDateFormat timeFomat=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:ms"); 60 System.out.println("TestHttpClient Total count="+count+" "+"startDate="+timeFomat.format(startDate)+" endDate="+timeFomat.format(endDate)+" diff="+interval+"秒"); 61 } 62 }
基于Netty 4 实现API远程调用
其实这种方式实现原理底层也是基于NIO,与HttpAsyncClients 相比,Netty 客户端这里允许只用1个线程,而HttpAsyncClients 至少需要2个线程,因为系统后台必须默认一个专门用来处理OP_ACCEPT线程。
如果只是单纯实现客户端调用,然后远程服务器返回数据。我认为只需要1个线程就可以,所以才会提这种方式。这种方式耗时为331秒与传统的线程池模式差不多,具体实现核心代码如下:
1 public class HttpClient { 2 public void run() throws Exception { 3 Date startDate = new Date(); 4 int count = 33;// 发送33次 5 int perOfPath = 3;// 每次模拟发送3个连接 6 EventLoopGroup group = new NioEventLoopGroup(1); // 设定一个线程作为轮询Socket事件 7 try { 8 Bootstrap client = new Bootstrap(); 9 client.group(group).channel(NioSocketChannel.class) 10 .handler(new ChannelInitializer<SocketChannel>() { 11 @Override 12 protected void initChannel(SocketChannel socketChannel) throws Exception { 13 socketChannel.pipeline().addLast(new HttpResponseDecoder()); 14 socketChannel.pipeline().addLast(new HttpRequestEncoder()); 15 socketChannel.pipeline().addLast(new HttpClientHandler()); 16 } 17 }); 18 19 for (int i = 0; i < count; i++) { 20 List<ChannelFuture> channelFutures = new ArrayList<ChannelFuture>(); 21 for(int j=0;j<perOfPath;j++) { 22 channelFutures.add(client.connect(HostInfo.HOST_NAME, HostInfo.PORT)); 23 } 24 25 for(int j=0;j<perOfPath;j++) { 26 channelFutures.get(j).channel().closeFuture().sync(); 27 } 28 } 29 } finally { 30 Date endDate = new Date(); 31 long interval = (endDate.getTime() - startDate.getTime())/1000; 32 SimpleDateFormat timeFomat=new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:ms"); 33 System.out.println("Task total count="+count*perOfPath+ " startDate="+timeFomat.format(startDate)+" endDate="+timeFomat.format(endDate)+" diff="+interval+"秒"); 34 System.out.println("--group.shutdownGracefully"); 35 group.shutdownGracefully(); 36 System.exit(1); 37 } 38 }
上面就是几种远程调用API方式,从目前来看用NIO 实现远程调用 与 传统的多线程调用耗时差不多,甚至基于HttpAsyncClients 比多线程模式还要慢10秒,这是因为我为了与多线程方式调用统一,目前多线程是用
了3个线程,也就是一次可以模拟发送3个请求。为了统一NIO 这种调用方式每次也只是模拟发送3个请求。实际上我们可以根据API 服务器处理能力来设定请求数,比如API服务器每次能同事处理100个连接,NIO 就可以
把每次请求变成100,或许有人就说了,使用多线程方式调用,也可以开100个线程来达到同样的效果。基于这个就得出今天的结论,使用NIO 方式调用的优点就是 用最小的线程数实现对API服务器最大程度调用。
以上就是使用NIO对客户端调用的优化,相比较客户端优化,也许对API服务器端进行优化意义更大一些,毕竟对于整个系统而言服务器的QPS 或者说TPS 才是最重要的效率考量,对于服务器端也同样可以基于NIO
这种方式来优化,对于服务器返回数据量比较大问题,也有一系列的数据传输压缩方案,比如Google Protocol Buffer 。