【海量长连接的挑战】
当客户端的并发连接数达到数十万或者数百万时,系统一个较小的抖动就会导致很严重的后果,例如服务端的GC,导致应用暂停(STW)的GC持续几秒,就会导致海量的客户端设备掉线或者消息积压,一旦系统恢复,会有海量的设备接入或者海量的数据发送,很可能瞬间就把服务端冲垮。
IOT设备接入有如下几个特点(以车联网为例)。
(1)使用的网络主要是运营商的无线移动网络,网络质量不稳定,例如在一些偏远地区、丘陵地带等信号很差,网络容易闪断。
(2)海量的端侧设备接入,而且通常使用长连接,服务端的压力很大。
(3)不稳定,消息丢失、重复发送、延迟送达、过期发送时有发生。
(4)协议不统一,有各种私有协议,开发和测试成本较高。
服务端的性能优化主要有如下几类场景。
(1)降成本:对于很多初创型公司,资金和资源相对比较紧张,希望服务端的单节点性能足够高,以降低硬件和运维成本。
(2)云服务提供商:对于提供IoT解决方案的云服务提供商,由于部署规模比较大(假如有上千个服务节点),单节点的性能提升效益会被放大,带来的经济收益很高。
(3)技术竞争力:在很多场景下,单个服务节点的处理性能仍然是最之一,高性能对提升产品竞争力非常重要。
【海量设备接入的服务端调优】
1.文件描述符
当并发接入的TCP 连接数超过上限时,就会提示“too many open files”所有新的客户端接入将失败。通过#vi/etc/security/limits.conf命令添加如下配置参数:
2.TCP/IP相关参数
(1)net.ipv4.tcp_rmem:为每个TCP 连接分配的读缓冲区内存大小。第一个值是socket接收缓冲区分配的最小字节数。第二个值是默认值,缓冲区在系统负载不高的情况下可以增长到该值。第三个值是接收缓冲区分配的最大字节数。
(2)net.ipv4.tcp_wmem:为每个TCP连接分配的写缓冲区内存大小。第一个值是socket发送缓冲区分配的最小字节数。第二个值是默认值,缓冲区在系统负载不高的情况下可以增长到该值。第三个值是发送缓冲区分配的最大字节数。
(3)net.ipv4.tcp_mem:内核分配给TCP连接的内存,单位是page (1个page通常为4096字节,可以通过#getconf PAGESIZE命令查看),包括最小、默认和最大三个配置项。
(4)net.ipv4.tcp_keepalive_time:最近一次数据包发送与第一次keep alive探测消息发送的时间间隔,用于确认TCP连接是否有效。
(5)tcp_keepalive_intvl:在未获得探测消息响应时,发送探测消息的时间间隔。
(6)tcp_keepalive_probes:判断TCP连接失效连续发送的探测消息个数,达到之后判定连接失效。
(7)net.ipv4.tcp_tw_reuse:是否允许将TIME_WAIT Socket重新用于新的TCP连接,默认为0,表示关闭。
(8)net.ipv4.tcp_tw_recycle:是否开启TCP连接中TIME_WAIT Socket的快速回收功能,默认为0,表示关闭。
(9)net.ipv4.tcp_fin_timeout:套接字自身关闭时保持在FIN_WAIT_2状态的时间,默认为60。
3.多网卡队列和软中断
随着网络带宽的不断提升,单核CPU不能完全满足网卡的需求,通过多队列网卡驱动的支持,将各个队列通过中断绑定到不同的CPU内核,以满足对网络吞吐量要求比较高的业务场景的需要。
多队列网卡需要网卡硬件支持,首先判断当前系统是否支持多队列网卡,通过命令“Ispci-vvv”或者“ethtool -1网卡interface名”查看网卡驱动型号,根据网卡驱动官方说明确认当前系统是否支持多队列网卡(是否支持多队列网卡与网卡硬件、操作系统版本等有关)。有些网卡驱动默认开启了多队列网卡,有些则没有,由于不同的网卡驱动、云服务商提供的开启命令不同,因此需要根据实际情况处理,此处不再详细列举开启方式。
对于不支持多队列网卡的系统,如果内核版本支持RPS(kernel 2.6.35及以上版本),开启RPS后可以实现软中断,提升网络的吞吐量。RPS根据数据包的源地址、目的地址及目的和源端口,算出一个hash值,然后根据这个hash值选择软中断运行的CPU,从上层来看,也就是说将每个连接和CPU绑定,,并通过这个hash值,在多个CPU上均衡软中断,提升网络并行处理性能,它实际提供了一种通过软件模拟多队列网卡的功能。
4.Netty线程数调优
对于线程池的调优,主要集中在用于接收海量设备TCP连接、TLS握手的
Acceptor线程池(Netty通常叫boss NioEventLoopGroup)上,以及用于处理网络数据读写、心跳发送的I/O工作线程池(Netty通常
叫work NioEventLoopGroup)上。
对于Netty 服务端,通常只需要启动一个监听端口用于端侧设备接入即可,但是如果服务端集群实例比较少,甚至是单机(或者双机冷备)部署,
在端侧设备在短时间内大量
接入时,需要对服务端的监听方式和线程模型做优化,以满足短时间内(例如30s)百万级的端侧设备接入的需要。
服务端可以监听多个端口,利用主从Reactor线程模型做接入优化,前端通过SLB做4层/7层负载均衡。主从Reactor线程模型特点如下:服务端用于接收客户端连接的不再是一个单独的NIO线程,而是一个独立的NIO线程池;Acceptor接收到客户端TCP连接请求并处理后(可能包含接入认证等),将新创建的SocketChannel注册到IO线程池(subReactor线程池)的某个I/O线程,由它负责SocketChannel的读写和编解码工作;Acceptor线程池仅用于客户端的登录、握手和安全认证等,一旦链路建立成功,就将链路注册到后端sub Reactor线程池的I/O线程,由I/O线程负责后续的IO操作。IoT服务端主从线程池模型如图11-2所示。
由于同时监听了多个端口,每个ServerSocketChannel都对应一个独立的Acceptor线程,这样就能并行处理,加速端侧设备的接入速度,减小端侧设备的连接超时失败率【适用于设备掉线后大批量上线的服务端优化】,提升服务端单节点的处理性能。
对于I/O工作线程池的优化,可以先采用系统默认值(即CPU内核数×2)进行性能测试,在性能测试过程中采集I/O线程的CPU占用大小,看是否存在瓶颈,具体策略如下。如果发现I/O线程的热点停留在读或者写操作,或者停留在ChannelHandler的执行处,则可以通过适当调大NioEventLoop线程的个数来提升网络的读写性能。
5.心跳优化
(1)要能够及时检测失效的连接,并将其剔除,防止无效的连接句柄积压,导致OOM等问题。
(2)设置合理的心跳周期,防止心跳定时任务积压,造成频繁的老年代GC(新生代和老年代都有导致STW的GC,不过耗时差异较大),导致应用暂停。
(3)使用Netty提供的链路空闲检测机制,不要自己创建定时任务线程池,加重系统的负担,以及增加潜在的并发安全问题。
心跳检测策略如下。
(1)连续N次心跳检测都没有收到对方的Pong应答消息或者Ping 请求消息,则认为链路已经发生逻辑失效,这被称为心跳超时。
(2)在读取和发送心跳消息的时候如果直接发生了I/O异常,说明链路已经失效,这被称为心跳失败。无论发生心跳超时还是心跳失败,都需要关闭链路,由客户端发起重连操作,保证链路能够恢复正常。
Netty提供了三种链路空闲检测机制,利用该机制可以轻松地实现心跳检测。
(1)读空闲,链路持续时间T没有读取到任何消息。
(2)写空闲,链路持续时间T没有发送任何消息。
(3)读写空闲,链路持续时间T没有接收或者发送任何消息。
链路空闲事件被触发后并没有关闭链路,而是触发IdleStateEvent事件,用户订阅IdleStateEvent事件,用于自定义逻辑处理,例如关闭链路、客户端发起重新连接、告警和打印日志等
。除了IdleStateHandler,也可以根据实际需要选择ReadTimeoutHandler或者WriteTimeoutHandler,链路空闲检测相关类库如图11-5所示。
6.接收和发送缓冲区优化
在一些场景下,
端侧设备会周期性地上报数据和发送心跳,单个链路的消息收发量并
不大,针对此类场景,可以通过调小TCP的接收和发送缓冲区来降低单个TCP连接的资
源占用率,例如将收发缓冲区设置为8KB,相关代码如下:
7.合理使用内存池
随着JVM虚拟机和JIT即时编译技术的发展,对象的分配和回收是一个非常轻量级
的工作。但是对于缓冲区Buffer,情况却稍有不同,特别是堆外直接内存的分配和回收,是
一个耗时的操作。为了尽量重用缓冲区,Netty提供了基于内存池的缓冲区重用机制。
每个NioEventLoop线程处理N个链路,在线程内部,链路的处理是串行的。假如A链路首先被处理,它会创建接收缓冲区等对象,待解码完成,构造的POJO对象被封装成任务后投递到后台的线程池中执行,然后接收缓冲区会被释放,每条消息的接收和处理都会重复接收缓冲区的创建和释放。
如果使用内存
池,则当A链路接收到新的数据报时,从NioEventLoop的内存池中申请空闲的ByteBuf,
解码后调用release将 ByteBuf释放到内存池中,供后续的B链路使用。
Netty内存池从实现上可以分为两类:堆外直接内存和堆内存。由于ByteBuf主要用
于网络I/O读写,因此采用堆外直接内存会减少一次从用户堆内存到内核态的字节数组拷
贝,所以性能更高。由于DirectByteBuf的创建成本比较高,因此如果使用DirectByteBuf,
则需要配合内存池使用,否则性价比可能还不如HeapByteBuf。
8.防止io线程被意外阻塞
通常情况下,大家都知道不能在 Netty的IO线程上做执行时间不可控的操作,问数据库、调用第三方服务等。但是有些隐形的阻塞操作却容易被忽略,例如打印日志。在生产环境中,通常需要实时打印接口日志,其他日志处于ERROR级别,当服务发生IO异常时,会记录异常日志。如果当前磁盘的 WIO比较高,写日志文件操作可能会被同步阻塞(阻塞时间无法预测)。这就会导致Netty的NioEventLoop线程被阻塞,Socket链路无法被及时关闭,其他的链路也无法进行读写操作。
9.io线程池与业务线程池分离
如果服务端不做复杂的业务逻辑操作,仅是简单的内存操作和消息转发,则可以通过
调大NioEventLoop工作线程池的方式,直接在I/O线程中执行业务ChannelHandler,这样
便减少了一次线程上下文切换,性能反而更高。
如果有复杂的业务逻辑操作,则建议IO线程和业务线程分离,对于IO线程,由于互相
之间不存在锁竞争,可以创建一个大的NioEventLoopGroup线程组,所有Channel都共享同
一个线程池。对于后端的业务线程池,则建议创建多个小的业务线程池,线程池可以与IO
线程绑定,这样既减少了锁竞争,又提升了后端的处理性能。I/O线程和业务线程分离原理如
10.jvm的优化
堆内存大小设计原则