單機百萬連接調優和Netty應用級別調優

作者: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.jarServer.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.shstartClient.sh

先啟動服務端

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
.....

更多優化建議

參考Netty性能調優奇技淫巧還有其他的嗎?

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線程

源碼

Github

參考資料

深度解析Netty源碼