天天看點

如何用 UDP 實作可靠傳輸?

作者:小林coding

計算機八股文刷題網站:https://xiaolincoding.com

大家好,我是小林。

我記得之前在群裡看到,有位讀者位元組一面的時候被問到:「如何基于 UDP 協定實作可靠傳輸?」

很多同學第一反應就會說把 TCP 可靠傳輸的特性(序列号、确認應答、逾時重傳、流量控制、擁塞控制)在應用層實作一遍。

實作的思路确實這樣沒錯,但是有沒有想過,既然 TCP 天然支援可靠傳輸,為什麼還需要基于 UDP 實作可靠傳輸呢?這不是重複造輪子嗎?

是以,我們要先弄清楚 TCP 協定有哪些痛點?而這些痛點是否可以在基于 UDP 協定實作的可靠傳輸協定中得到改進?

在之前這篇文章:TCP 就沒什麼缺陷嗎?,我已經說了 TCP 協定四個方面的缺陷:

  • 更新 TCP 的工作很困難;
  • TCP 建立連接配接的延遲;
  • TCP 存在隊頭阻塞問題;
  • 網絡遷移需要重建立立 TCP 連接配接;

現在市面上已經有基于 UDP 協定實作的可靠傳輸協定的成熟方案了,那就是 QUIC 協定,已經應用在了 HTTP/3。

這次,聊聊 QUIC 是如何實作可靠傳輸的?又是如何解決上面 TCP 協定四個方面的缺陷?

如何用 UDP 實作可靠傳輸?

QUIC 是如何實作可靠傳輸的?

要基于 UDP 實作的可靠傳輸協定,那麼就要在應用層下功夫,也就是要設計好協定的頭部字段。

拿 HTTP/3 舉例子,在 UDP 封包頭部與 HTTP 消息之間,共有 3 層頭部:

如何用 UDP 實作可靠傳輸?

整體看的視角是這樣的:

如何用 UDP 實作可靠傳輸?

接下來,分别對每一個 Header 做個介紹。

Packet Header

Packet Header 首次建立連接配接時和日常傳輸資料時使用的 Header 是不同的。如下圖(注意我沒有把 Header 所有字段都畫出來,隻是畫出了重要的字段):

如何用 UDP 實作可靠傳輸?

Packet Header

Packet Header 細分這兩種:

  • Long Packet Header 用于首次建立連接配接。
  • Short Packet Header 用于日常傳輸資料。

QUIC 也是需要三次握手來建立連接配接的,主要目的是為了協商連接配接 ID。協商出連接配接 ID 後,後續傳輸時,雙方隻需要固定住連接配接 ID,進而實作連接配接遷移功能。是以,你可以看到日常傳輸資料的 Short Packet Header 不需要在傳輸 Source Connection ID 字段了,隻需要傳輸 Destination Connection ID。

Short Packet Header 中的

Packet Number

是每個封包獨一無二的編号,它是嚴格遞增的,也就是說就算 Packet N 丢失了,重傳的 Packet N 的 Packet Number 已經不是 N,而是一個比 N 大的值。

如何用 UDP 實作可靠傳輸?
為什麼要這麼設計呢?

我們先來看看 TCP 的問題,TCP 在重傳封包時的序列号和原始封包的序列号是一樣的,也正是由于這個特性,引入了 TCP 重傳的歧義問題。

如何用 UDP 實作可靠傳輸?

TCP 重傳的歧義問題

比如上圖,當 TCP 發生逾時重傳後,用戶端發起重傳,然後接收到了服務端确認 ACK 。由于用戶端原始封包和重傳封包序列号都是一樣的,那麼服務端針對這兩個封包回複的都是相同的 ACK。

這樣的話,用戶端就無法判斷出是「原始封包的響應」還是「重傳封包的響應」,這樣在計算 RTT(往返時間) 時應該選擇從發送原始封包開始計算,還是重傳原始封包開始計算呢?

  • 如果算成原始封包的響應,但實際上是重傳封包的響應(上圖左),會導緻采樣 RTT 變大;
  • 如果算成重傳封包的響應,但實際上是原始封包的響應(上圖右),又很容易導緻采樣 RTT 過小;

RTO (逾時時間)是基于 RTT 來計算的,那麼如果 RTT 計算不精準,那麼 RTO (逾時時間)也會不精确,這樣可能導緻重傳的機率事件增大。

QUIC 封包中的 Pakcet Number 是嚴格遞增的, 即使是重傳封包,它的 Pakcet Number 也是遞增的,這樣就能更加精确計算出封包的 RTT。

如何用 UDP 實作可靠傳輸?

如果 ACK 的 Packet Number 是 N+M,就根據重傳封包計算采樣 RTT。如果 ACK 的 Pakcet Number 是 N,就根據原始封包的時間計算采樣 RTT,沒有歧義性的問題。

另外,還有一個好處,QUIC 使用的 Packet Number 單調遞增的設計,可以讓資料包不再像 TCP 那樣必須有序确認,QUIC 支援亂序确認,當資料包Packet N 丢失後,隻要有新的已接收資料包确認,目前視窗就會繼續向右滑動(後面講流量控制的時候,會舉例子)。

待發送端獲知資料包Packet N 丢失後,會将需要重傳的資料包放到待發送隊列,重新編号比如資料包Packet N+M 後重新發送給接收端,對重傳資料包的處理跟發送新的資料包類似,這樣就不會因為丢包重傳将目前視窗阻塞在原地,進而解決了隊頭阻塞問題。

是以,Packet Number 單調遞增的兩個好處:

  • 可以更加精确計算 RTT,沒有 TCP 重傳的歧義性問題;
  • 可以支援亂序确認,因為丢包重傳将目前視窗阻塞在原地,而 TCP 必須是順序确認的,丢包時會導緻視窗不滑動;

QUIC Frame Header

一個 Packet 封包中可以存放多個 QUIC Frame。

如何用 UDP 實作可靠傳輸?

每一個 Frame 都有明确的類型,針對類型的不同,功能也不同,自然格式也不同。

我這裡隻舉例 Stream 類型的 Frame 格式,Stream 可以認為就是一條 HTTP 請求,它長這樣:

如何用 UDP 實作可靠傳輸?
  • Stream ID 作用:多個并發傳輸的 HTTP 消息,通過不同的 Stream ID 加以差別,類似于 HTTP2 的 Stream ID;
  • Offset 作用:類似于 TCP 協定中的 Seq 序号,保證資料的順序性和可靠性;
  • Length 作用:指明了 Frame 資料的長度。

在前面介紹 Packet Header 時,說到 Packet Number 是嚴格遞增,即使重傳封包的 Packet Number 也是遞增的,既然重傳資料包的 Packet N+M 與丢失資料包的 Packet N 編号并不一緻,我們怎麼确定這兩個資料包的内容一樣呢?

是以引入 Frame Header 這一層,通過 Stream ID + Offset 字段資訊實作資料的有序性,通過比較兩個資料包的 Stream ID 與 Stream Offset ,如果都是一緻,就說明這兩個資料包的内容一緻。

舉個例子,下圖中,資料包 Packet N 丢失了,後面重傳該資料包的編号為 Packet N+2,丢失的資料包和重傳的資料包 Stream ID 與 Offset 都一緻,說明這兩個資料包的内容一緻。這些資料包傳輸到接收端後,接收端能根據 Stream ID 與 Offset 字段資訊将 Stream x 和 Stream x+y 按照順序組織起來,然後交給應用程式處理。

如何用 UDP 實作可靠傳輸?

總的來說,QUIC 通過單向遞增的 Packet Number,配合 Stream ID 與 Offset 字段資訊,可以支援亂序确認而不影響資料包的正确組裝,擺脫了TCP 必須按順序确認應答 ACK 的限制,解決了 TCP 因某個資料包重傳而阻塞後續所有待發送資料包的問題。

QUIC 是如何解決 TCP 隊頭阻塞問題的?

什麼是 TCP 隊頭阻塞問題?

TCP 隊頭阻塞的問題要從兩個角度看,一個是發送視窗的隊頭阻塞,另外一個是接收視窗的隊頭阻塞。

1、發送視窗的隊頭阻塞。

TCP 發送出去的資料,都是需要按序确認的,隻有在資料都被按順序确認完後,發送視窗才會往前滑動。

舉個例子,比如下圖的發送方把發送視窗内的資料全部都發出去了,可用視窗的大小就為 0 了,表明可用視窗耗盡,在沒收到 ACK 确認之前是無法繼續發送資料了。

如何用 UDP 實作可靠傳輸?

可用視窗耗盡

接着,當發送方收到對第

32~36

位元組的 ACK 确認應答後,則滑動視窗往右邊移動 5 個位元組,因為有 5 個位元組的資料被應答确認,接下來第

52~56

位元組又變成了可用視窗,那麼後續也就可以發送

52~56

這 5 個位元組的資料了。

如何用 UDP 實作可靠傳輸?

32 ~ 36 位元組已确認

但是如果某個資料封包丢失或者其對應的 ACK 封包在網絡中丢失,會導緻發送方無法移動發送視窗,這時就無法再發送新的資料,隻能逾時重傳這個資料封包,直到收到這個重傳封包的 ACK,發送視窗才會移動,繼續後面的發送行為。

舉個例子,比如下圖,用戶端是發送方,伺服器是接收方。

如何用 UDP 實作可靠傳輸?

用戶端發送了第 5~9 位元組的資料,但是第 5 位元組的 ACK 确認封包在網絡中丢失了,那麼即使用戶端收到第 6~9 位元組的 ACK 确認封包,發送視窗也不會往前移動。

此時的第 5 位元組相當于“隊頭”,因為沒有收到“隊頭”的 ACK 确認封包,導緻發送視窗無法往前移動,此時發送方就無法繼續發送後面的資料,相當于按下了發送行為的暫停鍵,這就是發送視窗的隊頭阻塞問題。

2、接收視窗的隊頭阻塞。

接收方收到的資料範圍必須在接收視窗範圍内,如果收到超過接收視窗範圍的資料,就會丢棄該資料,比如下圖接收視窗的範圍是 32 ~ 51 位元組,如果收到第 52 位元組以上資料都會被丢棄。

如何用 UDP 實作可靠傳輸?

接收視窗

接收視窗什麼時候才能滑動?當接收視窗收到有序資料時,接收視窗才能往前滑動,然後那些已經接收并且被确認的「有序」資料就可以被應用層讀取。

但是,當接收視窗收到的資料不是有序的,比如收到第 33~40 位元組的資料,由于第 32 位元組資料沒有收到, 接收視窗無法向前滑動,那麼即使先收到第 33~40 位元組的資料,這些資料也無法被應用層讀取的。隻有當發送方重傳了第 32 位元組資料并且被接收方收到後,接收視窗才會往前滑動,然後應用層才能從核心讀取第 32~40 位元組的資料。

好了,至此發送視窗和接收視窗的隊頭阻塞問題都說完了,這兩個問題的原因都是因為 TCP 必須按序處理資料,也就是 TCP 層為了保證資料的有序性,隻有在處理完有序的資料後,滑動視窗才能往前滑動,否則就停留。

  • 停留「發送視窗」會使得發送方無法繼續發送資料。
  • 停留「接收視窗」會使得應用層無法讀取新的資料。

其實也不能怪 TCP 協定,它本來設計目的就是為了保證資料的有序性。

HTTP/2 的隊頭阻塞

HTTP/2 通過抽象出 Stream 的概念,實作了 HTTP 并發傳輸,一個 Stream 就代表 HTTP/1.1 裡的請求和響應。

如何用 UDP 實作可靠傳輸?

HTTP/2

在 HTTP/2 連接配接上,不同 Stream 的幀是可以亂序發送的(是以可以并發不同的 Stream ),因為每個幀的頭部會攜帶 Stream ID 資訊,是以接收端可以通過 Stream ID 有序組裝成 HTTP 消息,而同一 Stream 内部的幀必須是嚴格有序的。

但是 HTTP/2 多個 Stream 請求都是在一條 TCP 連接配接上傳輸,這意味着多個 Stream 共用同一個 TCP 滑動視窗,那麼當發生資料丢失,滑動視窗是無法往前移動的,此時就會阻塞住所有的 HTTP 請求,這屬于 TCP 層隊頭阻塞。

如何用 UDP 實作可靠傳輸?

沒有隊頭阻塞的 QUIC

QUIC 也借鑒 HTTP/2 裡的 Stream 的概念,在一條 QUIC 連接配接上可以并發發送多個 HTTP 請求 (Stream)。

但是 QUIC 給每一個 Stream 都配置設定了一個獨立的滑動視窗,這樣使得一個連接配接上的多個 Stream 之間沒有依賴關系,都是互相獨立的,各自控制的滑動視窗。

假如 Stream2 丢了一個 UDP 包,也隻會影響 Stream2 的處理,不會影響其他 Stream,與 HTTP/2 不同,HTTP/2 隻要某個流中的資料包丢失了,其他流也會是以受影響。

如何用 UDP 實作可靠傳輸?

QUIC 是如何做流量控制的?

TCP 流量控制是通過讓「接收方」告訴「發送方」,它(接收方)的接收視窗有多大,進而讓「發送方」根據「接收方」的實際接收能力控制發送的資料量。

QUIC 實作流量控制的方式:

  • 通過 window_update 幀告訴對端自己可以接收的位元組數,這樣發送方就不會發送超過這個數量的資料。
  • 通過 BlockFrame 告訴對端由于流量控制被阻塞了,無法發送資料。

在前面說到,TCP 的接收視窗在收到有序的資料後,接收視窗才能往前滑動,否則停止滑動;TCP 的發送視窗在收到對已發送資料的順序确認 ACK後,發送視窗才能往前滑動,否則停止滑動。

QUIC 是基于 UDP 傳輸的,而 UDP 沒有流量控制,是以 QUIC 實作了自己的流量控制機制,QUIC 的滑動視窗滑動的條件跟 TCP 有一點差别,但是同一個 Stream 的資料也是要保證順序的,不然無法實作可靠傳輸,是以同一個 Stream 的資料包丢失了,也會造成視窗無法滑動。

QUIC 的 每個 Stream 都有各自的滑動視窗,不同 Stream 互相獨立,隊頭的 Stream A 被阻塞後,不妨礙 StreamB、C的讀取。而對于 HTTP/2 而言,所有的 Stream 都跑在一條 TCP 連接配接上,而這些 Stream 共享一個滑動視窗,是以同一個Connection内,Stream A 被阻塞後,StreamB、C 必須等待。

QUIC 實作了兩種級别的流量控制,分别為 Stream 和 Connection 兩種級别:

  • Stream 級别的流量控制:Stream 可以認為就是一條 HTTP 請求,每個 Stream 都有獨立的滑動視窗,是以每個 Stream 都可以做流量控制,防止單個 Stream 消耗連接配接(Connection)的全部接收緩沖。
  • Connection 流量控制:限制連接配接中所有 Stream 相加起來的總位元組數,防止發送方超過連接配接的緩沖容量。

Stream 級别的流量控制

最開始,接收方的接收視窗初始狀态如下(網上的講 QUIC 流量控制的資料太少了,下面的例子我是參考 google 文檔的:Flow control in QUIC):

如何用 UDP 實作可靠傳輸?

接着,接收方收到了發送方發送過來的資料,有的資料被上層讀取了,有的資料丢包了,此時的接收視窗狀況如下:

如何用 UDP 實作可靠傳輸?

可以看到,接收視窗的左邊界取決于接收到的最大偏移位元組數,此時的

接收視窗 = 最大視窗數 - 接收到的最大偏移數

這裡就可以看出 QUIC 的流量控制和 TCP 有點差別了:

  • TCP 的接收視窗隻有在前面所有的 Segment 都接收的情況下才會移動左邊界,當在前面還有位元組未接收但收到後面位元組的情況下,視窗也不會移動。
  • QUIC 的接收視窗的左邊界滑動條件取決于接收到的最大偏移位元組數。

PS:但是你要問我這麼設計有什麼好處?我也暫時沒想到,因為資料太少了,至今沒找到一個合理的說明,如果你知道,歡迎告訴我啊!

那接收視窗右邊界觸發的滑動條件是什麼呢?看下圖:

如何用 UDP 實作可靠傳輸?

接收視窗觸發的滑動

當圖中的綠色部分資料超過最大接收視窗的一半後,最大接收視窗向右移動,接收視窗的右邊界也向右擴充,同時給對端發送「視窗更新幀」,當發送方收到接收方的視窗更新幀後,發送視窗的右邊界也會往右擴充,以此達到視窗滑動的效果。

綠色部分的資料是已收到的順序的資料,如果中途丢失了資料包,導緻綠色部分的資料沒有超過最大接收視窗的一半,那接收視窗就無法滑動了,這個隻影響同一個 Stream,其他 Stream 是不會影響的,因為每個 Stream 都有各自的滑動視窗。

在前面我們說過 QUIC 支援亂序确認,具體是怎麼做到的呢?

接下來,舉個例子(下面的例子來源于:QUIC——快速UDP網絡連接配接協定):

如圖所示,目前發送方的緩沖區大小為8,發送方 QUIC 按序(offset順序)發送 29-36 的資料包:

如何用 UDP 實作可靠傳輸?

31、32、34資料包先到達,基于 offset 被優先亂序确認,但 30 資料包沒有确認,是以目前已送出的位元組偏移量不變,發送方的緩存區不變。

如何用 UDP 實作可靠傳輸?

img

30 到達并确認,發送方的緩存區收縮到門檻值,接收方發送 MAX_STREAM_DATA Frame(協商緩存大小的特定幀)給發送方,請求增長最大絕對位元組偏移量。

如何用 UDP 實作可靠傳輸?

img

協商完畢後最大絕對位元組偏移量右移,發送方的緩存區變大,同時發送方發現資料包33逾時

如何用 UDP 實作可靠傳輸?

img

發送方将逾時資料包重新編号為 42 繼續發送

如何用 UDP 實作可靠傳輸?

img

以上就是最基本的資料包發送-接收過程,控制資料發送的唯一限制就是最大絕對位元組偏移量,該值是接收方基于目前已經送出的偏移量(連續已确認并向上層應用送出的資料包offset)和發送方協商得出。

Connection 流量控制

而對于 Connection 級别的流量視窗,其接收視窗大小就是各個 Stream 接收視窗大小之和。

如何用 UDP 實作可靠傳輸?

Connection 流量控制

上圖所示的例子,所有 Streams 的最大視窗數為 120,其中:

  • Stream 1 的最大接收偏移為 100,可用視窗 = 120 - 100 = 20
  • Stream 2 的最大接收偏移為 90,可用視窗 = 120 - 90 = 30
  • Stream 3 的最大接收偏移為 110,可用視窗 = 120 - 110 = 10

那麼整個 Connection 的可用視窗 = 20 + 30 + 10 = 60

可用視窗 = Stream 1 可用視窗 + Stream 2 可用視窗 + Stream 3 可用視窗
           

QUIC 對擁塞控制改進

QUIC 協定目前預設使用了 TCP 的 Cubic 擁塞控制算法(我們熟知的慢開始、擁塞避免、快重傳、快恢複政策),同時也支援 CubicBytes、Reno、RenoBytes、BBR、PCC 等擁塞控制算法,相當于将 TCP 的擁塞控制算法照搬過來了。

QUIC 是如何改進 TCP 的擁塞控制算法的呢?

QUIC 是處于應用層的,應用程式層面就能實作不同的擁塞控制算法,不需要作業系統,不需要核心支援。這是一個飛躍,因為傳統的 TCP 擁塞控制,必須要端到端的網絡協定棧支援,才能實作控制效果。而核心和作業系統的部署成本非常高,更新周期很長,是以 TCP 擁塞控制算法疊代速度是很慢的。而 QUIC 可以随浏覽器更新,QUIC 的擁塞控制算法就可以有較快的疊代速度。

TCP 更改擁塞控制算法是對系統中所有應用都生效,無法根據不同應用設定不同的擁塞控制政策。但是因為 QUIC 處于應用層,是以就可以針對不同的應用設定不同的擁塞控制算法,這樣靈活性就很高了。

QUIC 更快的連接配接建立

對于 HTTP/1 和 HTTP/2 協定,TCP 和 TLS 是分層的,分别屬于核心實作的傳輸層、openssl 庫實作的表示層,是以它們難以合并在一起,需要分批次來握手,先 TCP 握手(1RTT),再 TLS 握手(2RTT),是以需要 3RTT 的延遲才能傳輸資料,就算 Session 會話服用,也需要至少 2 個 RTT。

HTTP/3 在傳輸資料前雖然需要 QUIC 協定握手,這個握手過程隻需要 1 RTT,握手的目的是為确認雙方的「連接配接 ID」,連接配接遷移就是基于連接配接 ID 實作的。

但是 HTTP/3 的 QUIC 協定并不是與 TLS 分層,而是QUIC 内部包含了 TLS,它在自己的幀會攜帶 TLS 裡的“記錄”,再加上 QUIC 使用的是 TLS1.3,是以僅需 1 個 RTT 就可以「同時」完成建立連接配接與密鑰協商,甚至在第二次連接配接的時候,應用資料包可以和 QUIC 握手資訊(連接配接資訊 + TLS 資訊)一起發送,達到 0-RTT 的效果。

如下圖右邊部分,HTTP/3 當會話恢複時,有效負載資料與第一個資料包一起發送,可以做到 0-RTT(下圖的右下角):

如何用 UDP 實作可靠傳輸?

QUIC 是如何遷移連接配接的?

基于 TCP 傳輸協定的 HTTP 協定,由于是通過四元組(源 IP、源端口、目的 IP、目的端口)确定一條 TCP 連接配接。

如何用 UDP 實作可靠傳輸?

TCP 四元組

那麼當移動裝置的網絡從 4G 切換到 WIFI 時,意味着 IP 位址變化了,那麼就必須要斷開連接配接,然後重建立立 TCP 連接配接。

而建立連接配接的過程包含 TCP 三次握手和 TLS 四次握手的時延,以及 TCP 慢啟動的減速過程,給使用者的感覺就是網絡突然卡頓了一下,是以連接配接的遷移成本是很高的。

QUIC 協定沒有用四元組的方式來“綁定”連接配接,而是通過連接配接 ID來标記通信的兩個端點,用戶端和伺服器可以各自選擇一組 ID 來标記自己,是以即使移動裝置的網絡變化後,導緻 IP 位址變化了,隻要仍保有上下文資訊(比如連接配接 ID、TLS 密鑰等),就可以“無縫”地複用原連接配接,消除重連的成本,沒有絲毫卡頓感,達到了連接配接遷移的功能。

參考資料:

  • https://www.taohui.tech/2021/02/04/%E7%BD%91%E7%BB%9C%E5%8D%8F%E8%AE%AE/%E6%B7%B1%E5%85%A5%E5%89%96%E6%9E%90HTTP3%E5%8D%8F%E8%AE%AE/
  • https://zhuanlan.zhihu.com/p/32553477

系列 TCP 文章:

  • TCP 三次握手與四次揮手面試題
  • TCP 重傳、滑動視窗、流量控制、擁塞控制
  • TCP 實戰抓包分析
  • TCP 半連接配接隊列和全連接配接隊列
  • 如何優化 TCP?
  • 如何了解是 TCP 面向位元組流協定?
  • 為什麼 TCP 每次建立連接配接時,初始化序列号都要不一樣呢?
  • SYN 封包什麼時候情況下會被丢棄?
  • 四次揮手中收到亂序的 FIN 包會如何處理?
  • 在 TIME_WAIT 狀态的 TCP 連接配接,收到 SYN 後會發生什麼?
  • TCP 連接配接,一端斷電和程序崩潰有什麼差別?
  • 拔掉網線後, 原本的 TCP 連接配接還存在嗎?
  • tcp_tw_reuse 為什麼預設是關閉的?
  • HTTPS 中 TLS 和 TCP 能同時握手嗎?
  • TCP Keepalive 和 HTTP Keep-Alive 是一個東西嗎?

微信搜尋公衆号:「小林coding」 ,回複「圖解」即可免費獲得「圖解網絡、圖解系統、圖解MySQL、圖解Redis」PDF 電子書

下一篇: house of kiwi