RPC入門總結(五)RPC IO基礎:Netty高性能併發關鍵技術點

轉載:Netty系列之Netty百萬級推送服務設計要點

轉載:RPC入門總結(四)RPC IO基礎:Netty原理和使用

轉載:Netty系列之Netty線程模型

轉載:用Netty開發中間件:高併發性能優化

轉載:Java與Netty實現高性能高併發

轉載:對於 Netty ByteBuf 的零拷貝(Zero Copy) 的理解

轉載:Netty系列之Netty高性能之道

一、Netty的零拷貝

即所謂的 Zero-copy, 就是在操作數據時, 不需要將數據 buffer 從一個內存區域拷貝到另一個內存區域。 因爲少了一次內存的拷貝, 因此 CPU 的效率就得到的提升。
在 OS 層面上的 Zero-copy 通常指避免在 用戶態(User-space) 與 內核態(Kernel-space) 之間來回拷貝數據。 例如 Linux 提供的 mmap 系統調用, 它可以將一段用戶空間內存映射到內核空間, 當映射成功後, 用戶對這段內存區域的修改可以直接反映到內核空間; 同樣地, 內核空間對這段區域的修改也直接反映用戶空間。 正因爲有這樣的映射關係, 我們就不需要在 用戶態(User-space) 與 內核態(Kernel-space) 之間拷貝數據, 提高了數據傳輸的效率。
而需要注意的是, Netty 中的 Zero-copy 與上面我們所提到到 OS 層面上的 Zero-copy 不太一樣, Netty的 Zero-coyp 完全是在用戶態(Java 層面)的, 它的 Zero-copy 的更多的是偏向於優化數據操作 這樣的概念。

Netty的“零拷貝”主要體現在如下三個方面:
1) Netty的接收和發送ByteBuffer採用DIRECT BUFFERS,使用堆外直接內存進行Socket讀寫,不需要進行字節緩衝區的二次拷貝。如果使用傳統的堆內存(HEAP BUFFERS)進行Socket讀寫,JVM會將堆內存Buffer拷貝一份到直接內存中,然後才寫入Socket中。相比於堆外直接內存,消息在發送過程中多了一次緩衝區的內存拷貝。
從源碼知,ByteBuffer由ChannelConfig分配,而ChannelConfig創建ByteBufAllocator默認使用Direct Buffer,這就避免了讀寫數據的二次內存拷貝問題,從而實現了讀寫Socket的零拷貝功能,

2) Netty提供了組合Buffer對象,可以聚合多個ByteBuffer對象,用戶可以像操作一個Buffer那樣方便的對組合Buffer進行操作,避免了傳統通過內存拷貝的方式將幾個小Buffer合併成一個大的Buffer。
1. 通過 CompositeByteBuf 實現零拷貝

假設我們有一份協議數據, 它由頭部和消息體組成, 而頭部和消息體是分別存放在兩個 ByteBuf 中的, 即:

ByteBuf header = ...
ByteBuf body = ...
我們在代碼處理中, 通常希望將 header 和 body 合併爲一個 ByteBuf, 方便處理, 那麼通常的做法是:

ByteBuf allBuf = Unpooled.buffer(header.readableBytes() + body.readableBytes());
allBuf.writeBytes(header);
allBuf.writeBytes(body);
可以看到, 我們將 header 和 body 都拷貝到了新的 allBuf 中了, 這無形中增加了兩次額外的數據拷貝操作了。而在Netty中可以使用CompositeByteBuf 實現該過程:

ByteBuf header = ...
ByteBuf body = ...
 
CompositeByteBuf compositeByteBuf = Unpooled.compositeBuffer();
compositeByteBuf.addComponents(true, header, body);
public CompositeByteBuf addComponents(boolean increaseWriterIndex, ByteBuf... buffers) {
...
}
addComponents方法將 header 與 body 合併爲一個邏輯上的 ByteBuf,雖然看起來 CompositeByteBuf 是由兩個 ByteBuf 組合而成的, 不過在 CompositeByteBuf 內部, 這兩個 ByteBuf 都是單獨存在的, CompositeByteBuf 只是邏輯上是一個整體。

上面 CompositeByteBuf 代碼還以一個地方值得注意的是, 我們調用 addComponents(boolean increaseWriterIndex, ByteBuf... buffers) 來添加兩個 ByteBuf, 其中第一個參數是 true, 表示當添加新的 ByteBuf 時, 自動遞增 CompositeByteBuf 的 writeIndex。如果我們調用的是

compositeByteBuf.addComponents(header, body);
那麼其實 compositeByteBuf 的 writeIndex 仍然是0, 因此此時我們就不可能從 compositeByteBuf 中讀取到數據。

2. 通過 wrap 操作實現零拷貝

例如我們有一個 byte 數組, 我們希望將它轉換爲一個 ByteBuf 對象, 以便於後續的操作, 那麼傳統的做法是將此 byte 數組拷貝到 ByteBuf 中, 即:

byte[] bytes = ...
ByteBuf byteBuf = Unpooled.buffer();
byteBuf.writeBytes(bytes);
顯然這樣的方式也是有一個額外的拷貝操作的, 我們可以使用 Unpooled 的相關方法, 包裝這個 byte 數組, 生成一個新的 ByteBuf 實例, 而不需要進行拷貝操作,上面的代碼可以改爲:

ByteBuf header = ...
ByteBuf body = ...
 
ByteBuf allByteBuf = Unpooled.wrappedBuffer(header, body);
3. 通過 slice 操作實現零拷貝

slice 操作和 wrap 操作剛好相反, Unpooled.wrappedBuffer 可以將多個 ByteBuf 合併爲一個, 而 slice 操作可以將一個 ByteBuf 切片 爲多個共享一個存儲區域的 ByteBuf 對象.
ByteBuf 提供了兩個 slice 操作方法:

public ByteBuf slice();
public ByteBuf slice(int index, int length);
不帶參數的 slice 方法等同於 buf.slice(buf.readerIndex(), buf.readableBytes()) 調用, 即返回 buf 中可讀部分的切片. 而 slice(int index, int length) 方法相對就比較靈活了, 我們可以設置不同的參數來獲取到 buf 的不同區域的切片。下面的例子展示了 ByteBuf.slice 方法的簡單用法:

ByteBuf byteBuf = ...
ByteBuf header = byteBuf.slice(0, 5);
ByteBuf body = byteBuf.slice(5, 10);
用 slice 方法產生 header 和 body 的過程是沒有拷貝操作的, header 和 body 對象在內部其實是共享了 byteBuf 存儲空間的不同部分而已。
3) Netty的文件傳輸採用了transferTo方法,它可以直接將文件緩衝區的數據發送到目標Channel,避免了傳統通過循環write方式導致的內存拷貝問題。

Netty 中使用 FileRegion 實現文件傳輸的零拷貝, 不過在底層 FileRegion 是依賴於 Java NIO FileChannel.transfer 的零拷貝功能.
首先我們從最基礎的 Java IO 開始吧。 假設我們希望實現一個文件拷貝的功能, 那麼使用傳統的方式, 我們有如下實現:

public static void copyFile(String srcFile, String destFile) throws Exception {
    byte[] temp = new byte[1024];
    FileInputStream in = new FileInputStream(srcFile);
    FileOutputStream out = new FileOutputStream(destFile);
    int length;
    while ((length = in.read(temp)) != -1) {
        out.write(temp, 0, length);
    }
 
    in.close();
    out.close();
}
上面的代碼中不斷中源文件中讀取定長數據到 temp 數組中,然後再將 temp 中的內容寫入目的文件, 這樣的拷貝操作對於小文件倒是沒有太大的影響,但是如果我們需要拷貝大文件時, 頻繁的內存拷貝操作就消耗大量的系統資源了。
下面我們來看一下使用 Java NIO 的 FileChannel 是如何實現零拷貝的:

public static void copyFileWithFileChannel(String srcFileName, String destFileName) throws Exception {
    RandomAccessFile srcFile = new RandomAccessFile(srcFileName, "r");
    FileChannel srcFileChannel = srcFile.getChannel();
 
    RandomAccessFile destFile = new RandomAccessFile(destFileName, "rw");
    FileChannel destFileChannel = destFile.getChannel();
 
    long position = 0;
    long count = srcFileChannel.size();
 
    srcFileChannel.transferTo(position, count, destFileChannel);
}
可以看到, 使用了 FileChannel 後, 我們就可以直接將源文件的內容直接拷貝(transferTo) 到目的文件中, 而不需要額外借助一個臨時 buffer,避免了不必要的內存操作。

有了上面的一些理論知識, 我們來看一下在 Netty 中是怎麼使用 FileRegion 來實現零拷貝傳輸一個文件的:

@Override
public void channelRead0(ChannelHandlerContext ctx, String msg) throws Exception {
    RandomAccessFile raf = null;
    long length = -1;
    try {
        // 1. 通過 RandomAccessFile 打開一個文件.
        raf = new RandomAccessFile(msg, "r");
        length = raf.length();
    } catch (Exception e) {
        ctx.writeAndFlush("ERR: " + e.getClass().getSimpleName() + ": " + e.getMessage() + '\n');
        return;
    } finally {
        if (length < 0 && raf != null) {
            raf.close();
        }
    }
 
    ctx.write("OK: " + raf.length() + '\n');
    if (ctx.pipeline().get(SslHandler.class) == null) {
        // SSL not enabled - can use zero-copy file transfer.
        // 2. 調用 raf.getChannel() 獲取一個 FileChannel.
        // 3. 將 FileChannel 封裝成一個 DefaultFileRegion
        ctx.write(new DefaultFileRegion(raf.getChannel(), 0, length));
    } else {
        // SSL enabled - cannot use zero-copy file transfer.
        ctx.write(new ChunkedFile(raf));
    }
    ctx.writeAndFlush("\n");
}
上面的代碼是 Netty 的一個例子,其源碼在 netty/example/src/main/java/io/netty/example/file/FileServerHandler.java
可以看到, 第一步是通過 RandomAccessFile 打開一個文件, 然後 Netty 使用了 DefaultFileRegion 來封裝一個 FileChannel,當有了 FileRegion 後, 我們就可以直接通過它將文件的內容直接寫入 Channel 中, 而不需要像傳統的做法: 拷貝文件內容到臨時 buffer, 然後再將 buffer 寫入 Channel. 通過這樣的零拷貝操作, 無疑對傳輸大文件很有幫助。

二、Netty內存管理

隨着JVM虛擬機和JIT即時編譯技術的發展,對象的分配和回收是個非常輕量級的工作。但是對於緩衝區Buffer,情況卻稍有不同,特別是對於堆外直接內存的分配和回收,是一件耗時的操作。爲了儘量重用緩衝區,Netty提供了基於內存池的緩衝區重用機制。netty 內存管理的高性能主要依賴於兩個關鍵點:內存的池化管理使用堆外直接內存(Direct Memory)

1. Netty內存池

Netty4的內存池集大家之精華,參考了各路英雄豪傑的優秀思想,它參考了slab分配Buddy(夥伴)分配。接觸過memcached的應該瞭解slab分配,它的思路是把內存分割成大小不等的內存塊,用戶線程請求內存時根據請求的內存大小分配最貼近size的內存塊,在減少內存碎片的同時又能很好的避免內存浪費。Buddy分配是在分配的過程中把一些內存塊等量分割,回收時合併,儘可能保證系統中有足夠大的連續內存。

從內存回收的角度分類Netty中的ByteBuf可分爲:
(1)基於對象池的ByteBuf(PoolByteBuf):PooledByteBuf和它的子類PoolDirectByteBuf、PoolUnsafeDirectByteBuf、PooledHeapByteBuf。它的特點是可以循環利用創建的ByteBuf,提高了內存的使用效率,PoolByteBuf的實現牽涉的數據結構很多,PoolByteBuf首先會申請一大塊內存區域PoolArena,PoolArena由多個Chunk組成,而每個Chunk由一個或多個page組成 
(2)普通的ByteBuf(UnPoolByteBuf):UnPoolDirectByteBuf、UnPoolUnsafeDirectByteBuf、UnPoolHeapByteBuf
總結: 
在高負載,大併發的情況下對象池的ByteBuf更好,而在一般情況下,可以使用UnPoolByteBuf

2. Netty直接內存

從內存分配角度分類Netty中的ByteBuf可分爲:

(1)堆內存字節緩衝區(HeapByteBuf):UnPoolHeapByteBuf、PooledHeapByteBuf 

它的特點是內存的分配和回收都在堆,所以速度很快;缺點就是進行Socket的IO讀寫,需要把堆內存對應的緩衝區複製到內核Channel中,這內存複製會影響性能 

(2)直接內存緩衝區(DirectByteBuf):UnPoolDirectByteBuf、UnPoolUnsafeDirectByteBuf、PoolDirectByteBuf、PoolUnsafeDirectByteBuf 

它的特點是由於內存的分配在非堆(方法區),不需要內存複製,所以IO讀取的速度較快,但是內存的分配較慢

總結: 

根據兩種內存的特點,我們可以知道,IO讀寫時最好使用DirectByteBuf,而在後端業務消息的解編碼最好使用HeapByteBuf

三、Netty的無鎖化串行設計理念

在大多數場景下,並行多線程處理可以提升系統的併發性能。但是,如果對於共享資源的併發訪問處理不當,會帶來嚴重的鎖競爭,這最終會導致性能的下降。爲了儘可能的避免鎖競爭帶來的性能損耗,可以通過串行化設計,即消息的處理儘可能在同一個線程內完成,期間不進行線程切換,這樣就避免了多線程競爭和同步鎖。
爲了儘可能提升性能,Netty採用了串行無鎖化設計,在IO線程內部進行串行操作,避免多線程競爭導致的性能下降。表面上看,串行化設計似乎CPU利用率不高,併發程度不夠。但是,通過調整NIO線程池的線程參數,可以同時啓動多個串行化的線程並行運行,這種局部無鎖化的串行線程設計相比一個隊列-多個工作線程模型性能更優。Netty的串行化設計工作原理圖如下:

Netty的NioEventLoop讀取到消息之後,直接調用ChannelPipeline的fireChannelRead(Object msg),只要用戶不主動切換線程,一直會由NioEventLoop調用到用戶的Handler,期間不進行線程切換,這種串行化處理方式避免了多線程操作導致的鎖的競爭,從性能角度看是最優的。

(但是遺憾的是,雖然IO操作可以做到無鎖化設計,但是在代碼實現時一般在複雜邏輯中要將IO設計與業務邏輯分離,IO操作後使用Runnable封裝爲任務,使用業務邏輯線程池知行業務,此時需要在串行化的基礎上再加線程池,那就是另外層面的事情了,業務的複雜性導致無鎖化串行只能是局部設計,最優的設計永遠在路上。)

四、Netty業務設計原則

1. 時間可控的簡單業務直接在IO線程上處理

如果業務非常簡單,執行時間非常短,不需要與外部網元交互、訪問數據庫和磁盤,不需要等待其它資源,則建議直接在業務ChannelHandler中執行,不需要再啓業務的線程或者線程池。避免線程上下文切換,也不存在線程併發問題。

2. 複雜和時間不可控業務建議投遞到後端業務線程池統一處理

對於此類業務,不建議直接在業務ChannelHandler中啓動線程或者線程池處理,建議將不同的業務統一封裝成Task,統一投遞到後端的業務線程池中進行處理。
過多的業務ChannelHandler會帶來開發效率和可維護性問題,不要把Netty當作業務容器,對於大多數複雜的業務產品,仍然需要集成或者開發自己的業務容器,做好和Netty的架構分層。

3. 業務線程避免直接操作ChannelHandler

對於ChannelHandler,IO線程業務線程都可能會操作,因爲業務通常是多線程模型,這樣就會存在多線程操作ChannelHandler。爲了儘量避免多線程併發問題,建議按照Netty自身的做法,通過將操作封裝成獨立的Task由NioEventLoop統一執行,而不是業務線程直接操作,

if (ctx.executor().inEventLoop()) {
    try{
        doFlush();
    } catch {
        if (logger.isWarnEnabled) {
            logger.warn("Unexpected exception while sending chunks.", e);
        }
    }
} else {
    ctx.executor().execute(new Runnable() {
        @Override
        public void run() {
            try{
                doFlush(ctx);
            } catch (Exception e) {
                ...
            }
        }
    })
}
如果你確認併發訪問的數據或者併發操作是安全的,則無需多此一舉,這個需要根據具體的業務場景進行判斷,靈活處理。

五、Netty海量推送服務設計要點

1. 最大句柄數修改

百萬長連接接入,首先需要優化的就是Linux內核參數,其中Linux最大文件句柄數是最重要的調優參數之一,默認單進程打開的最大句柄數是1024,通過ulimit -a可以查看相關參數,示例如下:

[root@lilinfeng ~]# ulimit -a
core file size          (blocks, -c) 0
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 256324
max locked memory       (kbytes, -l) 64
max memory size         (kbytes, -m) unlimited
open files                      (-n) 1024

......後續輸出省略
當單個推送服務接收到的鏈接超過上限後,就會報“too many open files”,所有新的客戶端接入將失敗。
通過vi /etc/security/limits.conf 添加如下配置參數:修改之後保存,註銷當前用戶,重新登錄,通過ulimit -a 查看修改的狀態是否生效。

*  soft  nofile  1000000
*  hard  nofile  1000000
需要指出的是,儘管我們可以將單個進程打開的最大句柄數修改的非常大,但是當句柄數達到一定數量級之後,處理效率將出現明顯下降,因此,需要根據服務器的硬件配置和處理能力進行合理設置。如果單個服務器性能不行也可以通過集羣的方式實現。

2. 當心CLOSE_WAIT

在百萬長連接的推送系統中,服務端需要能夠正確處理這些網絡異常,設計要點如下:
1. 客戶端的重連間隔需要合理設置,防止連接過於頻繁導致的連接失敗(例如端口還沒有被釋放);
2. 客戶端重複登陸拒絕機制
3. 服務端正確處理I/O異常解碼異常等,防止句柄泄露
4. 最後特別需要注意的一點就是close_wait 過多問題,

由於網絡不穩定經常會導致客戶端斷連,如果服務端沒有能夠及時關閉socket,就會導致處於close_wait狀態的鏈路過多。close_wait狀態的鏈路並不釋放句柄和內存等資源,如果積壓過多可能會導致系統句柄耗盡,發生“Too many open files”異常,新的客戶端無法接入,涉及創建或者打開句柄的操作都將失敗。
close_wait是被動關閉連接時形成的,根據TCP狀態機,服務器端收到客戶端發送的FIN,TCP協議棧會自動發送ACK,鏈接進入close_wait狀態。但如果服務器端不執行socket的close()操作,狀態就不能由close_wait遷移到last_ack,則系統中會存在很多close_wait狀態的連接。通常來說,一個close_wait會維持至少2個小時的時間(系統默認超時時間的是7200秒,也就是2小時)。如果服務端程序因某個原因導致系統造成一堆close_wait消耗資源,那麼通常是等不到釋放那一刻,系統就已崩潰。
導致close_wait過多的可能原因如下:
1. 程序處理Bug,導致接收到對方的fin之後沒有及時關閉socket,這可能是Netty的Bug,也可能是業務層Bug,需要具體問題具體分析;
2. 關閉socket不及時:例如I/O線程被意外阻塞,或者I/O線程執行的用戶自定義Task比例過高,導致I/O操作處理不及時,鏈路不能被及時釋放。
下面我們結合Netty的原理,對潛在的故障點進行分析。

設計要點1不要在Netty的I/O線程上處理業務(心跳發送和檢測除外)。

對於Java進程,線程不能無限增長,這就意味着Netty的Reactor線程數必須收斂。Netty的默認值是CPU核數 * 2,通常情況下,I/O密集型應用建議線程數儘量設置大些,但這主要是針對傳統同步I/O而言,對於非阻塞I/O,線程數並不建議設置太大,儘管沒有最優值,但是I/O線程數經驗值是[CPU核數 + 1,CPU核數*2 ]之間。

設計要點2:在I/O線程上執行自定義Task要當心

Netty的I/O處理線程NioEventLoop支持兩種自定義Task的執行:
1. 普通的Runnable: 通過調用NioEventLoop的execute(Runnable task)方法執行;
2. 定時任務ScheduledFutureTask:通過調用NioEventLoop的schedule(Runnable command, long delay, TimeUnit unit)系列接口執行。

在NioEventLoop中執行Runnable和ScheduledFutureTask,意味着允許用戶在NioEventLoop中執行非I/O操作類的業務邏輯,這些業務邏輯通常用消息報文的處理和協議管理相關。它們的執行會搶佔NioEventLoop I/O讀寫的CPU時間,如果用戶自定義Task過多,或者單個Task執行週期過長,會導致I/O讀寫操作被阻塞,這樣也間接導致close_wait堆積。

所以,如果用戶在代碼中使用到了Runnable和ScheduledFutureTask,請合理設置ioRatio的比例,通過NioEventLoop的setIoRatio(int ioRatio)方法可以設置該值,默認值爲50,即I/O操作和用戶自定義任務的執行時間比爲1:1。

我的建議是當服務端處理海量客戶端長連接的時候,不要在NioEventLoop中執行自定義Task,或者非心跳類的定時任務

設計要點3:IdleStateHandler使用要當心

很多用戶會使用IdleStateHandler做心跳發送和檢測,這種用法值得提倡。相比於自己啓定時任務發送心跳,這種方式更高效。但是在實際開發中需要注意的是,在心跳的業務邏輯處理中,無論是正常還是異常場景,處理時延要可控,防止時延不可控導致的NioEventLoop被意外阻塞

例如,心跳超時或者發生I/O異常時,業務調用Email發送接口告警,由於Email服務端處理超時,導致郵件發送客戶端被阻塞,級聯引起IdleStateHandler的AllIdleTimeoutTask任務被阻塞,最終NioEventLoop多路複用器上其它的鏈路讀寫被阻塞。對於ReadTimeoutHandler和WriteTimeoutHandler,約束同樣存在。

3. 合理的心跳週期

百萬級的推送服務,意味着會存在百萬個長連接,每個長連接都需要靠和App之間的心跳來維持鏈路。合理設置心跳週期是非常重要的工作,推送服務的心跳週期設置需要考慮移動無線網絡的特點。

當一臺智能手機連上移動網絡時,其實並沒有真正連接上Internet,運營商分配給手機的IP其實是運營商的內網IP,手機終端要連接上Internet還必須通過運營商的網關進行IP地址的轉換,這個網關簡稱爲NAT(NetWork Address Translation),簡單來說就是手機終端連接Internet 其實就是移動內網IP,端口,外網IP之間相互映射。

GGSN(GateWay GPRS Support Note)模塊就實現了NAT功能,由於大部分的移動無線網絡運營商爲了減少網關NAT映射表的負荷,如果一個鏈路有一段時間沒有通信時就會刪除其對應表,造成鏈路中斷,正是這種刻意縮短空閒連接的釋放超時,原本是想節省信道資源的作用,沒想到讓互聯網的應用不得以遠高於正常頻率發送心跳來維護推送的長連接。以中移動的2.5G網絡爲例,大約5分鐘左右的基帶空閒,連接就會被釋放。

由於移動無線網絡的特點,推送服務的心跳週期並不能設置的太長,否則長連接會被釋放,造成頻繁的客戶端重連,但是也不能設置太短,否則在當前缺乏統一心跳框架的機制下很容易導致信令風暴(例如微信心跳信令風暴問題)。具體的心跳週期並沒有統一的標準,180S也許是個不錯的選擇,微信爲300S。

4. 合理設置接收和發送緩衝區容量

對於長鏈接,每個鏈路都需要維護自己的消息接收和發送緩衝區,JDK原生的NIO類庫使用的是java.nio.ByteBuffer,它實際是一個長度固定的Byte數組,我們都知道數組無法動態擴容,ByteBuffer也有這個限制,容量無法動態擴展會給用戶帶來一些麻煩,例如由於無法預測每條消息報文的長度,可能需要預分配一個比較大的ByteBuffer,這通常也沒有問題。但是在海量推送服務系統中,這會給服務端帶來沉重的內存負擔。假設單條推送消息最大上限爲10K,消息平均大小爲5K,爲了滿足10K消息的處理,ByteBuffer的容量被設置爲10K,這樣每條鏈路實際上多消耗了5K內存,如果長鏈接鏈路數爲100萬,每個鏈路都獨立持有ByteBuffer接收緩衝區,則額外損耗的總內存 Total(M) = 1000000 * 5K = 4882M。內存消耗過大,不僅僅增加了硬件成本,而且大內存容易導致長時間的Full GC,對系統穩定性會造成比較大的衝擊。

實際上,最靈活的處理方式就是能夠動態調整內存,即接收緩衝區可以根據以往接收的消息進行計算,動態調整內存,利用CPU資源來換內存資源,具體的策略如下:

1. ByteBuffer支持容量的擴展和收縮,可以按需靈活調整,以節約內存;

2. 接收消息的時候,可以按照指定的算法對之前接收的消息大小進行分析,並預測未來的消息大小,按照預測值靈活調整緩衝區容量,以做到最小的資源損耗滿足程序正常功能。

幸運的是,Netty提供的ByteBuf支持容量動態調整,對於接收緩衝區的內存分配器,Netty提供了兩種:

1. FixedRecvByteBufAllocator:固定長度的接收緩衝區分配器,由它分配的ByteBuf長度都是固定大小的,並不會根據實際數據報的大小動態收縮。但是,如果容量不足,支持動態擴展。動態擴展是Netty ByteBuf的一項基本功能,與ByteBuf分配器的實現沒有關係;

2. AdaptiveRecvByteBufAllocator:容量動態調整的接收緩衝區分配器,它會根據之前Channel接收到的數據報大小進行計算,如果連續填充滿接收緩衝區的可寫空間,則動態擴展容量。如果連續2次接收到的數據報都小於指定值,則收縮當前的容量,以節約內存。

相對於FixedRecvByteBufAllocator,使用AdaptiveRecvByteBufAllocator更爲合理,可以在創建客戶端或者服務端的時候指定RecvByteBufAllocator,代碼如下:

Bootstrap b = new Bootstrap();
            b.group(group)
             .channel(NioSocketChannel.class)
             .option(ChannelOption.TCP_NODELAY, true)
             .option(ChannelOption.RCVBUF_ALLOCATOR, AdaptiveRecvByteBufAllocator.DEFAULT)
如果默認沒有設置,則使用AdaptiveRecvByteBufAllocator。
另外值得注意的是,無論是接收緩衝區還是發送緩衝區,緩衝區的大小建議設置爲消息的平均大小,不要設置成最大消息的上限,這會導致額外的內存浪費。通過如下方式可以設置接收緩衝區的初始大小:
/**
	 * Creates a new predictor with the specified parameters.
	 * 
	 * @param minimum
	 *            the inclusive lower bound of the expected buffer size
	 * @param initial
	 *            the initial buffer size when no feed back was received
	 * @param maximum
	 *            the inclusive upper bound of the expected buffer size
	 */
	public AdaptiveRecvByteBufAllocator(int minimum, int initial, int maximum) 

5. 內存池

推送服務器承載了海量的長鏈接,每個長鏈接實際就是一個會話。如果每個會話都持有心跳數據、接收緩衝區、指令集等數據結構,而且這些實例隨着消息的處理朝生夕滅,這就會給服務器帶來沉重的GC壓力,同時消耗大量的內存。
最有效的解決策略就是使用內存池,每個NioEventLoop線程處理N個鏈路,在線程內部,鏈路的處理時串行的。假如A鏈路首先被處理,它會創建接收緩衝區等對象,待解碼完成之後,構造的POJO對象被封裝成Task後投遞到後臺的線程池中執行,然後接收緩衝區會被釋放,每條消息的接收和處理都會重複接收緩衝區的創建和釋放。如果使用內存池,則當A鏈路接收到新的數據報之後,從NioEventLoop的內存池中申請空閒的ByteBuf,解碼完成之後,調用release將ByteBuf釋放到內存池中,供後續B鏈路繼續使用。
使用內存池優化之後,單個NioEventLoop的ByteBuf申請和GC次數從原來的N = 1000000/64 = 15625 次減少爲最少0次(假設每次申請都有可用的內存)。

在Netty 4中實現了一個新的ByteBuf內存池,它是一個純Java版本的 jemalloc (Facebook也在用)。現在,Netty不會再因爲用零填充緩衝區而浪費內存帶寬了。不過,由於它不依賴於GC,開發人員需要小心內存泄漏。如果忘記在處理程序中釋放緩衝區,那麼內存使用率會無限地增長。

Netty默認不使用內存池,需要在創建客戶端或者服務端的時候進行指定,代碼如下:

Bootstrap b = new Bootstrap();
            b.group(group)
             .channel(NioSocketChannel.class)
             .option(ChannelOption.TCP_NODELAY, true)
             .option(ChannelOption.ALLOCATOR, PooledByteBufAllocator.DEFAULT)
使用內存池之後,內存的申請和釋放必須成對出現,即retain()和release()要成對出現,否則會導致內存泄露。
值得注意的是,如果使用內存池,完成ByteBuf的解碼工作之後必須顯式的調用ReferenceCountUtil.release(msg)對接收緩衝區ByteBuf進行內存釋放,否則它會被認爲仍然在使用中,這樣會導致內存泄露。

6. 當心“日誌隱形殺手”

通常情況下,大家都知道不能在Netty的I/O線程上做執行時間不可控的操作,例如訪問數據庫、發送Email等。但是有個常用但是非常危險的操作卻容易被忽略,那便是記錄日誌。
通常,在生產環境中,需要實時打印接口日誌,其它日誌處於ERROR級別,當推送服務發生I/O異常之後,會記錄異常日誌。如果當前磁盤的WIO比較高,可能會發生寫日誌文件操作被同步阻塞,阻塞時間無法預測。這就會導致Netty的NioEventLoop線程被阻塞,Socket鏈路無法被及時關閉、其它的鏈路也無法進行讀寫操作等。
以最常用的log4j爲例,儘管它支持異步寫日誌(AsyncAppender),但是當日志隊列滿之後,它會同步阻塞業務線程,直到日誌隊列有空閒位置可用,相關代碼如下:

synchronized (this.buffer) {
      while (true) {
        int previousSize = this.buffer.size();
        if (previousSize < this.bufferSize) {
          this.buffer.add(event);
          if (previousSize != 0) break;
          this.buffer.notifyAll(); break;
        }
        boolean discard = true;
        if ((this.blocking) && (!Thread.interrupted()) && (Thread.currentThread() != this.dispatcher)) //判斷是業務線程
        {
          try
          {
            this.buffer.wait();//阻塞業務線程
            discard = false;
          }
          catch (InterruptedException e)
          {
            Thread.currentThread().interrupt();
          }

        }
類似這類BUG具有極強的隱蔽性,往往WIO高的時間持續非常短,或者是偶現的,在測試環境中很難模擬此類故障,問題定位難度非常大。這就要求讀者在平時寫代碼的時候一定要當心,注意那些隱性地雷。

7. TCP參數優化

常用的TCP參數,例如TCP層面的接收和發送緩衝區大小設置,在Netty中分別對應ChannelOptionSO_SNDBUFSO_RCVBUF,需要根據推送消息的大小,合理設置,對於海量長連接,通常32K是個不錯的選擇。
另外一個比較常用的優化手段就是軟中斷,如圖所示:如果所有的軟中斷都運行在CPU0相應網卡的硬件中斷上,那麼始終都是cpu0在處理軟中斷,而此時其它CPU資源就被浪費了,因爲無法並行的執行多個軟中斷。
大於等於2.6.35版本的Linux kernel內核,開啓RPS,網絡通信性能提升20%之上。RPS的基本原理:根據數據包的源地址目的地址以及目的和源端口,計算出一個hash值,然後根據這個hash值來選擇軟中斷運行的cpu。從上層來看,也就是說將每個連接和cpu綁定,並通過這個hash值,來均衡軟中斷運行在多個cpu上,從而提升通信性能。

8. JVM參數

最重要的參數調整有兩個:
-Xmx:JVM最大內存需要根據內存模型進行計算並得出相對合理的值;
GC相關的參數: 例如新生代和老生代、永久代的比例,GC的策略,新生代各區的比例等,需要根據具體的場景進行設置和測試,並不斷的優化,儘量將Full GC的頻率降到最低。

上文轉載:Netty系列之Netty百萬級推送服務設計要點,厲害了





發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章