低延遲系統的Java實踐
在很久很久以前,如果有人讓我用Java語言開發一個低延遲系統,我肯定會用迷茫的眼神望著他,然後說“are you kidding me?”。然而隨著Java語言的日臻完善以及JVM效能的極速提升,使得用Java語言開發低延遲(不要和實時系統搞混)系統越來越成為可能,其中就包括最典型的交易(支付)系統。當然作為系統架構師,他們會嘗試使用一些成熟分散式架構方案(通常是整合一些商業或開源專案),通過利用冗餘計算資源以及非同步通訊方式提高應用程式的吞吐量和響應率,使其到達低延遲系統的標準,這在社群中有大量的實踐案例,包括淘寶,京東、XX系等。然而我的興趣愛好是研究Java語言本身能為低延遲應用開發帶來什麼,在開發低延遲系統中我們有哪些實踐可以參照,這才是本文的討論重點。關於低延遲系統和實時系統的區別不再贅述,作為架構師的你們應該比我清楚的多。
作為低延遲系統,比如交易系統,應該有2個比較重要的引數指標:吞吐量和響應率(當然還有其他重要指標)。吞吐量表達了系統在單位時間內所處理的請求量;而響應率則表達了單次請求所消耗的單位時間。這2個指標基本能判斷出一個交易系統是否“足夠快”。當我們在使用Java語言開發低延遲系統時,應該放棄一些我們之前約定俗成的規則,其中就包括了我們一直信奉的Java程式設計原則——面向物件。有人肯定會說我,“這不是扯蛋嗎?那你還用Java幹啥?”。其實我們並不會放棄Java面向物件的思維方式,而是在使用的方式上有所改變而已。Java設計之初就是純面向物件的,記得之前所有Java入門書中都會有一句名言:“在Java世界,一切皆物件!”。有點跑題了,寫這篇文章也是因為之前看到了一篇文章《Using Java in Low Latency Environments》,在這篇文章中幾位大師討論了有關於Java在低延遲環境中的使用方法,有些原則非常值得參考,再結合自己實踐工作中的一些經驗的積累,所以總結了三條最最重要的基本準則,以供同學們參考。
如果你是一個Java老手,肯定對JVM或是Java語言的各種特性瞭如指掌。JVM的記憶體釋放是由GC自動完成的,程式設計師無法直接控制和干預GC的執行(有人會說,不是有System.gc()可以執行垃圾收集嘛,那就請你好好的去看一下Java Doc吧),這也是我對Java最大的詬病之一。我們都知道,當JVM在執行GC時,不管是YGC還是FULL GC,JVM都將阻塞其他所有正在執行的執行緒,雖然這個時間已經從分鐘級別降低到了毫秒級別,但是作為低延遲系統還是會受其影響,從而降低系統的響應率。這種情況直到Oracle推出帶有並行GC的JVM之前都會一直存在,為了避免這種情況,大師們的解決方案是降低GC的頻率,將GC控制在每天一次或是幾天一次,那到底這麼做呢?大師們為我們指明瞭一條明路。那就是環保——儘量少產生“垃圾”或不產生“垃圾”,簡單講就是少使用堆物件(用new關鍵字例項化的物件),甚至包括String物件。好吧,小夥伴們都驚呆了,你是要我去寫C程式碼嘛!!還好,我會C不會因此而失業——開個玩笑。其實大師們想表達的意思是物件複用技術,這種技術可以大量減少堆物件的產生。在我現在的交易系統開發中,基本不會關注物件的複用,字串物件更是當做了基本型別來使用。其實作為交易系統,業務邏輯非常的複雜,各種邏輯判斷,上百個交易業務屬性,再加上對面向物件技術以及設計模型的迷戀,勢必會引起堆物件的泛濫從而導致GC的頻繁執行。所以為了減少“垃圾”的產生,我們必須在物件的設計和使用上做一些約束,例如,用基本型別(short、int、long、double等)替換包裝物件、減小物件的規模(不要巢狀物件太多)、用陣列替換Java集合、使用物件池複用物件(例如:commons-pool庫)、減少第三方類庫的使用等等。當然我們所做的一切都比不上來自GC自身的改進,所以真心希望oracle儘快的推出可以並行的垃圾收集器,使其不再成為我們既愛又恨的關注點。
其次對低延遲系統具有影響的就是Java的記憶體模型,即JMM。Java記憶體模型定義了可見性和原子性,為了保證這兩項實現,我們必須使用同步,在Java中,所有執行緒的同步必須爭奪唯一的一把鎖,因此在需要低延遲的環境中鎖競爭會大大的影響吞吐量和響應率。在交易系統中,當請求量急劇上升時,鎖的競爭將更加的激烈,從而導致大量的執行緒阻塞或是餓死。那如何來規避這種情況的發生呢?那就是使用無鎖技術或無等待技術,具體而言就是在同步塊中不加鎖或是減小加鎖的程式碼範圍。我們可以避免使用synchronized或是使用ReentrantLock來自己控制鎖的範圍。ReentrantLock允許我們在程式碼塊上加鎖,但是必須要注意不要忘記釋放鎖。舉一個例子,假設我們的交易系統中有一個共享的資料,每個寫請求方法都需要用synchronized關鍵字加以同步,否則就會發生資料異常。現在我們用無鎖技術來規避synchronized,實現很簡單,就是使用一個佇列,將所有寫請求先放入佇列中,然後由一個執行緒迴圈佇列,將寫請求寫入共享資料中。其實這就是我們常說的“單一寫原則”,另外非同步處理也是一種“單一寫原則”的具體化實現。
IO可以說是影響低延遲系統性能最為關鍵的因素之一,而網路IO更是各種IO呼叫的重中之重。在交易系統中網路IO無法避免,我們必須通過乙太網從其他應用程式中獲取資源,比如:資料庫,訊息系統等,同時我們又會通過乙太網向其他應用系統輸出服務,比如:交易通知等。所以,網路質量將直接影響到交易系統的吞吐量和響應時間。在廣域網環境中,網路傳輸需要時間、為了保證TCP/IP可靠協議必須重新發送丟失的資料包,交換機或路由器也會產生網路阻塞,這些完全不可預知的問題都將影響到低延遲系統的效能,IO的延遲無時無刻的在考驗著我們的忍耐底線。到目前為止,我們還沒有一個絕對可行的方案來解決所有由IO引起的問題,但還是有一些指導建議值得我們去借鑑。我們可以通過預載入資源來最大限度的減少IO開銷,例如,在應用程式啟動的時候載入配置檔案或其他資原始檔等。這裡需要注意的是,載入的資源不能在應用程式中被垃圾回收,讓其存在於堆記憶體的P區中是一個不錯的選擇。另一方面,從JDK7開始,Java提供了SDP的支援,SDP協議可以大大的提升網路IO的效能,所謂SDP就是Sockets Direct Protocol,即套接字直聯協議。它不同於傳統TCP/IP協議,它需要硬體的支援,即InfiniBand網路裝置。SDP可以直接訪問遠端主機的記憶體,不再需要通過ISO的7層模型來進行資料的傳輸,所以它的效率要比乙太網的TCP/IP協議高很多。我們用一張圖就可以非常清楚的對比SDP協議和乙太網協議的本質區別.。(此圖從infoq上摘錄,非本人版權,特此宣告)
從上圖中可以看到,Java7提供的SDP協議是直接和物理層打交道,資料不再像之前的Java6那種乙太網的方式要經過ISO的各層。當然,對於開發人員來說這一切都是是完全透明的,我們還是在使用非常熟悉的java.net.*包中各種API進行網路應用程式的開發,所有的一切全部交給JDK。是不是覺得很Cool呢!不過本人還未對此進行過嘗試,因為我們公司目前還是乙太網,並沒有InfiniBand網路裝置,所以SDP技術還有待驗證。
綜上所述,在用Java做低延遲系統開發時,我們應該從三個方面著手製定優化方案,第一,有效減少垃圾收集的執行頻率;第二,有效的使用鎖機制或根本不用鎖;第三,減少IO(重點是網路IO)的等待處理時間。當然除此之外還有一些其他的小技巧,比如,不使用第三方庫、不使用反射庫(java.lang.reflect)、優化程式碼執行路徑、用DirectByteBuffers建立資料結構等等。
好像寫了那麼多自我感覺乾貨不是太多,其實我只是想拋磚引玉,通過這樣一篇文章能夠激發出更多的碰撞和辯論,低延遲系統的開發是一個大課題,其複雜程度遠遠超過本文所講述的內容,所以希望更多的人能參與進來,把磚頭扔向我。