億級流量架構演進實戰 - 架構演進構建 TCP 長連接網關 03

這不是一個講概念的專欄,而且我也不擅長講概念,每一篇文章都是一個故事,我希望你可以通過這些故事瞭解我當時在實際工作中遇到問題和背後的思考,架構設計是種經驗,我有幸參與到多個億級系統的架構設計中,有所收穫的同時也希望把這些收穫分享與大家。

2015 年,我在實現了 APP 服務端的平臺化轉型之後,進一步開始了對服務端架構的升級改造,故事由此繼續。

承接上篇,API 網關經過兩年的發展,逐漸演進拆分成了面向 ISV 的 API 開放網關和麪向客戶端的 API 服務網關,其中面向 ISV 的 API 開放網關延續了前兩篇已經介紹過的技術棧,後期則主要在高併發、高可用、高性能上進行技術攻堅;而面向客戶端的 API 服務網關則迎來了新一輪的蛻變。此時的 API 網關主要還是基於 HTTP 實現的,由於 HTTP 是無狀態的,這使得服務端對客戶端的中心管控問題就顯得愈加凸顯,尤爲嚴重的是在上文提到的一些事故。由此構建有狀態的 TCP 長連接 API 網關就成爲解決該問題的一顆銀彈,可喜的是,TCP 網關的雙向通信不僅可以保持客戶端與服務端的會話狀態,實現有效管控,更在未來爲重構消息 PUSH 系統提供了技術底座。

在 2016 年整體上線了基於 Netty4.x + Protobuf3.x 的 TCP 長連接網關,實現了支持 APP 上下行通信的高可用、高性能、高穩定的 TCP 網關,而其性能也較 HTTP 網關提升 10 倍以上,穩定性也遠遠高於 HTTP 網關。

1。Netty 框架

我們先來談談 Netty,談起 Netty 現在大家都很熟悉了,它在很多中間件和平臺架構裏都有扮演很關鍵的角色,我最早了解到 Netty 是在閱讀 dubbo 源碼時。

Netty 是一個可用於快速開發可維護的高性能協議服務器和客戶端的異步的事件驅動網絡應用框架(引自 netty.io),就我個人理解,它在實現基於 TCP NIO 長鏈接的通信領域可以提供強大的框架支持。所以,瞭解 Netty 是非常大有裨益的,推薦書籍《Netty in Action》(Norman Maurer)。本文不會對 Netty 技術做深入的闡述,有興趣的同學也可以訂閱我的專欄《Netty 核心源碼解讀》

言歸正傳,構建基於 Netty 實現 TCP 網關的第一步,就是 Netty 版本的選型問題,當時調研了 3.x 的 jboss 和 4.x 的改進版本,包括 Mina 的技術,最終綜合考慮選擇了 Netty 4.x 的主流版本。其次在架構結構的設計上,由於 Netty 本身就是一個容器服務,這就與 HTTP 網關需要 Nginx + Tomcat 的部署架構有所不同,APP 客戶端可以通過域名和端口直接訪問到 Netty 服務,也基於此,通過不同域名對應各地域的 VIP,VIP 發佈在 LVS,再由 LVS 將請求轉發給後端的 HAProxy,最後由 HAProxy 把請求轉發給後端的 TCP Netty 網關上,部署結構如下圖:

期間,遇到諸多技術上的小問題,尤爲煩擾的就是長連接的保持問題,因爲網絡問題導致 TCP 長連接很容易閃斷,這裏首先跟網絡部協同優化了很多細節,包括對 keepalive 參數設置和 gzip 的數據壓縮的調優等,其次是在 TCP 網關的 Session 設計和弱網閃斷重連等多個技術細節點上做了很多的創新,最終實現了百萬級 TCP 長連接的穩定服務。

2。Protobuf 格式

當然,提供一個穩定的 TCP 長連接服務更離不開對通信協議的設計考量,之前 HTTP 網關是基於 JSON 進行數據傳輸的,JSON 是 key-value 的鍵值對通信協議,生成的報文會很大,所以傳輸性能會有所影響。考慮到報文的傳輸性能,構建 TCP 網關的通信協議選型最後採用了 Protocol Buffer,一是 Protobuf 協議天然支持 Java、Objective-C 和 C++ 等語言,做到了語言無關、平臺無關;二是 Protobuf 協議數據壓縮比很高,通常一個整型要佔 8 比特位,通過 Protobuf 可以壓縮到 2 比特位進行通信傳輸,提升數據傳輸效率。

因爲目前 API 網關已經支持了泛化調用,泛化可以理解爲通過配置和協議轉化直接調用後端服務接口,所以,此時也就不需要每次有新需求,都要在網關增加 Protocol Buffer 對象定義新接口。數據傳輸的本質都是字節流的序列化和反序列化,所以 APP 的數據流可以以二進制流的方式在 TCP 網關直接反序列化爲後端服務的接口對象,完成整條通信鏈路 API 服務的請求調度。

3。業務線程池

接下來,我們來談談業務線程池,一個疑問:爲什麼要有業務線程池?其實,我在初期構建 TCP 網關也是沒有業務線程池的,直到一次事件後才加了單獨的業務線程池。其實,邏輯很簡單,我們知道通過 Netty 的 ChannlRead 方法就能方便的獲取到通信的入站(Inbound)和出站(Outbound)數據,如果在 ChannelRead 方法裏直接調用後端服務請求,就有可能由於後端服務響應 RT 高而阻塞住 Netty 的 IO 線程池組。爲了說清楚這其中的原由,我先簡單的介紹下 Netty 的線程池模型。

Netty 是 Reactor 模式的一個實現,Reactor 是一種經典的線程模型,Reactor 模型分爲單線程模型、多線程模型和主從多線程模型,三種常用模式。

Reactor 單線程模型

所謂 Reactor 單線程模型,指的是客戶端的所有操作都是在一個線程上完成的,包括請求的連接和讀寫操作。

在 Reactor 單線程模型中,由 Acceptor 負責監聽客戶端 accept 事件,當有客戶端連接後,服務端創建對應的 Channel,並註冊到 Reactor 上,進行讀寫事件的監聽,當有事件觸發後,事件會觸發 Reactor 進行相應的讀寫處理,Reactor 會創建獨立的 Handler 對請求數據進行處理,其中 Handler 是具體處理事件的處理器。

Reactor 多線程模型

在 Reactor 單線程模型中,雖然 Reactor 可以支持多個客戶端的同時請求,但如果 Handler 出現阻塞,就會造成客戶端請求被積壓,嚴重的會導致整個服務不能接收客戶端的新請求。所以,在這種線程模型下,將接收連接和處理請求分成兩個部分,通過引入線程池的方式,將建立連接的請求先放到線程池中,一個線程負責一個或多個 Channel 的請求處理,這樣客戶端的請求就不會出現阻塞。

所謂 Reactor 多線程模型,指的是由一個專門的線程負責 Acceptor 處理連接請求,由一個線程池負責多線程處理請求的讀寫操作。

Reactor 主從多線程模型

在 Reactor 多線程模型中,由於 Reactor 處於一個承上啓下的位置,需要處理 Acceptor 請求,並分發給 Handler 進行處理,所以,當客戶端的請求進一步增加的時候,Reactor 就會出現瓶頸。爲了解決 Acceptor 一個線程可能存在的性能問題,通過將 Reactor 分爲兩部分:Main Reactor 和 Sub Reactor。這種拆分之後,當 Acceptor 接收到客戶端的請求之後,會先創建 Channel 並註冊到 Main Reactor 的線程池上,由 Main Reactor 負責處理連接請求,當連接正式建立後,Main Reactor 會將 Channel 移除並重新註冊到 Sub Reactor 的線程池上,由 Sub Reactor 負責處理請求的讀寫操作。

所謂 Reactor 主從多線程模型,指的是由一個獨立的線程池負責 Acceptor 處理連接請求,由另一個獨立的線程池負責處理請求的讀寫操作。

最後,我說一下我自己對 Reactor 的整體理解,我認爲 Reactor 是一種設計模式,因主要應用於服務端的網絡框架的線程池模型的實現,所以,很多時候又稱爲了 Reactor 線程模型。我理解,多線程 Reactor 模型通過使用 Acceptor 處理就緒的 OP_ACCEPT 事件,爲請求連接創建 SocketChannel,並將該 Channel 註冊到事件監聽器,監聽 OP_READ / OP_WRITE 等事件,Acceptor 是 Reactor 非常重要的模塊之一;當事件監聽器獲取到就緒的讀寫事件,就會進行事件的分發,由 Reactor 創建 Handler 進行多線程的併發處理 IO 讀寫。

Netty 線程模型

在瞭解了線程模型及 Reactor 線程模型之後,那麼 Netty 是哪種模型呢?Netty 的線程模型很像是 Reactor 主從多線程模型,但有所不同的是,Netty 沒有使用線程池並行的處理請求,而是由多個 Reactor 組成一個 Reactor Group,請求在每個 Reactor 中串行的被處理執行。

在 Netty 中主要有 Boss Reactor 和 Worker Reactor,Boss Reactor 負責連接請求,Worker Reactor 負責處理請求的讀寫。在實現上,Reactor 線程模型對應的實現類是 EventLoop,常用的是 NioEventLoop,一個 NioEventLoop 聚合了一個多路複用器 Selector。

一個 EventLoop 可以處理一個或多個連接請求,連接被封裝成 Channel,一個 EventLoop 始終由一個線程驅動,所以一個 Channel 內所有的請求和事件都是由 EventLoop 的這個線程來處理。一個或多個 EventLoop 組成一個 EventLoopGroup,一個 EventLoopGroup 相當於 Reactor 的線程池。從線程模型的角度理解,一個 EventLoopGroup 可以類似於一個 ExecutorService,一個 EventLoop 可以理解成一個線程。

TCP 網關線程模型

在實現上,TCP 網關共使用了三組線程池,分別是的 BossGroup、WokerGroup 和 ExecutorGroup,三組線程池分別實現了 TCP 長連接的請求接入、IO 讀寫和業務操作。因爲 TCP 網關採用 Netty 進行構建,所以,BossGroup 和 WorkerGrlup 採用的是 Netty 的線程池 NioEventLoopGroup。由 BossGroup 負責處理客戶端的 TCP 連接請求,WorkerGroup 負責處理 I/O 讀寫操作、執行系統 Task 和定時任務。

ExecutorGroup 是 TCP 網關實現的線程池,負責處理 TCP 網關業務及將請求轉發給後端服務等業務操作。

我附上一張 TCP 網關線程模型的整體架構圖,一起來看下。

在實現上,設置 bossGroup 線程數爲 1,因爲 TCP 網關對外監聽的是一個端口,所以使用一個線程處理。設置 workerGroup 線程數爲 CPU 核數乘以 2,比如 8 核 CPU 會創建 16 個線程,但注意如果容器是 Docker,這個值使用下面的方法就不準了。原則是每個 CPU 綁定的 worker 線程數不用設置過多,避免不必要的 CPU 線程切換,默認值是 CPU 核數乘以 2。

int GROUP_SIZE = 1;
int THREAD_SIZE = Runtime.getRuntime().availableProcessors() * 2;
EventLoopGroup bossGroup = new NioEventLoopGroup(GROUP_SIZE);
EventLoopGroup workerGroup = new NioEventLoopGroup(THREAD_SIZE);
public void init() throws Exception {
    ServerBootstrap bootstrap = new ServerBootstrap();
    bootstrap.group(bossGroup, workerGroup);
    bootstrap.channel(NioServerSocketChannel.class);
    bootstrap.childHandler(new ServerChannelInitializer());  
    bootstrap.bind(port).sync();
}

Netty 不是已經提供了線程池的實現,爲什麼還需要實現使用 ExecutorGroup?這是因爲,Netty 的兩個線程池組 BossGroup 和 WorkerGroup 是實現了線程隔離的,但沒有對 IO 線程和業務線線程進行隔離。在這種模式下,每個 EventLoop 負責處理的 IO 操作與業務操作是在同一個線程裏執行,如果業務操作出現了阻塞,就會影響 EvenLoop 下所分配的所有 Channel,因爲 EventLoop 是通過多路複用器 Selector 獲取就緒事件,並串行的執行處理請求讀寫事件。

下面,我就說下上文提到的事件,在使用 Netty 構建 TCP 網關時,每個客戶端會與服務端建立一個 TCP 長連接,一個長連接對應一個 Channel,而這個 Channel 是由一個 EventLoop 進行事件監聽和處理的,客戶端當時連續向 TCP 網關請求多次調用,正巧第一個請求事件在處理時阻塞了,這就導致了 EventLoop 中的就緒事件出現了積壓,從而造成了客戶端請求無響應的現象,或是等了半天又一下子返回所有請求結果。

所以,我通過引入線程池的方式,對業務處理從 WorkerGroup 進行了剝離。我們知道, Netty 通過 ChannelPipeline 管道技術處理 Handler,在處理我自己的 TcpHandler 的 channelRead 方法裏,將請求放入一個線程池裏進行異步的處理,這樣就不會出現 EventLoop 的事件阻塞。在實踐中,引入線程池之後,客戶端請求無響應的問題就基本得到解決。

@ChannelHandler.Sharable
public class TcpServerHandler extends ChannelInboundHandlerAdapter {
    private ExecutorService threadPool 
                 = new ThreadPoolExecutor(minPoolSize, 
                              maxPoolSize,
                              aliveTime,
                              TimeUnit.MILLISECONDS,
                              blockQueue, 
                              threadFactory, 
                              rejectedHandler);
    public void channelRead(ChannelHandlerContext ctx, Object o) 
                                                    throws Exception {
        /* */
        Task task = new Task(this, ctx, o);
        threadPool.execute(task);
        /* */
    }
}

其實,Netty 裏也提供了對業務線程池的支持,我們看下 Netty 提供的方法:

ChannelPipeline addLast(EventExecutorGroup group, String name, ChannelHandler handler);

Netty 使用 add 方法向 ChannelPipeline 中添加 ChannelHandler,ChannelPipeline 會從 EventExecutorGroup 中選擇一個 EventExecutor 分配給這個 ChannelHandler。上述方法中,傳入 group 參數,則 ChannelPipeline 會從 group 中選擇一個 EventExecutor 分配給這個 ChannelHandler,否則,ChannelPipeline 會從 WorkerGroup 中的 EventExecutorGroup 進行選擇,這也就會出現業務操作與 WorkerGroup 的 IO 操作共享線程池的情況。

但是,Netty 提供的這種方式和我自己實現線程池的方式,哪種方式好呢?通過分析,Netty 提供的方法與使用自定義線程池有所不同,ThreadPool 採用的是多線程並行執行任務,而 Netty 傳入線程池的方式,是將一個 EventExecutor 線程綁定到該 Channel 對應的 ChannelHandler 上。所以,也就是說一個客戶端長連接的 Channel 中的事件,還是會在一個 EventExecutor 出現阻塞。

不過,我認爲這種方式還是解決了一個問題的,那就是一個 EventLoop 是綁定了多個 Channel 的,並由 EventLoop 中的 Selector 進行統一的事件分發,通過傳入 group 的方式,多個 Channel 則可能會被重新綁定到不同的 EventExecutor 中,這就可以解決一個 Channel 由於 IO 阻塞了,其他的 Channel 也會出現阻塞的情況。但是,不能解決一個 Channel 中某一個事件阻塞了,造成該 Channel 就會阻塞的問題。

如此看來,就不難理解,使用 Netty 時爲什麼要設置單獨的業務線程池了。

4。總結

言而總之,本篇文章重點講述了 TCP 網關的 Netty 框架、Protobuf 格式、業務線程池。下篇文章,我將繼續介紹心跳、Session 管理、斷線重連。如果你覺得有收穫,歡迎你把今天的內容分享給更多的朋友。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/HeTTfek3GDMJSl-0oyZe5A