網關基於 Netty 在 Http 協議的實踐
我們網關現在完全基於 netty 實現 http 協議,包含客戶端和服務端,http 客戶端有很多選擇,比如 HttpClient ,jdk 自帶的等,都能模擬 http , 但是和 netty 相比,netty 支持堆外內存,而且內存自己管理,不需要頻繁的申請和回收, 可以減少 GC 的壓力,以及極致的優化。所以 netty http 協議是實現 http client 的首選。
我們網關服務用 Netty 實現 http 協議,主要是下面幾點
-
編解碼
-
引用次數釋放
-
Head 請求
-
連接池
-
連接複用
-
Netty http 服務端
-
完全異步
http 編解碼
網上有很多文章說到了 netty 的 http 編解碼,都只是一個 demo,並沒有在生產環境實踐過的。
channelPipeline.addLast("idleStateHandler", new SouthgateReadIdleStateHandler(readIdleSec, 0, 0, TimeUnit.MILLISECONDS));
channelPipeline.addLast("httpEncode",new HttpRequestEncoder());
//channelPipeline.addLast("httpDecode",newHttpResponseDecoder());
//SouthgateHttpObjectAggregator 支持southgate channel 複用 和 HEAD 請求
channelPipeline.addLast("httpDecode",newSouthgateHttpResponseDecoder());
channelPipeline.addLast("aggregator", new HttpObjectAggregator(MAX_CONTENT_LENGTH));
httpEncode 和 httpDecode 必不可少,這是 http 協議的核心, 我們除了這兩個外,還加了一個空閒超時管理的 handler,來負責連接不用時,主動關閉連接,防止資源不釋放
還有一個主要的聚合的 handler HttpObjectAggregator,沒有該 HttpObjectAggregator 跑個簡單的 http demo 可以,因爲 HttpObjectAggregator 是負責多個 chunk 的 http 請求和響應的。他讓我們的 handler 處理看到的是一個完整的 fullHttpResponse, 不需要考慮是 Content 是否是 LastHttpContent,netty 的 LastHttpContent 代表 body 結束部分。一個 chunk 代表一個 HttpContent, 最後一個 chunk 由 LastHttpContent 表示。
Head 請求
http head 請求時,響應是沒有響應頭的,如果我們按上面設置的編解碼,那我們還不能正常解析 head 請求,因爲 netty HttpRequestEncoder 沒有緩存請求的 method,所以每次解析 body 部分時都,都是去讀 body,導致解析出錯,netty 官方是通過 HttpClientCodec 來解決該問題,緩存每次請求的 method,通過判斷如果 method 爲 head,則不讀 body,直接返回一個 LastHttpContent 即空的 body 來表示 body 部分。
encode 前,先緩存當前請求的 metod
if (msg instanceof HttpRequest && !done) {
queue.offer(((HttpRequest) msg).method());
}
在收到響應做 decode 時:
// Get the getMethod of the HTTP request that corresponds to the
// current response.
HttpMethod method = queue.poll();
可以看出,用 HttpClientCodec 必須是一個連接對應一個,否則 method 回亂掉,如果想在 http 上做類似 rpc 的連接複用,提供併發性能,那這個是不實現是不行的,需要自己實現,我們是自己重寫了 HttpResponseDecoder 的 isContentAlwaysEmpty 方法,HttpClientCodec 裏面的 decode 也是重寫了該方法。
ByteBuf 釋放,防止內存泄漏
引用計數
netty 的 bytebuffer 從內存池裏取出來用時,對應的 relCnt 是 1, 有些需要自己釋放比如讀操作,爲了怕忘了釋放 release 操作,netty 有個檢查機制,有些會自動釋放比如寫請求,netty 在做完 encode 後發送完後,netty 會對 httpContent 做一次 release,即 relCnt 變爲 0,那麼所對應的 byteBuff 會被回收,以便重用, 只要 relCnt 即引用次數爲 0,就不能再對其進行任何操作,因爲已經被回收, Netty 的 MessageToMessageEncoder encode 如下:
try {
//這裏是具體的http 協議編碼
encode(ctx, cast, out);
} finally {
//編碼完後主動release
ReferenceCountUtil.release(cast);
}
netty 在 inbound 操作時,需要自己主動釋放,即你在 handler 處理完後就主動調用 release 釋放,如果在 handler 還沒有處理完,需要交給業務線程繼續處理的,你就在業務線程裏 release,release 可以通過 netty 提供的工具類 ReferenceCountUtil 來做
ReferenceCountUtil.release(httpResponse);
如果你是繼承 Netty 的 SimpleChannelInboundHandler,那處理就不樣,因爲 SimpleChannelInboundHandler 是幫你主動做了 release,所以你在異步處理的時候,你先需要 retain 一次,否則你業務線程裏操作時回報 relCnt 已經爲 0 的不合法異常。
還有個需要注意的是,網絡應用程序都有重試機制,如果 encode 後,發送失敗, 重試時如果沒有在發送之前做 retain 操作,則會出現引用次數 relCnt 爲 0 的不合法異常。所以在正常發之前,最好先 retain 操作。
((FullHttpRequest)httpRequest).retain(event.getMaxRedoCount());
這樣增加了引用次數 relCnt 後,如果一次就發送成功,不需要重試時,則需要自己主動釋放
int refCnt = ((FullHttpResponse)httpResponse).refCnt();
if(refCnt > 0){
ReferenceCountUtil.release(httpResponse,refCnt);
}
PoolThreadCache
Netty 默認啓用線程本地緩存,所以在分配和釋放的時候,都看該線程的 PoolThreadCache 是否有可用的 buffer,如果沒有再從該線程綁定的 arena 中分配,釋放也是一樣,先釋放到該線程的 PoolThreadCache 的對應的 MemoryRegionCache 的 MpscArrayQueue 裏,如果 queue 放不下了,才放回 pool 裏,所以特別需要注意的是:申請和釋放就需要在同一個線程裏,我們在解碼的時候申請是 IO 線程,如果我們在業務線程裏才釋放,更重要的是如果業務沒有申請 buffer 的話,這樣就泄漏了。因爲業務線程的 PoolThreadCache 對應的 MemoryRegionCache 的 queue 裏的 buffer 都不能用,你 dump 的話,會發現很多 MpscArrayQueue queue 對象,有些業務異步處理的話,必須要在業務線程裏釋放,比如網關係統,所以一定要忌用 ThreadLocalCache,可以通過如下設置:
System.setProperty("io.netty.recycler.maxCapacity","0");
System.setProperty("io.netty.allocator.tinyCacheSize","0");
System.setProperty("io.netty.allocator.smallCacheSize","0");
System.setProperty("io.netty.allocator.normalCacheSize","0");
ThreadLocalCache 雖然可以減少鎖競爭的開銷,因爲 io 線程都在自己的地盤分配 buffer,所以不需要到 arena 中去競爭,非常高效,但是這樣非常容易觸發內存泄漏,是把雙刃劍。
連接池
http 協議是獨佔協議,一個請求獨佔一個連接,如果沒有連接池,在高併發時,會出現連接用爆的情況,把系統壓垮了。
netty 自帶了連接池和一般的連接池,除了完全異步外,無其他的區別,實現瞭如下功能:
-
固定連接數,沒有連接可用,而且連接數沒有達到最大值時,就會創建新的連接。
-
有限隊列,沒有連接可用,而且連接數達到上限,則進入隊列等待。
-
超時機制,不可能讓等待連接的請求一直等,這樣資源得不到釋放,所以一定要有超時機制,即等待一定的時間還時獲取不到時,則超時,獲取失敗。
-
補救措施,如果想在獲取超時還時不甘心就此罷休,還支持去建立一個新的連接。失敗補救措施,可以自己定義。默認支持兩種策略,報超時和建新的連接
代碼如下:
final SouthgateChannelPool fixedChannelPool = new SouthgateChannelPool(bootstrap, nettyClientChannelPoolHandler, new ChannelHealthChecker() {
@Override
public io.netty.util.concurrent.Future<Boolean> isHealthy(Channel channel) {
// 保證拿到的連接是可用的, 避免由於 slow receivers 造成oom(從pool中取channel 總會checkHealth)
// http://normanmaurer.me/presentations/2014-facebook-eng-netty/slides.html#10.0
// TODO 是否啓動check before borrow, 以及如何check
EventLoop loop = channel.eventLoop();
return channel.isOpen() && channel.isActive() && channel.isWritable() ? loop.newSucceededFuture(Boolean.TRUE)
: loop.newSucceededFuture(Boolean.FALSE);
//http 連接是獨佔的,再高併發下,獲取連接超時時,直接創建新的連接,等空閒時會自動關閉
}},
FixedChannelPool.AcquireTimeoutAction.NEW, nettyConfig.getAcquireConnectionTimeout(), nettyConfig.getMaxConnections(),nettyConfig.getMaxPendingAcquires(),
true,hostProfile);
需要注意的是,或者連接時的健康檢查,我們需要保證拿到的連接時是可用的,判斷可用除了需要 open 和 active,還最後加上 isWritable。
isWritable 是防止把連接對應的發送鏈表寫太多,導致內存溢出或者 full gc,我們一般通過設置寫水位上線。
bootstrap.option(ChannelOption.WRITE_BUFFER_WATER_MARK, new WriteBufferWaterMark(LOW_WATER_MARK, HIGH_WATER_MARK));
通過 WRITE_BUFFER_WATER_MARK 設置,該連接的等待發送的消息大於設置的值時,isWritable() 返回 false,即該連接不能再發生消息了。
連接複用
Http 協議天生就是獨佔,因爲協議裏沒有唯一的請求 ID,即一個連接同一時候,只能承載一個請求, 這樣在高併發下,連接勢必會成爲瓶頸,連接複用能用少量的連接支持高併發,提高吞吐量
想在 http 上做連接複用,有點事倍功半的意思,如果想達到事半功倍的效果,需要多方的調優纔行。
要想複用,我們首先得明白後端 web 容器是怎麼管理連接的,我們一般都用 tomcat,下面以 tomcat 的爲例說幾個關鍵點。
tomcat 維持連接支持重用,但會在下面兩種情況下會關閉連接:
-
空閒超時關閉,默認 20 秒
-
重用次數達到限制時關閉 由 maxKeepAliveRequests 參數控制,默認 100
maxKeepAliveRequests 參數如果你設置 - 1,那就時長連接了。否則,一個連接只要發送了 100 次就會在響應頭裏設置 Connection:close 告訴客戶端,我要關閉連接了,這也是爲啥你用了連接池,還是不斷新建連接的請求,在壓測時特別明顯。
知道 tomcat 的這些特性後,我們就能讓連接複用了比較簡單了。也就是和 rpc 協議的做法一樣,在 header 裏添加一個唯一請求 ID, 服務端需要把該 ID 寫會給網關係統。
需要注意的是,不是這樣就萬事大吉了,我們通過分析 tomcat nio 的代碼,發現 tomcat 的讀請求是同步的,即一個連接上堆積了多個請求,tomcat nio 是必須一個接一個處理完,不能併發同時處理多個請求。因爲 tomcat 的 nio 解析 http 包是在 tomcat 的 socketprocess task 由 Catalina-exec 線程處理的。即 tomcat 的 catalina 線程即要負責 io 讀取和業務執行兩件事情,除非業務另起了業務線程來異步處理,或者是 Serlvet3.0 異步,並不是 nio poller 線程。
由於 tomcat 是同步處理請求,這樣勢必導致接收的慢即接收緩衝區很容易寫滿,從而引發發送端堆積,因爲接受端回告訴發送端你不能發了,最終導致連接不可用。
還有一個是連接複用也解決不了 tcp 層的頭 Head of block 問題,即一個連接上先發的包由於丟包或者延遲沒有到達,即使該連接上後面的其他請求包都到達了,tcp 層還是等那個延遲的包。這個在 google 最新的 QUIC 協議裏有解決這個問題。
接入端用 Netty
有同學會問,我們都有了 tomcat 這麼好的容器來接受 http 請求,爲啥要用 netty 來做,個人覺得用 netty 來做 http 協議接入有如下好處:
-
Netty 的高性能就不用說了,比如對象池,內存池,邊緣觸發模式,對 epoll bug 的處理等,
-
netty 的堆外內存,能很大程度上減少 gc 的壓力,因爲堆外內存真正的數據大對像號稱冰山對象 bytebuffer 是不受 jvm 管理的,而 jvm 管理的只是一個很小的 DirectByteBuffer 對象
-
讀和寫分別減少一次 copy,如果是 tomcat,我們必須通過 getInputStream() 來獲取 http 的 body,而這是需要從 tomcat 內部的 inputBuffer copy 出來的,需要注意的是 tomcat 的底層 inputBuffer 默認是堆內的,這樣的話,tomcat 從 OS 緩衝區 copy 出來會多一次 copy,即 OS-->Direct Buffer-->tomcat socketbuffer, 用 netty 後,而 Netty 是使用堆外內存,相對於 tomcat 可以減少 1 次或者 2 次 copy(tomcat 使用堆內 buffer),特別是在併發量大的情況下,tomcat 堆 buffer 下 GC 壓力很大,用 Netty 後, 同樣壓力,GC 比較平穩。
-
tomcat 在應對大併發時會容易引起 nginx 的 block,tomcat 默認的連接數是 10000,假如併發超過了 10000,tomcat 在 accept 完 10000 個後,不會去 accept 後面的連接 (都已經完成 tcp 三次握手),這些連接都在 tcp 的連接隊列裏面,而客戶端完成連接後就就開始寫數據,最終表現客戶端超時,用 netty 後,就可以在連接數達到限制後,我們之間關閉該連接,不讓客戶端等待超時才關閉。
完全異步
網關係統設計必須是異步的,才能接入各種後端響應時間不同的應用,後端響應慢,不會阻塞請求的進入。
Tomcat 做容器
異步後,tomcat 的線程返回時我們不能讓 response 響應客戶端,這裏需要 servlet3.0 的異步支持。啥時候響應,當然是我們收到後端服務的結果後,再主動寫 response 給客戶端。
Netty 實現
netty 實現 http 服務端,需要自己實現異步線程池,從接入端到發起請求的客戶端都得益於 netty 的事件驅動機制,沒有阻塞。
總體線程模型關係圖如下:
業界大廠基本都是這個線程模型,開源界大佬 Netflix 的 zuul2 也是有原來的 servlet3 異步機制改造位 netty 做接入端和服務調用的客戶端。zuul2 更激進的是 接入端和客戶端共用同一個 event loop pool,一個請求的處理和響應都是有同一個 io worker 線程處理,節省了線程上下文切換的開銷, 但是萬一那個工程師寫了個阻塞的代碼,比如網絡調用等,那對線上是災難,所以我們爲規避這個風險,接收這點上下文切換的開銷是值得得
總結
目前我們是基於 http1 開發的接入端,現在 http2 大行其道,我們也正在開發支持中。未來還要考慮自定義協議等等。
作者:絕塵駒
來源:https://www.jianshu.com/p/1346f7fb6442
版權申明:內容來源網絡,版權歸原創者所有。除非無法確認,我們都會標明作者及出處,如有侵權煩請告知,我們會立即刪除並表示歉意。謝謝!
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/C0oz5cM6kcH0KVfapftgYw