如何設計一個牛逼的文件搬運工?
- 2019 年 11 月 10 日
- 筆記
- 前言
- 理念
- 設計
- RPC 協議
- 總結
本文系「莫那·魯道」投稿 原文地址:thinkinjava.cn/2019/10/29/2019/1029-SF/ 項目倉庫:https://github.com/stateIs0/send_file 歡迎大家 star, pr,issue。我來改進。 ? 歡迎胖友給艿艿,投稿、投稿、投稿。
前言
之前討論零拷貝的時候,我們知道,兩台機器之間傳輸文件,最快的方式就是 send file,眾所周知,在 Java 中,該技術對應的則是 FileChannel 類的 transferTo 和 transferFrom 方法。
在平時使用伺服器的時候,比如 nginx ,tomcat ,都有 send file 的選項,利用此技術,可大大提高文件傳輸效能。
另外,可能也有人談論 send file 的缺點,例如不能利用 gzip 壓縮,不能加密。這裡本文不做探討。
紙上得來終覺淺,絕知此事要躬行。
那麼,如何使用這兩個 api 實現一個 send file 伺服器和客戶端呢?
想像一下,你寫的 send file 伺服器利用 send file 技術,利用萬兆網卡,從各個 client 端 copy 海量文件,瞬間打爆你那 1TB 的磁碟和 48核的 CPU。並且,注意:只需很小的 JVM 記憶體就可以實現這樣一台強悍的伺服器。為什麼?如果你知道 send file 的原理,就會知道,使用 send file 技術時, 在用戶態中,是不需要多少記憶體的,數據都在內核態。
是不是很有成就感?什麼?沒有?那打擾了 ?。
另外,關於 send file,我們都知道,由於是直接從內核緩衝區進入到網卡驅動,我們幾乎可以稱之為 「零拷貝」,他的性能十分強勁。
但是。
除了這個,還有其他的嗎?答案是有的,send file 利用 DMA 的方式 copy 數據,而不是利用 CPU。注意,不利用 CPU 意味著什麼?意味著數據不會進入「快取行」,進一步,不會進入快取行,代表著快取行不會因為這個被污染,再進一步,就是不需要維護快取一致性。
還記得我們因為這個特性搞的那些關於 「偽共享」 的各種黑科技嗎?是不是又學到了一點呢??
理念
作為一個純粹的,高尚的,有趣的 sendFile 伺服器或者客戶端,使用場景是嵌入到某個服務中,或者某個中間件中,不需要搞成誇張的容器。我們可以借鑒一下,客戶端可以做成 Jedis 那樣的,如果你想搞個連接池也不是不可以,但 client 自身實例,還是單連接的。服務端可以做成 sun 的 httpServer 那種輕量的,隨時啟動,隨時關閉。
同時, 支援 oneway 的高性能發送,因為,只要機器不宕機,發送到網卡就意味著發送成功,這樣能大幅提高發送速度,減少客戶端阻塞時間。
另外,也支援帶有 ack 的穩定發送,即只有返回 ack 了,才能確認數據已經寫到目標伺服器磁碟了。
server 端支援海量連接,必須得是 reactor 網路模型,但我們不想在這麼小的組件里用 netty,太重了,還容易和使用方有 jar 衝突。所以,我們可以利用 Java 的 selector + nio 自己實現 Reactor 模型。
設計
IO 模型設計
設計圖:

如上圖,Server 端支援海量客戶端連接。
server 端含有 多個處理器,其中包括 accept 處理器,read 處理器 group, write 處理器 group。
accept 處理器將 serverSocketChannel 作為 key 註冊到一個單獨的 selector 上。專門用於監聽 accept 事件。類似 netty 的 boss 執行緒。
當 accept 處理器成功連接了一個 socket 時,會隨機將其交給一個 readProcessor(netty worker 執行緒?) 處理器,readProcessor 又會將其註冊到 readSelector 上,當發生 read 事件時,readProcessor 將接受數據。
可以看到,readProcessor 可以認為是一個多路復用的執行緒,利用 selector 的能力,他高效的管理著多個 socket。
readProcessor 在讀到數據後,會將其寫入到磁碟中(DMA 的方式,性能炸裂)。
然後,如果 client 在 RPC 協議中聲明「需要回復(id 不為 -1)」 時,那就將結果發送到 Reply Queue 中,反之不必。
當結果發送到 Reply Queue 後,writer 組中的 寫執行緒,則會從 Queue 中拉取回復包,然後將結果按照 RPC 協議,寫回到 client socket 中。
client socket 也會監聽著 read 事件,注意:client 是不需要 select 的,因為沒必要,selector 只是性能優化的一種方式——即一個執行緒管理海量連接,如果沒有 select, 應用層無法用較低的成本處理海量連接,注意,不是不能處理,只是不能高效處理。
回過來,當 client socket 得到 server 的數據包,會進行解碼反序列化,並喚醒阻塞在客戶端的執行緒。從而完成一次調用。
執行緒模型
設計圖:

如上圖所示。
在 client 端:
每個 Client 實例,維護一個 TCP 連接。該 Client 的寫入方法是執行緒安全的。
當用戶並發寫入時,可並發寫的同時並發回復,因為寫和回復是非同步的(此時可能會出現,執行緒 A 先 send ,執行緒 B 後 send,但由於網路延遲,B 先返回)。
在 server 端:
server 端維護著一個 ServerSocketChannel 實例,該實例的作用就是接收 accep 事件,且由一個執行緒維護這個 accept selector 。
當有新的 client 連接事件時,accept selector 就將這個連接「交給「 read 執行緒(默認 server 有 4 個 read 執行緒)。
什麼是「交給」?
注意:每個 read 執行緒都維護著一個單獨的 selector。4 個 read 執行緒,就維護了 4 個 selector。
當 accept 得到新的客戶端連接時,先從 4 個read 執行緒組裡 get 一個執行緒,然後將這個 客戶端連接 作為 key 註冊到這個執行緒所對應的 read selector 上。從而將這個 Socket 「交給」 read 執行緒。
而這個 read 執行緒則使用這個 selector 輪詢事件,如果 socket 可讀,那麼就進行讀,讀完之後,利用 DMA 寫進磁碟。
RPC 協議
Server RPC 回復包協議
欄位名稱 |
欄位長度(byte) |
欄位作用 |
---|---|---|
magic_num |
4 |
魔數校驗,fast fail |
version |
1 |
rpc 協議版本 |
id |
8 |
Request id, TCP 多路復用 id |
length |
8 |
rpc 實際消息內容的長度 |
Content |
length |
rpc 實際消息內容(JSON 序列化協議) |
Client RPC 發送包協議
欄位名稱 |
欄位長度(byte) |
欄位作用 |
---|---|---|
magic_num |
4 |
魔數校驗,fast fail |
id |
8 |
Request id, TCP 多路復用 id, 默認 -1,表示不回復 |
nameContent |
2 |
Request id, TCP 多路復用 id |
bodyLength |
8 |
rpc 實際消息內容的長度 |
nameContent |
bodyLength |
文件名 UTF-8 數組 |
為什麼 發送包和返回包協議不同?為了高效。
總結
注意:這是一個能用的,性能不錯的,輕量的 SendFile 伺服器實現,本地測試時, IO寫盤達到 824MB/S,4c 4.2g inter i7 CPU 滿載。

程式碼地址:https://github.com/stateIs0/send_file
同時,歡迎大家 star, pr,issue。我來改進。