1、網絡通信時,為了確保數據不丟包,早在幾十年前就發明了tcp協議!然而此一時非彼一時,隨着技術進步和業務需求增多,tcp也暴露了部分比較明顯的缺陷,比如:
- 建立連接的3次握手延遲大; TLS需要至少需要2個RTT,延遲也大
- 協議缺陷可能導致syn反射類的DDOS攻擊
- tcp協議緊耦合到了操作系統,升級需要操作系統層面改動,無法快速、大面積推廣升級補丁包
- 對頭阻塞:數據被分成sequence,一旦中間的sequence丟包,后面的sequence也不會處理
- 中轉設備僵化:路由器、交換機等設備“認死理”,比如只認80、443等端口,其他端口一律丟棄
為了解決這些問題,牛逼plus的google早在10年前,也就是2012年發布了基於UDP的quic協議!為啥不基於tcp了,因為tcp有上述5條缺陷的嘛,所以干脆“另起爐灶”重新開搞!
2、正式介紹前,先看一張圖:quci在右邊,底層用了udp的協議;自生實現了Multistreaming、tls、擁塞控制,然后支撐了上層的http/2,所以我個人理解quic是一個夾在應用層和傳輸層之間的協議!
上面“數落”了tcp協議的5點不是,quic又是怎么基於udp解決這些問題的了?quic 是基於 UDP 實現的協議,而 UDP 是不可靠的面向報文的協議,這和 TCP 基於 IP 層的實現並沒有什么本質上的不同,都是:
- 底層只負責盡力而為的,以 packet 為單位的傳輸;
- 上層協議實現更關鍵的特性,如可靠,有序,安全等。
(1)由於quic並未改造udp,而是直接使用udp,所以不需要改動現有的操作系統,也兼容了現有的網絡中轉設備,這些都不需要做任何改動,所以quic部署的改造成本相對較低!但是quic畢竟是新的協議,在哪部署和使用了?只有應用層了!這個和操作系統是解耦的,全靠3環的app自己想辦法實現(和之前介紹的協程是不是類似了?)!google已經開源了算法,下載連接見文章末尾的參考5;PS:微軟也實現了QUIC協議,名稱叫MsQuic,源碼在這:https://github.com/microsoft/msquic;
這里多說幾句:應用層app能操作的最底層協議就是傳輸層了。大家在用libc庫編寫通信代碼時可以對指定的ip地址和端口收發數據,沒法改自己的mac地址吧?也沒法改自己的ip地址吧?這些都是操作系統內核封裝的,app的開發人員是不需要、也是沒法改變的,所以站在安全防護的角度,部分大廠基於傳輸層自研了類似quic的通信協議,逆向時需要人工挨個分析協議字段的含義了,現成的fiddler/charles/burpsuit等https/http的抓包工具是無效的,用wireshark這類工具抓包也無法自動解析這些廠家自研的協議!
(2)TCP連接需要3次握手,tls最少需要2次RTT,兩個加起來一共要耗費5個RTT,究其原因一方面是 TCP 和 TLS 分層設計導致的:分層的設計需要每個邏輯層次分別建立自己的連接狀態。另一方面是 TLS 的握手階段復雜的密鑰協商機制導致的,quic又是怎么改進的了?quic建立握手的步驟如下:
- 客戶端判斷本地是否已有服務器的全部配置參數(證書配置信息),如果有則直接跳轉到(5),否則繼續 。
- 客戶端向服務器發送 inchoate client hello(CHLO) 消息,請求服務器傳輸配置參數。
- 服務器收到 CHLO,回復 rejection(REJ) 消息,其中包含服務器的部分配置參數
- 客戶端收到 REJ,提取並存儲服務器配置參數,跳回到 (1) 。
- 客戶端向服務器發送 full client hello 消息,開始正式握手,消息中包括客戶端選擇的公開數。此時客戶端根據獲取的服務器配置參數和自己選擇的公開數,可以計算出初始密鑰 K1。
- 服務器收到 full client hello,如果不同意連接就回復 REJ,同(3);如果同意連接,根據客戶端的公開數計算出初始密鑰 K1,回復 server hello(SHLO) 消息, SHLO 用初始密鑰 K1 加密,並且其中包含服務器選擇的一個臨時公開數。
- 客戶端收到服務器的回復,如果是 REJ 則情況同(4);如果是 SHLO,則嘗試用初始密鑰 K1 解密,提取出臨時公開數。
- 客戶端和服務器根據臨時公開數和初始密鑰 K1,各自基於 SHA-256 算法推導出會話密鑰 K2。
- 雙方更換為使用會話密鑰 K2 通信,初始密鑰 K1 此時已無用,QUIC 握手過程完畢。之后會話密鑰 K2 更新的流程與以上過程類似,只是數據包中的某些字段略有不同。這里為啥不繼續使用key1,而是要重新生成key2來加密了?核心是為了前向安全!萬一key1泄漏,之前用key1加密的數據全都被解密。所以為了前向安全,每次通信時會重新生成key2加密!
總的來說:
- udp本身就不是面向連接的協議,所以省略了tcp 3次握手連接的耗時;直接通過事先內置的服務器參數發起通信請求;
- 既然不是面向連接的,怎么確保所有的數據都能到達了?通過stream id和stream offset確保數據包不會丟失,接收方能收到完整的全量數據
- 第一次用DH算法計算對稱加密的密鑰需要1個RTT;后續每次都用這個緩存的密鑰加密,又省了一個RTT;本質上是把tcp的打招呼、握手,還有tls交換密鑰的工作在1個RTT中全做了,這就是相比於tcp實現的tls效率高的根本原因!
注意:通信雙方用於密鑰交換的DH算法無法防止中間人攻擊,所以僅通過密鑰交換是無法防止被抓包的,所以還要通過證書等其他方式驗證身份!x音就是通過libboringssl.so(google開源的一個openssl分支)SSL_CTX_set_custom_verify函數驗證客戶端是否是原來的client,而不是抓包軟件!
(3)擁塞控制:QUIC 使用可插拔的擁塞控制,相較於 TCP,它能提供更豐富的擁塞控制信息。比如對於每一個包,不管是原始包還是重傳包,都帶有一個新的序列號(seq),這使得 QUIC 能夠區分 ACK 是重傳包還是原始包,從而避免了 TCP 重傳模糊的問題。QUIC 同時還帶有收到數據包與發出 ACK 之間的時延信息。這些信息能夠幫助更精確的計算 RTT!同時,因為quic不依賴操作系統,而是在應用層實現,所以開發人員對於quic有非常強的操控能力:完全可以根據不同的業務場景,實現和配置不同的擁塞控制算法以及參數;比如Google 提出的 BBR 擁塞控制算法與 CUBIC 是思路完全不一樣的算法,在弱網和一定丟包場景,BBR 比 CUBIC 更不敏感,性能也更好;
(4)隊頭阻塞:TCP 為了保證可靠性,使用了基於字節序號的 Sequence Number 及 Ack 來確認消息的有序到達;一旦中間某個sequence的包丟失,哪怕是這個sequence后面的數據已經到達接收端,操作系統也不會立即把數據發給上層的應用來接受處理,而是一直等待發送端重新發送丟失的sequence包,舉例如下:
應用層可以順利讀取 stream1 中的內容,但由於 stream2 中的第三個 segment 發生了丟包,TCP 為了保證數據的可靠性,需要發送端重傳第 3 個 segment 才能通知應用層讀取接下去的數據。所以即使 stream3、stream4 的內容已順利抵達,應用層仍然無法讀取,只能等待 stream2 中丟失的包進行重傳。在弱網環境下,HTTP2 的隊頭阻塞問題在用戶體驗上極為糟糕!quic是怎么既確保數據傳輸可靠不丟失,又解決隊頭阻塞的這個問題的了?
對於數據包的傳輸,肯定是要編號的,否則接受方在拼接這些數據包的時候怎么知道順序了?quic協議用Packet Number 代替了 TCP 的 Sequence Number,不同之處在於:
- 每個 Packet Number 都嚴格遞增,也就是說就算 Packet N 丟失了,重傳的 Packet N 的 Packet Number 已經不是 N,而是一個比 N 大的值,比如Packet N+M;
- 數據包支持亂序確認,不再要求 TCP 那樣必須有序確認
當數據包 Packet N 丟失后,只要有新的已接收數據包確認,當前窗口就會繼續向右滑動。待發送端獲知數據包 Packet N 丟失后,會將需要重傳的數據包放到待發送隊列,重新編號比如數據包 Packet N+M 后重新發送給接收端,對重傳數據包的處理跟發送新的數據包類似,這樣就不會因為丟包重傳將當前窗口阻塞在原地,從而解決了隊頭阻塞問題;但是問題又來了:怎么確認Package N+M就是重傳PackageN的數據包了?這就涉及到quic另一個重要的特性了:多路復用!比如用戶訪問某個網頁,這個頁面有兩個文件,分別是index.htm和index.js,可以同時、分別傳輸這兩個文件!每個傳輸的stream都有各自的id,所以可以通過id確認是哪個stream超時丟包了!但包的Packet 編號是N+M,怎么進一步確認就是重傳的Packet N包了?這就需要另一個重要的變量了:offset!怎么樣,單從英語是不是就能猜到這個變量的作用了?每個數據包都有個offset字段,用於標識在stream id中的偏移!接收方完全可以根據offset來拼接收到的數據包!
總結:quic協議可以在亂序發送的情況下任然可靠不丟失,靠的就是每個數據包的offset字段;再搭配上stream id字段,接收方完全可以在亂序的情況下無誤拼接收到的數據包了!
(4)除了以上通過stream id和stream offset確保數據不丟失外,quic還采用了另一個叫向前糾錯 (Forward Error Correction,FEC)的校驗方式:即每個數據包除了它本身的內容之外,還包括了部分其他數據包的數據,因此少量的丟包可以通過其他包的冗余數據直接組裝而無需重傳。向前糾錯犧牲了每個數據包可以發送數據的上限,但是減少了因為丟包導致的數據重傳,因為數據重傳將會消耗更多的時間(包括確認數據包丟失、請求重傳、等待新數據包等步驟的時間消耗);這個原理和糾刪碼沒有本質區別!
(5)通信雙方不論使用何種協議,發送的數據必須事前約定好格式,否則接受方怎么從數據包(本質就是一段字符串)中解析和提取關鍵的信息了?quic協議的格式如下:
數據包中除了個別報文比如 PUBLIC_RESET 和 CHLO,所有報文頭部(上圖紅色部分)都是經過認證的(哈希散列值),報文 Body (上圖綠色部分)都是經過加密的,這樣只要對 QUIC 報文任何修改,接收端都能夠及時發現;每個字段的含義如下:
- Flags:用於表示 Connection ID 長度、Packet Number 長度等信息;
- Connection ID:客戶端隨機選擇的最大長度為64位的無符號整數,用於標識連接;如果app更換了ip地址(比如wifi和4G之間切換了),仍然可以通過這個id和服務端在0 RTT下通信!
- QUIC Version:QUIC 協議的版本號,32 位的可選字段。如果 Public Flag & FLAG_VERSION != 0,這個字段必填。客戶端設置 Public Flag 中的 Bit0 為1,並且填寫期望的版本號。如果客戶端期望的版本號服務端不支持,服務端設置 Public Flag 中的 Bit0 為1,並且在該字段中列出服務端支持的協議版本(0或者多個),並且該字段后不能有任何報文;
- Packet Number:長度取決於 Public Flag 中 Bit4 及 Bit5 兩位的值,最大長度 6 字節。發送端在每個普通報文中設置 Packet Number。發送端發送的第一個包的序列號是 1,隨后的數據包中的序列號的都大於前一個包中的序列號;
- Stream ID:用於標識當前數據流屬於哪個資源請求,用於消除隊頭阻塞;
- Offset:標識當前數據包在當前 Stream ID 中的字節偏移量,用於消除隊頭阻塞。
(6)為了便於理解和記憶,這里把quic的要點做了總結,如下:
3、正式因為quic有這么多優點,國內很多互聯網一、二線廠商都開始采用,其中比較著名的app就是x音了!lib庫中有個libsscronet.so就支持quic協議!
參考:
1、 https://zhuanlan.zhihu.com/p/32553477