5.0 实现自己的通信框架
实现自己的通信框架
一、通信框架功能设计
1.1 功能描述
通信框架承载了业务内部各模块之间的消息交互和服务调用,它的主要功能如下:
基于Netty的NIO通信框架,提供高性能的异步通信能力;
提供消息的编解码框架,可以实现POJO的序列化和反序列化;
消息内容的防篡改机制
提供基于IP地址的白名单接入认证机制;
链路的有效性校验机制;
链路的断连重连机制
1.2 通信模型
(1)客户端发送应用握手请求消息,携带节点ID等有效身份认证信息;
(2)服务端对应用握手请求消息进行合法性校验,包括节点ID有效性校验、节点重复登录校验和IP地址合法性校验,校验通过后,返回登录成功的应用握手应答消息;
(3)链路建立成功之后,客户端发送业务消息;
(4)链路成功之后,服务端发送心跳消息;
(5)链路建立成功之后,客户端发送心跳消息;
(6)链路建立成功之后,服务端发送业务消息;
(7)服务端退出时,服务端关闭连接,客户端感知对方关闭连接后,被动关闭客户端连接。
备注:需要指出的是,协议通信双方链路建立成功之后,双方可以进行全双工通信,无论客户端还是服务端,都可以主动发送请求消息给对方,通信方式可以是TWO WAY或者ONE WAY。双方之间的心跳采用Ping-Pong机制,当链路处于空闲状态时,客户端主动发送Ping消息给服务端,服务端接收到Ping消息后发送应答消息Pong给客户端,如果客户端连续发送N条Ping消息都没有接收到服务端返回的Pong消息,说明链路已经挂死或者对方处于异常状态,客户端主动关闭连接,间隔周期T后发起重连操作,直到重连成功。
1.3 消息定义
消息定义包含两部分:
消息头;消息体。
在消息的定义上,因为是同步处理模式,不考虑应答消息需要填入请求消息ID,所以消息头中只有一个消息的ID。如果要支持异步模式,则请求消息头和应答消息头最好分开设计,应答消息头中除了包括本消息的ID外,还应该包括请求消息ID,以方便请求消息的发送方根据请求消息ID做对应的业务处理。
消息体则支持Java对象类型的消息内容。
Netty消息定义表
名称 | 类型 | 长度 | 描述 |
---|---|---|---|
header | Header | 变长 | 消息头定义 |
body | Object | 变长 | 消息的内容 |
消息头定义(Header)
名称 | 类型 | 长度 | 描述 |
---|---|---|---|
md5 | String | 变长 | 消息体摘要,缺省MD5摘要 |
msgID | Long | 64 | 消息的ID |
Type | Byte | 8 | 0:业务请求消息 1:业务响应消息 2:业务one way消息 3:握手请求消息 4:握手应答消息 5:心跳请求消息 6:心跳应答消息 |
Priority | Byte | 8 | 消息优先级:0~255 |
Attachment | Map | 变长 | 可选字段,用于扩展消息头 |
1.4 链路的建立
客户端的说明如下:如果A节点需要调用B节点的服务,但是A和B之间还没有建立物理链路,则有调用方主动发起连接,此时,调用方为客户端,被调用方为服务端。
考虑到安全,链路建立需要通过基于Ip地址或者号段的黑白名单安全认证机制,作为样例,本协议使用基于IP地址的安全认证,如果有多个Ip,通过逗号进行分割。在实际的商用项目中,安全认证机制会更加严格,例如通过密钥对用户名和密码进行安全认证。
客户端与服务端链路建立成功之后,由客户端发送业务握手请求的认证消息,服务端接收到客户端的握手请求消息之后,如果IP校验通过,返回握手成功应答消息给客户端,应用层链路建立成功。握手应答消息中消息体为byte类型的结果,0:认证成功;-1认证失败;服务端关闭连接。
链路建立成功之后,客户端和服务端就可以互相发送业务消息了,在客户端和服务端的消息通信过程中,业务消息体的内容需要通过MD5进行摘要防篡改。
1.5 可靠性设计
1.5.1 心跳机制
在凌晨等业务低谷时段,如果发生网络闪断、连接被Hang住等问题时,由于没有业务消息,应用程序很难发现。到了白天业务高峰期时,会发生大量的网络通信失败,严重的会导致一段时间进程内无法处理业务消息。为了解决这个问题,在网络空闲时采用心跳机制来检测链路的互通性,一旦发现网络故障,立即关闭链路,主动重连。
当读或者写心跳消息发生I/O异常的时候,说明已经中断,此时需要立即关闭连接,如果是客户端,需要重新发起连接。如果是服务端,需要清空缓存的半包信息,等到客户端重连。
空闲的连接和超时
检测空闲连接以及超时对于及时释放资源来说是至关重要的。由于这是一项常见的任务,Netty 特地为它提供了几个ChannelHandler 实现。
IdleStateHandler 当连接空闲时间太长时,将会触发一个IdleStateEvent 事件。然后,可以通过在ChannelInboundHandler 中重写userEventTriggered()方法来处理该IdleStateEvent 事件。
ReadTimeoutHandler 如果在指定的时间间隔内没有收到任何的入站数据,则抛出一个ReadTimeoutException 并关闭对应的Channel。可以通过重写你的ChannelHandler 中的exceptionCaught()方法来检测该Read-TimeoutException。
1.5.2 重连机制
如果链路中断,等到INTEVAL时间后,由客户端发起重连操作,如果重连失败,间隔周期INTERVAL后再次发起重连,直到重连成功。
为了保持服务端能够有充足的时间释放句柄资源,在首次断连时客户端需要等待INTERVAL时间之后再发起重连,而不是失败后立即重连。
为了保证句柄资源能够及时释放,无论什么场景下重连失败,客户端必须保证自身的资源被及时释放,包括但不现居SocketChannel、Socket等。
重连失败后,可以打印异常堆栈信息,方便后续的问题定位。
1.5.3 重复登录保护
当客户端握手成功之后,在链路处于正常状态下,不允许客户端重复登录,以防止客户端在异常状态下反复重连导致句柄资源被耗尽。
服务端接收到客户端的握手请求消息之后,对IP地址进行合法性校验,如果校验成功,在缓存的地址表中查看客户端是否已经登录,如果登录,则拒绝重复登录,同时关闭TCP链路,并在服务端的日志中打印握手失败的原因。
客户端接收到握手失败的应答消息之后,关闭客户端的TCP连接,等待INTERVAL时间之后,再次发起TCP连接,直到认证成功。
1.5.4 实现
参考 netty-adv模块下的代码
完成后Handler示意图如下:
其中认证申请和认证检查可以在完成后移除。
前期准备
cn.tuling.nettyadv.vo中定义了消息有关的实体类,为了防篡改,消息体需要进行摘要,vo包下提供了EncryptUtils类,可以对消息体进行摘要,目前支持MD5、SHA-1和SHA-256 这三种,缺省为MD5,其中MD5额外提供了加盐摘要。
同时在cn.tuling.nettyadv.kryocodec中定义了有关序列化和反序列化的工具类和Handler,本项目中序列化使用了Kryo序列化框架。
服务端
服务端中NettyServe类是服务端的主入口,内部使用了ServerInit类进行Handler的安装。
最先安装的当然是解决粘包和半包问题的Handler,很自然,这里应该用LengthFieldBasedFrameDecoder进行解码,为了实现方便,我们也没有在消息报文中附带消息的长度,由Netty帮我们在消息报文的最开始增加长度,所以编码器选择了LengthFieldPrepender。
接下来,自然就是序列化和反序列化,直接使用我们在kryocodec下已经准备好的KryoDecoder和KryoEncoder即可。
服务端需要进行登录检查、心跳应答、业务处理,对应着三个handler,于是我们分别安装了LoginAuthRespHandler、HeartBeatRespHandler、ServerBusiHandler。
为了节约网络和服务器资源,如果客户端长久没有发送业务和心跳报文,我们认为客户端出现了问题,需要关闭这个连接,我们引入Netty的ReadTimeoutHandler,当一定周期内(默认值50s,我们设定为15s)没有读取到对方任何消息时,会触发一个ReadTimeouttException,这时我们检测到这个异常,需要主动关闭链路,并清除客户端登录缓存信息,等待客户端重连。
客户端
客户端的主类是NettyClient,并对外提供一个方法send,供业务使用内部使用了ClientInit类进行Handler的安装。
最先安装的当然是解决粘包和半包问题的Handler,同样这里应该用LengthFieldBasedFrameDecoder进行解码,编码器选择了LengthFieldPrepender。
接下来,自然就是序列化和反序列化,依然使用KryoDecoder和KryoEncoder即可。
客户端需要主动发出认证请求和心跳请求。
在TCP三次握手,链路建立后,客户端需要进行应用层的握手认证,才能使用服务,这个功能由LoginAuthReqHandler负责,而这个Handler在认证通过后,其实就没用了,所以在认证通过后,可以将这个LoginAuthReqHandler移除(其实服务端的认证应答LoginAuthRespHandler同样也可以移除)。
对于发出心跳请求有两种实现方式,一是定时发出,本框架的第一个版本就是这种实现方式,但是这种方式其实有浪费的情况,因为如果客户端和服务器正在正常业务通信,其实是没有必要发送心跳的;所以第二种方式就是,当链路写空闲时,为了维持通道,避免服务器关闭链接,发出心跳请求。为了实现这一点,我们首先在整个pipeline的最前面安装一个CheckWriteIdleHandler进行写空闲检测,空闲时间定位8S,取服务器读空闲时间15S的一半,然后再安装一个HearBeatReqHandler,因为写空闲会触发一个FIRST_WRITER_IDLE_STATE_EVENT入站事件,我们在HearBeatReqHandler的userEventTriggered方法中捕捉这个事件,并发出心跳请求报文。
考虑到在我们的实现中并没有双向心跳(即是客户端向服务器发送心跳请求,是服务器也向客户端发送心跳请求),客户端这边同样需要检测服务器是否存活,所以我们客户端这边安装了一个ReadTimeoutHandler,捕捉ReadTimeoutException后提示调用者,并关闭通信链路,触发重连机制。
7、为了测试,单独建立一个BusiClient,模拟业务方的调用。因为客户端的网络通信代码是在一个线程中单独启动的,为了协调主线程和通信线程的工作,我们引入了线程中的等待通知机制。
测试
- 正常情况
- 客户端宕机,服务器应能清除客户端的缓存信息,允许客户端重新登录
- 服务器宕机,客户端应能发起重连
- 在LoginAuthRespHandler中进行注释,可以模拟当服务器不处理客户端的请求时,客户端在超时后重新进行登录。
功能的增强
作为一个通信框架,支持诊断也是很重要的,所以我们在服务端单独引入了一个MetricsHandler,可以提供:目前在线Channel数、发送队列积压消息数、读取速率、写出速率相关数据,以方便应用方对自己的应用的性能和繁忙程度进行检查和调整。
当然对于一个通信框架还可以提供SSL安全访问、流控、I/O线程和业务线程分离、参数的可配置化等等功能,我们就不一一展现了,同学们可以自行研究后实现,因为Netty对上述功能已经提供了很好的支持,大家后面要学习的Dubbo框架源码分析中基本都有对应的实现。
二、面试难题分析
2.1 Netty是如何解决JDK中的Selector BUG的?
Selector BUG:JDK NIO的BUG,例如臭名昭著的epoll bug,它会导致Selector空轮询,最终导致CPU 100%。官方声称在JDK1.6版本的update18修复了该问题,但是直到JDK1.7版本该问题仍旧存在,只不过该BUG发生概率降低了一些而已,它并没有被根本解决,甚至JDK1.8的131版本中依然存在。
JDK官方认为这是Linux Kernel 版本的bug,可以参见:
https://bugs.java.com/bugdatabase/view_bug.do?bug_id=6403933
https://bugs.java.com/bugdatabase/view_bug.do?bug_id=2147719
https://bugs.java.com/bugdatabase/view_bug.do?bug_id=6670302
https://bugs.java.com/bugdatabase/view_bug.do?bug_id=6481709
简单来说,JDK认为linux的epoll告诉我事件来了,但是JDK没有拿到任何事件(READ、WRITE、CONNECT、ACCPET),但此时select()方法不再选择阻塞了,而是选择返回了0,于是就会进入一种无限循环,导致CPU 100%。
这个问题的具体原因是:在部分Linux的2.6的kernel中,poll和epoll对于突然中断的连接socket会对返回的eventSet事件集合置为POLLHUP或POLLERR,eventSet事件集合发生了变化,这就可能导致Selector会被唤醒。但是这个时候selector的select方法返回numKeys是0,所以下面本应该对key值进行遍历的事件处理根本执行不了,又回到最上面的while(true)循环,循环往复,不断的轮询,直到linux系统出现100%的CPU情况,最终导致程序崩溃。
Netty解决办法:对Selector的select操作周期进行统计,每完成一次空的select操作进行一次计数,若在某个周期内连续发生N次空轮询,则触发了epoll死循环bug。重建Selector,判断是否是其他线程发起的重建请求,若不是则将原SocketChannel从旧的Selector上去除注册,重新注册到新的Selector上,并将原来的Selector关闭。
具体代码在NioEventLoop的select方法中:
2.2 如何让单机下Netty支持百万长连接?
单机下能不能让我们的网络应用支持百万连接?可以,但是有很多的工作要做。
操作系统
首先就是要突破操作系统的限制。
在Linux平台上,无论编写客户端程序还是服务端程序,在进行高并发TCP连接处理时,最高的并发数量都要受到系统对用户单一进程同时可打开文件数量的限制(这是因为系统为每个TCP连接都要创建一个socket句柄,每个socket句柄同时也是一个文件句柄)。
可使用ulimit命令查看系统允许当前用户进程打开的句柄数限制:
$ ulimit -n
1024
这表示当前用户的每个进程最多允许同时打开1024个句柄,这1024个句柄中还得除去每个进程必然打开的标准输入,标准输出,标准错误,服务器监听 socket,进程间通讯的unix域socket等文件,那么剩下的可用于客户端socket连接的文件数就只有大概1024-10=1014个左右。也就是说缺省情况下,基于Linux的通讯程序最多允许同时1014个TCP并发连接。
对于想支持更高数量的TCP并发连接的通讯处理程序,就必须修改Linux对当前用户的进程同时打开的文件数量。
修改单个进程打开最大文件数限制的最简单的办法就是使用ulimit命令:
$ ulimit –n 1000000
如果系统回显类似于"Operation not permitted"之类的话,说明上述限制修改失败,实际上是因为在中指定的数值超过了Linux系统对该用户打开文件数的软限制或硬限制。因此,就需要修改Linux系统对用户的关于打开文件数的软限制和硬限制。
软限制(soft limit):是指Linux在当前系统能够承受的范围内进一步限制一个进程同时打开的文件数;
硬限制(hardlimit):是根据系统硬件资源状况(主要是系统内存)计算出来的系统最多可同时打开的文件数量。
第一步,修改/etc/security/limits.conf文件,在文件中添加如下行:
* soft nofile 1000000
* hard nofile 1000000
'*'号表示修改所有用户的限制;
soft和hard为两种限制方式,其中soft表示警告的限制,hard表示真正限制,nofile表示打开的最大文件数。1000000则指定了想要修改的新的限制值,即最大打开文件数(请注意软限制值要小于或等于硬限制)。修改完后保存文件。
第二步,修改/etc/pam.d/login文件,在文件中添加如下行:
session required /lib/security/pam_limits.so
这是告诉Linux在用户完成系统登录后,应该调用pam_limits.so模块来设置系统对该用户可使用的各种资源数量的最大限制(包括用户可打开的最大文件数限制),而pam_limits.so模块就会从/etc/security/limits.conf文件中读取配置来设置这些限制值。修改完后保存此文件。
第三步,查看Linux系统级的最大打开文件数限制,使用如下命令:
[speng@as4 ~]$ cat /proc/sys/fs/file-max
12158
这表明这台Linux系统最多允许同时打开(即包含所有用户打开文件数总和)12158个文件,是Linux系统级硬限制,所有用户级的打开文件数限制都不应超过这个数值。如果没有特殊需要,不应该修改此限制,除非想为用户级打开文件数限制设置超过此限制的值。
如何修改这个系统最大文件描述符的限制呢?修改sysctl.conf文件
vi /etc/sysctl.conf
# 在末尾添加
fs.file_max = 1000000
# 立即生效
sysctl -p
2.3 Netty调优
设置合理的线程数
对于线程池的调优,主要集中在用于接收海量设备TCP连接、TLS握手的 Acceptor线程池( Netty通常叫 boss NioEventLoop Group)上,以及用于处理网络数据读写、心跳发送的1O工作线程池(Nety通常叫 work Nio EventLoop Group)上。
对于Nety服务端,通常只需要启动一个监听端口用于端侧设备接入即可,但是如果服务端集群实例比较少,甚至是单机(或者双机冷备)部署,在端侧设备在短时间内大量接入时,需要对服务端的监听方式和线程模型做优化,以满足短时间内(例如30s)百万级的端侧设备接入的需要。
服务端可以监听多个端口,利用主从 Reactor线程模型做接入优化,前端通过SLB做4层门7层负载均衡。
主从 Reactor线程模型特点如下:服务端用于接收客户端连接的不再是一个单独的NO线程,而是一个独立的NIO线程池; Acceptor接收到客户端TCP连接请求并处理后(可能包含接入认证等),将新创建的 Socketchanne注册到I/O线程池(subReactor线程池)的某个IO线程,由它负责 Socketchannel的读写和编解码工作; Acceptor线程池仅用于客户端的登录、握手和安全认证等,一旦链路建立成功,就将链路注册到后端 sub reactor线程池的IO线程,由IO线程负责后续的IO操作。
对于IO工作线程池的优化,可以先采用系统默认值(即CPU内核数×2)进行性能测试,在性能测试过程中采集IO线程的CPU占用大小,看是否存在瓶颈, 具体可以观察线程堆栈,如果连续采集几次进行对比,发现线程堆栈都停留在 Selectorlmpl. lockAndDoSelect,则说明IO线程比较空闲,无须对工作线程数做调整。
如果发现IO线程的热点停留在读或者写操作,或者停留在 Channelhandler的执行处,则可以通过适当调大 Nio EventLoop线程的个数来提升网络的读写性能。
心跳优化
针对海量设备接入的服务端,心跳优化策略如下。
-
要能够及时检测失效的连接,并将其剔除,防止无效的连接句柄积压,导致OOM等问题
-
设置合理的心跳周期,防止心跳定时任务积压,造成频繁的老年代GC(新生代和老年代都有导致STW的GC,不过耗时差异较大),导致应用暂停
-
使用Nety提供的链路空闲检测机制,不要自己创建定时任务线程池,加重系统的负担,以及增加潜在的并发安全问题。
当设备突然掉电、连接被防火墙挡住、长时间GC或者通信线程发生非预期异常时,会导致链路不可用且不易被及时发现。特别是如果异常发生在凌晨业务低谷期间,当早晨业务高峰期到来时,由于链路不可用会导致瞬间大批量业务失败或者超时,这将对系统的可靠性产生重大的威胁。
从技术层面看,要解决链路的可靠性问题,必须周期性地对链路进行有效性检测。目前最流行和通用的做法就是心跳检测。心跳检测机制分为三个层面:
-
TCP层的心跳检测,即TCP的 Keep-Alive机制,它的作用域是整个TCP协议栈。
-
协议层的心跳检测,主要存在于长连接协议中,例如MQTT。
-
应用层的心跳检测,它主要由各业务产品通过约定方式定时给对方发送心跳消息实现。
心跳检测的目的就是确认当前链路是否可用,对方是否活着并且能够正常接收和发送消息。作为高可靠的NIO框架,Nety也提供了心跳检测机制。
一般的心跳检测策略如下。
-
连续N次心跳检测都没有收到对方的Pong应答消息或者Ping请求消息,则认为链路已经发生逻辑失效,这被称为心跳超时。
-
在读取和发送心跳消息的时候如果直接发生了IO异常,说明链路已经失效,这被称为心跳失败。无论发生心跳超时还是心跳失败,都需要关闭链路,由客户端发起重连操作,保证链路能够恢复正常。
Nety提供了三种链路空闲检测机制,利用该机制可以轻松地实现心跳检测
-
读空闲,链路持续时间T没有读取到任何消息。
-
写空闲,链路持续时间T没有发送任何消息
-
读写空闲,链路持续时间T没有接收或者发送任何消息
对于百万级的服务器,一般不建议很长的心跳周期和超时时长。
接收和发送缓冲区调优
在一些场景下,端侧设备会周期性地上报数据和发送心跳,单个链路的消息收发量并不大,针对此类场景,可以通过调小TCP的接收和发送缓冲区来降低单个TCP连接的资源占用率
当然对于不同的应用场景,收发缓冲区的最优值可能不同,用户需要根据实际场景,结合性能测试数据进行针对性的调优
合理使用内存池
随着JVM虚拟机和JT即时编译技术的发展,对象的分配和回收是一个非常轻量级的工作。但是对于缓冲区 Buffer,情况却稍有不同,特别是堆外直接内存的分配和回收,是一个耗时的操作。
为了尽量重用缓冲区,Nety提供了基于内存池的缓冲区重用机制。
在百万级的情况下,需要为每个接入的端侧设备至少分配一个接收和发送ByteBuf缓冲区对象,采用传统的非池模式,每次消息读写都需要创建和释放 ByteBuf对象,如果有100万个连接,每秒上报一次数据或者心跳,就会有100万次/秒的 ByteBuf对象申请和释放,即便服务端的内存可以满足要求,GC的压力也会非常大。
以上问题最有效的解决方法就是使用内存池,每个 NioEventLoop线程处理N个链路,在线程内部,链路的处理是串行的。假如A链路首先被处理,它会创建接收缓冲区等对象,待解码完成,构造的POJO对象被封装成任务后投递到后台的线程池中执行,然后接收缓冲区会被释放,每条消息的接收和处理都会重复接收缓冲区的创建和释放。如果使用内存池,则当A链路接收到新的数据报时,从 NioEventLoop的内存池中申请空闲的 ByteBuf,解码后调用 release将 ByteBuf释放到内存池中,供后续的B链路使用。
Nety内存池从实现上可以分为两类:堆外直接内存和堆内存。由于 Byte Buf主要用于网络IO读写,因此采用堆外直接内存会减少一次从用户堆内存到内核态的字节数组拷贝,所以性能更高。由于 DirectByteBuf的创建成本比较高,因此如果使用 DirectByteBuf,则需要配合内存池使用,否则性价比可能还不如 Heap Byte。
Netty默认的IO读写操作采用的都是内存池的堆外直接内存模式,如果用户需要额外使用 ByteBuf,建议也采用内存池方式;如果不涉及网络IO操作(只是纯粹的内存操作),可以使用堆内存池,这样内存的创建效率会更高一些。
IO线程和业务线程分离
如果服务端不做复杂的业务逻辑操作,仅是简单的内存操作和消息转发,则可以通过调大 NioEventLoop工作线程池的方式,直接在IO线程中执行业务 Channelhandler,这样便减少了一次线程上下文切换,性能反而更高。
如果有复杂的业务逻辑操作,则建议IO线程和业务线程分离,对于IO线程,由于互相之间不存在锁竞争,可以创建一个大的 NioEvent Loop Group线程组,所有 Channel都共享同一个线程池。
对于后端的业务线程池,则建议创建多个小的业务线程池,线程池可以与IO线程绑定,这样既减少了锁竞争,又提升了后端的处理性能。
针对端侧并发连接数的流控
无论服务端的性能优化到多少,都需要考虑流控功能。当资源成为瓶颈,或者遇到端侧设备的大量接入,需要通过流控对系统做保护。流控的策略有很多种,比如针对端侧连接数的流控:
在Nety中,可以非常方便地实现流控功能:新增一个FlowControlchannelhandler,然后添加到 ChannelPipeline靠前的位置,覆盖 channelActive()方法,创建TCP链路后,执行流控逻辑,如果达到流控阈值,则拒绝该连接,调用 ChannelHandler Context的 close(方法关闭连接。
JVM层面相关性能优化
当客户端的并发连接数达到数十万或者数百万时,系统一个较小的抖动就会导致很严重的后果,例如服务端的GC,导致应用暂停(STW)的GC持续几秒,就会导致海量的客户端设备掉线或者消息积压,一旦系统恢复,会有海量的设备接入或者海量的数据发送很可能瞬间就把服务端冲垮。
JVM层面的调优主要涉及GC参数优化,GC参数设置不当会导致频繁GC,甚至OOM异常,对服务端的稳定运行产生重大影响。
1. 确定GC优化目标
GC(垃圾收集)有三个主要指标。
-
吞吐量:是评价GC能力的重要指标,在不考虑GC引起的停顿时间或内存消耗时,吞吐量是GC能支撑应用程序达到的最高性能指标。
-
延迟:GC能力的最重要指标之一,是由于GC引起的停顿时间,优化目标是缩短延迟时间或完全消除停顿(STW),避免应用程序在运行过程中发生抖动。
-
内存占用:GC正常时占用的内存量。
JVM GC调优的三个基本原则如下。
-
Minor go回收原则:每次新生代GC回收尽可能多的内存,减少应用程序发生Full gc的频率。
-
GC内存最大化原则:垃圾收集器能够使用的内存越大,垃圾收集效率越高,应用程序运行也越流畅。但是过大的内存一次 Full go耗时可能较长,如果能够有效避免FullGC,就需要做精细化调优。
-
3选2原则:吞吐量、延迟和内存占用不能兼得,无法同时做到吞吐量和暂停时间都最优,需要根据业务场景做选择。对于大多数应用,吞吐量优先,其次是延迟。当然对于时延敏感型的业务,需要调整次序。
2.确定服务端内存占用
在优化GC之前,需要确定应用程序的内存占用大小,以便为应用程序设置合适的内存,提升GC效率。内存占用与活跃数据有关,活跃数据指的是应用程序稳定运行时长时间存活的Java对象。活跃数据的计算方式:通过GC日志采集GC数据,获取应用程序稳定时老年代占用的Java堆大小,以及永久代(元数据区)占用的Java堆大小,两者之和就是活跃数据的内存占用大小。
3.GC优化过程
-
GC数据的采集和研读
-
设置合适的JVM堆大小
-
选择合适的垃圾回收器和回收策略
当然具体如何做,请参考JVM相关课程。而且GC调优会是一个需要多次调整的过程,期间不仅有参数的变化,更重要的是需要调整业务代码。
2.4 什么是水平触发(LT)和边缘触发(ET)?
Level_triggered(水平触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据一次性全部读写完,那么下次调用 epoll_wait()时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你。
Edge_triggered(边缘触发):当被监控的文件描述符上有可读写事件发生时,epoll_wait()会通知处理程序去读写。如果这次没有把数据全部读写完,那么下次调用epoll_wait()时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会通知你。这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符!!
select(),poll()模型都是水平触发模式,信号驱动IO是边缘触发模式,epoll()模型即支持水平触发,也支持边缘触发,默认是水平触发。JDK中的select实现是水平触发,而Netty提供的Epoll的实现中是边缘触发。
2.5 请说说DNS域名解析的全过程
本题其实是“浏览器中输入URL到返回页面的全过程”这个题目的衍生题:
-
根据域名,进行DNS域名解析;
-
拿到解析的IP地址,建立TCP连接;
-
向IP地址,发送HTTP请求;
-
服务器处理请求;
-
返回响应结果;
-
关闭TCP连接;
-
浏览器解析HTML;
-
浏览器布局渲染;
可见DNS域名解析是其中的一部分。
DNS一个由分层的服务系统,大致说来,有3种类型的 DNS服务器:根 DNS服务器、顶级域(Top-Level Domain,TLD) DNS服务器和权威DNS服务器。
根 DNS服务器。截止到2022年4月22日,有1533个根名字服务器遍及全世界,可到https://root-servers.org/查询分布情况,根名字服务器提供 TLD服务器的IP地址。
顶级域(DNS)服务器。对于每个顶级域(如com、org、net、edu和 gov)和所有国家的顶级域(如uk、fr、ca和jp),都有TLD服务器(或服务器集群)。TLD服务器提供了权威DNS服务器的IP地址。
权威DNS服务器。在因特网上的每个组织机构必须提供公共可访问的 DNS记录,这些记录将这些主机的名字映射为IP地址。一个组织机构的权威DNS服务器收藏了这些DNS记录。一个组织机构能够选择实现它自己的权威 DNS服务器以保存这些记录;也可以交由商用DNS服务商存储在这个服务提供商的一个权威DNS 服务器中,比如阿里云旗下的中国万网。
有另一类重要的DNS服务器,称为本地DNS 服务器( local DNS server)。严格说来,一个本地DNS服务器并不属于该服务器的层次结构,但它对 DNS层次结构是至关重要的。每个ISP都有一台本地DNS服务器。同时很多路由器中也会附带DNS服务。
当主机发出DNS请求时,该请求被发往本地DNS服务器,它起着代理的作用,并将该请求转发到DNS服务器层次结构中,同时本地DNS服务器也会缓存DNS记录。
所以一个 DNS客户要决定主机名www.baidu.com 的IP地址。粗略说来,将发生下列事件。客户首先与根服务器之一联系,它将返回顶级域名com的 TLD服务器的IP地址。该客户则与这些TLD服务器之一联系,它将为baidu.com返回权威服务器的IP地址。最后,该客户与baidu.com权威服务器之一联系,它为主机名www.baidu.com返回其IP地址。