作者:Grey
原文地址:單機百萬連接調優和Netty應用級別調優
說明
本文為深度解析Netty源碼的學習筆記。
單機百萬連接調優
准備兩台Linux服務器,一個充當服務端,一個充當客戶端。
服務端
-
操作系統:CentOS 7
-
配置:4核8G
-
IP:192.168.118.138
客戶端
-
操作系統:CentOS 7
-
配置:4核8G
-
IP:192.168.118.139
服務端和客戶端均要配置java環境,基於jdk1.8。
如何模擬百萬連接
如果服務端只開一個端口,客戶端連接的時候,端口號是有數量限制的(非root用戶,從1024到65535,大約6w),所以服務端開啟一個端口,客戶端和服務端的連接最多6w個左右。
為了模擬單機百萬連接,我們在服務端開啟多個端口,例如8000~8100
,一共100個端口,客戶端還是6w的連接,但是可以連接服務端的不同端口,所以就可以模擬服務端百萬連接的情況。
准備服務端程序
服務端程序的主要邏輯是:
綁定8000
端口一直到8099
端口,一共100個端口,每2s鍾統計一下連接數。
channelActive
觸發的時候,連接+1
, channelInactive
觸發的時候,連接-1
。
代碼見:Server.java
准備客戶端程序
客戶端程序的主要邏輯是:
循環連接服務端的端口(從8000
一直到8099
)。
代碼見:Client.java
准備好客戶端和服務端的代碼后,打包成Client.jar
和Server.jar
並上傳到客戶端和服務端的/data/app
目錄下。打包配置參考pom.xml
服務端和客戶端在/data/app
下分別准備兩個啟動腳本,其中服務端准備的腳本為startServer.sh
, 客戶端准備的腳本為startClient.sh
,內容如下:
startServer.sh
java -jar server.jar -Xms6.5g -Xmx6.5g -XX:NewSize=5.5g -XX:MaxNewSize=5.5g -XX:MaxDirectMemorySize=1g
startClient.sh
java -jar client.jar -Xms6.5g -Xmx6.5g -XX:NewSize=5.5g -XX:MaxNewSize=5.5g -XX:MaxDirectMemorySize=1g
腳本文件見:startServer.sh 和 startClient.sh
為了方便演示,可以先把服務端和客戶端的防火牆先關閉
systemctl stop firewalld.service
systemctl disable firewalld.service
先啟動服務端
cd /data/app/
./startServer.sh
查看日志,待服務端把100個端口都綁定好以后。
在啟動客戶端
cd /data/app/
./startClient.sh
然后查看服務端日志,服務端在支撐了3942個端口號以后,報了如下錯誤:
Caused by: java.io.IOException: Too many open files
at sun.nio.ch.FileDispatcherImpl.init(Native Method)
at sun.nio.ch.FileDispatcherImpl.<clinit>(FileDispatcherImpl.java:35)
突破局部文件句柄限制
使用ulimit -n
命令可以查看一個jvm進程最多可以打開的文件個數,這個是局部文件句柄限制,默認是1024,我們可以修改這個值
vi /etc/security/limits.conf
增加如下兩行
* hard nofile 1000000
* soft nofile 1000000
以上配置表示每個進程可以打開的最大文件數是一百萬。
突破全局文件句柄限制
除了突破局部文件句柄數限制,還需要突破全局文件句柄數限制,修改如下配置文件
vi /proc/sys/fs/file-max
將這個數量修改為一百萬
echo 1000000 > /proc/sys/fs/file-max
通過這種方式修改的配置在重啟后失效,如果要使重啟也生效,需要修改如下配置
vi /etc/sysctl.conf
在文件末尾加上
fs.file-max=1000000
服務端和客戶端在調整完局部文件句柄限制和全局文件句柄限制后,需要重啟一下客戶端和服務端操作系統 再次啟動服務端,待端口綁定完畢后,啟動客戶端。
查看服務端日志,可以看到,服務端單機連接數已經達到百萬級別。
.....
connections: 434703
connections: 438238
connections: 441195
connections: 444082
connections: 447596
.....
connections: 920435
connections: 920437
connections: 920439
connections: 920442
connections: 920443
connections: 920445
.....
Netty應用級別調優
場景
服務端接受到客戶端的數據,進行一些相對耗時的操作(比如數據庫查詢,數據處理),然后把結果返回給客戶端。
模擬耗時操作
在服務端,模擬通過sleep
方法來模擬耗時操作,規則如下:
-
在
90.0%
情況下,處理時間為1ms
-
在
95.0%
情況下,處理時間為10ms
-
在
99.0%
情況下,處理時間為100ms
-
在
99.9%
情況下,處理時間為1000ms
代碼如下
protected Object getResult(ByteBuf data) {
int level = ThreadLocalRandom.current().nextInt(1, 1000);
int time;
if (level <= 900) {
time = 1;
} else if (level <= 950) {
time = 10;
} else if (level <= 990) {
time = 100;
} else {
time = 1000;
}
try {
Thread.sleep(time);
} catch (InterruptedException e) {
}
return data;
}
客戶端統計QPS和AVG邏輯
獲取當前時間戳,客戶端在和服務端建立連接后,會每隔1s給服務端發送數據,發送的數據就是當前的時間戳,服務端獲取到這個時間戳以后,會把這個時間戳再次返回給客戶端,所以客戶端會拿到發送時候的時間戳,然后客戶端用當前時間減去收到的時間戳,就是這個數據包的處理時間,記錄下這個時間,然后統計數據包發送的次數,根據這兩個變量,可以求出QPS和AVG,其中:
QPS 等於 總的請求量 除以 持續到當前的時間
AVG 等於 總的響應時間除以請求總數
客戶端源碼參考:Client.java
服務端源碼參考:Server.java
服務端在不做任何優化的情況下,關鍵代碼如下
...
bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new FixedLengthFrameDecoder(Long.BYTES));
ch.pipeline().addLast(/*businessGroup,*/ ServerBusinessHandler.INSTANCE);
// ch.pipeline().addLast(ServerBusinessThreadPoolHandler.INSTANCE);
}
});
...
@ChannelHandler.Sharable
public class ServerBusinessHandler extends SimpleChannelInboundHandler<ByteBuf> {
public static final ChannelHandler INSTANCE = new ServerBusinessHandler();
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
ByteBuf data = Unpooled.directBuffer();
data.writeBytes(msg);
Object result = getResult(data);
ctx.channel().writeAndFlush(result);
}
protected Object getResult(ByteBuf data) {
int level = ThreadLocalRandom.current().nextInt(1, 1000);
int time;
if (level <= 900) {
time = 1;
} else if (level <= 950) {
time = 10;
} else if (level <= 990) {
time = 100;
} else {
time = 1000;
}
try {
Thread.sleep(time);
} catch (InterruptedException e) {
}
return data;
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
// ignore
}
}
運行服務端和客戶端,查看客戶端日志
.....
qps: 1466, avg response time: 35.68182
qps: 832, avg response time: 214.28384
qps: 932, avg response time: 352.59363
qps: 965, avg response time: 384.59448
qps: 957, avg response time: 403.33804
qps: 958, avg response time: 424.5246
qps: 966, avg response time: 433.35272
qps: 980, avg response time: 484.2116
qps: 986, avg response time: 478.5395
.....
優化方案一:使用自定義線程池處理耗時邏輯
將服務端代碼做如下調整
bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new FixedLengthFrameDecoder(Long.BYTES));
//ch.pipeline().addLast(/*businessGroup,*/ ServerBusinessHandler.INSTANCE);
ch.pipeline().addLast(ServerBusinessThreadPoolHandler.INSTANCE);
}
});
其中ServerBusinessThreadPoolHandler
中,使用了自定義的線程池來處理耗時的getResult
方法。關鍵代碼如下:
private static ExecutorService threadPool = Executors.newFixedThreadPool(1000);
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
ByteBuf data = Unpooled.directBuffer();
data.writeBytes(msg);
threadPool.submit(() -> {
Object result = getResult(data);
ctx.channel().writeAndFlush(result);
});
}
再次運行服務端和客戶端,可以查看客戶端日志,QPS和AVG指標都有明顯的改善
....
qps: 1033, avg response time: 17.690498
qps: 1018, avg response time: 17.133448
qps: 1013, avg response time: 15.563113
qps: 1010, avg response time: 15.415672
qps: 1009, avg response time: 16.049961
qps: 1008, avg response time: 16.179882
qps: 1007, avg response time: 16.120466
qps: 1006, avg response time: 15.822202
qps: 1006, avg response time: 15.987518
....
實際生產過程中,Executors.newFixedThreadPool(1000);
中配置的數量需要通過壓測來驗證。
優化方案二:使用Netty原生的線程池優化
我們可以通過Netty提供的線程池來處理耗時的Handler,這樣的話,無需調整Handler的邏輯(對原有Handler無代碼侵入),關鍵代碼:
bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new FixedLengthFrameDecoder(Long.BYTES));
// ch.pipeline().addLast(ServerBusinessHandler.INSTANCE);
// 使用業務線程池方式
// ch.pipeline().addLast(ServerBusinessThreadPoolHandler.INSTANCE);
// 使用Netty自帶線程池方式
ch.pipeline().addLast(businessGroup,ServerBusinessHandler.INSTANCE);
}
});
其中businessGroup
是Netty自帶的線程池
EventLoopGroup businessGroup = new NioEventLoopGroup(1000);
ServerBusinessHandler
中的所有方法,都會在businessGroup
中執行。
再次啟動服務端和客戶端,查看客戶端日志
.....
qps: 1027, avg response time: 23.833092
qps: 1017, avg response time: 20.98855
qps: 1014, avg response time: 18.220013
qps: 1012, avg response time: 17.447332
qps: 1010, avg response time: 16.502508
qps: 1010, avg response time: 15.692251
qps: 1009, avg response time: 15.968423
qps: 1008, avg response time: 15.888149
.....
更多優化建議
1.如果QPS過高,數據傳輸過快的情況下,調用writeAndFlush可以考慮拆分成多次write,然后單次flush,也就是批量flush操作
2.分配和釋放內存盡量在reactor線程內部做,這樣內存就都可以在reactor線程內部管理
3.盡量使用堆外內存,盡量減少內存的copy操作,使用CompositeByteBuf可以將多個ByteBuf組合到一起讀寫
4.外部線程連續調用eventLoop的異步調用方法的時候,可以考慮把這些操作封裝成一個task,提交到eventLoop,這樣就不用多次跨線程
5.盡量調用ChannelHandlerContext.writeXXX()方法而不是channel.writeXXX()方法,前者可以減少pipeline的遍歷
6.如果一個ChannelHandler無數據共享,那么可以搞成單例模式,標注@Shareable,節省對象開銷對象
7.如果要做網絡代理類似的功能,盡量復用eventLoop,可以避免跨reactor線程