Go 在證券行情系統中的應用
本文內容包含三個部分:證券行業系統背景介紹,證券行情業務特點,行情系統開發遇到的挑戰。
一 證券行情系統背景介紹
以行情云和交易云為核心,廣發證券構建了 Open Trading 交易平臺、GF Quant量化分析平臺、各類交易終端、開發者社區等FinTech生態系統,從理念到技術水平均走在業內前沿。以交易系統和高頻行情為核心,我們在外面構建了廣發交易云和 Open Trading交易平臺,這個交易平臺對外提供API接口,還有 FIX(金融信息交換)協議。
下方的DMA是直接市場訪問,我們通過API接口對外可以支持手機證券客戶端,手機證券客戶端主要給個人投資者使用,還有上面的操盤手,操盤手是我們正在研發中的專業操盤軟件,用于PC客戶端。機構投資客戶端和第三方終端都可以接入到我們這個平臺。
外面橙色這一圈,就是比較新的概念,如開發者社區,開發者可以開發一些插件,發布到插件市場。這些插件可以放到操盤手上面,插件可以自己定制一些交易的算法和功能,可以用到我們軟件上面做交易。投資者社區可以討論一些投資的想法或者交易算法,設計一些好的交易算法可以發布到算法市場。最外層就是其他延伸類的服務。
重點說一下高頻行情,高頻行情信息包括實時報價、逐筆成交、分時成交、周期K線,還有資金流向。 這些數據由 券商通過專項線路從交易所獲得原始數據 后 計算生成。目前直 連交易所有 兩條線路,一條是互聯網專線,還有一條是衛星專線。衛星專線因為延時很大,所以實時性不是很好,我們通常 只 做災難備份。
二 證券行情 業務 特點
第一個特點超低延遲。延遲過大會導致投資決策失誤,客戶流失。 例如 投資人通過客戶端看到的行情不是最新行情, 看到 的現價和實際情況不一致。比如實際已經漲到10塊1毛,他看到 的 是10塊錢,下一個10塊錢的買單,這時候訂單就沒辦法成交,如果是牛市可能就錯過了 買入機會 。
第二超高并發 。 牛市時全民炒股刷新行情數據,行情 刷新的請求量 超 出 平時很多倍,其中帶寬和并發量都是海量級別。我國現有1.2億的股民, 平均 每十人里面就有一位股民,用戶量是非常大的 ,訪問時間也容易集中到開盤或收盤的幾分鐘 。
第三個特點是超高可靠性。數據出錯可導致真金白銀的損失,因為這些數據也是我們拿到交易所原始數據之后 計算出來 的,計算出錯都可能導致用戶投資決策失誤。
第四個特點是超嚴格監管 。 這點金融行業之外的開發人員可能理解不深刻。特別是股災之后,現在整個行業都進入全面監管、從嚴監管的時代,股災之后證監會還有交易所都是我們的監管方,經常 到 證券公司檢查,檢查系統各方面是不是合規,服務器放在哪里,數據怎么存儲,都是有合規約束。 其他行業的 互聯網 產品 開發,后端服務可以放到公有云上面,但是券商很多都不能放在外面。都是用自有機房 私有云 ,自己要造一些 輪子 ,運維也會麻煩一些。
舉個例子,2015年5月29日上證指數沖擊五千點,某些 券商 信息系統發生了中斷或者緩慢,引起各方廣泛關注,也受到證監會的處罰 (引用自證券日報《證監部門處罰部分信息系統癱瘓券商》) 。當時交易量創下了世界紀錄,一天的交易量超過過去幾 周 。多 家 券商行情系統因為訪問量太大出現了 雪 崩式癱瘓, 當同行們 的行情系統卡的不更新 時, 廣發 的情況 好一點,雖然卡一點但還是在更新, 在業內 贏得了不少好評和口碑。
三 行情系統開發遇到的挑戰
任何事物都是有兩面性的。我們選擇技術棧的時候,有我們看中的優點還有缺點需要彌補,行情系統開發的過程中我們先后遇到以下問題:
1 開發語言的選擇問題
我們有三項選擇, 首先 是C/C++,這 兩種 語言是歷史悠久的高性能系統級語言,類似于左上角這輛豐田AE86,上世紀70年代設計理念,80年代投入使用,這輛車非常輕,車重不足1噸,很多愛好者都會改裝它,最 輕 可以改裝到800多 公斤 ,非常適合愛改裝和造輪子的老司機。雖然歷史悠久但是還有人在玩,選這輛車的人 經常 會改裝 整車 一半 的零件 ,他們說豐田只造了半輛車,剩下一半都要自己改裝。很多 用 C/C++ 做項目開發 人說,每次做項目如果選C/C++, 項目啟動的 第一件事就是自己寫一個網絡庫,這是 項目 造的第一個輪子 (最近業內流行的是造協程的輪子) 。
第二個選擇就是J ava ,這是金融機構廣泛使用的安全可靠的系統級語言,類似于 解放軍裝備的 T99主戰坦克,誕生于上世紀90年代,車重50噸,它的特點是火力猛裝甲厚行動遲緩安全性高。 T99 坦克時速最高可以到50?60公里,目前 世界 最快的坦克只能開到100公里。大家選 這種坦克型語言 主要看重是它們的安全性非常好。
最后是我們的Golang語言,為并發而生,集成現代設計理念的系統級語言,類似于特斯拉Model S,誕生于近幾年,集成了AutoPilot等高級駕駛輔助功能,代表了業界發展方向。選擇特斯拉的人群, 相信 它是未來的發展方向,比如自動輔助駕駛,可以減輕司機的負擔。Golang也是 如此 ,Golang集成的新工具都省掉很多輪子,可以直接拿來用,開發 效率 很高,解放了程序員 的生產力 。
昨天晚飯的時候有阿里的工程師說,他們想在公司內部推廣Golang,但是阿里的技術 棧 基于J ava ,運維系統和發布系統都 只支持Java ,換一種語言運維人員不會支持。這個 問題 只能等Golang 更加 流行, 企業內部系統的支持 慢慢跟上來。創業型 團隊 完全沒有這種歷史負擔可以直接用Golang,我們開發更快性能更高。 廣發證券 現在行情后端的團隊所有的代碼都是用Golang 開發 的。
2 GC問題的困擾
海量并發和海量數據處理系統,GC的內存對象掃描標記不僅消耗大量CPU資源,還會因為GC過程Stop The World造成毫秒級延時,拖慢行情推送速度。 右側圖表是 美國 證券市場訂單處理時間的演化圖( 國內情況也類似 ) , 從 最初一千毫秒現在到零點幾毫秒。局域網內部的延時是零點幾毫秒,跨省的光纖延時是幾十毫秒,如果是中國到美國這種跨洋的專線延時就在上百個毫秒。因為現在訂單執行很快,所以我們考慮網絡延時的情況下還要把系統做的更快。行情和交易都是與時間賽跑的 實時應用 領域。 舉 一個國外的例子,因為光纖在鋪設的時候為了避開障礙物,都選擇沿著公路馬路鋪設,為了把芝加哥期貨交易所和紐約交易所通訊時間縮短3毫秒,花費數億美元埋藏一般條遇山開山遇河挖隧道只為了走直線的光纖。雖然光在 光纖 里面 以 光速 傳播 ,但是光 信號 在 光纖內 傳播會有衰減,每隔一段距離都需要加一個信號放大器,信號放大器就是一個 中繼 器,每一個 中繼 器都會增加 網絡 延時。所以如果能把光纖鋪設更短,網絡延時就會縮小。再比如頻繁花巨資更新通信設備,只是為了微秒的提速,乃至colocation到把機器并排放到證交所的 機器的 旁邊。
再說國內的情況, 雖然 做高頻交易 的 可能性不大,但是國內有漲停板和跌停板的限制。比如順豐上市連續五個漲停板, 一般人 想買順豐的股票買不到,一開盤就是漲停價,封死在那里。如果想買到漲停板怎么辦呢?這個時候就要在開市一瞬間,用極速交易系統發一個買單過去沖到所有買單最前面。如果訂單在開市時間點之前到達是作廢的,進不了交易系統,必須在開市 后 的第一時間進去。據說現在 有 同行研究 用原子鐘與交易所對準時間, 在交易所開盤時間點到達 時 把訂單發過去,這樣就能搶 先買 到漲停板 里的賣單 。 時間就是金錢在這里得到充分的體現。
2.1 Go在GC性能上的改進
討論一下Go在GC上的性能問題。Go1.8版本 是當前 最新版本,相比于1.7版本GC暫停大幅減少,通常低于100微秒甚至10微秒。我們實際測試了一下, 右側圖表是 一個負載不是很高的服務器,GC普通情況下暫停確實已經影響不是太大。我們 只 關心毫秒 級 延遲,所以100微秒對我們沒有影響。Go使用CMS,它的優點是不中斷業務的情況下并行執行,將停頓時間降低到最小,缺點是 GC 并行執行需要更多的 狀態 同步開銷,降低 了GC 吞吐量以及堆空間增長難以預測。為什么這么說?很多支持協程的語言,發現堆里面剩余空間不足的時候,會把業務協程給暫停下來, 當 所有的業務全部 暫停 ,這個時候去做批量化的 GC 處理, 堆空間 就不會難以預測。比如 算法 限定 堆對象上限為 30 0MB , 達到此上限時 把所有業務 暫停 做一次清理, 堆對象自然不 會超過30 0MB 。如果做并行 GC ,發現快到30 0MB時啟動 GC,如果業務線程在快速申請釋放對象,GC的線程 回收效率可能 跟不上, 堆對象 就會超過30 0MB 到很高。上面的毛刺就是GC 撿垃圾的速度 跟不上業務線 丟垃圾 的速度,導致我們的堆空間暴漲。
2.2 GC算法考量的因素
第一點是并發 , 回收器利用多核 處理器 并行執行。一個核心在跑業務的時候,另外一個核心能不能去把它產生的垃圾收回來 。
第二點是停頓時間,回收器會造成多長時間的停頓。比如G o 使用的并行的GC就可以把停頓時間降低到最小,暫停 業務線程 只是為了同步狀態,然后業務線 程 可以繼續跑, GC繼續 掃描垃圾并回收掉。
第三點是停頓頻率。回收器造成的停頓頻率分布我們希望它越均勻越好,或者說在業務線程空閑的時候,可以多停頓一下,把所有的 垃圾 回收回來。
第四點壓縮, 即 移動內存對象整理內存碎片發頻繁申請 釋放大量 內存對象,如果內存對象不能移動,回收 后的空閑 內存 區 可能是一小塊一小塊零散的碎片。這時候如果要分配大對象,小碎片用不上,只能分配新空間才能把大對象放上去,小碎片就造成內存空間的浪費。一個好的GC算法,可以移動內存對象,通過移動整理來把小碎片合并成一塊大的 空閑 區域,這就是內存碎片的整理。
第五點堆 內存 的開銷,回收器 算法 需要 消耗 多少額外的內存開銷來做GC掃描以及統計。
第六點GC吞吐量,在給定的CPU時間內,回收器可以回收多少內存垃圾。GC吞吐量不夠的 時候 ,回收垃圾需要更長的 處理 時間。
上圖 是線上跑的一個系統,這個系統的壓力不是太大,每秒處理1000多條數據,跑了一段時間之后,我們就發現內存堆空間 占用 了1個多G B ,我們預測他的內存幾百M B 就夠用了。但是跑太久就會出現堆空間不斷增大,可能 的原因a 是無壓縮造成。目前Go GC算法不支持壓縮,其實不支持壓縮也是在考慮很多情況下的權衡。比如說Go要跟C g o線程 互操作 ,一個對象要跟Cgo線程之間共享, 壓縮 可能導致Cgo沒法訪問。Go考慮到這種 場景 ,就選擇不移動對象,內存垃圾壓縮實現不是太好,比J ava 要差一點。然后 原因b 吞吐量不足,為了暫停時間盡可能短,犧牲的就是吞吐量。GC S top The World 時間和吞吐量我們只能二選一,Go 選擇停頓時間短所以 吞吐量會差一點。 原因c 是無停頓,處理不及時,因為Go在跑的時候,其他GC也會同時在處理,沒有把業務線程停下來。比如右邊綠色升上去了,這個就是申請的對象速度非常快,黃色的GC沒有跟上來,這個也會導致堆空間增大一些。 原因d 并發執行不可預測,在并發時就無法預測堆空間會漲到哪里 , 如果申請的速度非常快,這個會有可能漲到天上,最后內存爆掉。
2.3 避免Goroutine的頻繁創建銷毀
前面講了很多GC問題,這種情況下就要避免出現GC問題。我們要避免Goroutine的頻繁創建銷毀,并發量小于1000時,每個請求分配一個Goroutine,并發模型簡單易于開發,類似于Apache而并發模型。Apache每新建一個連接時就從進程池中分配一個處理,這種并發模型非常簡單,代碼也是同步的。并發量大于1000時,頻繁創建的Goroutine在銷毀時產會生大量的內存垃圾,比如每秒創建或銷毀1000個Goroutine時,垃圾就非常多,GC線程就會非常繁忙。CPU 30%-50%的時間用來處理GC。整個系統的響應速度就會很慢。這時就不能每個 請求創建一個 Goroutine那么奢侈 ,最好用 采用Nginx并發模型。
2.4 對象緩存池的使用
為了避免GC問題,減輕GC的壓力還 可以使用 對象緩存池。不創建新對象才能避免GC,沒有生就沒有死,不創建新的就不會有回收問題。 業務正常狀態下 對象的創建速度和銷毀速度近似平衡 ,所以一個緩存池可以完美的解決問題 。Go的標準庫里面有一個sync.pool 的緩存池實現 ,缺點是沒有辦法控制緩存對象數量和銷毀時機。sync.pool的對象緩存在下一次做GC的時候,會全部回收。
介紹一個 對象緩存池的簡單實 現 。首先創建一個 Channel, 長度設置為一萬,也就是緩存池的容量。然后寫一個分配的函數 AllocSetU64 ,分配 方法 通過 Select語句實現 ,第一 個case 取出一個緩存對象,如果這個 Channel 為空 說明緩存池空 ,第一個 取緩存的 case 被跳過 直接進 入default ,只能創建新的 對象 。 釋放函數FreeSetU64的回收也是利用這個Channel , 如果沒有滿就丟 到Ch annel , 如果滿就直接執行 default的空操作, 意思是解除引用 把對象 留給GC回收 。況 右邊圖表就是這段代碼運行時的統計情 , 可以看到加了這個緩存池之后,實際上線跑的時候,第一行申請新對象的統計為0,表示沒有創建新對象,而分配時復用舊對象是6.25K,當前這個時間點把對象釋放的數量也是6.25K,業務在跑的時候,對象創建速度和釋放速度是差不多的 。 對象的創建和 釋放全部循環使用了 緩存池 對象 , 這樣就不會有對象的銷毀,所以GC的壓力就會小很多。
2.5 棧對象和堆對象
-
棧對象在函數返回時釋放,堆對象由GC釋放
-
Go編譯器的做法:不逃逸的對象放棧上,可能逃逸的放堆上
-
盡量使用棧對象,特別是在快速調用和返回的函數中,棧對象的分配速度比堆對象快一倍
-
長時間不返回的函數中,過多的棧對象可能增加Goroutine棧空間維護的開銷
-
go tool compile -m 輔助分析對象的分配情況
關于 棧對象和堆對象 , C/ C++程序員會 有明確的概念 ,Golang程序員可能 極少關注 。 Go的 棧對象在函數返回時釋放,堆對象由GC釋放。
Go編譯器的做法是不逃逸的對象放在棧上,可能逃逸的放堆上面。比如一個函數里面申請臨時變量,用完之后就不再用了,這個對象可以放在棧里面,函數返回就釋放掉。如果在函數里面 創建一個 對象,把地址返回 到函數外 ,這時候 的對象就 逃逸 了 出去,這個對象會被編譯器分 配 到堆里面。
如果想減輕堆里 的 GC 壓力 ,自然盡可能把對象放到棧里面,特別在快速調用和返回函數中,棧對象分配速度比堆對象快一倍。 原因是 在棧里面分配對象非常容易,只需要把棧指針 往后 挪一下,挪出來的空間就可以 放新對象; 如果 在 堆里面分配,堆里面有分配算法問題 要執行 , 另外當 堆里面空間用完了,還需要分配新的堆空間。所以堆里面分配速度會慢很多,而且會產生垃圾。
最后長時間不返回的函數中,過多的棧對象可能會增加Goroutine棧空間維護的開銷,Goroutine的棧是分配在進程堆空間 , 默認每個棧分配4K內存,當 函數 生成 對象非常多 , 棧不斷的增加 超 過 4K的時候怎么辦呢?Golang有兩種辦法,一種不分段的棧, 分配一個更大的棧空間再 把小的 棧空間里的數據 拷貝過來 就能 繼續增長。 Go 默認 使用分段的棧, 會在另外一個地方再分配4K作為一個新 棧節點 , 這個4K節點和舊的4K節點用 鏈表連接起來 。 當 棧對象太多棧 空間 不夠用 , 如果分段就會分配新的段 , 在某 種情況性能會非常差:比如調用一個函數,這個函數 消耗的棧空間 超過4K , 之前的段就滿了,就要分配一塊新區域,這個函數一返回新分配的那塊區域就需要被釋放掉,如果再調用就會又增長,也就是棧 空 間會不斷增長、收縮,這個過程性能開銷會很大。如果遇到這種情況,最好不要把非常大的數組放在棧里要放在堆里面。有時候如果不知道對象編譯器放到堆里還是棧里,可以用一些工具 如go tool compile -m 來輔助分析對象的分配情況,編譯器會把 所有對象分析 出來,告訴你這個變量是放到堆里面還是棧里面。
3 面向并發的數據結構
在多線程時代并發訪問臨界區資源時往往要加鎖,鎖的存在使得并行任務互相干擾影響性能。在多處理器多核時代并發問題會更加復雜,同塊 內存 單元的讀寫也會互相干擾影響性能。
先介紹Cache Miss的代價,內存延遲往往很高,從10到100納秒不等,一個3.0GHz的CPU在100ns 內 可以處理多達1200條指令。一次緩存失效就會失去執行500條指令的機會。 參考右 圖, 兩 個CPU插槽里 都 是多核 處理器 ,從C1到C n 多個核心, 多個核心 的一級緩存、二級緩存獨立,三級緩存共享。一級緩存二級緩存每一次讀寫使用的時間非常短是納秒級別,如果到三級就是12納秒,如果三級緩存還沒有命中,訪問內存就是56納秒。在開發高性能 程序 時,就要考慮訪問的內存空間,是不是盡可能用 上 CPU緩存。
先介紹一下原理再 討論 我們遇到的問題, 訪問 同一塊內存區域 的 一個Goroutine在第一個CPU上,另一個Goroutine在另一個CPU上。他們都會訪問同一個內存區域,這個內存區域在左右兩邊的緩存里都有。兩邊修改之后如何保持一致呢?CPU內部有個Cache一致性協議。緩存段處于獨占或修改狀態的時候才可以修改 ( 我們的緩存里面分為一段一段,每64字節 為 一段, 每 一段緩存影射 一塊內存) 。每個緩存段都是有狀態的,比如左邊的Socket1在三級緩存里 某 個段 被 設成獨占狀態,這種狀態 下左邊的 CPU就可以修改這個段。申請獨占的時候就會出現一個問題,比如Socket2 的 CPU要申請為獨占,就會通過QPI總線告訴 Socket1的 CPU這一段已經失效了,如果Socket1再訪問內存的話,就不能使用緩存,必須從內存里面重新加載,這時候性能損耗就會非常 大 , 處理 延時也 更高 。
介紹完 CPU緩存 技術背景知識,我們再看一下Per-CPU的存儲。如兩個協程在工作時都要做一個統計狀態上報,比如協程一收到一條消息我們有一個統計的API去把統計變量加1,另外一個協議也收到一條數據,也去把統計變量加1。假如兩個協程在兩個CPU上時,第一個協程加1的時候,就會在三級緩存里面獨占內存 緩存段 ,導致第二個CPU 對應的緩存段 失效,之后第二個協程又申請獨占 緩存 , 再 加1導致左邊失效。兩個協程跑的時候導致對方的緩存不斷失效,就 需要不斷透過緩存直接 訪問內存,緩存作用 喪失 性能會下降幾十倍。
對于 這種情況 可以 使用Per-CPU storage,每個CPU給一個統計變量。比如CPU1有一個狀態統計,CPU2有一個狀態統計,最后匯總就是最終的統計量。比如這個計數問題,要計數的時候就把所有CPU統計情況加起來得到最終結果。Go要怎么實現這個問題呢?Go在做這種高性能計算的時候,兩個協程之間要不互相干擾, 需把 協程綁定在 各自 操作系統 線程上 ,這樣 協程 就不會跑到其他 協程 的操作系統線程上。操作系統線程又可以做一個CPU 親和性 綁定, 兩次綁定之后,Goroutine就只會在一個CPU核心上 跑且 每次操作 對應CPU的storage, 有一個另外的Goroutine來讀( 定時每5秒、10秒) ,把CPU統計量匯總加起來,這樣就可以解決上面的問題。
再看一下另外一個并行讀鎖之間的干擾,多線程時代為了避免鎖的開銷,有些情況數據庫讀的非常多,寫的非常少,就有讀寫鎖的概念。通常認為兩個讀鎖之間不會互相干擾,所以很多時候我們都是大量使用讀寫鎖。但是 分析 讀鎖的代碼, 讀鎖需 要統計當前有多少讀鎖加在上面, 有計數 變量 每次做 +1計算。 如果一個Goroutine在循環 里面頻繁 使用 讀鎖,加讀鎖,釋放讀鎖,另外一個 Goroutine 也有這樣一個循環加讀鎖,釋放讀鎖,這時會同樣出現 上文 的問題,兩個協程之間操作同一個 內存 區域,導致 各自 CPU緩存失效,性能大 幅 的降低。
介紹 支持并發訪問的Map 。 Goroutine 里的 Map非常好用,但 不支持 并發的讀寫。所以多個Goroutine之間如果要共享一個map,要給Map再另外加一個鎖。本來引入Goroutine就是為了并行,但是加了鎖以后就不能并行, 無奈之下 我們自己要造一個輪子 ,實現支持并發訪問的Map 。 實現 支持并發讀寫的map 原理非常簡單,類似于右邊的表格,這個多級Hash Map的每一行從上往下依次是第一級、第二級、第三級、...... 每一個表格里面的單元就是一個哈希存儲單元。往Map里增加對象時,先去算第一級哈希表里的存儲單元,如果是空閑的就可以放入新的對象,如果第一級被占就往下找第二級,依此類推。我們知道哈希是要解決沖突問題,常見的沖突在算法上有拉鏈法和開放地址探查法,因為要修改哈希表元數據或者臨近存儲節點元數據,這些算法都需要加一些鎖,而這種多級哈希表不需要修改哈希表元數據或其他節點的元數據所以無需加鎖,從而可以并發訪問。在我們實踐過程中通常用的8級哈希。8級哈希的查找是非常快的。我們有右邊這樣的監控圖,最上面就是第一級哈希,這里面用了100K,第二級是50多K,第三級25K到第四第五級,第五級之后基本上沒有被使用。可以預估一下需要支持最大的數量有多少,算出要把這樣一個哈希設置為多大。通常最多只能到第五級,如果到第五級之后會有溢出可能需要擴容。
4、融合替代方案
通過我們的內存分析發現很多 Go的第 三庫設計的時候沒有考慮性能問題,比如一些服務發現,還有一些編碼、解碼的庫,頻繁的創建大量堆對象留下很多內存垃圾。為避免使用這種庫有時要用Cgo重寫,比如Protobuffer解包交給Cgo,就不會有內存垃圾。
Goroutine難以管理10萬級以上的連接。我們有一個手機客戶端的推送服務,服務器單機支持30萬并發,因為手機客戶端網絡非常不穩定,走到電梯或者墻角網絡就斷了,通常移動網絡里面可能會1%的重連,所以每秒有3K的Goroutine創建銷毀,這種情況下GC壓力很大,我們要用Cgo的方案解決。
從Go中調用Cgo函數的開銷非常大,主要是因為二者的棧結構不同。解決方案是創建一個Cgo線程常駐在內存,如果有任務需要交給它就通過內存通信,Go把這個對象丟到內存隊列里,Cgo處理完之后再丟回內存隊列,Go再從里面拿回結果。
做網絡服務的時候,希望網絡IO性能越高越好,吐吞量越大越好。我們使用Docker等虛擬網絡的時候可以把MTU調大,使用巨型幀傳輸數據。默認局域網里的MTU是1500字節,這個長度適合互聯網傳輸,但如果傳輸是在內網之間1500就太小可以調到9000以上,協議棧一次可以傳輸更多數據。我們用UDP發包的時候都是用UDP的大包,每次調用可以一次性把幾千的數據拷到內核里,減少了系統調用的數量,這些數據一次性穿透協議中發送出去。如上圖我們的Docker網絡,兩個通信的容器就在同一個Docker Host里面,這個數據包不會傳到外網,我們可以把MTU設的越大越好,它不經過物理網卡,直接在協議站里從一個容器拷貝到另外一個容器。
有時候會存在兩個Docker不在同一臺主機上面,會存在跨主機的通信問題。這時網卡在硬件上有分片offload和校驗offload功能。offload在這里可以理解為減負,這個事情本來協議棧可以干,但是網卡可以幫忙干,幫協議中做一個加速,減輕CPU的負擔。左圖解釋了在開啟分片offload之前協議棧的數據是怎么傳輸的,應用層有一大塊數據要傳輸交給TCP的協議棧,就會在IP層切成一片片,切完片交給網卡發出去。這時候如果用TCP抓包,抓到的都是小于1500字節,已經分好片的。右圖把網卡分片功能打開之后,應用程序發一個大包到TCP層,到了IP層還是不會做分片,協議棧就不管分片這事情,所以CPU資源就省下來了。網卡收到大包之后,網卡自主分成一片一片的發出去。所以開啟后數據在協議棧里面處理會非常快,CPU的負荷會降低很多,分片和校驗都交給網卡。
最后分享一下我們實際使用中遇到的問題,大包無法正常接收。我們有一個服務可以把UDP包給另外一個服務器,另外一個服務器做匯總統計。發現UDP包長度超過1493的時候,另外一端收到的UDP校驗就會出錯,協議棧就會把這個包丟掉。UDP協議里面有一個長度字段是兩個字節,所以一個UDP長度最多可以達到65535,為什么這里到1493就出錯了?第一個包是從Docker虛擬網卡發出來時我們抓到的,這個時候看UDP校驗出錯,這個沒有關系,因為它支持分片,這個時候協議棧沒有做分片也沒有做校驗。第二條抓到的時候,是在Docker上面,第三條和第四條已經到了我們的物理網卡,最后通過虛擬網卡到物理網卡上面,雖然做了分片,但是分片后還是錯的。
最后終于查到原因:左邊是Docker里面看到的虛擬網卡Veth,Veth是虛擬網卡并非真實硬件設備,它并不真實處理分片但是總是默認報告它支持分片。所以左圖Veth的UDP分片顯示已經打開了,而右邊Host物理網卡不支持UDP分片。Veth給內核報告它支持分片,協議棧的傳輸層和IP層遇到大包就不會拆分,如果大包要發到另外一臺主機,就會從Veth轉發到Host的物理網卡,這個網卡不支持分片,Linux的補救措施是CPU計算IP分片后再交給網卡,雖然照顧到了IP層但是忽略了更上一層的傳輸層如這里UDP的分片,所以UDP的校驗字段沒有重新計算是錯的。這個問題查了之后發現,很多基于docker的技術社區都 有提出類似問題但是沒有好的解決方案,我們只能在需要跨主機通信而Host網卡不支持UDP分片時關閉Dokcer容器內Veth的UDP分片功能。
來自:http://mp.weixin.qq.com/s/N_mW0UG_q4Oi0VMCahhxCA