高吞吐高並發Java NIO服務的架構(NIO架構及應用之一)
Java NIO成功的應用在了各種分布式、即時通信和中間件Java系統中。證明了基於NIO構建的通信基礎,是一種高效,且擴展性很強的通信架構。
基於Reactor模式的高可擴展性架構這個架構的基本思路在“基於高可用性NIO服務器架構”(http://today.java.net/pub/a/today/2007/02/13/architecture-of-highly-scalable-nio-server.html
)中有了清晰的論述。經過幾年實際運營的經驗,這種架構的靈活性得到了很好的驗證。我們注意幾點,
1,一個小的線程池負責dispatch NIO事件。
2,注冊事件,即操作selecter時,要使用一個同步鎖(即Architecture of a Highly Scalable NIO-Based Server一文中的guard對象),即對同一個selector的操作是互斥的。
3,這個小的線程池不處理邏輯業務,大小可以是Runtime.getRuntime().availableProcessors() + 1,即你系統有效CPU個數+1。這是因為我們假設有一個線程專門處理accept事件,
而其他線程處理read/write操作。
4,用另一個單獨的線程池處理邏輯業務
在淘寶網團隊博客上分析Netty架構的時候也談到了這個思路,我決定說的比較好。這里引用一段:
網絡動作歸結到最簡單就是服務器端bind->accept->read->write,客戶端 connect->read->write,一般bind或者connect后會有多次read、write。這種特性導致,bind,accept與read,write的線程分離,connect與read、write線程分離,這樣做的好處就是無論是服務器端還是客戶端吞吐量將有效增大,以便充分利用機器的處理能力,而不是卡在網絡連接上,不過一旦機器處理能力充分利用后,這種方式反而可能會因為過於頻繁的線程切換導致性能損失而得不償失,並且這種處理模型復雜度比較高。
那么如果是我們自己開發基於NIO實現高效和高可擴展服務,還有哪些構架方面的問題需要考慮呢?
NIO構架中比較需要經驗和比較復雜的主要是2點:1,)是基於提高的性能的線程池設計;2)基於網絡通訊量的通訊完整性校驗的構架。
1. 基於提高的性能的線程池設計
既然有一個單獨處理邏輯業務的線程池,這個線程池的大小應該由你的業務來決定。對於高效服務器來說,這個線程池大小會對你的服務性能產生很大的影響。設置多少合適呢?
這里真的有很多情況需要考慮,換句話說,這里水很深。我只能根據自己的經驗舉幾個例子。真正到了運營系統上,一邊測試一邊調整一邊總結吧。
假設消息解析用時5毫秒,數據庫操作用時20毫秒,其他邏輯處理用時20毫秒,那么整個業務處理用時45毫秒。
因為數據庫操作主要是IO讀寫操作,為使CPU得到最大程度的利用,在一個16核的服務器上,應該設置 (45/ 25)
* 16 = 29 個線程即可。
假設不是所有的操作都是在平均時間內完成,比如數據庫操作,假設是在12~35毫秒區間內。即有線程會不斷的被某些操作block住,為了充分利用CPU能力,因設置為((35 + 25)/ 25)* 16 = 39個線程。
所以原則上,如果應用是一個偏重數據庫操作的應用,則線程數應高些;如果應用是一個高CPU應用,則線程數不用太高。
假設邏輯處理中,對共享資源的操作用時5毫秒。此時同時只能有一個線程對共享資源進行操作,那么在一個16核的服務器上,應該設置 (37 / 5) * 1 = 8 個線程即可。
假設只有一部分操作對共享資源有寫,其他只是讀。這樣采用樂觀鎖,使寫操作降為所有操作的10%,那么有90%的業務,其合適的線程數可為39個線程。10%的業務應為8個線程。平均則為 35 + 1 = 36個線程。可見仔細的分析共享資源的使用,能很好的提高系統性能。
根據線程CPU占用率和CPU個數來設置線程數的假設前提是所有線程都要要運行。但實際系統中線程處理要處理不同時間達到的請求。
場景:假設線程處理不是同時進行的
假設有一個消息服務器,每秒處理500個消息,即認為平均每2ms接受一個新請求。假設處理一個請求需要100ms,那么當接收到第51個請求時,第一個線程就已經空閑。這個請求可以由第一個線程處理,而不需要新線程。這樣,需要50個線程。如果每個消息請求CPU空閑時間為10ms,那么為對於每個線程,並發的數量為 100/90 = 1.1;因此合適的線程為 50 * 1.1 * 核數。
跑一個小測試程序,code見附件
執行一個task耗時1000ms,其中50%CPU占滿。每100毫秒處理一個task。CPU4核。
這樣計算 (1000/100) * 2 * 4 = 40
測試結果,設置不同的線程數執行100個task,結果
線程數 | 全部執行使用時間
100 | 14484
80 | 14097
40 | 14407
20 | 16016
10 | 16548
在線程數達到40之后,再增加線程,因為CPU已經被充分使用,因此處理速度沒有得到響應增加。反而有線程開銷有可能下降。因此在CPU占用率和處理task間隔恆定的情況下,使用以上公式計算適合的線程數量可以得到較優結果。
2. 基於網絡通訊量的通訊完整性校驗
先看看READ事件的觸發條件:
If the selector detects that the corresponding channel is ready for reading, has reached end-of-stream, has been remotely shut down for further reading,
or has an error pending, then it will add OP_READ to the key's ready-operation set and add the key to its selected-key set.
就是說,NIO構架中不能保證每次READ事件發生時從channel中讀出的數據就是完整。例如,在通訊數據量較大時,網絡層write buffer很容易被寫滿。此時讀到的數據就是不完整的。
從構架角度,應根據應用場景設計三種不同的處理方式。
基本上有三種類型的應用,
1. 較低的通信量應用。這類應用的特點是所有的通信量不是很大,而且數據包小。所有數據都能在一次網絡層buffer flush中全部寫出。比如ZooKeeper client對cluster的操作。這種通信模式是完全不需要進行數據包校驗的。
2. 基於RPC模式的應用。比如Hadoop,每次NameNode和DataNode之間的通訊都是通過RPC框架封裝,轉變成client對server的調用。所有的操作都是通過Java反射機制反射成方法調用,這樣操作的特點是每次讀到的數據都是可以通過ObjectInputStream(new ByteArrayInputStream(bytes)).readObject()操作的。這樣的應用,應該在第一種應用的架構基礎上增加對ObjectInputStream的校驗。如果校驗失敗,則說明這次通信沒有完成,應和下次read到數據合並在一起處理。
3. 基於大量數據通信的應用。這種應用的特點是基於一種大數據量通信協議,比如RTSP。數據包是否完整需要經過通信協議約定的校驗符進行校驗。這樣就必須實現一個校驗類。如果校驗失敗,則說明這次通信沒有完成,應和下次read到數據合並在一起處理。
