使用Netty處理Java中成千上萬個連線的原理 -DZone效能

banq發表於2020-02-16

C10K問題是代表一萬個併發處理連線的術語。為此,我們經常需要更改已建立的網路套接字的設定以及Linux核心的預設設定,監視  TCP傳送/接收緩衝區和佇列的使用,  尤其是將我們的應用程式調整為合適的選項來解決這個問題。

在今天的文章中,我將討論如果我們要構建可處理數千個連線的可伸縮應用程式,則需要遵循一些通用原則。如果您想從應用程式和底層系統中獲得一些見識,我將參考Netty Framework,TCP和Socket內部以及一些有用的工具。

原則1:確保您的應用適合C10K問題

如上所述,當我們需要在最少的上下文切換和低記憶體佔用的情況下儘可能多地利用CPU時,則需要使程式中的執行緒數非常接近於給定專用處理器的數量。

請牢記這一點,唯一可能的解決方案是選擇一些非阻塞業務邏輯或具有很高CPU / IO處理時間比例(但是已經很危險)的業務邏輯。

有時,在您的應用程式堆疊中識別此行為不是很容易,將需要重新排列應用程式/程式碼,新增其他外部佇列(RabbitMQ)或主題(Kafka),使用分散式系統,緩衝任務並能夠從中拆分非阻塞程式碼。

但是,根據我的經驗,由於以下原因,值得重寫我的程式碼並使之更加不受阻塞:

  • 我將我的應用程式分為兩個不同的應用程式,即使它們共享相同的“域”,它們也很可能不會共享相同的部署和設計策略(例如,應用程式的一部分是可以使用執行緒池實現的REST端點,基於HTTP的伺服器,第二部分是佇列/主題的使用者,該佇列/主題使用非阻塞驅動程式將某些內容寫入DB。
  • 我能夠以不同的方式縮放這兩個部分的例項數,因為負載/ CPU /記憶體很可能完全不同。

使用適當的工具:

  • 我們保持執行緒數量儘可能少。不要忘記不僅檢查伺服器執行緒,還檢查應用程式的其他部分:佇列/主題使用者,DB驅動程式設定,日誌記錄設定(使用非同步微批處理)。始終進行執行緒轉儲dump,以檢視在您的應用程式中建立了哪些執行緒以及建立了多少執行緒(不要忘記使其在負載下進行,否則您的執行緒池將不會被完全初始化,其中很多都是延遲建立執行緒的)。我總是從執行緒池中為我的自定義執行緒命名(找到受害者並除錯程式碼要容易得多)。
  • 請注意,如果堵塞發生在對其他服務的HTTP / DB呼叫,我們可以使用反應式客戶端,該客戶端自動為傳入的響應註冊回撥。考慮使用更適合服務2服務通訊的協議,例如RSocket。
  • 檢查您的應用程式中包含的執行緒數是否一直很少。它指的是您的應用程式是否具有有限的執行緒池,並且能夠承受給定的負載。

如果您的應用程式具有多個處理流,請始終驗證其中哪些正在阻塞以及哪些是非阻塞。如果阻塞流的數量很大,那麼您幾乎肯定需要使用不同執行緒(來自預定義執行緒池)處理每個請求。在這種情況下,請考慮將基於執行緒池的HTTP Server與工作程式一起使用,在該伺服器上,所有請求都與一個非常大的執行緒池放在不同的執行緒上以提高吞吐量。

原理2:快取連線,而不是執行緒

該原理與HTTP Server程式設計模型的主題緊密相關  。主要思想不是將連線繫結到單個執行緒,而是使用一些庫,這些庫支援稍微複雜但更有效的讀取TCP方法。

這並不意味著TCP連線絕對是免費的。最關鍵的部分是  TCP握手。因此,您應該始終使用持久連線(如設定Nginx的keep-alive)。如果僅將一個TCP連線用於傳送一條訊息,則將支付8個TCP段的開銷(連線和關閉連線= 7個段)。

接受新的TCP連線

如果我們處在無法使用持久連線的情況下,那麼很可能在很短的時間內就會產生大量已建立的連線。必須將這些已建立的連線排隊,並等待接受我們的應用程式。

使用Netty處理Java中成千上萬個連線的原理 -DZone效能

在上圖中,我們可以看到積壓了SYN和LISTEN。在  SYN Backlog中, 我們可以找到僅等待使用TCP Handshake進行確認的連線。但是,在LISTEN  Backlog列表中, 我們已經完全初始化了連線,即使使用僅等待應用程式接受的TCP傳送/接收緩衝區也是如此。請閱讀SYN Flood DDoS攻擊。 

如果我們承受著很大的負擔,並且有很多傳入連線,那麼實際上存在一個問題,負責接受連線的應用程式執行緒可能很繁忙:對已經連線的客戶端執行IO。

new ServerBootstrap()
     .channel(EpollServerSocketChannel.class)
     .group(bossEventLoopGroup, workerEventLoopGroup)
     .localAddress(8080)
     .childOption(ChannelOption.SO_SNDBUF, 1024 * 1024)
     .childOption(ChannelOption.SO_RCVBUF, 32 * 1024)
     .childHandler(new CustomChannelInitializer());

在上面的程式碼段(Netty Server配置API)中,我們可以看到  bossEventLoopGroup 和   workerEventLoopGroup 。雖然   workerEventLoopGroup 預設情況下是使用CPU數量* 2個執行緒/事件迴圈建立的, 用於執行IO操作,但  bossEventLoopGroup 其中一個執行緒用於接受新連線。但是在這種情況下,如果由於在ChannelHandlers中執行I / O或執行更長的操作,因此接受新的連線可能會堵塞捱餓 。

如果遇到完全LISTEN Backlog問題,則可以增加bossEventLoopGroup中的執行緒數   。我們可以很容易地測試我們的過程是否能夠承受傳入連線的負載。我修改了測試應用程式Websocket-Broadcaster  以連線2萬個客戶端,並多次執行以下命令: 

$ ss -plnt sport = :8081|cat
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 42 128 *:8081 *:* users:(("java",pid=7418,fd=86))
$ ss -plnt sport = :8081|cat
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0 128 *:8081 *:* users:(("java",pid=7418,fd=86))
$ ss -plnt sport = :8081|cat
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 20 128 *:8081 *:* users:(("java",pid=7418,fd=86))
$ ss -plnt sport = :8081|cat
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 63 128 *:8081 *:* users:(("java",pid=7418,fd=86))
$ ss -plnt sport = :8081|cat
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0 128 *:8081 *:* users:(("java",pid=7418,fd=86))

  • Send-Q:LISTEN Backlog的總大小
  • Recv-Q:LISTEN Backlog列表中的當前連線數

修改
LISTEN Backlog的總大小:

# Current default size of LISTEN Backlog
# Feel free to change it and test SS command again
cat /proc/sys/net/core/somaxconn
128

TCP傳送/接收緩衝區

但是,當連線就緒時,最貪婪的部分是TCP傳送/接收緩衝區  ,該緩衝區用於將應用程式寫入的位元組傳輸到基礎網路堆疊。這些緩衝區的大小可以通過應用程式設定:

new ServerBootstrap()

     .channel(EpollServerSocketChannel.class)

     .group(bossEventLoopGroup, workerEventLoopGroup)

     .localAddress(8080)

     .childOption(ChannelOption.SO_SNDBUF, 1024 * 1024)

     .childOption(ChannelOption.SO_RCVBUF, 32 * 1024)

     .childHandler(new CustomChannelInitializer());

有關Java中的Socket Options的更多資訊,請檢視  StandardSocketOptions  類。較新版本的Linux可以與TCP擁塞視窗配合使用,自動調整緩衝區以達到當前負載的最佳大小。

 在進行任何自定義大小調整之前,請閱讀“  TCP緩衝區大小調整”。較大的緩衝區可能會導致記憶體浪費,另一方面,較小的緩衝區可能會限制讀取器或寫入器的應用程式,因為將沒有空間將位元組傳輸到網路堆疊或從網路堆疊傳輸位元組。

為什麼快取Thread是一個壞主意?

Java Thread是一個非常昂貴的物件,因為它一對一對映到了核心執行緒(希望Loom專案來得早可以拯救我們)。在Java中,我們可以使用-Xss 選項限制執行緒的堆疊大小,該  選項預設設定為1MB。這意味著一個執行緒佔用1MB的虛擬記憶體,實際的RSS(居民集大小)等於堆疊的當前大小;記憶體在開始時並未完全分配並對映到實體記憶體(這經常被誤解,如本文中所展示的:  Java執行緒需要多少記憶體?)。通常(根據我的經驗),如果我們真的不使用某些貪婪的框架或遞迴,則執行緒的大小以數百千位元組(200-300kB)為單位。這種記憶體屬於本機記憶體。我們可以在“本  機記憶體跟蹤”中進行跟蹤。

$ java -XX:+UnlockDiagnosticVMOptions -XX:NativeMemoryTracking=summary /
-XX:+PrintNMTStatistics -version
openjdk version "11.0.2" 2019-01-15
OpenJDK Runtime Environment AdoptOpenJDK (build 11.0.2+9)
OpenJDK 64-Bit Server VM AdoptOpenJDK (build 11.0.2+9, mixed mode)
Native Memory Tracking:
Total: reserved=6643041KB, committed=397465KB
-                 Java Heap (reserved=5079040KB, committed=317440KB)
                            (mmap: reserved=5079040KB, committed=317440KB)
-                     Class (reserved=1056864KB, committed=4576KB)
                            (classes #426)
                            (  instance classes #364, array classes #62)
                            (malloc=96KB #455)
                            (mmap: reserved=1056768KB, committed=4480KB)
                            (  Metadata:   )
                            (    reserved=8192KB, committed=4096KB)
                            (    used=2849KB)
                            (    free=1247KB)
                            (    waste=0KB =0,00%)
                            (  Class space:)
                            (    reserved=1048576KB, committed=384KB)
                            (    used=270KB)
                            (    free=114KB)
                            (    waste=0KB =0,00%)
-                    Thread (reserved=15461KB, committed=613KB)
                            (thread #15)
                            (stack: reserved=15392KB, committed=544KB)
                            (malloc=52KB #84)
                            (arena=18KB #28)

 大量執行緒的另一個問題是龐大的  Root Set。例如,我們有4個CPU和200個執行緒。在這種情況下,我們仍然只能在給定的時間執行4個執行緒,但是如果所有200個執行緒已經在忙於處理某個請求,我們將為已經在Java Heap上分配但無法建立任何物件的物件付出巨大的代價:執行緒必須等待CPU空閒的時間。

所有已分配且仍在使用的物件是Live Set一部分,Live Set是在垃圾收集週期中必須遍歷且無法收集的可訪問物件。

為什麼Root Set 很重要?

使用Netty處理Java中成千上萬個連線的原理 -DZone效能

當前只有4個執行緒在CPU上執行並且其餘執行緒僅在CPU Run Queue中等待時,紅點可以表示任何時間點  。任務的完成並不意味著到目前為止分配的所有物件仍然處於活動狀態,並且也並不都是活動集的一部分  ,它們已經變為垃圾,等待下一個GC週期被刪除。這種情況有什麼不好?

  • Live Set太大:每個執行緒都會在Java Heap上保留分配的活動物件,這些物件僅等待CPU取得一些進展。當我們調整堆大小時,我們需要牢記這一點,這種方式將以非常低的效率使用大量記憶體,特別是如果我們要設計處理大量請求的小型服務時。   
  • 由於更大的Root Set而導致GC暫停更大:更大的Root Set對我們的垃圾收集器意味著複雜的工作。 現代GC首先從識別Root Set(也稱為快照快照或初始標記)開始 ,主要是通過活動執行緒保持堆外部分配的可訪問物件(但不僅是執行緒),然後同時遍歷物件圖/查詢當前Live Set的參考。根root設定越大,GC識別和遍歷它的工作就越多,此外,初始標記通常是稱為“世界停止” 的階段。併發遍歷龐大的物件圖還會導致較大的延遲和GC本身的較差的可預測性(GC必須更早地開始併發階段以使其在堆滿之前,這也取決於分配率)。
  • 升級到老一代:較大的“ Live Set”還將影響將給定物件視為活動物件的時間。即使保持該物件的執行緒大部分時間都花在了CPU之外,這也增加了將該物件提升為舊一代(或至少提升為生存空間)的機會。

原則3:停止生成垃圾

如果您真的想編寫一個承受巨大負擔的應用程式,那麼您需要考慮所有物件的分配,並且不要讓JVM浪費任何單個位元組。Netty給我們帶來了  ByteBuffers 和ByteBuf 。這是非常先進的,因此非常簡短。

ByteBuffers是JDK的byte持有者,有兩個選項HeapByteBuffer (在堆上分配的位元組陣列)和  DirectByteBuffer(堆外記憶體),DirectByteBuffers可以直接傳遞給本機OS功能來執行I / O,換句話說,當您使用Java執行I / O時,您可以傳遞一個引用DirectByteBuffer (帶有偏移量和長度)。 

這可以在許多用例中提供幫助。

假設有1萬個連線,並且希望向所有連線廣播相同的字串值。沒有理由傳遞字串並導致將相同的字串10k次對映到byte,甚至更糟的是,為每個客戶端連線生成新的字串物件,並使用相同的位元組陣列汙染堆;相反,我們可以生成自己的DirectByteBuffer 並將其提供給所有連線,通過JVM將其傳遞給作業系統。

但是,有一個問題。DirectByteBuffer 分配非常昂貴。因此,在JDK中,每個進行I / O的執行緒都為此內部快取了一個DirectByteBuffer 。

那麼,為啥需要HeapByteBuffer? HeapByteBuffer 在分配方面要便宜得多。如果考慮到上面的示例,我們至少可以省去第一步-將字串編碼為位元組陣列(而不是將其編碼10k次),然後我們可以依靠它的DirectByteBuffer自動快取機制  ,而不必為每個新的字串訊息分配新 的DirectByteBuffer付出高昂成本,否則我們將需要在業務程式碼中開發自己的快取機制。 

何時使用沒有快取的DirectByteBuffer 和使用帶有自動快取的HeapByteBuffer ?需要權衡。

上面還提到了Netty的ByteBuf機制。實際上也是ByteBuffers概念;但是,我們可以享受基於2個索引的便利API(一個用於讀取,一個用於寫入)。另一個區別是回收記憶體。DirectByteBuffer 是基於JDK Cleaner  類。

這意味著我們需要執行GC,否則我們將耗盡本機記憶體。對於非常優化的應用程式,可能不會出現問題,因為它們不會在堆上分配,這意味著不會觸發任何GC。然後,我們需要依靠顯式GC(System#gc())進行救援,併為下一個本機分配回收足夠的記憶體。

Netty ByteBuf 可以建立兩種版本:池化和非池化,本機記憶體的釋放(或將緩衝區放回池中)是基於引用計數機制的。這是某種額外的手動工作。當我們想減少參考計數器時我們需要編寫,但是它解決了上面提到的問題。

原理4:衡量您在高峰時段產生的負荷型別如果您想深入瞭解TCP層,那麼我強烈建議:

使用  bpftrace, 我們可以編寫一個簡單的程式並獲得快速結果,並能夠調查問題。這是socketio-pid.bt的示例,  顯示了根據PID粒度傳輸了多少位元組。

#!/snap/bin/bpftrace
#include <linux/fs.h>
BEGIN
{
      printf("Socket READS/WRITES and transmitted bytes, PID: %u\n", $1);
}
kprobe:sock_read_iter,
kprobe:sock_write_iter
/$1 == 0 || ($1 != 0 && pid == $1)/
{
       @kiocb[tid] = arg0;
}
kretprobe:sock_read_iter
/@kiocb[tid] && ($1 == 0 || ($1 != 0 && pid == $1))/
{
       $file = ((struct kiocb *)@kiocb[tid])->ki_filp;
       $name = $file->f_path.dentry->d_name.name;
       @io[comm, pid, "read", str($name)] = count();
       @bytes[comm, pid, "read", str($name)] = sum(retval > 0 ? retval : 0);
       delete(@kiocb[tid]);
}
kretprobe:sock_write_iter
/@kiocb[tid] && ($1 == 0 || ($1 != 0 && pid == $1))/
{
       $file = ((struct kiocb *)@kiocb[tid])->ki_filp;
       $name = $file->f_path.dentry->d_name.name;
       @io[comm, pid, "write", str($name)] = count();
       @bytes[comm, pid, "write", str($name)] = sum(retval > 0 ? retval : 0);
       delete(@kiocb[tid]);
}
END
{
       clear(@kiocb);
}

我可以看到五個稱為server-io-x的 Netty執行緒,每個執行緒代表一個事件迴圈。每個事件迴圈都有一個連線的客戶端,應用程式使用Websocket協議將隨機生成的字串訊息廣播到所有連線的客戶端。

@bytes  —讀/寫位元組的總和

@io  — 總共有多個讀/寫操作(1條讀訊息表示Websocket握手)

./socketio-pid.bt 27069
Attaching 6 probes...
Socket READS/WRITES and transmitted bytes, PID: 27069
@bytes[server-io-3, 27069, read, TCPv6]: 292
@bytes[server-io-4, 27069, read, TCPv6]: 292
@bytes[server-io-0, 27069, read, TCPv6]: 292
@bytes[server-io-2, 27069, read, TCPv6]: 292
@bytes[server-io-1, 27069, read, TCPv6]: 292
@bytes[server-io-3, 27069, write, TCPv6]: 1252746
@bytes[server-io-1, 27069, write, TCPv6]: 1252746
@bytes[server-io-0, 27069, write, TCPv6]: 1252746
@bytes[server-io-4, 27069, write, TCPv6]: 1252746
@bytes[server-io-2, 27069, write, TCPv6]: 1252746
@io[server-io-3, 27069, read, TCPv6]: 1
@io[server-io-4, 27069, read, TCPv6]: 1
@io[server-io-0, 27069, read, TCPv6]: 1
@io[server-io-2, 27069, read, TCPv6]: 1
@io[server-io-1, 27069, read, TCPv6]: 1
@io[server-io-3, 27069, write, TCPv6]: 1371
@io[server-io-1, 27069, write, TCPv6]: 1371
@io[server-io-0, 27069, write, TCPv6]: 1371
@io[server-io-4, 27069, write, TCPv6]: 1371
@io[server-io-2, 27069, write, TCPv6]: 1371

原則5:吞吐量和延遲之間的平衡

如果考慮應用程式效能,很可能最終會在吞吐量和延遲之間進行權衡。這種權衡涉及所有程式設計領域,JVM領域的一個著名示例是Garbage Collector:您是否要在某些批處理應用程式中專注於使用ParallelGC的吞吐量,還是需要低延遲的大多數併發GC,例如ShenandoahGC或ZGC?

但是,在這一部分中,我將重點介紹可以由我們基於Netty的應用程式或框架驅動的另一種折衷型別。假設我們有將訊息推送到連線的客戶端的WebSocket伺服器。我們真的需要儘快傳送特定訊息嗎?還是可以等待更長的時間,然後建立一批包含五個訊息並一起傳送的訊息?

Netty實際上支援完全涵蓋此用例的重新整理機制。假設我們決定使用批處理將syscall攤銷到20%,並犧牲延遲以支援整體吞吐量。

請檢視我的JFR Netty Socket Example

如果您想了解有關Java Flight Recorder的更多資訊,請閱讀我的文章  使用Java Flight Recorder挖掘套接字。

原則6:緊跟新趨勢並不斷嘗試 

...

點選標題見原文

 

  

 

相關文章