如何让单机下 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 调优会是一个需要多次调整的过程,
期间不仅有参数的变化,更重要的是需要调整业务代码。
什么是水平触发(LT)和边缘触发(ET)?
Level_triggered(水平触发 ) :当被监控的文件描述符上有可读写事件发生时, epoll_wait()
会通知处理程序去读写。如果这次没有把数据一次性全部读写完,那么下次调用 epoll_wait() 时,它还会通知你在上没读写完的文件描述符上继续读写,当然如果你一直不去读写,它会
一直通知你。
Edge_triggered(边缘触发 ) :当被监控的文件描述符上有可读写事件发生时, epoll_wait()
会通知处理程序去读写。如果这次没有把数据全部读写完,那么下次调用 epoll_wait() 时,
它不会通知你,也就是它只会通知你一次,直到该文件描述符上出现第二次可读写事件才会
通知你。这种模式比水平触发效率高,系统不会充斥大量你不关心的就绪文件描述符!!
select() , poll() 模型都是水平触发模式,信号驱动 IO 是边缘触发模式, epoll() 模型即支
持水平触发,也支持边缘触发,默认是水平触发。 JDK 中的 select 实现是水平触发,而 Netty
提供的 Epoll 的实现中是边缘触发。
请说说 DNS 域名解析的全过程
本题其实是“浏览器中输入 URL 到返回页面的全过程”这个题目的衍生题:
1.根据域名,进行 DNS 域名解析;
2.拿到解析的 IP 地址,建立 TCP 连接;
3.向 IP 地址,发送 HTTP 请求;
4.服务器处理请求;
5.返回响应结果;
6.关闭 TCP 连接;
7.浏览器解析 HTML;
8.浏览器布局渲染;
可见 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 地址。