MySQL JDBC StreamResult通訊原理淺析

使用MySQL JDBC讀取過較大數據量的人應該清楚(例如超過1GB),在讀取的時候內存極可能會Java堆內存溢出,而咱們的解決方案是statement.setFetchSize(Integer.MIN_VALUE)並確保遊標是隻讀向前滾動的便可(爲遊標的默認值),也能夠強制類型轉換爲com.mysql.jdbc.StatementImpl,而後調用其內部方法:enableStreamingResults()這樣讀取數據內存就不會掛掉了,這二者達到的效果是一致的。固然也可使用useCursorFetch,可是這種方式測試結果性能要比StreamResult慢不少,爲何?在本文會闡述其大體的原理。mysql

我在前面的部分文章和書籍中都有介紹過其MySQL JDBC在這一塊內部處理的代碼分紅三個不一樣的類來完成的,不過我一直沒有去深究過數據庫和JDBC之間究竟是如何通訊的過程。有一段時間我一直認爲這都屬於服務端行爲或者是客戶端與服務端配合的行爲,而後並不其然,今天咱們來說一下這個行爲是怎麼回事。sql

【先回顧一下簡單的通訊】:數據庫

JDBC與數據庫之間的通訊是經過Socket完成的,所以咱們能夠把數據庫當成一個SocketServer的提供方,所以當SocketServer返回數據的時候(相似於SQL結果集的返回)其流程是:服務端程序數據(數據庫) -> 內核Socket Buffer -> 網絡 -> 客戶端Socket Buffer -> 客戶端程序(JDBC所在的JVM內存)數組

到目前爲止,IT行業中你們所看到的JDBC不管是:MySQL JDBC、SQL Server JDBC、PG JDBC、Oracle JDBC。甚至因而NoSQL的Client:Redis Client、MongoDB Client、Memcached,數據的返回基本也是這樣一個邏輯。網絡

【使用MySQL JDBC默認直接讀取數據爲何會掛?】socket

(1)MySQL Server方在發起的SQL結果集會所有經過OutputStream向外輸出數據,也就是向本地的Kennel對應的socket buffer中寫入數據,這是一次內存拷貝(內存拷貝這個不是本文的重點)。性能

(2)此時Kennel的Buffer有數據的時候就會把數據經過TCP鏈路(JDBC主動發起的Socket鏈路),回傳數據,此時數據會回傳到JDBC所在機器上,會先進入Kennel區域,一樣進入到一個Buffer區。測試

(3)JDBC在發起SQL操做後,Java代碼是在inputStream.read()操做上阻塞,當緩衝區有數據的時候,就會被喚醒,而後將緩衝區的數據讀取到Java內存中,這是JDBC端的一次內存拷貝。大數據

(4)接下來MySQL JDBC會不斷讀取緩衝區數據到Java內存中,MySQL Server會不斷髮送數據。注意在數據沒有徹底組裝完以前,客戶端發起的SQL操做不會響應,也就是給你的感受MySQL服務端還沒響應,其實數據已經到本地,JDBC還沒對調用execute方法的地方返回結果集的第一條數據,而是不斷從緩衝器讀取數據。優化

(5)關鍵是這個傻帽就像一把這個數據讀取完,根本無論家裏放不放的下,就會將整個表的內容讀取到Java內存中,先是FULL GC,接下來就是內存溢出。

【JDBC參數上設置useCursorFetch=true能夠解決】

這個方案配合FetchSize設置,確實能夠解決問題,這個方案其實就是告訴MySQL服務端我要多少數據,每次要多少數據,通訊過程有點像這樣:

這樣作就像咱們生活中的那樣,我須要什麼就去超市買什麼,須要多少就去買多少。不過這種交互不像如今網購,坐在家裏就能夠把東西送到家裏來,它必定要走路(網絡鏈路),也就是須要網絡的時間開銷,假如數據有1億數據,將FetchSize設置成1000的話,會進行10萬次來回通訊;若是網絡延遲同機房0.02ms,那麼10萬次通訊會增長2秒的時間,不算大。那麼若是跨機房2ms的延遲時間會多出來200秒(也就是3分20秒),若是國內跨城市10~40ms延遲,那麼時間將會1000~4000秒,若是是跨國200~300ms呢?時間會多出十多個小時出來。

在這裏的計算中,咱們尚未包含系統調用次數增長了不少,線程等待和喚醒的上下文次數變多,網絡包重傳的狀況對總體性能的影響,所以這種方案看似合理,可是性能確不怎麼樣。

另外,因爲MySQL方不知道客戶端何時將數據消費完,而自身的對應表可能會有DML寫入操做,此時MySQL須要創建一個臨時表空間來存放須要拿走的數據。所以對於當你啓用useCursorFetch讀取大表的時候會看到MySQL上的幾個現象:

(1)IOPS飆升,由於存在大量的IO讀取,若是是普通硬盤,此時可能會引發業務寫入的抖動

(2)磁盤空間飆升,這塊臨時空間可能比原表更大,若是這個表在整個庫內部佔用至關大的比重有可能會致使數據庫磁盤寫滿,空間會在結果集讀取完成後或者客戶端發起Result.close()時由MySQL去回收。

(3)CPU和內存會有必定比例的上升,根據CPU的能力決定。

(4)客戶端JDBC發起SQL後,長時間等待SQL響應數據,這段時間就是服務端在準備數據,這個等待與原始的JDBC不設置任何參數的方式也表現出等待,在內部原理上是不同的,前者是一直在讀取網絡緩衝區的數據,沒有響應給業務,如今是MySQL數據庫在準備臨時數據空間,沒有響應給JDBC。

【Stream讀取數據】

咱們知道第1種方式會致使Java掛掉,第2種方式效率低並且對MySQL數據庫的影響較大,客戶端響應也較慢,僅僅可以解決問題而已,那麼如今來看下Stream讀取方式。

前面提到當你使用statement.setFetchSize(Integer.MIN_VALUE)或com.mysql.jdbc.StatementImpl.enableStreamingResults()就能夠開啓Stream讀取結果集的方式,在發起execute以前FetchSize不能再手工設置,且確保遊標是FORWARD_ONLY的。

這種方式很神奇,彷佛內存也不掛了,響應也變快了,對MySQL的影響也變小了,至少IOPS不會那麼大了,磁盤佔用也沒有了。之前僅僅看到JDBC中走了單獨的代碼,認爲這是MySQL和JDBC之間的另外一種通訊協議,卻不知,它居然是「客戶端行爲」,沒錯,你沒看錯,它就是客戶端行爲。

它在發起enableStreamingResults()的時候,幾乎不會作任何與服務端的交互工做,也就是服務端會按照方式1回傳數據,那麼服務端使勁向緩衝區懟數據,客戶端是如何扛得住壓力的呢?

在JDBC當中,當你開啓Stream結果集處理的時候,它並非一把將全部數據讀取到Java內存中的,也就是圖1中並非一次性將數據讀取到Java緩衝區的,而是每次讀取一個package(這個package能夠理解成Java中的一個byte[]數組),一次最多讀取這麼多,而後會看是否繼續向下讀取保證數據的完整性。業務代碼是按照字節解析成行也業務方使用的。

服務端剛開始使勁向緩衝區懟數據,這些數據也會懟滿客戶端的內核緩衝區,當兩邊的緩衝區都被懟滿的時候,服務端的1個Buffer嘗試經過TCP傳遞數據給接收方時,此時因爲消費方的緩衝區也是滿的,所以發送方的線程會阻塞住,等待對方消費,對方消費一部分,就能夠推送一部分數據過去。連起來看就是JDBC的Stream數據將來得及消費以前,緩衝區數據若是是滿的,那麼MySQL發送數據的線程就阻塞住了,這樣確保了一個平衡(關於這一點,你們可使用Java的Socket來嘗試下是不是這樣的)。

對於JDBC客戶端,數據獲取的時候每次都在本地的內核緩衝區當中,就在小區的快遞包裹箱拿回家一個距離,那麼天然比起每次去超市的RT要小得多了,並且這個過程是準備好的數據,因此沒有IO阻塞的過程(除非MySQL服務端傳遞的數據還不如消費端處理數據來得快,那通常也只有消費端不作任何業務,拿到數據直接放棄的測試代碼,纔會發生這樣的事情),這個時候不論:跨機房、跨地區、跨國家,只要服務端開始響應就會源源不斷地傳遞數據過來,而這個動做即便是第1種方式也是必然須要經歷的過程。

相對於第1種方式,JDBC使用的時候會不致使內存溢出,即便讀取大表不內存溢出也會很長時間纔會響應;不過這種方式相對方式1來說對數據庫影響相對較大,在傳遞的數據的過程當中,相應的數據行會被上鎖(防止被修改),使用InnoDB會分段加鎖處理,使用MyISAM會加全表鎖,可能致使業務阻塞。

【理論上能夠更進一步,只要你願意】

理論上這種方式是比較好的了,可是就完美主義來說,咱們能夠繼續探討一下,對於懶人來說,咱們連到小區樓下快遞包裹箱去拿一下的動力也是沒有的,咱們內心想的就是要是誰給我拿到家裏來送到我嘴巴里,連嘴巴都給我掰開多好。

在技術上理論上確實能夠作到這樣,由於JDBC從內核拷貝內存到Java當中是須要花時間的,要是有另外一我的把這個事情作了,我在家裏幹別的事情的時候它就給我送到家裏來了,我要用的時候就直接從家裏來,這個時間豈不是省掉了。每錯,對於你來說確實省掉了,不過問題就是誰來送?

在程序中必定須要加一個線程來幹這個事情,把內核的數據拷貝到應用內存,甚至於解析成行數據,應用程序直接使用,但這必定完美嗎?其實這個中間就有個協調問題了,例如家裏要炒菜,缺一包調料,本來能夠本身到樓下買,可是非要讓別人送家裏,這個時候其它的菜都下鍋了,就剩一包調料,那麼你沒別的辦法,只能等這包調料送到家裏來之後才能進行炒菜的下一道工序。因此,在理想狀況下,它能夠節約不少次內存拷貝時間,會增長一些協調鎖的開銷。

那麼能夠不能夠直接從內核緩衝區讀取數據呢?

理論上也是能夠的,在解釋這個問題以前,咱們先了解下除了這一次內存拷貝還有那些:

JDBC按照二進制將內核緩衝區的數據讀取後,也會進一步解析成具體的結構化數據,因爲此時要給業務方返回ResultSet的具體行的結構化數據,也就是生成RowData的數據必定會有一次拷貝,並且JDBC返回某些對象類型數據的時候(例如byte []數組),在某些場景的實現,它不但願你經過結果集修改返回結果中的byte []的內容(byte[1] = 0xFF)去修改ResultSet自己內容,可能還會再作1次內存拷貝,業務代碼使用過程當中還會存在拼字符串,網絡輸出等,又是一堆的內存拷貝,這些在業務層面是沒法避免的,相對這點點拷貝來說,簡直微不足道,因此咱們也沒去幹這事情,覺得從總體上看幾乎微不足道,除非你的程序瓶頸在這裏。

所以從總體上看內存拷貝是沒法避免的,多的這一次無非是系統級的調用,開銷會更大一點,從技術上來說,咱們是能夠作到直接從內核態直接讀取數據的;但這個時候就須要按照字節將Buffer從的數據拿走才能讓遠程更多的數據傳遞過來,沒有第三個位置存放Buffer了,不然又回到了內核到應用的內存拷貝上來了。

相對來說,服務端卻是能夠優化直接將數據經過直接IO的方式傳遞(不過這種方式數據的協議就和數據的存儲格式一致了,顯然只是理論上的), 要真正作到自定義的協議,又要經過內核態數據直接發送,須要經過修改OS級別的文件系統協議,來達到轉換的目的。

本文做者:鍾隱

閱讀原文

本文爲雲棲社區原創內容,未經容許不得轉載。

相關文章
相關標籤/搜索