網關基於 Netty 在 Http 協議的實踐

我們網關現在完全基於 netty 實現 http 協議,包含客戶端和服務端,http 客戶端有很多選擇,比如 HttpClient ,jdk 自帶的等,都能模擬 http , 但是和 netty 相比,netty 支持堆外內存,而且內存自己管理,不需要頻繁的申請和回收, 可以減少 GC 的壓力,以及極致的優化。所以 netty http 協議是實現 http client 的首選。

我們網關服務用 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 維持連接支持重用,但會在下面兩種情況下會關閉連接:

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 協議接入有如下好處:

完全異步

網關係統設計必須是異步的,才能接入各種後端響應時間不同的應用,後端響應慢,不會阻塞請求的進入。

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