Trip-com APP QUIC 應用和優化實踐

作者簡介

 

Logan,攜程移動開發專家,關注大前端技術領域,對 APP 網絡、性能、穩定性有深入研究。

Trip.com APP(攜程國際版)主要服務於海外用戶,這些用戶請求大多需要回源至國內,具有鏈路長、網絡不穩定、丟包率高等特性。爲了解決用戶請求耗時長、成功率低的痛點,在 2021 年初我們嘗試引入 QUIC 來提升網絡質量。經過近一年的優化實踐,取得了顯著的成果:網絡耗時降低 20%,成功率提升至 99.5%,極大地改善了用戶體驗。本文將從客戶端的視角詳細介紹 QUIC 的應用和優化經驗。

一、背景

Trip.com APP 原網絡框架是基於 TCP 的,經過一系列優化後,成功率和耗時均已到達瓶頸。主要的失敗原因集中在請求超時和鏈接斷開。這是 TCP 協議本身的限制導致:

1)TCP 是基於鏈接的,用戶網絡發生切換,或者 NAT rebinding 都會導致鏈接斷開請求失敗,同時每次重新建立鏈接均需要握手耗時。

2)TCP 內置了 CUBIC 擁塞控制算法,這種基於丟包的擁塞控制在 Trip.com 的長肥管道場景(請求大多是海外回源國內)及弱網環境下更容易超時失敗。

3)TCP 的頭部阻塞場景進一步增加請求耗時。

而 QUIC(quick udp internet connection)是一種基於 UDP 的可靠傳輸協議,有 0 RTT,鏈接遷移,無隊頭阻塞,可插拔的擁塞控制算法等優秀特性,非常契合我們的用戶請求場景。

二、配套

1)Trip.com QUIC 客戶端的實現採用了 Google 開源的 Cronet,並在此基礎上做了進一步的 size 精簡和訂製性的優化。

2)Trip.com QUIC 服務端使用了 Nginx 的 QUIC 分支,目前還沒有 release 版本,使用中修復了很多 bug,並做了適配性的改造。

三、應用和優化實踐

引入 QUIC 過程中最大的難點就是 Cronet 庫體積過大,需要經過裁剪後才能在 APP 中使用。使用後經過對比實驗發現 QUIC 並沒有達到預期的效果,這是因爲 QUIC 的 0 RTT,鏈接遷移等諸多優秀特性並不是開箱即用的,需要做定製化的改造才能享受到這些特性帶來的性能提升。於是我們又進一步做了 IP 直連,支持單域名多 IP 鏈接,0 RTT 和鏈接遷移改造,QPACK 優化,擁塞控制算法選擇,QUIC 使用方式優化等許多改造。經過改造後的整套網絡方案極大的提升了網絡質量,改善了用戶體驗,接下來詳細介紹下我們優化過程中踩過的坑和相關經驗成果。

3.1 Cronet 代碼裁剪

業內有很多客戶端 QUIC 的實現方案,Cronet 是最成熟的,但是 5M 的 size 讓很多 APP 望而卻步,所以我們做的第一件事就是對 Cronet 進行裁剪。

Cronet 是 chrome 的核心網絡庫,內部集成了 HTTP1/HTTP2/SPDY/QUIC,QPCK,BoringSSL,LOG,緩存,DNS 解析等很多模塊,我們僅保留了 QUIC/QPACK/BoringSSL 等必須的核心功能,將 Cronet 庫 size 減少 60% 以上。針對 Cronet 的環境搭建、ninja 編譯、如何修改. gn 文件來剔除無用模塊,具體的邏輯代碼刪減等細節後續會推出專門的文章來介紹,同時也會爭取將裁剪後的代碼開源供大家使用。

3.2 IP 直連

我們通過修改 Cronet 源碼,直接指定最優 QUIC IP,實現了 IP 直連,減少 DNS 解析耗時。

DNS 解析是需要耗時的,並且可能出現解析失敗,DNS 攔截等問題。有時還會受網絡運營商的影響,DNS 解析出的不是最優 ServerIP。

Cronet 對 DNS 解析做了很多優化,UDP 請求,TCP 補償,支持 Https 解析以防止 DNS 攔截等,但這是瀏覽器需要的通用方案。

對於企業自己的 APP 來說,域名固定,服務入口 IP 變化較少,所以 Trip.com APP 內置了 QUIC Server IP 列表(支持 IP 列表動態更新),根據用戶位置和網絡狀況指定最優 IP。使得 DNS 解析的耗時和失敗率均達到了 0 。

以下爲目前的 QUIC 接入方案,海外用戶可以靈活的選擇通過蟲洞方式接入或者海外運營商提供的 IPA 加速(UDP 轉發)節點接入,大陸用戶可以通過普通的 IP 直連方式接入。

3.3 支持單域名多鏈接

Cronet 對一個域名僅支持建立一個 QUIC 鏈接,但在大多數 APP 的使用場景中域名固定,需要建立基於該域名下多個 IP 的 QUIC 鏈接,擇優使用,於是我們做了進一步的改造。

Cronet 建立鏈接是用域名作爲 session_key 的,所以一個域名只能同時建立一個鏈接,如果存在同域名的 session 直接複用,代碼如下:

這顯然不能滿足我們的需求:

1)用戶的網絡變化會導致最優 IP 的變化,比如用戶的網絡從電信的 Wi-Fi 切換到聯通的 4G,此時最優 IP 也會從電信切換到聯通。

2)某個 IP 對應的機房發生故障需要立馬切換到其他 IP。

3)某些情況下,需要同時建立不同 IP 的多條鏈接來發送請求,對比不同鏈接的表現(成功率,耗時等),動態調整 IP 權重。

所以我們對鏈接的管理進行了訂製化的改造:

1)對 HTTP request 增加 IP 參數,支持對不同請求指定任意 IP。

2)修改 Cronet QuicSessionKey 的重載方法,將指定的 IP+Host 做爲 Session Key 以支持單域名多 IP 鏈接。

改造核心代碼如下:

改造後的代碼支持了單域名多鏈接,形成了 QUIC 鏈接池,能讓開發者靈活的選取最優的鏈接進行使用,進一步降低了請求耗時,提升了請求成功率。

3.4 0 RTT 優化

0 RTT 是 QUIC 最讓人心動的一個特性,沒有握手延遲,直接發送請求數據。但由於負載均衡的存在和重放攻擊的威脅使得我們必須對 Cronet 和 Nginx 進行定製化的改造才能完整的體驗 0 RTT 帶來的巨大的性能提升。

目前 Trip.com 的多數請求是回源到國內的,以一個紐約用戶訪問爲例,紐約到上海的直線距離是 14000km,假設兩地直連光纖,光的傳輸速度爲 300000km/s,考慮折射率,光纖中的傳輸速度爲 200000km/s,那麼 1 個 RTT 則需要 14000/200000 *2 = 140ms。而實際上的傳輸鏈路很複雜,要遠大於這個數字,所以 RTT 的減少對我們來說是至關重要的。首先讓我們瞭解一下 0 RTT 的工作原理。

使用 TLS1.3 的情況下,首次建立鏈接,在發送真正的請求數據前 TCP 需要經過兩個完整的 RTT(TLS1.2 需要 3 個 RTT),一次用於 TCP 握手,一次用於 TLS 加密握手。而 QUIC 由於 UDP 不需要建立鏈接,僅需要一次 TLS 加密握手,如下圖所示。

多數情況下,在整個 APP 的生命週期內首次建鏈只會發生一次,之後客戶端再需要建立鏈接會節省一個 RTT,這時候 QUIC 能以 0 RTT 的方式直接發送請求(Early Data)如下圖所示:

QUIC 使用了 DH 加密算法,DH 加密算法比較複雜,在這裏不做詳細解釋,有興趣的可以參考這篇 wiki:《迪菲 - 赫爾曼密鑰交換》。大概的原理是客戶端和服務端各自生成自己的非對稱公私鑰對,並將公鑰發給對方,利用對方的公鑰和自己的私鑰可以運算出同一個對稱密鑰。同時客戶端可以緩存服務的公鑰(以 SCFG 的方式),用於下次建立鏈接時使用,這個就是達成 0-RTT 握手的關鍵。客戶端可以選擇內存緩存或者磁盤緩存 SCFG。內存緩存在 APP 本次生命週期內有效,磁盤緩存可以長期有效。

但是 SCFG 中的 ticket 有時效性(比如設置爲 24 小時),過了有效期,Client 發起 0 RTT 請求會收到 Server 的 reject,然後重新握手,這反而增加了建立鏈接開銷。Trip.com 是旅遊類的低頻 APP,所以使用了內存緩存,對於社交 / 視頻 / 本地生活等高頻類 APP 可以考慮使用磁盤緩存。

0 RTT 開啓後我們實驗觀察請求耗時並沒有明顯降低。通過 Wireshark 抓包發現 GET 請求和 POST 請求的 0 RTT 方式並不一致。

POST 請求的 0 RTT 如下圖所示,客戶端會同時向服務發送 Initial 和 0 RTT 包,但是並沒有發送真正的應用請求數據(Early Data),而是等服務返回後再同時發送 Handshake 完成包及數據請求包。這說明 POST 是在 TLS 加密完成後纔開始發送請求數據,依然經歷了一次完整的 RTT 握手,雖然握手包大小和數量相對於首次建立鏈接有所減少,但是 RTT 並沒減少。

GET 請求的 0 RTT 如下圖所示,客戶端同時向服務發送 Initial 和兩個 0 RTT 包,其中第二個 0 RTT 包中攜帶了 early_data,即真正的請求數據。

深究其原因會發現這是 0 RTT 不具備前向安全性和容易受到重放攻擊導致的。這裏重點說一下重放攻擊,如下圖所示,如果用戶被誘導往某個賬戶裏轉賬 0.1 元,該請求正好是發生在 0 RTT 階段,即 early_data 裏攜帶的正好是一個轉賬類的請求,並且該請求如果被攻擊者監聽到,攻擊者不斷的向服務發送同樣的 0 RTT 包重放這個請求,會導致客戶的銀行卡餘額被掏空!

對於 Cronet 來說無法細化哪個請求使用 Early Data 是安全的,只能按照類型劃分,POST,PUT 請求均是等握手結束後再發請求數據,而 GET 請求則可以使用 Early Data。注意,握手結束後的數據是前向安全的, 此時會再生成一個臨時密鑰用於後續會話。

但對 Trip.com APP 而言我們可以做更加細分的處理,能較好的區分出是否爲冪等請求,對冪等類請求放開 Early Data,非冪等類則禁止。在 APP 中,大多數請求爲信息獲取類的冪等請求,因此可以充分利用 0 RTT 來減少建立鏈接耗時,提升網絡性能。

同時我們也對 Nginx 做了 0 RTT 改造。現實情況下服務是多機部署,通過負載均衡設備進行請求轉發的。由於每臺機器生成的 SCFG 並不一致(即生成的公私鑰對不唯一),當客戶端 IP 地址發生變化,重新建立鏈接時,請求會隨機打到任意一臺機器上,如果與首次建立鏈接的機器不一致則校驗失敗,nginx 會返回 reject,然後客戶端會重新發起完整的握手請求建立鏈接。具體的改造方式參照我們在服務端的 QUIC 應用和優化實踐一文。

簡單的來說,通過改造,保證所有機器的 SCFG 一致。目前 Trip.com 0 RTT 成功率在 99.9% 以上。

上面的兩條完成後,再對比一下:

1)正常的 Http2.0 請求在發送請求前,需要經過 DNS 解析 + TCP 三次握手(1 個 RTT)+TLS 加密握手(TLS1.2 需要 2 個 RTT,TLS1.3 需要 1 個 RTT),共 2 個 RTT(TLS1.2 共 3 個 RTT)。

2)自研的 TCP 框架需要經歷 TCP 三次握手共 1 個 RTT。

3)經過我們優化後的 QUIC 大多數情況下發送請求前只需要 0 RTT。

使用改造後的 QUIC,在 Trip.com APP 中,用戶建立鏈接的耗時約等於 0,極大的降低了請求耗時。

3.5 鏈接遷移改造

QUIC 的鏈接遷移能讓用戶在網絡變化(NAT rebinding 或者網絡切換)時保持鏈接不斷開,但因爲負載均衡的存在會使用戶網絡變化時請求轉發到不同的服務器上導致遷移失敗,因此需要做一些定製化的改造才能體驗這一特性帶來的用戶體驗提升。

TCP 鏈接是基於五元組的,客戶端 IP 或者端口號發生變化都會導致鏈接斷開請求失敗。大家生活中的網絡情況日趨複雜,經常在不同的 Wi-Fi、蜂窩網之間來回切換,如果每次切換都出現失敗必然是非常影響體驗的。

而 QUIC 的鏈接標識是一個 20 字節的 connection id。用戶網絡發生變化時(無論是 IP 還是端口變化),由於鏈接標識的唯一性,無需創建新的鏈接,繼續用原有鏈接發送請求。這種用戶無感的網絡切換就是鏈接遷移。下圖是鏈接遷移的工作流程:

名詞解釋:

Probing Frame 是指具有探測功能的 Frame,比如 PATH_CHALLENGE, PATH_RESPONSE, NEW_CONNECTION_ID, PADDING 均爲 Probing Frame。

一個 Packet 中只包含 Probing Frame 則稱爲 Probing packet,其他 Packet 則稱爲 Non Probing Packet。

如上圖所示,開始時用戶和服務正常通信。某個時間點用戶的網絡從 WI-FI 切換到 4G,並繼續正常向服務發送請求,服務檢測到該鏈接上客戶端地址發生變化,開始進行地址驗證。即生成一個隨機數並加密發給客戶端(Path_Challenge), 如果客戶端能解密並將該隨機數發回給服務(Path_Response),則驗證成功,通信恢復。

但由於負載均衡的存在會使用戶網絡變化時請求轉發到不同機器上導致遷移失敗,我們首先想到的是修改負載均衡的轉發規則,利用 connection id 的 hash 進行轉發似乎就可以解決這個問題,但是 QUIC 的標準規定鏈接遷移時 connection id 也必須進行更改,同時建立鏈接前初始化包中的 connection id 以及鏈接建立完成後的 connection id 也不一致,所以此方案也行不通。最終我們通過兩個關鍵點的改造實現了鏈接遷移:

1)修改 connection id 的生成規則,將本機的特徵信息加入到 connection id 中;

2)增加 QUIC SLB 層,該層僅針對 connection id 進行 UDP 轉發,當鏈接遷移發生時如果本機緩存中不存在則直接從 connection id 中解析出具體的機器,找到對應的機器後進行轉發,如下圖所示:

改造細節也可參照服務端的 QUIC 應用和優化實踐一文。

通過鏈接遷移的改造,Trip.com 用戶不會再因爲 NAT rebinding 或者網絡切換導致請求失敗,提升了請求成功率,改善了用戶體驗。

3.6 QPACK 優化

QPACK 即 QUIC 頭部壓縮,複用了 HTTP2/HPACK 的核心概念,但是經過重新設計,保證了 UDP 無序傳輸下的正確性。QPACK 有靈活的實現方式,目標是以更少的頭部阻塞來接近 HPACK 的壓縮率。而我們的改造能使得 Trip.com APP 的請求壓縮率和頭部阻塞均達到最優。

nginx 要開啓 QPACK 動態表,需要指定兩個參數:

1)http3_max_table_capacity 動態表大小,Trip.com 指定爲 16K;

2)http3_max_blocked_streams 最大阻塞流數量,如果指定爲 0,則禁用了 QPACK 動態表;

伴隨着 QPACK 動態表的開啓,HTTP3 是會出現頭部阻塞的(目前發現的唯一一個 QUIC 中頭部阻塞的場景),QPACK 是如何工作,在 Trip.com APP 中如何做才能讓 QPACK 既能擁有高壓縮率又能避免頭部阻塞呢?

我們知道 HTTP header 是由許多 field 組成的,比如下圖是一個典型的 HTTP header。:authority: www.trip.com 就是其中一個 field。:authority 爲 field name,www.trip.com 是 field value。

當 QUIC 鏈接建立後,會初始化兩個單向 stream,Encoder Stream 和 Decoder Stream。一旦建立,這兩個 stream 是不能關閉的,之後 HTTP header 動態表的更新就在這兩個 stream 上進行。我們以發送 request 爲例,客戶端維護了一張動態表,並通過指令通知服務端進行更新,以保持客戶端和服務端的動態表完全一致,如圖所示:

我們可以看到 encoder 和 decoder 共享一張靜態表,這張表是由 ietf 標準規定的,服務和客戶端寫死在代碼裏永遠不會變的,表的內容固定爲 99 個字段,截取部分示例:

而動態表初始爲空,有需要纔會插入。

假如我們連續發送三個請求 request A,B,C,三個請求的 Http Header 均爲:

{
  :authority: www.trip.com
  :method: POST
  cookie:this is a very large cookie maybe more than 2k
  x-trip-defined-header-field-name: trip defined headerfield value
}

發送 request A 之前客戶端會將 header 做第一次壓縮,主要是用靜態表進行替換,並將某些數據插入動態表。規則爲:

1)靜態表存在完全匹配(name+value 完全一致),則直接替換爲靜態表的 index,比如: method: POST 直接替換爲 20;

2)動態表存在完全匹配,則直接替換爲動態表 index,首次請求動態表爲空;

3)靜態表存在 name 匹配,則 name 替換爲 index,value 插入動態表,比如: authority:www.trip.com 會替換爲 0: www.trip.com,其中 www.trip.com 會插入動態表,假設在動態表中的 index 爲 1,我們用 d1 表示動態表中的 index 1;

4)動態表存在 name 匹配,則 name 替換爲 index,value 插入動態表;

5)均不存在,name 和 value 均插入動態表,比如 x-trip-defined-header-field-name: trip defined header field value 會整條插入;

客戶端本地插入動態表後必須要向服務端發送指令同步更新服務端動態表。經過首次壓縮,header 變爲:

{
  0: d1,
  20,
  5:d2,
  d3,
}

替換後的 header 已經非常小了,但是 QPACK 還會對替換後的 header 進行二次 encode。具體壓縮代碼如下:

其中 SecondPassEncode 會對所有的 field 再次進行處理,不同類型字段處理方式不同,比如對 string 類型進行 huffman 壓縮。

二次 encode 後就只有幾個字節了。所以我們用 wireshark 抓包會發現 http header 非常小,小到只有一兩個字節,這就是 QPACK 壓縮的威力。

當壓縮後的 header 傳到服務端時,服務端找到解析 header 需要的最大的動態表 index,目前是 d3,如果比當前的動態表最大 index 還要大,說明動態表插入請求還沒收到,這是 UDP 傳輸的無序性導致的,需要進行等待。

等待期間 Request B, C 的請求也到了,他們的 header 是一致的,但是都沒法解析,因爲 requestA 的動態表插入請求還沒收到,於是出現了頭部阻塞。nginx 有 http3_max_blocked_streams 字段配置允許阻塞的 stream 數量,如果超過了,後續的請求不會等待動態表的插入而是直接將完整的字段 x-trip-defined-header-field-name: trip defined header field value 壓縮後發送給服務端。

正常使用 QPACK 只需要做好配置就 ok 了,但是 Trip.com APP 中有些特殊的 Http header 字段,比如 x-xxxx-id: GUID . 這類字段的 value 是每次變化的 GUID,用作請求的唯一標識或用來對請求進行鏈路追蹤等。由於這類字段的 value 每次變化,導致動態表頻繁插入很快就會超過閾值,動態表超過閾值後會對老的字段進行清理,刪除後如果後續請求又用到了這些字段則還需要再次進行插入。動態表的頻繁插入刪除則會加重頭部阻塞。

所以針對這些 value 一定會變化的字段,我們需要做特殊處理,這類字段的 value 不插入動態表,即不以動態表索引的方式進行替換,只做二次 encode 壓縮處理如下(代碼較長不做完整展示):

改造後的 QPACK 在最佳壓縮率和頭部阻塞之間取得了平衡,減少了請求 size 的同時加快了請求速度,進一步提升了用戶體驗。

3.7 擁塞控制算法對比

Cronet 內置了 CUBIC,BBR,BBRV2 三種擁塞控制算法,我們可以根據需求靈活的選擇,也可以插拔方式的使用其他擁塞控制算法。經過線上實驗對比,在 Trip.com APP 場景中,BBR 性能優於 CUBIC、BBRV2。所以目前 Trip.com 默認使用 BBR。後續也會引入其他擁塞控制算法進行對比,並持續優化。

3.8 使用方式優化

在生產實驗中我們發現,QUIC 並不合適所有的網絡狀況,所以我們不是用 QUIC 完全的替換原有 TCP 框架,而是做了有機融合,擇優使用。

以下是兩種比較常見的不支持 QUIC 場景:

1)某些辦公網 443 端口會直接禁止 UDP 請求;

2)某些網絡代理類型不支持 QUIC;

爲了能適配各種網絡環境,保證請求成功率,我們對 QUIC 的使用方式也進行了優化。

上圖是目前 Trip.com 客戶端的網絡框架,APP 啓動或網絡變化時會通過一定的權重計算,選擇最優的協議(TCP 或 QUIC)進行使用,並且進一步選擇最優的 Server IP 預建立鏈接池,當有業務請求需要發送時,從鏈接池裏選擇最優鏈接進行發送。

改造後的使用方式充分利用了 TCP 和 QUIC 在不同網絡環境下的優勢,保證了用戶請求的成功率,並能在各種複雜的網絡環境下取得最佳的發送速度。目前 Trip.com APP 80% 以上的網絡請求通過 QUIC 進行發送,私有 TCP 協議和 Http2.0 作爲補充,整體的成功率和性能得到了很大的提升。

四、總結和規劃

由於 QUIC 具有精細的流量控制,可插拔式的擁塞算法,0 RTT,鏈接遷移,無隊頭阻塞的多路複用等諸多優點,已經被越來越多的廠商應用到生產環境,並取得了非常顯著的成果。

我們也通過一年多的實踐,深入瞭解了 QUIC 的優點和適用場景,並通過定製化的改造使得網絡性能得到了極大的提升。但由於配套不完善,目前市面上所有的 QUIC 都無法達到開箱即用的效果。所以我們也希望貢獻自己的力量,儘快開源改造後的整套網絡方案,能讓開發者可以不進行任何改動就能體驗到 QUIC 帶來的提升,實現真正的開箱即用。請大家持續關注我們。

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