01

總覽與定位

封包已經走到 L4 門口:這一層要決定的不再是「怎麼送達」,而是「送達要不要保證」。

分層模型篇把一個封包送到了主機門口——IP 負責找到哪一台機器,port 負責找到哪一個程式。 從這裡開始,傳輸層(L4)要回答的是另一個問題:這條資料通道,要給應用什麼樣的保證? TCP 與 UDP 就是兩個答案完全相反的協定,而它們的差異會一路影響到本系列後面每一個協定的設計。

座標從分層模型接手:L4 的兩種契約

network-layers-for-dev 講完封包如何被逐層封裝、如何一跳跳穿過交換器與路由器抵達主機; nat-port-forwarding 講完 NAT 如何靠「IP + port」把連線映射回內網,以及誰能主動連誰。 這兩件事共同決定了本篇的起跑線:位址與路徑都已就緒,剩下的是端到端的傳輸語義。 TCP 給你一條可靠、有序的位元組串流(Byte Stream);UDP 給你一個盡力而為的資料報(Datagram)通道。 本篇只談這一層,不再重講 IP 子網切分與 LAN 路由細節。

TCP — 面向連線的位元組串流
RFC 9293 · SOCK_STREAM
  • 先三向交握建連線,才有資料通道
  • 序號 + 確認 + 重傳(Retransmission)=不掉、不重複
  • 依序交付:應用讀到的順序等於送出順序
  • 自帶流量控制(Flow Control)與壅塞控制(Congestion Control)
  • header 20 bytes 起跳,狀態存在雙方核心裡
UDP — 無連線的資料報
RFC 768 · SOCK_DGRAM
  • 沒有交握、沒有連線狀態,送了就走
  • 不保證到達、不保證順序、不保證只到一次
  • 保留訊息邊界:一次送、一次收,不會黏在一起
  • 不做流量控制與壅塞控制——由應用自己負責
  • header 只有 8 bytes:來源 port、目的 port、長度、checksum
面向TCPUDP對應用的實際意義
連線面向連線(先交握)無連線TCP 有 1-RTT 的建連成本,UDP 第一個封包就能帶資料
可靠性重傳直到確認不重傳TCP 用延遲換到達,UDP 用到達率換延遲上限
順序依序交付抵達順序即交付順序TCP 前面塞住後面也讀不到;UDP 亂序由應用處理
資料邊界無(位元組串流)有(資料報)TCP 應用必須自己做 framing,UDP 天然一則一則
速率控制內建雙重視窗無UDP 應用不自律就會壓垮網路與自己

兩種契約沒有優劣,只有「這個應用能不能忍受掉包,以及能不能忍受等待」。

一句話答法TCP 用交握、序號、重傳與雙重視窗換來「可靠有序的位元組串流」,代價是延遲與狀態;UDP 只保留 port 分流與 checksum,把可靠性與節奏控制全部交還給應用。
02

TCP 的可靠性機器:交握、序號與重傳

「可靠」不是形容詞,是四個具體機制:初始序號同步、累積確認、重傳、有序交付。

TCP 連線的一生從三向交握(Three-way Handshake)開始。很多人記得「三次」,卻答不出為什麼是三次: 因為要同步的不是一個方向,而是兩個方向各自的初始序號(ISN, Initial Sequence Number)。 A 告知並被確認、B 告知並被確認,總共四件事——中間兩件可以合併成一個封包,於是變成三次。

ClientServer
SYN · seq=x ▶
客戶端進入 SYN_SENT:提出自己的初始序號 x,同時協商 MSS、視窗縮放、SACK 等選項。
◀ SYN + ACK · seq=y, ack=x+1
伺服器進入 SYN_RECV,確認 x 並提出自己的初始序號 y。這條半完成的連線被放進核心的 SYN queue。
ACK · ack=y+1 ▶
客戶端進入 ESTABLISHED;伺服器收到後把連線移進 accept queue,等應用呼叫 accept() 領走。

一次來回(RTT,Round-Trip Time,封包往返一趟的時間)就能完成交握——但這 1-RTT 是 TCP 相對 UDP 的固定起手成本:TLS 篇的 0-RTT 只省得掉 TLS 交握那一趟、省不掉它;QUIC 篇的交握合一則把它和 TLS 交握併成同一趟。

連線建立後,TCP 眼中的資料是一條連續編號的位元組串流:每個 byte 都有序號,接收端用累積確認(Cumulative ACK) 回報「我已經連續收到 n 之前的所有資料,下一個請給我 n」。掉包的偵測與補救有兩條路:

機制觸發條件反應速度對壅塞視窗的影響
超時重傳(RTO)計時器到期仍未收到 ACK慢(RTO 以 RTT 估計值為基礎,且會指數退避)視為嚴重壅塞:視窗砍回 1
快速重傳(Fast Retransmit)連收 3 個重複 ACK快(不必等計時器)視為輕度壅塞:視窗減半
SACK(Selective ACK)選項啟用時隨 ACK 附帶—告知「哪些片段已收到」,避免整批重送

現代 Linux 預設開啟 SACK:發送端因此能只補洞、不必把整個視窗重來一遍。這個細節在本篇的互動模擬器裡看得到。

陷阱TCP 不保證訊息邊界——「黏包」不是 bug

你呼叫三次 send() 各送 100 bytes,對端可能一次 recv() 就讀到 300 bytes,也可能分五次讀到。 TCP 的契約只有「位元組順序不變」,從來沒有承諾保留你的訊息切分。 所以任何跑在 TCP 上的協定都必須自己做 framing:長度前綴(HTTP/2、gRPC)、分隔符(HTTP/1.1 的 \r\n\r\n)或固定長度。 HTTP 篇的 Content-Length 與 HTTP/2 frame、WebSocket 篇的 framing、MQTT 篇的 Remaining Length,都是對這件事的回答——每個上層協定都得先解決它。

關閉則是四次揮手:兩個方向各自 FIN / ACK,因為 TCP 是全雙工,一方沒話說不代表另一方也講完了。 主動關閉的一方會停在 TIME_WAIT 約 2 倍 MSL,用來吸收延遲抵達的舊封包、並確保最後那個 ACK 有機會重送:

ESTABLISHED FIN → FIN_WAIT ← ACK / FIN TIME_WAIT 2×MSL CLOSED

這解釋了一個經典現象:服務重啟時出現 Address already in use,正是舊連線還卡在 TIME_WAIT; 解法是 SO_REUSEADDR,而不是盲目調低 TIME_WAIT(那會讓舊封包有機會污染新連線)。

一句話答法TCP 的可靠性 = 三向交握同步雙向初始序號 + 累積確認 + 超時/快速重傳 + 接收端排序後才交付;它保證位元組順序,但不保證訊息邊界,framing 是應用自己的事。
03

流量控制與壅塞控制

兩個都是「別送太快」,但保護的對象完全不同:一個保護接收端,一個保護整條網路。

面試最常混淆的一組概念。分清楚只要問一句:是誰喊停?

 流量控制(Flow Control)壅塞控制(Congestion Control)
保護誰接收端的緩衝區中間網路(路由器佇列)
誰喊停接收端明示:ACK 裡的 window 欄位沒人明示,發送端自己從丟包/延遲推測
控制變數rwnd(接收視窗)cwnd(壅塞視窗,發送端私有)
失效後果接收端 buffer 溢位、資料被丟全網佇列爆滿,吞吐量崩潰(congestion collapse)

兩者同時生效,發送端每一刻能送出的量取兩者較小值。

發送端真正能送出而未被確認的資料量是:

可送出未確認資料量 = min(rwnd, cwnd)

其中 rwnd 由接收端在每個 ACK 中告知。若接收端應用讀取太慢,rwnd 會一路縮到 0(zero window), 發送端就只能定期送 window probe 等待對方鬆口——這就是 TCP 層的 backpressure,稍後在第 6 節〈I/O 模型與 Backpressure〉會看到它如何一路傳導回應用程式碼。

滑動視窗(Sliding Window)是實作方式:發送端維護一段「已送出但未確認」的區間, 收到累積 ACK 就把左邊界往右滑,空出額度再送新資料。視窗大小決定了管線能填多滿:

BDP(頻寬延遲乘積) = 頻寬 × RTT

視窗若小於 BDP,鏈路永遠跑不滿——這就是「跨洲傳大檔案很慢,但不是因為頻寬不夠」的根因, 也是為什麼 window scaling 選項(把 16-bit 視窗欄位放大)是長肥管線的必要配備。

壅塞控制則是一套「試探-退讓」的迴圈。以經典的 Reno 家族為例:

  • 慢啟動(Slow Start):cwnd 從 1 個 MSS 起跳,每個 RTT 翻倍——名字叫慢,成長其實是指數。
  • 壅塞避免(Congestion Avoidance):cwnd 超過門檻 ssthresh 後改為每 RTT 加一,線性試探。
  • 快速重傳/快速恢復:收到 3 個重複 ACK,判定只是輕度丟包,ssthresh 與 cwnd 減半後繼續。
  • 超時:判定網路嚴重惡化,cwnd 直接砍回 1,重新慢啟動。
演算法壅塞訊號特性典型場景
Reno / NewReno丟包教科書基準,成長保守已少見於預設值
CUBIC丟包以三次函數探測,高 BDP 下回復快;Linux 預設一般資料中心與公網服務
BBR頻寬與 RTT 估計不以丟包為準,主動避開緩衝區膨脹(bufferbloat)長肥管線、高丟包無線鏈路

關鍵區分:CUBIC 把「丟包」當成網路已經塞住的證據;BBR 認為丟包可能只是無線干擾,改用「送多快才不會讓佇列變長」來判斷。

關鍵有序交付的副作用:Head-of-Line Blocking

TCP 承諾依序交付,於是產生一個結構性代價:Head-of-Line Blocking(HoL Blocking,隊頭阻塞)—— 序號較小的那個片段丟了,即使後面的資料早就安穩躺在接收端核心的緩衝區裡,也不能交給應用, 必須等破洞被重傳補上。這是可靠有序這個承諾的必然結果,不是實作缺陷。 請先記住這個名詞:它會在 HTTP/2 的多路復用(Multiplexing)與 QUIC 的設計動機裡再次登場,而且是主角。

一句話答法流量控制由接收端用 rwnd 喊停、保護對方 buffer;壅塞控制由發送端用 cwnd 自律、保護整條網路;實際發送量取兩者最小值,而有序交付的代價就是 HoL Blocking。
04

UDP:最小契約,與它真正的用武之地

UDP 不是「殘缺的 TCP」,而是刻意保留的一塊空白——讓應用自己決定要什麼保證。

UDP 的 header 只有 8 bytes:來源 port、目的 port、長度、checksum。它做的事情僅僅是 「把 IP 的主機到主機,延伸成程式到程式」。沒有連線、沒有狀態、沒有重傳、沒有視窗。 但這份極簡帶來三個 TCP 給不了的特性:

UDP 的特性技術原因誰需要它
延遲有上限不重傳、不排隊等破洞語音/視訊:晚到的音框不如直接丟掉補插值
保留訊息邊界一次 sendto 對應一次 recvfromDNS 查詢、metrics、syslog:天然一則一則
一對多支援廣播與多播服務發現(mDNS)、區網探測
可自訂可靠性應用層自己疊 ACK/序號/FEC遊戲狀態同步、QUIC 這類自建 transport

最後一列是關鍵:選 UDP 常常不是為了「不要可靠性」,而是為了要一種 TCP 沒提供的可靠性。

典型的取捨是「部分可靠」:即時遊戲對玩家座標更新根本不需要重傳(下一個封包就會帶來更新的位置), 但對「玩家開了一槍」這種事件則需要確認與重送。TCP 無法區分這兩者——它一視同仁地保證全部; UDP 讓應用自由選擇要對哪些訊息付出可靠性成本。同樣的思路,也支撐了在 UDP 之上重建整個可靠傳輸層的做法(系列後面的 QUIC 篇會專門處理)。

陷阱三個關於 UDP 的常見錯答

1.「UDP 比較快」——快在沒有交握等待、沒有重傳造成的隊頭停等,不是在頻寬。同樣的鏈路上,穩定大檔案傳輸 TCP 通常更快,因為壅塞控制會把管線填滿。
2.「UDP 沒有大小限制」——單一資料報超過路徑 MTU 就會觸發 IP 分片,而只要掉一片,整個資料報都作廢。實務上把應用層 payload 控制在約 1200 bytes 以內最安全。
3.「反正是 UDP,狂送就好」——沒有壅塞控制的高速 UDP 流會擠垮共用鏈路上的 TCP 流,最終連自己也一起賠進去。自建 UDP 協定必須自己實作速率控制。

需求TCP 直接給你UDP 上要自己做
知道對方收到了內建 ACK自訂應用層 ACK 與序號
補回遺失資料重傳自行重送,或用 FEC 冗餘編碼直接復原
順序正確排序緩衝自帶序號並在應用層重排(或乾脆不排)
不壓垮網路cwnd 演算法自行實作速率估計與退讓
連線概念五元組 + 狀態機自訂 session id(好處:換網不斷線)

這張表是所有「在 UDP 上造輪子」專案的工作清單——每一列都是實打實的工程量。

一句話答法UDP 只提供 port 分流與 checksum,保留訊息邊界與延遲上限;選它通常不是為了放棄可靠性,而是為了在應用層打造 TCP 給不了的「部分可靠」與自訂節奏——代價是那張工作清單全部得自己扛。
05

Socket API:一條連線在程式碼裡的一生

bind / listen / accept / connect 不是四個 API,而是連線狀態機在使用者空間的四個掛載點。

Socket 是作業系統給的通訊端點抽象,在程式裡就是一個檔案描述符(fd)。 而一條 TCP 連線在核心裡的唯一識別,是眾所皆知的五元組:

連線識別 = (協定, 來源 IP, 來源 port, 目的 IP, 目的 port)

這也解釋了「一台伺服器只開一個 port,為什麼能同時服務幾萬個客戶端」——因為每個客戶端的來源 IP/port 不同,五元組就不同。 真正的上限不是 65535 個 port,而是 fd 數量與記憶體。

Server:被動等待
socket()建立端點;此時還沒佔用任何位址
bind()綁定本機 IP 與 port,宣告「打到這個位址的封包歸我」
listen()轉為被動 socket,核心開始代替你完成交握並備妥兩個佇列
accept()從 accept queue 取出一條已完成交握的連線,得到新 fd
read/write在新 fd 上收送位元組串流
close()送出 FIN,進入四次揮手
Client:主動發起
socket()同上;通常不需要顯式 bind
connect()送出 SYN;核心自動挑一個 ephemeral port 當來源 port
(阻塞中)等待 SYN+ACK;非阻塞模式下立即回 EINPROGRESS
read/write交握完成後直接在原 fd 上讀寫
close()主動關閉方會停留在 TIME_WAIT
(UDP)無交握:sendto/recvfrom 直接收送,connect() 只是記住預設對端
# Server
fd = socket(AF_INET, SOCK_STREAM, 0)   # 建立端點,尚未綁定任何位址
setsockopt(fd, SO_REUSEADDR, 1)        # 允許重啟時立刻重新綁定(跳過 TIME_WAIT 殘留)
bind(fd, "0.0.0.0:8080")               # 綁 0.0.0.0 = 所有網卡;綁 127.0.0.1 則外部永遠連不到
listen(fd, backlog=1024)               # 轉被動;核心開始接受交握、填充 accept queue
while True:
    conn, peer = accept(fd)            # 取出已完成三向交握的連線,conn 是新的 fd
    handle(conn)

# Client
fd = socket(AF_INET, SOCK_STREAM, 0)
connect(fd, "203.0.113.7:8080")        # 送出 SYN;來源 port 由核心從 ephemeral 範圍挑選

注意 accept() 的語義:交握已經由核心做完了,應用只是來領取結果。

SYN queue(半連線佇列)
狀態 SYN_RECV · net.ipv4.tcp_max_syn_backlog
收到 SYN、回了 SYN+ACK,正在等對方最後那個 ACK。塞滿時新 SYN 會被丟棄,客戶端只看到「連線逾時」。SYN flood 攻擊打的就是這條佇列,防禦手段是 SYN cookies。
accept queue(全連線佇列)
狀態 ESTABLISHED · min(backlog, somaxconn)
交握已完成,等應用呼叫 accept()。塞滿時核心預設直接丟掉那個 ACK,客戶端卻以為連上了,於是出現「連得上但一直沒回應」的詭異現象。
關鍵交握快 ≠ 服務健康

因為交握由核心完成,即使你的應用整個卡死在某個鎖上、完全沒在呼叫 accept(), 客戶端的 connect() 仍會在 accept queue 未滿之前成功。 這正是「TCP 探針顯示健康、實際請求全部逾時」的成因——健康檢查請用應用層(送一個真的請求),不要只用 TCP connect。 線上看佇列堆積:ss -ltn 的 Recv-Q 就是目前等待 accept 的連線數,Send-Q 是佇列上限。

綁定了 port,不等於外面連得到你。這一段直接接回 nat-port-forwarding 的結論: bind() 只決定「本機哪個位址上的封包歸這個 socket」,而「封包能不能走到這台機器」是路徑上 NAT 與防火牆說了算:

情境能不能被主動連上原因(回扣 NAT 篇)
綁 127.0.0.1:8080不能只在 loopback 上收;就算做了 Port Forwarding 也送不進去。容器裡最常見的踩雷
綁 0.0.0.0:8080,位於 NAT 之後不能(預設)NAT 表只有出站建立的映射列;入站封包找不到對應列會被丟棄
NAT 上設定 Port Forwarding(DNAT)可以預先建立靜態映射,把公有 IP:port 改寫成內網主機:port
位於 CGNAT/雙層 NAT 之後不能你控制不了上層 NAT,開洞無效;只能走 Tunnel 或改由內往外建連
客戶端主動 connect() 出去可以出站流量會在 NAT 上自動建立映射,回程封包因此有路可還原

最後一列是整個即時通訊生態的地基:因為「內往外」永遠通、「外往內」預設不通,所以裝置端一律主動建立並長期維持一條連線,之後所有雙向訊息都在這條連線上跑。本系列後面的 WebSocket 與 MQTT 都建立在這個前提上。

陷阱NAT 映射有壽命,閒置就會被回收

NAT 表項與有狀態防火牆的連線紀錄都有 idle timeout(家用路由器常見數分鐘到數十分鐘,行動網路可能更短)。 一條完全沒有流量的 TCP 長連線,會在你毫無察覺的情況下被中間設備悄悄拆掉——兩端的 socket 都還是 ESTABLISHED,但封包已經到不了對方。 這就是為什麼長連線一定要有應用層 Keep-Alive 或心跳(Heartbeat):它既是活性偵測,也是「餵養」NAT 映射的手段。

一句話答法server 走 socket→bind→listen→accept、client 走 socket→connect,交握由核心在 SYN queue 與 accept queue 中完成,應用只是領取;而 bind 成功只代表本機收得到,能不能被外部連上取決於 NAT 與防火牆——這正是即時系統一律採用「客戶端主動建長連線」的原因。
06

I/O 模型與 Backpressure

從 thread-per-connection 到 epoll 事件迴圈;以及當對端讀不動時,你的程式該做什麼。

拿到 fd 之後,「怎麼等資料」的策略決定了一台機器能扛多少連線。

模型作法連線數上限的瓶頸適用
Blocking I/O一連線一執行緒,read() 直接卡住執行緒堆疊記憶體與 context switch 成本連線數少、邏輯簡單的內部服務
Non-blocking + 輪詢fd 設 O_NONBLOCK,沒資料回 EAGAIN空轉燒 CPU幾乎不單獨使用
select / poll一次等多個 fd每次呼叫都要傳入並掃描全部 fd,O(n)可攜性需求、fd 數量小
epoll / kqueue / IOCP核心維護興趣集合,只回報就緒的 fd成本隨就緒數而非總連線數成長C10K 以上的長連線服務
io_uring共享環形佇列提交/完成事件系統呼叫次數大幅下降高 IOPS、新版核心

從 select 到 epoll 的躍遷關鍵不是「更快」,而是把複雜度從「總連線數」換成「當下有事的連線數」——這正是十萬級長連線得以成立的原因。

fd = socket(); set_nonblocking(fd);
ep = epoll_create1(0);
epoll_ctl(ep, ADD, listen_fd, EPOLLIN);

while (true) {
  n = epoll_wait(ep, events, MAX, timeout);        # 只回報「就緒」的 fd
  for (ev in events[0..n]) {
    if (ev.fd == listen_fd)  accept_all();         # ET 模式必須 accept 到 EAGAIN 為止
    else if (ev.mask & EPOLLIN)   read_until_eagain(ev.fd);
    else if (ev.mask & EPOLLOUT)  flush_send_queue(ev.fd);   # 補送上次沒寫完的資料
  }
}

Level-Triggered(預設)只要還有資料就持續通知;Edge-Triggered 只在狀態變化時通知一次,因此必須把 fd 讀/寫到 EAGAIN,否則資料會卡在核心裡永遠等不到下一次事件。這是 ET 模式最經典的 bug。

事件迴圈解決了「同時等很多連線」,但沒解決另一半問題:對端讀不動的時候怎麼辦。

Backpressure(背壓)就是這個問題的答案,而它其實是第 3 節談流量控制時那個 rwnd 一路傳導上來的結果: 接收端應用讀得慢 → 接收端核心 buffer 滿 → rwnd 縮到 0 → 發送端不能再送 → 發送端核心 send buffer 滿 → write() 在阻塞模式下卡住、在非阻塞模式下回 EAGAIN。 那個 EAGAIN 就是網路對你的應用說「慢一點」。

陷阱把 backpressure 吃掉,等於把問題換成 OOM

最常見的錯誤處理:write() 回 EAGAIN 時,把送不出去的資料塞進一個無上限的使用者態佇列,然後告訴自己「之後再送」。 這等於用你的堆積記憶體去補償網路的速度差——只要有一個慢速消費者(slow consumer),記憶體就會被單一連線吃光,最後整個行程 OOM, 所有連線一起陪葬。原本只是一個人網路差,變成全體服務中斷。

處理策略作法適用資料
暫停生產通知上游停止產生資料,直到 EPOLLOUT 就緒檔案傳輸、串流轉發,資料不可丟
丟棄與合併只保留最新狀態,覆蓋佇列中的舊值行情報價、玩家座標、儀表板指標
有界佇列 + 斷線佇列超過上限就主動關閉這條連線推播閘道:犧牲一個慢客戶端保全整體
降級取樣降低發送頻率或解析度視訊、日誌、追蹤資料

四種策略的共同點:都預先決定了「撐不住時要犧牲什麼」。沒有明確答案的系統,預設答案就是 OOM。

$ ss -ltn                                    # 監聽中的 socket
State   Recv-Q  Send-Q  Local Address:Port
LISTEN  0       1024          0.0.0.0:8080   # Recv-Q = 等 accept 的連線數,Send-Q = backlog 上限

$ ss -tn state established
Recv-Q  Send-Q  Local Address:Port   Peer Address:Port
0       262144  10.0.0.5:8080        10.0.0.9:51234   # Send-Q 持續堆高 = 對端來不及收
131072  0       10.0.0.5:8080        10.0.0.9:51235   # Recv-Q 堆高 = 你自己來不及讀

看 ss 的兩個佇列就能立刻定位 backpressure 發生在哪一端:Send-Q 堆積是對方慢,Recv-Q 堆積是自己慢。

一句話答法blocking 一連線一執行緒撐不過 C10K,epoll 讓成本隨「就緒連線數」而非總連線數成長;而 EAGAIN 就是 TCP flow control 傳到應用層的 backpressure 訊號——正確反應是暫停、丟棄或斷線,絕不是無上限地往記憶體堆。
07

互動 · TCP 可靠傳輸模擬器

親手在傳輸中途弄丟一個封包,看 TCP 如何重傳與排序,而 UDP 如何若無其事地留下一個永久缺口。

下面的模擬器把一段資料切成 8 個片段(seq 1–8)。點擊發送端任一個尚未確認的片段,就能預先注入丟包; 按「下一步」逐步推進,每一輪分成「發送」與「回應」兩個階段。切到 UDP 模式再跑一次,對照兩者的差別。

互動 · TCP 可靠傳輸模擬器 先點封包注入丟包,再按「下一步」
未送出 / 待重傳 在途 亂序緩衝(已 SACK) 已確認 / 已交付 遺失
選擇協定後按「下一步」開始傳輸。
發送端 · 傳送視窗點擊尚未確認的封包可注入/取消丟包
網路 · 在途封包紅色代表這一輪在路上遺失
接收端 · 交付應用的順序虛線框=正在等的破洞
cwnd
1
ssthresh
4
累積 ACK
1
重傳次數
0
永久遺失
0
已交付
0 / 8

三個值得停下來觀察的畫面:(1) TCP 模式下對 seq 4 注入丟包(前兩輪先讓視窗長到 4),第三輪送出 4–7 時,5、6、7 會全部亮成「亂序緩衝」——它們已經安穩躺在接收端,卻因為前面缺一塊而不能交給應用,這就是 HoL Blocking 的具體樣貌。 (2) 下一輪只會補送 seq 4,5、6、7 因為已被 SACK 告知而跳過不重傳;同時 cwnd 立刻減半,順利的幾輪則從指數翻倍轉為線性加一——慢啟動與壅塞避免的分界就在 ssthresh。 (3) 切到 UDP 模式重跑:遺失的片段直接變成永久缺口,接收端毫不猶豫地把後面的資料交給應用——沒有重傳、沒有等待,也沒有人會告訴發送端出事了。

一句話答法同一次丟包,TCP 的反應是「重傳 + 把後續資料扣在緩衝區等破洞補上 + 壓低 cwnd」,UDP 的反應是「什麼都不做」——可靠性的成本與延遲的代價,在這個對照裡一眼看完。
08

面試折衷與實務選型

面試官問的從來不是「TCP 和 UDP 差在哪」,而是「你這個場景為什麼選它、代價是什麼」。

決策點選項Trade-off
傳輸協定TCP vs UDPTCP:省下所有可靠性工程,但吃 1-RTT 建連與 HoL Blocking;UDP:延遲可控且可自訂語義,但那張工作清單全得自己寫
連線策略短連線 vs 長連線短連線無狀態易擴充,但每次付交握成本;長連線省 RTT,但要處理心跳、重連、NAT 逾時與連線狀態管理
並行模型thread-per-conn vs event loop執行緒模型好寫好除錯,連線數上不去;事件迴圈可扛十萬連線,但任何阻塞操作都會拖垮整個迴圈
壅塞演算法CUBIC vs BBRCUBIC 保守公平、生態成熟;BBR 在高丟包長距鏈路吞吐量顯著更好,但與 CUBIC 共用瓶頸時可能過度搶佔
Backpressure緩衝 vs 丟棄 vs 斷線緩衝保資料但風險是 OOM;丟棄保系統但損失內容;斷線最乾脆,代價是使用者體驗
健康檢查TCP connect vs 應用層探針TCP 探針便宜但只驗到核心;應用層探針才驗得到「accept 之後真的有人在處理」

每一列的共同結構:便宜的選項把成本推遲到故障時,昂貴的選項把成本前置到設計時。

面試高頻追問怎麼答

Q:為什麼是三次交握,兩次不行嗎? 答:要同步的是雙向各自的初始序號,需要「告知+確認」共四件事,中間兩件可合併故為三次;兩次則伺服器無法確認客戶端真的收得到它的序號,也擋不住延遲重複的舊 SYN 建出幽靈連線。
Q:TIME_WAIT 太多怎麼辦? 答:先確認是誰主動關閉(TIME_WAIT 落在主動方);用連線復用/長連線減少關閉次數,並開 SO_REUSEADDR;不要粗暴縮短 2MSL。
Q:流量控制和壅塞控制差在哪? 答:一句話——rwnd 保護接收端 buffer(對方明說),cwnd 保護中間網路(自己猜),實際發送量取兩者最小值。
Q:伺服器連得上卻沒回應,怎麼查? 答:交握由核心完成,先看 ss -ltn 的 Recv-Q——accept queue 堆積代表應用沒在 accept(),問題在應用不在網路。
Q:既然 TCP 這麼好,為什麼還有人用 UDP 自己造輪子? 答:因為 TCP 的可靠性是全有全無且綁死有序交付;需要部分可靠、需要 stream 之間互不阻塞、或需要換網不斷線時,只能在 UDP 上重做。

把這一篇收攏成一句:TCP 幫你把不可靠的封包網路,包裝成一條看起來像檔案讀寫的可靠位元組串流—— 代價是建連的 RTT、有序交付帶來的 HoL Blocking,以及一套你必須看得懂的視窗與佇列行為。 而 UDP 把這些全部拿掉,換來一張白紙與完全的自由。

但這條可靠管線還缺了一件事:它從頭到尾只保證「不掉、不亂」,沒保證「沒人看得懂」。 你寫進 socket 的每一個位元組,在路徑上的每一台路由器、每一個中間設備眼裡都是明文。 下一篇補上這缺失的一層——TLS 與 HTTPS:如何在完全不改動 TCP 的前提下,把裸奔的位元組串流變成一條加密、且能驗證對方身分的通道。

一句話答法選 TCP 是把可靠性外包給核心並接受它的延遲與 HoL 代價,選 UDP 是收回控制權並承擔全部工程量;面試要答得出「這個場景不能忍受什麼」,而不是背誦兩張特性表。