1、序言:

        一提到微服务,大家马上想到微服务定义、微服务架构风格、各种微服务框架,如SpringCloud、Dubbo,ServiceMesh等。

        本文从另一个角度,即服务全链路访问路径的角度,详细描述微服务架构中,从客户端发起请求到服务端接收、处理请求,并返回处理结果给客户端的整个访问路径,及相关应用服务器内部的线程处理模型、服务的线程处理模型等。了解服务的完整处理过程,洞悉服务由哪些线程处理,这些线程处于什么状态,有助于服务的全链路监控、优化、错误排查等。

        最近,笔者有幸参与了某银行的一个采用微服务架构风格的项目,实践了一下微服务架构。此次就以这个项目为基本例子,部分内容稍作修改。

        对于本文存在不适、错误的地方,希望各位及时地指出,我将积极的采纳和修改。

2、整体逻辑部署架构

        本项目整体逻辑部署方案如下:

全链路业务监控预算大约多少 全链路服务是什么_全链路业务监控预算大约多少

        上图红色连线描述了从浏览器调用一个服务的全链路访问路径,忽略掉运营商的网络设备、数据中心的网络设置的各种路由,只关注应用相关的处理部分,则主要涉及以下几个部分:

        1、域名解析;

        2、流量路由网关:

        在异地多活部署方案中使用,实现根据不同的路由策略对访问流量进行路由,此处可以忽略;

        3、API网关:

        通过API网关,接入公共渠道,在网关上实现通讯协议解析,安全认证,流量控制,数据转换,协议转换等功能。API网关自主研发,基于Netty4实现,此次只关注Netty的线程模型。

        4、应用服务器:

        为了便于说明问题,描述开发环境采用的Tomcat的线程模型。对于其它应用服务器,读者自行查阅相关资料。

        5、服务内部处理:

        由于部分组件内部采用了Java自身的ThreadPoolExecutor,所以此次描述了Java线程池内部结构。

        在此,再次强调一下,本文只是我对于微服务架构设计过程中,对于一个服务请求处理过程中,所涉及到的线程进行一个剖析,以便自己可以快速的监控服务、发现问题、解决问题,所以本文描述的内容以线程模型为主。

3、域名解析

        域名解析过程如下图所示:




全链路业务监控预算大约多少 全链路服务是什么_线程模型_02



        1、在浏览器中输入服务访问地址,如http://www.xxx.com/app/getUsers.json,域名为www.xxx.com,操作系统会先检查自己本地的hosts文件是否有这个网址映射关系,如果有,就先调用这个IP地址映射,完成域名解析。

        2、如果hosts里没有这个域名的映射,则查找本地DNS解析器缓存,是否有这个网址映射关系,如果有,直接返回,完成域名解析。

        3、如果hosts与本地DNS解析器缓存都没有相应的网址映射关系,首先会找TCP/IP参数中设置的首选DNS服务器,在此我们叫它本地DNS服务器,此服务器收到查询时,如果要查询的域名,包含在本地配置区域资源中,则返回解析结果给客户机,完成域名解析,此解析具有权威性。

        4、如果要查询的域名,不由本地DNS服务器区域解析,但该服务器已缓存了此网址映射关系,则调用这个IP地址映射,完成域名解析,此解析不具有权威性。

        5、如果本地DNS服务器本地区域文件与缓存解析都失效,则根据本地DNS服务器的设置(是否设置转发器)进行查询,如果未用转发模式,本地DNS就把请求发至13台根DNS,根DNS服务器收到请求后会判断这个域名(.com)是谁来授权管理,并会返回一个负责该顶级域名服务器的一个IP。本地DNS服务器收到IP信息后,将会联系负责.com域的这台服务器。这台负责.com域的服务器收到请求后,如果自己无法解析,它就会找一个管理.com域的下一级DNS服务器地址(http://xxx.com)给本地DNS服务器。当本地DNS服务器收到这个地址后,就会找http://xxx.com域服务器,重复上面的动作,进行查询,直至找到www .xxx.com主机。

        6、如果用的是转发模式,此DNS服务器就会把请求转发至上一级DNS服务器,由上一级服务器进行解析,上一级服务器如果不能解析,或找根DNS或把转请求转至上上级,以此循环。不管是本地DNS服务器用是是转发,还是根提示,最后都是把结果返回给本地DNS服务器,由此DNS服务器再返回给客户机。

        从客户端到本地DNS服务器是属于递归查询,而DNS服务器之间就是的交互查询就是迭代查询。

        对于复杂的应用,尤其是跨地域跨运营商的大型应用,则需要更加复杂的全局负载均衡机制,因而需要专门的设备或者服务器来做这件事情,这就是GSLB,全局负载均衡器。

4、操作系统

        以Linux操作系统为例,优化Linux下高并发socket最大连接数所受的各种限制;

        Linux内核优化:

        1、修改用户进程可同时打开文件数限制;

        2、修改网络内核对TCP连接的有关限制:

            2.1、修改Linux网络内核对本地端口号范围有限制;

            2.2、修改Linux网络内核的IP_TABLE防火墙对最大跟踪的TCP连接数有限制;

        3、使用支持高并发网络I/O的编程技术:尽量使用epoll或AIO技术来实现并发的TCP连接上的I/O控制;


5、Netty线程模型

        Netty的线程模型如下图所示:

全链路业务监控预算大约多少 全链路服务是什么_线程模型_03

        Netty采用了Reactor模式。Netty基于Multiple Reactors模式做了一定的修改,Mutilple Reactors模式有多个reactor:mainReactor和subReactor,其中mainReactor负责客户端的连接请求,并将请求转交给subReactor,由subReactor负责相应通道的IO请求,非IO请求(具体逻辑处理)的任务则会直接写入队列,等待worker threads进行处理。

        Netty服务器端处理过程:

        1、NioEventLoopGroup初始化:NIO事件循环处理器组,由多个NioEventLoop(NIO事件循环处理器)组成。其中,每个NioEventLoop由一个执行线程和一个任务队列组成。线程负责执行IO任务和非IO任务。IO任务即selectionKey中ready的事件,譬如accept、connect、read、write等;非IO任务则为添加到taskQueue中的任务,譬如register0、bind、channelActive等任务。每个NioEventLoop持有一个Nio的Selector,它是SelectableChannel对象的多路复用选择器,负责监听注册到它的channel。

      2、Boss线程启动过程:调用bind方法,监控指定端口。调用过程如下:bind(inetPort)-->doBind(localAddress)-->initAndRegister()-->group().register(channel, regPromise)方法中,从bossGroup中选择一个EventExecutor(它实际是一个SingleThreadEventLoop),然后执行register方法。next().register(channel, promise) -->channel.unsafe().register(this, promise)-->register0(promise) -->doRegister()-->electionKey = javaChannel().register(eventLoop().selector, 0, this),即把channel注册到Selector。

        至此,启动一个boss线程,监听注册到该线程所持有的Selector的channel上发生的accept事件。

        3、当有OP_ACCEPT事件到达时,分发给NioMessageUnsafe的read方法进行处理。

    NioEventLoop调用过程如下: run()-->processSelectedKeys()-->processSelectedKeysOptimized-->processSelectedKey(SelectionKey k, AbstractNioChannel ch)-->unsafe.read()-->doReadMessages(readBuf) -->buf.add(new NioSocketChannel(this, ch)),其中SocketChannel ch = javaChannel().accept();

        当doReadMessages执行完,调用pipeline.fireChannelRead(readBuf.get(i))。由于channelRead是Inbound事件,会调用ServerBootstrapAcceptor的channelRead方法。

        该方法首先child.pipeline().addLast(childHandler)将服务端main函数中实例化的ChannelInitializer加入到管道中,该处理器的initChannel方法会在channelRegistered事件触发时被调用。

        然后设置NioSocketchannel的一些属性。

        最后进行注册:childGroup.register(child)。 这里采用的是childGroup,即worker线程池所在的Group,从Group中选择一个NioEventLoop,并启动其持有的worker线程,执行register0任务。

        register0任务做的事情是:将socketChannal注册到selector中,触发channelRegistered事件,调用ChannelInitializer的initChannel方法将main函数中设置的处理器(譬如:EchoServerHandler)加入到管道中,然后触发channelActive事件,最后里面触发read事件,将ops设置为read。

        到此,worker线程所属的NioEventLoop持有的selector就开始监听socketChannel的read事件了。

        4、当有OP_READ事件到达时,分发给NioByteUnsafe的read方法进行处理。

    NioEventLoop调用过程如下:unsafe.read()-->byteBuf = allocHandle.allocate(allocator)--> pipeline.fireChannelRead(byteBuf)-->pipeline.fireChannelReadComplete()。

        由于Netty是事件驱动的,通过ChannelHandler链来控制执行流向。因为ChannelHandler链的执行过程是在worker线程中同步执行的,所以如果业务处理handler耗时长,将严重影响可支持的并发数。针对这种情况,Netty提供了良好的可扩展性,通过在ChannelPipeline 中添加 Netty 内置的 ChannelHandler 实现类ExecutionHandler 实现ChannelHandler 线程池化。

        对于 ExecutionHandler 需要的线程池模型, Netty 提供了两种可 选:

        1)MemoryAwareThreadPoolExecutor 可控制 Executor 中待处理任务的上限( 超过上限时,后续进来的任务将被阻 塞),并可控制单个Channel 待处理任务的上限;

        2)OrderedMemoryAwareThreadPoolExecutor 是MemoryAwareThreadPoolExecutor 的子类, 它还可以保证同一 Channel 中处理的事件流的顺序性, 这主要是控制事件在异步处 理模式下可能出现的错误的事件顺序, 但它并不保证同一 Channel 中的事件都在一个线程中执行。

6、Tomcat线程模型

        Tomcat NIO线程模型如下图所示:

全链路业务监控预算大约多少 全链路服务是什么_微服务_04

        Tomcat NIO主要由NioEndpoint实现。NioEndpoint由LimitLatch、Acceptor、Poller、Excutor四个部分。

        1、LimitLatch是连接控制器,它负责维护连接数的计算,NIO模式下默认是10000,达到这个阈值后,就会拒绝连接请求;

        2、Acceptor负责接收连接,默认是1个线程来执行,将请求的事件注册到事件列表;

        3、Poller来负责轮询,Poller线程数量是cpu的核数Math.min(2,Runtime.getRuntime().availableProcessors())。由Poller将就绪的事件生成SocketProcessor同时交给Excutor去执行;

        4、Excutor线程池的大小就是我们在Connector节点配置的maxThreads的值。在Excutor的线程中,会完成从socket中读取http request,解析成HttpServletRequest对象,分派到相应的servlet并完成逻辑,然后将response通过socket发回client。在从socket中读数据和往socket中写数据的过程,并没有像典型的非阻塞的NIO的那样,注册OP_READ或OP_WRITE事件到主Selector,而是直接通过socket完成读写,这时是阻塞完成的,但是在timeout控制上,使用了NIO的Selector机制,但是这个Selector并不是Poller线程维护的主Selector,而是BlockPoller线程中维护的Selector,称之为辅Selector;

        Tomcat NIO处理过程:

启动接收线程,Acceptor工作在阻塞模式,多个线程调用同一个ServerSocketChannel的accept方法监听指定的端口,accept方法线程安全(synchronized);

        2. ServerSocketChannel.accept()接收新连接;

通道设置为非阻塞;

构造NioChannel对象;

        5. 构造PollerEvent,并添加到事件队列;

        6. 依次轮询Poller,把PollerEvent置入Poller队列。PollerEvent实现了Runnable接口,由执行Poller的线程在events()方法中调用run方法直接执行,负责把Acceptor接收到的SocketChannel注册到Poller的Selector;

        7. Selector监听注册到它的SocketChannel的OP_READ事件;

根据选择的SelectionKey构造SocketProcessor,并提交到请求处理线程;

        9. Excutor线程池中的某个线程完成具体的业务处理;

7、线程池

        Java ThreadPoolExecutor内部结构:

全链路业务监控预算大约多少 全链路服务是什么_微服务_05

        ThreadPoolExecutor是Executor执行框架最重要的一个实现类,提供了线程池管理和任务管理是两个最基本的能力。ThreadPoolExecutor是一个基于生产者消费者模型的执行器。

        生产者消费者模型包含三个角色:生产者,工作队列,消费者。对于ThreadPoolExecutor来说:

         1. 生产者是任务的提交者,是外部调用ThreadPoolExecutor的线程;

         2. 工作队列是一个阻塞队列的接口,具体的实现类可以有很多种;

         3. 消费者是封装了线程的Worker类的集合;

       1、线程池状态:

        一个32位的原子整形作为线程池的状态控制描述符。低29位作为工作者线程的数量。所以工作者线程最多有2^29 -1个。高3位来保持线程池的状态。ThreadPoolExecutor总共有5种状态:

           *  RUNNING:  可以接受新任务并执行;
           *  SHUTDOWN: 不再接受新任务,但是仍然执行工作队列中的任务;
           *   STOP:     不再接受新任务,不执行工作队列中的任务,并且中断正在执行的任务;
           *   TIDYING:  所有任务被终止,工作线程的数量为0,会去执行terminated()钩子方法;
           *   TERMINATED: terminated()执行结束;

        2、工作线程创建和回收策略

        ThreadPoolExecutor通过corePoolSize,maximumPoolSize,allowCoreThreadTimeOut,keepAliveTime等几个参数提供一个灵活的工作线程创建和回收的策略。

        创建策略:

        1. 当工作线程数量小于corePoolSize时,不管其他线程是否空闲,都创建新的工作线程来处理新加入的任务

        2. 当工作线程数量大于corePoolSize,小于maximumPoolSize时,只有当工作队列满了,才会创建新的工作线程来处理新加入的任务。当工作队列有空余时,只把新任务加入队列

        3. 把corePoolSize和maximumPoolSize 设置成相同的值时,线程池就是一个固定(fixed)工作线程数的线程。

        回收策略:

        1. keepAliveTime变量设置了空闲工作线程超时的时间,当工作线程数量超过了corePoolSize后,空闲的工作线程等待超过了keepAliveTime后,会被回收。后面会说怎么确定一个工作线程是否“空闲”。

        2. 如果设置了allowCoreThreadTimeOut,那么core Thread也可以被回收,即当core thread也空闲时,也可以被回收,直到工作线程集合为0。

        3、工作队列策略

        工作队列BlockingQueue<Runnable>workQueue 是用来存放提交的任务的。它有4个基本的策略,并且根据不同的阻塞队列的实现类可以引入更多的工作队列的策略。4个基本策略:

        1. 当工作线程数量小于corePoolSize时,新提交的任务总是会由新创建的工作线程执行,不入队列;

        2. 当工作线程数量大于corePoolSize,如果工作队列没满,新提交的任务就入队列;

        3. 当工作线程数量大于corePoolSize,小于MaximumPoolSize时,如果工作队列满了,新提交的任务就交给新创建的工作线程,不入队列;

        4. 当工作线程数量大于MaximumPoolSize,并且工作队列满了,那么新提交的任务会被拒绝执行。具体看采用何种拒绝策略;

        根据不同的阻塞队列的实现类,又有几种额外的策略:

     1. 采用SynchronousQueue直接将任务传递给空闲的线程执行,不额外存储任务。这种方式需要无限制的MaximumPoolSize,可以创建无限制的工作线程来处理提交的任务。这种方式的好处是任务可以很快被执行,适用于任务到达时间大于任务处理时间的情况。缺点是当任务量很大时,会占用大量线程;

        2. 采用无边界的工作队列LinkedBlockingQueue。这种情况下,由于工作队列永远不会满,那么工作线程的数量最大就是corePoolSize,因为当工作线程数量达到corePoolSize时,只有工作队列满的时候才会创建新的工作线程。这种方式好处是使用的线程数量是稳定的,当内存足够大时,可以处理足够多的请求。缺点是如果任务直接有依赖,很有可能形成死锁,因为当工作线程被消耗完时,不会创建新的工作现场,只会把任务加入工作队列。并且可能由于内存耗尽引发内存溢出OOM;

        3. 采用有界的工作队列AraayBlockingQueue。这种情况下对于内存资源是可控的,但是需要合理调节MaximumPoolSize和工作队列的长度,这两个值是相互影响的。当工作队列长度比较小的时,必定会创建更多的线程。而更多的线程会引起上下文切换等额外的消耗。当工作队列大,MaximumPoolSize小的时候,会影响吞吐量,并且会触发拒绝机制。

        4、拒绝执行策略

        当Executor处于shutdown状态或者工作线程超过MaximumPoolSize并且工作队列满了之后,新提交的任务将会被拒绝执行。RejectedExecutionHandler接口定义了拒绝执行的策略。具体的策略有:

        CallerRunsPolicy:由调用者线程来执行被拒绝的任务,属于同步执行;

        AbortPolicy:中止执行,抛出RejectedExecutionException异常;

        DiscardPolicy:丢弃任务;

        DiscardOldestPolicy:丢弃最老的任务;

        5、工作线程Worker的设计

        工作线程没有直接使用Thread,而是采用了Worker类封装了Thread,目的是更好地进行中断控制。

        Worker直接继承了AbstractQueuedSynchronizer来进行同步操作,它实现了一个不可重入的互斥结构。当它的state属性为0时表示unlock,state为1时表示lock。任务执行时必须在lock状态的保护下,防止出现同步问题。因此当Worker处于lock状态时,表示它正在运行,当它处于unlock状态时,表示它“空闲”。当它空闲超过keepAliveTime时,就有可能被回收。

        Worker还实现了Runnable接口, 执行它的线程是Worker包含的Thread对象,在Worker的构造函数可以看到Thread创建时,把Worker对象传递给了它。

        Worker被它的线程执行时,run方法调用了ThreadPoolExecutor的runWorker方法。

        1. wt指向当前执行Worker的run方法的线程,也就是指向了Worker包含的工作线程对象。

        2. task指向Worker包含的firstTask对象,表示当前要执行的任务。

        3. 当task不为null或者从工作队列中取到了新任务,那么先加锁w.lock表示正在运行任务。在真正开始执行task.run()之前,先判断线程池的状态是否已经STOP,如果是,就中断Worker的线程。

        4. 一旦判断当前线程不是STOP并且工作线程没有中断。那么就开始执行task.run()了。Worker的interruptIfStarted方法可以中断这个Worker的线程,从而中断正在执行任务。