Netty实战总结

Netty

1、netty 底层原理

2、nio和bio、IO模型区别

io模型是实现io的模型原理,比如select,poll,epoll是nio在不同操作系统下(win或linux)的三种实现方式。

参考 

3、异步非阻塞原理

4、Netty高性能的原因?

5、netty常用组件及原理?

6、nettY 群组聊天实现

参考

7、IM服务整体架构流程(Netty),发送消息的整个流程是什么样的

8、服务端存用户标识和channel的关联关系,此标识用用户Id还是Token更合适?

参考一

9、常见的IM聊天工具用的是啥协议?

10、IM聊天架构关于聊天的表到底怎样分库分表更合适。

11、已读过的信息存在数据库中什么时候查询用(因为前端一般会把已读的数据缓存到本地手机)

 12、 系统用户下线后删除和channel的映射关系,此时用户标识已经不存在了,怎么处理删除啊?

思考思路
 上文中提到了在断开连接的回调方法 handlerRemoved 中可以获取当前连接的 channel,那么我们能不能根据 channel 获取 userId,再根据获取到的 userId 删除 HashMap中的键值对。

 根据 channel 获取 userId 也可以用 HashMap 来做,把channel作为key,userId作为value。但是这样的话我们需要维护两个HashMap,不管是空间还是时间上的性能损耗都会增多。

 如果在用户退出聊天时,不对HashMap进行处理,而是换成我们在分发消息时做一次判断看channel是否连接正常,可以在此时断开连接,并且保证把消息发送给了一个在线用户。
 

解决思路

 Netty 给 Channel 提供了attr( ) 方法类似添加键值对的功能。这样也可以实现在断开连接时获取到channel绑定的用户 id,再根据用户 id 移除映射。

channel.attr(AttributeKey.valueOf("userId")).set(userId);

参考1​​​​​​​

13、 Netty 是如何解决 JDK 中的 Selector BUG 的

参考​​​​​​​

Netty 的特点?

一个高性能、异步事件驱动的 NIO 框架,它提供了对 TCP、UDP 和文件传输的支持使用更高效的 socket 底层,对 epoll 空轮询引起的 cpu 占用飙升在内部进行了处理,避免了直接使用 NIO 的陷阱,简化了 NIO 的处理方式。采用多种 decoder/encoder 支持,对 TCP 粘包/分包进行自动化处理可使用接受/处理线程池,提高连接效率,对重连、心跳检测的简单支持可配置 IO 线程数、TCP 参数, TCP 接收和发送缓冲区使用直接内存代替堆内存,通过内存池的方式循环利用 ByteBuf,通过引用计数器及时申请释放不再引用的对象,降低了 GC频率。使用单线程串行化的方式,高效的 Reactor 线程模型大量使用了 volitale、使用了 CAS 和原子类、线程安全类的使用、读写锁的使用。

Netty 的线程模型

Netty 通过 Reactor 模型基于多路复用器接收并处理用户请求,内部实现了两个线程池,boss 线程池和 work 线程池,其中 boss 线程池的线程负责处理请求的 accept 事件,当接收

到 accept 事件的请求时,把对应的 socket 封装到一个 NioSocketChannel 中,并交给 work 线程池,其中 work 线程池负责请求的 read 和 write 事件,由对应的 Handler 处理。

单线程模型:所有 I/O 操作都由一个线程完成,即多路复用、事件分发和处理都是在一个 Reactor 线程上完成的。既要接收客户端的连接请求,向服务端发起连接,又要发送/读取

                    请求或应答/响应消息。一个 NIO 线程同时处理成百上千的链路,性能上无法支撑,速度慢,若线程进入死循环,整个程序不可用,对于高负载、大并发的应用场景不合适。

多线程模型:有一个 NIO 线程(Acceptor) 只负责监听服务端,接收客户端的 TCP 连接请求;NIO 线程池负责网络 IO 的操作,即消息的读取、解码、编码和发送;1 个 NIO 线

                    程可以同时处理 N 条链路,但是 1 个链路只对应 1 个 NIO 线程,这是为了防止发生并发操作问题。但在并发百万客户端连接或需要安全认证时,一个 Acceptor 线程可能会                      存在性能不足问题。

主从多线程模型: Acceptor 线程用于绑定监听端口,接收客户端连接,将 SocketChannel 从主线程池的 Reactor 线程的多路复用器上移除,重新注册到 Sub 线程池的线程上,用于
                             处理 I/O 的读写等操作,从而保证 mainReactor 只负责接入认证、握手等操作。
Netty 中有哪些重要组件?
Channel Netty 网络操作抽象类,它除了包括基本的 I/O 操作,如 bind connect 、read、 write 等。
EventLoop :主要是配合 Channel 处理 I/O 操作,用来处理连接的生命周期中所发生的 事情。
ChannelFuture Netty 框架中所有的 I/O 操作都为异步的,因此我们需要 ChannelFuture 的 addListener() 注册一个 ChannelFutureListener 监听事件,当操作执行成功 或者失败时,监                               听就会自动触发返回结果。
ChannelHandler :充当了所有处理入站和出站数据的逻辑容器。 ChannelHandler 主要用 来处理各种事件,这里的事件很广泛,比如可以是连接、数据接收、异常、数据转换等。
ChannelPipeline :为 ChannelHandler 链提供了容器,当 channel 创建时,就会被自动分配到它专属的 ChannelPipeline ,这个关联是永久性的。
TCP 粘包 / 拆包的原因及解决方法?
TCP 是以流的方式来处理数据,一个完整的包可能会被 TCP 拆分成多个包进行发送,也 可能把小的封装成一个大的数据包发送。
TCP 粘包 / 分包的原因: 应用程序写入的字节大小大于套接字发送缓冲区的大小,会发生拆包现象,而应用程序 写入数据小于套接字缓冲区大小,网卡将应用多次写入的数据发送到网络上,这将会发生粘 包现象;进行 MSS 大小的 TCP 分段,当 TCP 报文长度 -TCP 头部长度 >MSS 的时候将发生拆包以太网帧的 payload (净荷)大于 MTU 1500 字节)进行 ip 分片。
解决方法:
消息定长: FixedLengthFrameDecoder 类包 尾增加特殊字符分割:行分隔符类:LineBasedFrameDecoder 或自定义分隔符类 : DelimiterBasedFrameDecoder 将消息分为消息头和消息体:LengthFieldBasedFrameDecoder 类。分为有头部的拆包与粘包、长度字段在前且有头部的拆包与粘包、多扩展头部的拆包与粘包。
请概要介绍下序列化
序列化(编码)是将对象序列化为二进制形式(字节数组),主要用于网络传输、数据持久化等;而反序列化(解码)则是将从网络、磁盘等读取的字节数组还原成原始对象,主
要用于网络传输对象的解码,以便完成远程调用。
影响序列化性能的关键因素:序列化后的码流大小(网络带宽的占用)、序列化的性能(CPU 资源占用);是否支持跨语言(异构系统的对接和开发语言切换)。
Java 默认提供的序列化:无法跨语言、序列化后的码流太大、序列化的性能差。
XML ,优点:人机可读性好,可指定元素或特性的名称。
           缺点:序列化数据只包含数据 本身以及类的结构,不包括类型标识和程序集信息;只能序列化公共属性和字段;不能序列化方法;文件庞大,文件格式复杂,传输占带宽。
适用场景:当做配置文件存储数据,实时数据转换。
JSON ,是一种轻量级的数据交换格式,
优点:兼容性高、数据格式比较简单,易于读写、序列化后数据较小,可扩展性好,兼容性好、与 XML 相比,其协议比较简单,解析速 度比较快。
缺点:数据的描述性比 XML 差、不适合性能要求为 ms 级别的情况、额外空间开销比较大。
适用场景(可替代XML):跨防火墙访问、可调式性要求高、基于 Web browser 的 Ajax 请求、传输数据量相对小,实时性要求相对低(例如秒级别)的服务。
Fastjson ,采用一种“假定有序快速匹配”的算法。
优点:接口简单易用、目前 java 语言中最快的 json 库。
缺点:过于注重快,而偏离了“标准”及功能性、代码质量不高,文档不全、安全漏洞较多。
适用场景:协议交互、Web 输出、 Android 客户端 。
Thrift,不仅是序列化协议,还是一个 RPC 框架。
优点:序列化后的体积小 , 速度快、支持多种语言和丰富的数据类型、对于数据字段的增删具有较强的兼容性、支持二进制压缩编码。
缺点:使用者较少、跨防火墙访问时,不安全、不具有可读性,调试代码时相对困难、不能与其他传输层协议共同使用(例如 HTTP )、无法支持向持久层直接读写数据,即不适合做数据持久化序列化协议。
适用场景:分布式系统的 RPC 解决方案。
Protobuf,将数据结构以 .proto 文件进行描述,通过代码生成工具可以生成对应数据结构的 POJO 对象和 Protobuf 相关的方法和属性。
优点:序列化后码流小,性能高、结构化数据存储格式(XML JSON 等)、通过标识字段的顺序,可以实现协议的前向兼容、结构化的文档更容易管理和维护。
缺点:需要依赖于工具生成代码、支持的语言相对较少,官方只支持 Java C++ python
适用场景:对性能要求高的 RPC 调用、具有良好的跨防火墙的访问属性、适合应用层对象的持久化
其它
protostuff 基于 protobuf 协议,但不需要配置 proto 文件,直接导包即可
Jboss marshaling 可以直接序列化 java 类, 无须实 java.io.Serializable 接口
Message pack 一个高效的二进制序列化格式
Hessian 采用二进制协议的轻量级 remoting onhttp 工具kryo 基于 protobuf 协议,只支持 java 语言 , 需要注册( Registration ),然后序列化( Output ),反序列化(Input
Netty 是如何解决 JDK 中的 Selector BUG 的?
Selector BUG JDK NIO BUG ,例如臭名昭著的 epoll bug ,它会导致 Selector 空轮询, 最终导致 CPU 100% 。官方声称在 JDK1.6 版本的 update18 修复了该问题,但是直到 JDK1.7 版本该问题仍旧存在,只不过该 BUG 发生概率降低了一些而已,它并没有被根本解决。这个问题的具体原因是:在部分 Linux 2.6 kernel 中, poll epoll 对于突然中断的
连接 socket 会对返回的 eventSet 事件集合置为 POLLHUP ,也可能是 POLLERR eventSet 事件集合发生了变化,这就可能导致 Selector 会被唤醒。这个时候 selector select 方法,返回 numKeys 是 0 ,所以下面本应该对 key 值进行遍历的事件处理根本执行不了,又回到最上面的 while(true)循环,循环往复,不断的轮询,直到 linux 系统出现 100% CPU 情况,最终导 致程序崩溃。
修复的办法有两个:
一、将 SelectKey 去除掉,然后“刷新”一下 Selector ,刷新的方式也就是调用 Selector.selectNow 方法,这种修改仍然不是可靠的,一共有两点:
1. 多个线程中的 SelectionKey key cancel ,很可能和下面的 Selector.selectNow 同时并发,如果是导致 key cancel 后运行很可能没有效果
2. 与其说第一点使得 NIO 空转出现的几率大大降低,经过 Jetty 服务器的测试报告发现,这种重复利用 Selector 并清空 SelectionKey 的改法很可能没有任何的效果。
二、创建一个新的 Selector ,这种处理方法要保险的多,基本上不会有任何的问题。
Netty 采用就是这种解决办法:对 Selector select 操作周期进行统计,每完成一次空的 select 操作进行一次计数,若在某个周期内连续发生 N 次空轮询,则触发了 epoll 死循环
bug 。重建 Selector ,判断是否是其他线程发起的重建请求,若不是则将原 SocketChannel 从旧的 Selector 上去除注册,重新注册到新的 Selector 上,并将原来的 Selector 关闭。
Netty 的优势有哪些?
使用简单:封装了 NIO 的很多细节,使用更简单。
功能强大:预置了多种编解码功能,支持多种主流协议。
定制能力强:可以通过 ChannelHandler 对通信框架进行灵活地扩展。
性能高:通过与其他业界主流的 NIO 框架对比, Netty 的综合性能最优。
稳定: Netty 修复了已经发现的所有 NIO bug ,让开发人员可以专注于业务本身。
社区活跃: Netty 是活跃的开源项目,版本迭代周期短, bug 修复速度快。
Netty 高性能表现在哪些方面?
IO 线程模型:同步非阻塞,用最少的资源做更多的事。
内存零拷贝:尽量减少不必要的内存拷贝,实现了更高效率的传输。
内存池设计:申请的内存可以重用,主要指直接内存。内部实现是用一颗二叉查找树管理内存分配情况。
串形化处理读写:避免使用锁带来的性能开销。即消息的处理尽可能在同一个线程内完成,期间不进行线程切换,这样就避免了多线程竞争和同步锁。表面上看,串行化设计似乎 CPU 利用率不高,并发程度不够。但是,通过调整 NIO 线程池的线程参数,可以同时启动多个串行化的线程并行运行,这种局部无锁化的串行线程设计相比一个队列- 多个工作线程模 型性能更优。
高性能序列化协议:支持 protobuf 等高性能序列化协议。
高效并发编程的体现: volatile 的大量、正确使用; CAS 和原子类的广泛使用;线程安全容器的使用;通过读写锁提升并发性能。
Netty 发送消息有几种方式?
Netty 有两种发送消息的方式:
直接写入 Channel 中,消息从 ChannelPipeline 当中尾部开始移动;
写入和 ChannelHandler 绑定的 ChannelHandlerContext 中,消息从 ChannelPipeline 中的下一个 ChannelHandler 中移动。
Netty 的内存管理机制是什么?
首先会预申请一大块内存 Arena Arena 由许多 Chunk 组成,而每个 Chunk 默认由 2048 个 page 组成。
Chunk 通过 AVL 树的形式组织 Page ,每个叶子节点表示一个 Page ,而中间节点表示内存区域,节点自己记录它在整个 Arena 中的偏移地址。
当区域被分配出去后,中间节点上的标记位会被标记,这样就表示这个中间节点以下的所有节点都已被分配了。
大于 8k 的内存分配在 poolChunkList 中,而 PoolSubpage 用于分配小于 8k 的内存,它会把一个 page 分割成多段,进行内存分配。
ByteBuf 的特点
支持自动扩容( 4M ),保证 put 方法不会抛出异常、通过内置的复合缓冲类型,实现零拷贝(zero-copy ); 不需要调用 flip() 来切换读 / 写模式,读取和写入索引分开;
引用计数基于原子变量 AtomicIntegerFieldUpdater 用于内存回收; PooledByteBuf 采用二叉树来实现一个内存池,集中管理内存的分配和释放,不用每次使用都新建一个缓冲区对象。UnpooledHeapByteBuf 每次都会新建一个缓冲区对象。
如何让单机下 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 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 线程的个数来提升网络的读写性能。
心跳优化
针对海量设备接入的服务端 , 心跳优化策略如下。
(1) 要能够及时检测失效的连接 , 并将其剔除 , 防止无效的连接句柄积压 , 导致 OOM 等问题
(2) 设置合理的心跳周期 , 防止心跳定时任务积压 , 造成频繁的老年代 GC( 新生代和老年代
都有导致 STW GC, 不过耗时差异较大 ), 导致应用暂停
(3) 使用 Nety 提供的链路空闲检测机制 , 不要自己创建定时任务线程池 , 加重系统的负担 ,
以及增加潜在的并发安全问题。
当设备突然掉电、连接被防火墙挡住、长时间 GC 或者通信线程发生非预期异常时 , 会导
致链路不可用且不易被及时发现。特别是如果异常发生在凌晨业务低谷期间 , 当早晨业务高
峰期到来时 , 由于链路不可用会导致瞬间大批量业务失败或者超时 , 这将对系统的可靠性产生
重大的威胁。
从技术层面看 , 要解决链路的可靠性问题 , 必须周期性地对链路进行有效性检测。目前最
流行和通用的做法就是心跳检测。心跳检测机制分为三个层面:
(1)TCP 层的心跳检测 , TCP Keep-Alive 机制 , 它的作用域是整个 TCP 协议栈。
(2) 协议层的心跳检测 , 主要存在于长连接协议中 , 例如 MQTT
(3) 应用层的心跳检测 , 它主要由各业务产品通过约定方式定时给对方发送心跳消息实现。 心跳检测的目的就是确认当前链路是否可用 , 对方是否活着并且能够正常接收和发送消
息。作为高可靠的 NIO 框架 ,Nety 也提供了心跳检测机制。
一般的心跳检测策略如下。
(1) 连续 N 次心跳检测都没有收到对方的 Pong 应答消息或者 Ping 请求消息 , 则认为链路
已经发生逻辑失效 , 这被称为心跳超时。
(2) 在读取和发送心跳消息的时候如果直接发生了 IO 异常 , 说明链路已经失效 , 这被称为
心跳失败。无论发生心跳超时还是心跳失败 , 都需要关闭链路 , 由客户端发起重连操作 , 保证链
路能够恢复正常。
Nety 提供了三种链路空闲检测机制 , 利用该机制可以轻松地实现心跳检测
(1) 读空闲 , 链路持续时间 T 没有读取到任何消息。
(2) 写空闲 , 链路持续时间 T 没有发送任何消息
(3) 读写空闲 , 链路持续时间 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( 垃圾收集 ) 有三个主要指标。
(1) 吞吐量 : 是评价 GC 能力的重要指标 , 在不考虑 GC 引起的停顿时间或内存消耗时 , 吞吐量是 GC 能支撑应用程序达到的最高性能指标。
(2) 延迟 :GC 能力的最重要指标之一 , 是由于 GC 引起的停顿时间 , 优化目标是缩短延迟时间或完全消除停顿(STW), 避免应用程序在运行过程中发生抖动。
(3) 内存占用 :GC 正常时占用的内存量。
JVM GC 调优的三个基本原则如下。
(1) Minor go 回收原则 : 每次新生代 GC 回收尽可能多的内存 , 减少应用程序发生 Full gc 的频率。
(2)GC 内存最大化原则 : 垃圾收集器能够使用的内存越大 , 垃圾收集效率越高 , 应用程序运行也越流畅。但是过大的内存一次 Full go 耗时可能较长 , 如果能够有效避免 FullGC, 就需要做 精细化调优。
(3)3 2 原则 : 吞吐量、延迟和内存占用不能兼得 , 无法同时做到吞吐量和暂停时间都最优, 需要根据业务场景做选择。对于大多数应用 , 吞吐量优先 , 其次是延迟。当然对于时延敏感 型的业务, 需要调整次序。
2. 确定服务端内存占用
在优化 GC 之前 , 需要确定应用程序的内存占用大小 , 以便为应用程序设置合适的内存 , 提升 GC 效率。内存占用与活跃数据有关 , 活跃数据指的是应用程序稳定运行时长时间存活的
Java 对象。活跃数据的计算方式 : 通过 GC 日志采集 GC 数据 , 获取应用程序稳定时老年代占用的 Java 堆大小 , 以及永久代 ( 元数据区 ) 占用的 Java 堆大小 , 两者之和就是活跃数据的内存占用大小。
3.GC 优化过程
1 GC 数据的采集和研读
2 、设置合适的 JVM 堆大小
3 、选择合适的垃圾回收器和回收策略
当然具体如何做,请参考 JVM 相关课程。而且 GC 调优会是一个需要多次调整的过程,期间不仅有参数的变化,更重要的是需要调整业务代码。
select poll epoll 的区别?
select poll epoll 都是操作系统实现 IO 多路复用的机制。 我们知道, I/O 多路复用就通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),
能够通知程序进行相应的读写操作。那么这三种机制有什么区别呢 ?
1 、支持一个进程所能打开的最大连接数
2 FD 巨 增后带来的 IO 效率问题
  3 、 消息传递方式
总结:
综上,在选择 select poll epoll 时要根据具体的使用场合以及这三种方式的自身特点。
1 、表面上看 epoll 的性能最好,但是在连接数少并且连接都十分活跃的情况下, select 和 poll 的性能可能比 epoll 好,毕竟 epoll 的通知机制需要很多函数回调。
2 select 低效是因为每次它都需要轮询。但低效也是相对的,视情况而定,也可通过良好的设计改善。
什么是水平触发 (LT) 和边缘触发 (ET)
Level_triggered( 水平触发 ) :当被监控的文件描述符上有可读写事件发生时, epoll_wait() 会通知处理程序去读写。如果这次没有把数据一次性全部读写完( 如读写缓冲区太小 ) ,那么
下次调用 epoll_wait() 时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会一直通知你!如果系统中有大量你不需要读写的就绪文件描述符,
而它们每次都会返回,这样会大大降低处理程序检索自己关心的就绪文件描述符的效率!
Edge_triggered( 边缘触发 ) :当被监控的文件描述符上有可读写事件发生时, epoll_wait() 会通知处理程序去读写。如果这次没有把数据全部读写完( 如读写缓冲区太小 ) ,那么下次调
epoll_wait() 时,它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第 二次可读写事件才会通知你!这种模式比水平触发效率高,系统不会充斥大量你不关心
的就绪文件描述符!
select(),poll() 模型都是水平触发模式,信号驱动 IO 是边缘触发模式, epoll()模型即支持水平触发,也支持边缘触发,默认是水平触发。JDK 中的 select 实现是水平触发,而 Netty
提供的 Epoll 的实现中是边缘触发。
请说说 DNS 域名解析的全过程
直接内存深入辨析
在所有的网络通信和应用程序中,每个 TCP Socket 的内核中都有一个发送缓冲区 (SO_SNDBUF)和一个接收缓冲区 (SO_RECVBUF) ,可以使用相关套接字选项来更改该缓冲区大
小。
当某个应用进程调用 write 时,内核从该应用进程的缓冲区中复制所有数据到所写套接 字的发送缓冲区。如果该套接字的发送缓冲区容不下该应用进程的所有数据( 或是应用进程的缓冲区大于套接字的发送缓冲区,或是套接字的发送缓冲区中已有其他数据) ,假设该套接字是阻塞的,则该应用进程将被投入睡眠。内核将不从 write 系统调用返回,直到应用进程缓冲区中的所有数据都复制到套接字发送缓冲区。因此,从写一个 TCP 套接字的 write 调用成功返回仅仅表示我们可以重新使用原来的应用进程缓冲区,并不表明对端的 TCP 或应用进程已接收到数据。
Java 程序自然也要遵守上述的规则。但在 Java 中存在着堆、垃圾回收等特性,所以在 实际的 IO 中,在 JVM 内部的存在着这样一种机制:
IO 读写上,如果是使用堆内存, JDK 会先创建一个 DirectBuffer ,再去执行真正的写操作。这是因为,当我们把一个地址通过 JNI 传递给底层的 C 库的时候,有一个基本的要求, 就是这个地址上的内容不能失效。然而,在 GC 管理下的对象是会在 Java 堆中移动的。也就是说,有可能我把一个地址传给底层的 write ,但是这段内存却因为 GC 整理内存而失效了。 所以必须要把待发送的数据放到一个 GC 管不着的地方。这就是调用 native 方法之前,数据—定要在堆外内存的原因。 可见,DirectBuffer 并没有节省什么内存拷贝,只是因为 HeapBuffer 必须多做一次拷贝,使用 DirectBuffer 就会少一次内存拷贝。相比没有使用堆内存的 Java 程序,使用直接内存的Java 程序当然更快一点。从垃圾回收的角度而言,直接内存不受 GC( 新生代的 Minor GC) 影响,只有当执行老年代的 Full GC 时候才会顺便回收直接内存,整理内存的压力也比数据放到 HeapBuffer 要小。
堆外内存的优点和缺点
堆外内存相比于堆内内存有几个优势:
1 减少了垃圾回收的工作,因为垃圾回收会暂停其他的工作(可能使用多线程或者时间片的方式,根本感觉不到)
2 加快了复制的速度。因为堆内在 flush 到远程时,会先复制到直接内存(非堆内存), 然后在发送;而堆外内存相当于省略掉了这个工作。
而福之祸所依,自然也有不好的一面:
1 堆外内存难以控制,如果内存泄漏,那么很难排查。
2 堆外内存相对来说,不适合存储很复杂的对象。一般简单的对象或者扁平化的比较适合。
零拷贝
什么是零拷贝 ?
零拷贝 ( 英语 : Zero-copy) 技术是指计算机执行操作时, CPU 不需要先将数据从某处内存 复制到另一个特定区域。这种技术通常用于通过网络传输文件时节省 CPU 周期和内存带宽。
零拷贝技术可以减少数据拷贝和共享总线操作的次数,消除传输数据在存储器之间不必要的中间拷贝次数,从而有效地提高数据传输效率。
零拷贝技术减少了用户进程地址空间和内核地址空间之间因为上 : 下文切换而带来的开销。
可以看出没有说不需要拷贝,只是说减少冗余 [ 不必要 ] 的拷贝。
下面这些组件、框架中均使用了零拷贝技术: Kafka Netty Rocketmq Nginx Apache
Linux I/O 机制与 DMA
在早期计算机中,用户进程需要读取磁盘数据,需要 CPU 中断和 CPU 参与,因此效率比较低,发起 IO 请求,每次的 IO 中断,都带来 CPU 的上下文切换。因此出现了—— DMA
DMA(Direct Memory Access ,直接内存存取 ) 是所有现代电脑的重要特色,它允许不同速度的硬件装置来沟通,而不需要依赖于 CPU 的大量中断负载。
DMA 控制器,接管了数据读写请求,减少 CPU 的负担。这样一来, CPU 能高效工作了。 现代硬盘基本都支持 DMA
因此 IO 读取,涉及两个过程:
1 DMA 等待数据准备好,把磁盘数据读取到操作系统内核缓冲区。
2 、用户进程,将内核缓冲区的数据 copy 到用户空间。
这两个过程,都是阻塞的
传统数据传送机制
比如:读取文件,再用 socket 发送出去,实际经过四次 copy
伪码实现如下:
buffer = File.read()
Socket.send(buffer)
1 、第一次:将磁盘文件,读取到操作系统内核缓冲区;
2 、第二次:将内核缓冲区的数据, copy 到应用程序的 buffer
3 、第三步:将 application 应用程序 buffer 中的数据, copy socket 网络发送缓冲区 ( 属于操作系统内核的缓冲区)
4 、第四次:将 socket buffer 的数据, copy 到网卡,由网卡进行网络传输。.
分析上述的过程,虽然引入 DMA 来接管 CPU 的中断请求,但四次 copy 是存在“不必 要的拷贝”的。实际上并不需要第二个和第三个数据副本。应用程序除了缓存数据并将其传 输回套接字缓冲区之外什么都不做。相反,数据可以直接从读缓冲区传输到套接字缓冲区。 显然,第二次和第三次数据 copy 其实在这种场景下没有什么帮助反而带来开销,这也正是零拷贝出现的背景和意义。 同时,read 和 send 都属于系统调用,每次调用都牵涉到两次上下文切换:
总结下,传统的数据传送所消耗的成本: 4 次拷贝, 4 次上下文切换。 4 次拷贝,其中两次是 DMA copy ,两次是 CPU copy
Linux 支持的 ( 常见 ) 零拷贝
目的:减少 IO 流程中不必要的拷贝,当然零拷贝需要 OS 支持,也就是需要 kernel 暴露 api
mmap 内存映射
硬盘上文件的位置和应用程序缓冲区 (application buffers) 进行映射(建立一种一一对应关系),由于 mmap() 将文件直接映射到用户空间,所以实际文件读取时根据这个映射关系,
直接将文件从硬盘拷贝到用户空间,只进行了一次数据拷贝,不再有文件内容从硬盘拷贝到内核空间的一个缓冲区。mmap 内存映射将会经历: 3 次拷贝 : 1 cpu copy 2 DMA copy ; 以及 4 次上下文切换
sendfile
linux 2.1 支持的 sendfile
当调用 sendfile() 时, DMA 将磁盘数据复制到 kernel buffer ,然后将内核中的 kernel buffer 直接拷贝到 socket buffer ;但是数据并未被真正复制到 socket 关联的缓冲区内。取而代之的
是,只有记录数据位置和长度的描述符被加入到 socket 缓冲区中。 DMA 模块将数据直接从内核缓冲区传递给协议引擎,从而消除了遗留的最后一次复制。 一旦数据全都拷贝到 socket buffer sendfile() 系统调用将会 return 、代表数据转化的完成。socket buffer 里的数据就能在网络传输了。 sendfile 会经历: 3 次拷贝, 1 CPU copy 2 DMA copy
以及 2 次上下文切换
splice
Linux 2.6.17 支持 splice
数据从磁盘读取到 OS 内核缓冲区后,在内核缓冲区直接可将其转成内核空间其他数据buffer,而不需要拷贝到用户空间。
如下图所示,从磁盘读取到内核 buffer 后,在内核空间直接与 socket buffer 建立 pipe管道。和 sendfile() 不同的是, splice() 不需要硬件支持。
注意 splice sendfile 的不同, sendfile 是将磁盘数据加载到 kernel buffer 后,需要一次CPU copy,拷贝到 socket buffer 。而 splice 是更进一步,连这个 CPU copy 也不需要了,直接
将两个内核空间的 buffer 进行 pipe 。 splice 会经历 2 次拷贝 : 0 cpu copy 2 DMA copy
以及 2 次上下文切换
总结 Linux 中零拷贝
最早的零拷贝定义,来源于
Linux 2.4 内核新增 sendfile 系统调用,提供了零拷贝。磁盘数据通过 DMA 拷贝到内核 Buffer 后,直接通过 DMA 拷贝到 NIO Buffer(socket buffer) ,无需 CPU 拷贝。这也是零 拷贝这一说法的来源。这是真正操作系统 意义上的零拷贝 ( 也就是狭义零拷贝 ) 但是我们知道,由 OS 内核提供的 操作系统意义上的零拷贝,发展到目前也并没有很多种,也就是这样的零拷贝并不是很多; 随着发展,零拷贝的概念得到了延伸,就是目前的减少不必要的数据拷贝都算作零拷贝的范畴。
Java 生态圈中的零拷贝
Linux 提供的零拷贝技术 Java 并不是全支持,支持 2 ( 内存映射 mmap sendfile)
NIO 提供的内存映射 MappedByteBuffer
NIO 中的 FileChannel.map() 方法其实就是采用了操作系统中的内存映射方式,底层就是调用 Linux mmap() 实现的。 将内核缓冲区的内存和用户缓冲区的内存做了一个地址映射。这种方式适合读取大文件, 同时也能对文件内容进行更改,但是如果其后要通过 SocketChannel 发送,还是需要 CPU 进行数据的拷贝。
NIO 提供的 sendfile
Java NIO 中提供的 FileChannel 拥有 transferTo transferFrom 两个方法,可直接把FileChannel 中的数据拷贝到另外一个 Channel ,或者直接把另外一个 Channel 中的数据拷
贝到 FileChannel 。该接口常被用于高效的网络 / 文件的数据传输和大文件拷贝。在操作系 统支持的情况下,通过该方法传输数据并不需要将源数据从内核态拷贝到用户态,再从用户
态拷贝到目标通道的内核态,同时也避免了两次用户态和内核态间的上下文切换,也即使用了“零拷贝”,所以其性能一般高于 Java IO 中提供的方法。
Kafka 中的零拷贝
Kafka 两个重要过程都使用了零拷贝技术,且都是操作系统层面的狭义零拷贝,一是Producer 生产的数据存到 broker ,二是 Consumer broker 读取数据。
Producer 生产的数据持久化到 broker ,采用 mmap 文件映射,实现顺序的快速写入; Customer 从 broker 读取数据,采用 sendfile ,将磁盘文件读到 OS 内核缓冲区后,直接 转到 socket buffer 进行网络发送。
Netty 的零拷贝实现
Netty 的零拷贝主要包含三个方面:
在网络通信上  :    Netty 的接收和发送 ByteBuffer 采用 DIRECT BUFFERS ,使用堆外直接内存进行 Socket 读写,不需要进行字节缓冲区的二次拷贝。如果使用传统的堆内存
HEAP BUFFERS)进行 Socket 读写, JVM 会将堆内存 Buffer 拷贝一份到直接内存中,然后才写入Socket 中。相比于堆外直接内存,消息在发送过程中多了一次缓冲区的内存拷贝。
在缓存操作上 :  Netty 提供了 CompositeByteBuf 类,它可以将多个 ByteBuf 合并为一个逻辑上的 ByteBuf ,避免了各个 ByteBuf 之间的拷贝。 通过 wrap 操作,我们可以将 byte[] 数组、 ByteBuf ByteBuffer 等包装成一个 NettyByteBuf 对象,进而避免了拷贝操作。 ByteBuf支持 slice 操作,因此可以将 ByteBuf 分解为多个共享同一个存储区域的 ByteBuf ,避免了内存的拷贝。
在文件传输上 :  Netty 的通过 FileRegion 包装的 FileChannel.tranferTo 实现文件传输,它可以直接将文件缓冲区的数据发送到目标 Channel ,避免了传统通过循环 write 方式导致的内存拷贝问题。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

寅灯

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值