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

Grey Zeng發表於2021-10-29

作者: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原始碼

相關文章