降低20%鏈路耗時,Trip.com APP QUIC應用和優化實踐
作者簡介:競哲,攜程資深后端開發工程師,關注網絡協議、RPC、消息隊列以及云原生等領域。
一、背景
QUIC 全稱 quick udp internet connection,即“快速 UDP 互聯網連接”(和英文 quick 諧音,簡稱“快”),是由 google 提出的使用 udp 進行多路并發傳輸的協議,是HTTP3的標準傳輸層協議。近幾年隨著QUIC協議在IETF的標準化發展,越來越多的國內外大廠開始在生產環境對QUIC進行落地,以此提升某些業務場景下的服務性能。
Trip.com App作為一個面向國際化的App,承載了大量海外用戶的請求,這些請求需要從海外回源到上海,具有鏈路長、網絡不穩定等特點。在這樣的背景下,我們嘗試使用QUIC對App的傳輸鏈路進行了優化,并與目前的TCP傳輸鏈路進行對比實驗。結果表明,QUIC協議的落地降低了Trip.com App大約20%的鏈路耗時,大大提升了用戶的體驗。
二、QUIC簡介
簡單來說,QUIC是基于UDP封裝的安全的可靠傳輸協議,通過TLS1.3來保證傳輸的安全性,并通過協議標準來保證基于UDP傳輸的可靠性。目前QUIC協議已經成為新一代HTTP協議HTTP3的標準底層協議。QUIC在整個網絡協議棧中的位置如下所示:
相較于TCP協議,QUIC主要具備以下優勢:
三、QUIC服務端落地實踐
QUIC的落地需要客戶端與服務端的共同支持,客戶端我們使用了Google的開源Cronet網絡庫,服務端基于Nginx的官方quic分支。為了滿足我們的業務與部署場景,客戶端與服務端都對原生的代碼進行了一些改造。本文主要介紹服務端的改造內容以及整體架構。
3.1 服務端整體架構
服務端的整體分層如圖所示:
其中:
- AX是公網的負載均衡器,通過配置可以做到將相同客戶端ip+port的數據包轉發到下游的同一臺實例上。
- Nginx Stream層是基于Nginx實現的數據包轉發層,主要用來支持連接遷移的功能。
- Nginx QUIC即QUIC服務端,基于Nginx的官方quic分支開發改造,用來接收QUIC數據包,并解析出Http請求轉發到公司的API Gateway上。
為了充分利用CPU的性能,提升整個系統的高可用性,Nginx Stream和Nginx QUIC都是集群多進程部署。這就為請求轉發、實現連接遷移和0-RTT帶來了一系列問題。下面針對遇到的問題以及我們的解決方案進行簡單的介紹。
3.2 集群多進程部署
由于我們在多進程部署Nginx QUIC服務端時采用reuseport的形式監聽端口,所以在介紹多進程部署之前,先簡單介紹Linux系統的reuseport機制和Nginx的進程模型。
所謂reuseport,簡單理解就是允許多個套接字對同一ip+port進行監聽。Linux在接收到數據時,會根據四元組轉發數據到相應的套接字,即來源于同一個客戶端的數據總會被分發給相同的套接字。Nginx基于這個特性,在啟動時,對于配置reuseport的端口,會在創建與進程數量一致的套接字,監聽同一端口,并為每個進程分配其中的一個套接字。這樣便實現了多進程監聽同一端口的功能,并且來源于同一源ip+port的數據總是會被分發給同一進程。
有了以上理論基礎,我們來看集群多進程部署時,我們的系統會出現什么問題。由于Nginx Stream層主要用來支持連接遷移,所以此處暫時先忽略NginxStream的存在,即服務的架構為AX直連Nginx QUIC集群。
由于AX為四層負載均衡器,所以在對UDP數據包進行轉發時,可能將隸屬于同一QUIC請求的多個UDP包轉發到不同的下游實例中,這就會導致請求無法被處理。其次,即使所有的數據包都被AX轉發到同一臺Nginx QUIC實例上,由于Nginx QUIC實例為多進程部署,如果多個數據包的源ip+port不同,也會被分發到不同的Nginx進程中。以上兩種情況都會導致正常的請求無法被處理,整個系統完全無法正常工作。
幸運的是,經過調研我們發現AX可以通過配置將來源于同一源ip+port的數據包通過同一出口ip+port轉發到同一臺服務器實例上,服務端實例通過reuseport機制就可以做到將數據分發給同一進程。這樣如果客戶端的ip+port不發生變化,其請求就會被一直轉發到同一臺服務端實例的同一進程中。
以上方案只是針對客戶端ip+port不發生變化的場景,但在移動網絡的環境下,客戶端網絡變化是十分正常的事情。當客戶端網絡發生變化時,客戶端就需要與服務端進行重新握手建立連接,這樣就會帶來額外的時延,影響用戶體驗。
雖然QUIC有對連接遷移的支持,Nginx也對連接遷移的功能進行了實現,但都是針對端對端的場景,在我們集群多進程部署的場景下無法正常工作。為此,我們對代碼以及部署架構進行了一些定制化改造以支持集群多進程部署場景下的連接遷移。
3.3 連接遷移
連接遷移是QUIC的一個重大特性。指的是當客戶端或服務端的ip+port發生變化時,兩端可以保證連接不中斷繼續通信。大多數情況下都是客戶端ip+port變化導致的連接遷移,服務端的ip+port基本不會改變。本文中討論的也是客戶端ip+port發生變化的場景。
在以TCP作為傳輸層協議的網絡鏈路中,當用戶的網絡發生變化,如移動網與wifi的切換,客戶端需要重新與服務端建立連接才可以繼續通信。由于TCP建立連接需要三次握手,這就需要消耗額外的RTT(Round-Trip Time,往返時延)。尤其在弱網或者跨地區調用的場景下,建立連接需要消耗更多的時間,這對用戶來說體驗非常差。
而由于QUIC基于UDP協議,UDP并沒有連接的概念,因此便可以在QUIC協議中通過connectionId來標識一個唯一的連接。當四元組發生改變時,只要connectionId “不變”,便可以維持連接不斷,從而實現連接遷移的功能。
3.3.1 Nginx-QUIC連接遷移功能的實現
為了利于大家對Nginx連接遷移功能實現的理解,先簡單介紹一下QUIC的握手過程中涉及到的數據包類型。客戶端在首次與服務端建立連接時,首先會發送一個Initial包。服務端收到Initial包后返回Handshake包,客戶端收到Handshake包后,便可以正常發送應用請求數據。此處我們省略了握手過程中的ACK、加解密過程,精簡后的握手流程如圖所示:
對QUIC握手流程有了大致的了解之后,我們來看Nginx是如何實現連接遷移功能的。
客戶端發起與服務器建立連接的請求時,會在Initial包中攜帶一個由客戶端隨機生成的dcid。Nginx服務端收到Initial包后,會隨機生成一個cid在Handshake包中返回,后續客戶端發送的數據包都會以這個服務端返回的cid作為dcid。握手完成后,Nginx會生成多個cid保存在內存中,并在握手完成后將這些cid發送給客戶端作為備用,cid的個數取決于客戶端與服務端 active_connection_id_limit 參數的限制。
當客戶端主動發起連接遷移時,會從備用的cid集合中取出一個作為后續數據包的dcid。Nginx收到攜帶新dcid的非探測包后,感知到客戶端發生了遷移,會對新的客戶端地址進行驗證,當驗證通過后,后續數據包都會發送給新的客戶端地址,也就完成了整個連接遷移的流程。
以上對連接遷移的實現,僅僅在端對端且服務器單進程部署的場景下可以很好地工作。但是,在實際的生產環境中,由于引入了AX等負載均衡設備,而且服務端為多機多進程部署,因此當客戶端網絡環境發生變化時,新的請求數據包可能會被轉發到新的服務器進程中。由于新的服務器進程并不包含此客戶端連接遷移所需的上下文,就會導致客戶端遷移失敗。
解決上述問題的思路就是引入一個中間的LB層,這層的主要作用是解析出udp包中的dcid,然后根據dcid轉發,把連接遷移前后的數據包轉發到同一服務器的同一進程上。
此處我們基于Nginx搭建了Nginx Stream層,作為QUIC的LB層來實現數據包轉發。由于連接遷移后,客戶端會使用全新的dcid來發送數據包,為了使得Nginx Stream層通過新dcid的也可以路由到遷移前的機器上,我們還對服務端生成客戶端dcid的邏輯進行了改造,在dcid中包含了服務端的ip+port信息。這樣,當Stream層的機器收到遷移后的數據包時,便可以根據dcid中的ip+port信息將其轉發到客戶端遷移之前的服務端機器上。
加入Nginx Stream層之后的服務端整體架構如圖所示:
以上方案只是實現了將客戶端連接遷移前后的請求轉發到同一臺服務器上,但無法保證請求被轉發到服務器的同一進程中。試想,當客戶端發生連接遷移時,AX可能會將客戶端遷移后的數據包發送到跟之前不同的Nginx Stream實例上,雖然Nginx Stream可以通過dcid中的ip+port信息將數據包轉發到同一臺Nginx QUIC實例上,但對于Nginx QUIC來說,源ip+port為Nginx Stream的ip+port,源ip+port發生了改變。由于Nginx多進程分發請求依賴了操作系統的reuseport機制,而Linux的reuseport是根據四元組進行請求分發的,因此源ip+port的改變就可能會導致請求在服務端被分發到與遷移前不同的Nginx進程中,從而導致連接遷移失敗。
為了解決這個問題,就要求NginxStream可以將數據包準確地轉發到指定機器的指定進程中。我們進行了調研,總結下來大致分為兩種解決方案:
- 一是修改操作系統reuseport的分發機制,使其根據dcid將數據分發到指定進程。
- 二是使不同的進程監聽不同的端口,這樣就可以通過將數據包轉發到不同端口,從而轉發到具體的進程。
第一種方案需要修改Linux內核代碼,成本較高,并且dcid在整個系統中屬于QUIC的應用層數據,我們認為不應該在操作系統代碼中嵌入應用層的數據邏輯,因此最終選擇了多端口的解決方案。
3.3.2 基于監聽多端口的連接遷移方案
為了降低NginxStream配置文件復雜度和系統的整體維護成本,以及未來支持服務端的平滑無損升級,我們讓每個進程監聽了兩種不同的端口:
- 第1種是監聽端口listening port,主要接收客戶端的initial或0-RTT包,用來建立連接,每個進程的listening port相同;
- 第2種是worker port,用來接收客戶端第一個包之后的所有數據包,每個進程的workerport不同。
基于此方案,由于不同進程監聽了相同的listening port,因此在建立連接時,由Linux根據四元組進行請求分發,從而實現進程間的負載均衡。一旦客戶端與服務端建立了連接,后續此客戶端所有請求報文中的dcid都包含了此服務器的ip+worker port信息,因此后續請求(包括連接遷移后的請求)都會被Nginx Stream層轉發到同一服務器的同一進程進行處理,從而實現了多進程場景下對連接遷移功能的支持。
完整的連接遷移過程如下圖:
可以看到,整個連接遷移過程對Nginx Stream來說是透明的,它只負責解析dcid中的ip+port信息,并進行數據包的轉發。Stream層并不需要感知服務端worker port的存在,僅僅在收到initial或0-RTT包時,需要根據dcid的hash值將其轉發到Nginx QUIC機器的listening port上,因此在Stream機器的配置文件中,只需要配置Nginx QUIC的listening port。這樣,如果Nginx QUIC需要修改單機的進程數,Nginx Stream層無需做任何改動。
而在Nginx QUIC端,只需在Nginx現有進程模型的基礎上,為每個進程額外分配一個worker port,并在為客戶端生成的dcid中包含ip+worker port的信息即可。
3.4 0-RTT實現
QUIC的另一個重要特性是支持0-RTT建立連接,它通過UDP+TLS1.3構建安全的互聯服務。對于TLS1.3來說,如果是首次建立連接需要1-RTT,非首次可以實現0-RTT。
當客戶端與服務端第一次建立連接時,服務端會依賴加密層的TLS會生成加密的New Session Ticket返回給客戶端,并將解密的ticket_key保存在本地。客戶端在發起0-RTT請求時,會在報文的PSK中帶上NewSession Ticket。服務端收到報文時,通過ticket_key對PSK中的信息進行解密。如果解密成功,則可以恢復之前的session,后續直接進行安全通信。如果失敗,則需要重新握手建立TLS連接。
0-RTT實現示意圖如下:
可以看到,服務端支持0-RTT的關鍵就是需要包含恢復session所需的ticket_key。但是,nginx中生成的ticket_key是保存在進程的上下文中的,由于nginx中各個進程的上下文是相互獨立的,因此不同進程間的ticket_key無法共享。
并且0-RTT握手時數據包中的dcid由客戶端隨機生成,不包含路由信息,所以Nginx Stream層也無法通過dcid將0-RTT請求轉發到之前已經與客戶端建連的進程中。這就導致如果服務端進程無法解密收到的0-RTT數據包,就會導致0-RTT握手失敗,客戶端需要重新發起1-RTT握手請求。
為了解決這個問題,我們引入了Redis,使得服務端集群中的各機器各進程共享ticket_key。一個進程在生成ticket_key之前,先去查找redis是否已經存在ticket_key,如果已經存在直接查詢出來作為當前進程ticket_key,如果不存在直接生成并且存入Redis。這樣可以保證在各個進程中的ticket_key相同,最終的效果是如果A進程加密的session,0-RTT時被轉發到了B進程,因為A、B進程的ticket_key相同,B進程也可以解密session,完成連接的建立。
改造后的0-RTT流程如下所示:
四、總結
QUIC協議由于其強大的拓展性與靈活性,以及弱網環境下呈現出來的優勢,近幾年逐漸被各大廠商應用在生產環境中。我們針對IBU業務海外流量大的特點,基于Nginx官方的quic分支進行改造,在生產環境實現了多機多進程環境下0-RTT、連接遷移等功能,并取得了Trip.com App請求總體平均耗時減少20%的良好效果。后續我們也會緊跟社區步伐,將更多QUIC的優秀特性更新集成到我們的服務中,推動QUIC協議在攜程更好地落地。