這篇文章帶給大家的內容是關於淺談MySQL JDBC StreamResult通訊原理,有一定的參考價值,有需要的朋友可以參考一下,希望對你有幫助。
使用MySQL JDBC讀取過較大資料量的人應該清楚(例如超過1GB),在讀取的時候記憶體很可能會Java堆記憶體溢出,而我們的解決方案是statement .setFetchSize(Integer.MIN_VALUE)並確保遊標是唯讀向前捲動的即可(為遊標的預設值),也可以強制型別轉換為com.mysql.jdbc.StatementImpl,然後呼叫其內部方法:enableStreamingResults()這樣讀取資料記憶體就不會掛掉了,這兩者達到的效果是一致的。當然也可以使用useCursorFetch,但這種方式測試結果效能比StreamResult慢很多,為什麼?在本文會闡述其大致的原則。
我在前面的部分文章和書籍中都有介紹過其MySQL JDBC在這一塊內部處理的程式碼分成三個不同的類別來完成的,不過我一直沒有去深究過資料庫和JDBC之間到底是如何通信的過程。有一段時間我一直認為這都屬於服務端行為或是客戶端與服務端配合的行為,然後並不其然,今天我們來講一下這個行為是怎麼回事。
【先回顧簡單的通訊】:
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預設直接讀取資料為什麼會掛? 】
(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層級的檔案系統協議,來達到轉換的目的。
以上是淺談MySQL JDBC StreamResult通訊原理的詳細內容。更多資訊請關注PHP中文網其他相關文章!