OSI 七層與 TCP/IP 四層模型 基礎
分層的目的是關注點分離:每一層只依賴下一層提供的服務、為上一層提供抽象。面試不太會要你背七層名稱,但會用「L4 vs L7 負載平衡」「這是 L3 問題還是 L7 問題」這類術語,所以要能對應。
| OSI 層 | TCP/IP 層 | 代表協定 / 技術 | 資料單位 | 相關設備 / 元件 |
|---|---|---|---|---|
| 7 應用層(Application) | 應用層 | HTTP、gRPC、DNS、SMTP、WebSocket | Message | L7 LB(Nginx、HAProxy http mode、ALB)、API Gateway、WAF |
| 6 表現層(Presentation) | TLS(常被歸在這附近)、編碼、壓縮 | — | ||
| 5 會議層(Session) | (TCP/IP 中沒有獨立對應) | — | ||
| 4 傳輸層(Transport) | 傳輸層 | TCP、UDP、QUIC(建立在 UDP 上) | Segment / Datagram | L4 LB(LVS、NLB、HAProxy tcp mode)、port |
| 3 網路層(Network) | 網際網路層 | IP、ICMP、路由協定(BGP、OSPF) | Packet | Router、L3 switch |
| 2 資料連結層(Data Link) | 網路存取層 | Ethernet、ARP、VLAN、Wi-Fi | Frame | Switch、網卡 MAC |
| 1 實體層(Physical) | 電訊號、光纖 | Bit | 線材、Hub |
封裝(encapsulation)過程:應用資料 → 加 TCP header(src/dst port、seq、ack、flags、window)→ 加 IP header(src/dst IP、TTL、protocol)→ 加 Ethernet header(src/dst MAC)→ 送上線路。每經過一個 router,L2 header 會換掉(下一跳的 MAC),L3 的來源/目的 IP 不變(除非經過 NAT)。
以 MTU 1500 的 Ethernet 為例,扣掉 IPv4 header 20 bytes 與 TCP header 20 bytes(不含選項),一個 segment 最多攜帶約 1460 bytes 的資料,這就是常見的 MSS 值。封包大於路徑 MTU 時需要分片或靠 PMTU discovery,VPN / overlay 網路(VXLAN)會吃掉一些 header 空間,MTU 設錯時常見症狀是「小請求正常、大回應卡住」。
從輸入 URL 到頁面出現:完整流程 高頻
經典開放題,可以講 5 分鐘,也可以講 30 分鐘。好的回答是先給完整骨架,再依面試官興趣往某一段深入。
- URL 解析:判斷 scheme、host、port、path;若輸入的不是 URL 就丟給搜尋引擎。檢查 HSTS 清單,若該網域要求 HTTPS,直接改用 https(避免第一次明文請求被劫持)。
- DNS 解析:依序查瀏覽器快取 → OS 快取(
/etc/hosts、systemd-resolved / nscd)→ 設定的 recursive resolver(ISP 或 8.8.8.8、1.1.1.1)。Client 對 resolver 是遞迴查詢(「請給我最終答案」);resolver 對 root → TLD(.com)→ 權威 DNS 是迭代查詢(每一層回答「去問下一個」)。各層結果依 TTL 快取。 - 建立 TCP 連線:三次交握,花 1 RTT。若有可重用的 keep-alive 連線則跳過。
- TLS 交握:TLS 1.3 為 1 RTT(恢復連線可 0-RTT),TLS 1.2 為 2 RTT。協商加密套件、驗證憑證鏈、建立 session key。HTTP/2 透過 TLS 的 ALPN 擴充協商;HTTP/3 則跑在 QUIC 上,傳輸與加密交握合併。
- 送出 HTTP 請求:method、path、headers(Host、Cookie、Accept-Encoding、User-Agent…)。
- CDN / LB / 應用:請求先到最近的 CDN edge;未命中則回源(origin)。在 origin 端經過 L4/L7 LB → reverse proxy(Nginx)→ 應用伺服器 → 快取 / 資料庫,產生回應。
- 回應:status code、headers(Content-Type、Cache-Control、Set-Cookie、Content-Encoding)、body(可能 gzip/br 壓縮、chunked 傳輸)。
- 瀏覽器渲染:解析 HTML 建 DOM、CSS 建 CSSOM、合成 render tree、layout、paint、composite;遇到
<script>(非 async/defer)會阻塞解析;子資源(CSS、JS、圖片)重複上述網路流程,但可共用連線(HTTP/2 多工)。
後端職缺的面試官通常想聽網路與伺服器端的部分:DNS 遞迴/迭代與 TTL、TCP + TLS 各要幾個 RTT、為什麼連線重用很重要、CDN 如何命中、LB 怎麼分流、伺服器端從 accept 到回應經過哪些元件。可以主動算一筆帳:跨洋 RTT 約 150ms 時,新連線的 DNS + TCP + TLS 1.2 光是交握就要好幾個 RTT(數百 ms),這就是 CDN、keep-alive、TLS 1.3、HTTP/3 存在的原因。
TCP 連線管理:三次交握、四次揮手與狀態機 高頻
這是 Appier 面試實際被問過的題目。不要只畫出 SYN / SYN-ACK / ACK,要能回答「為什麼是三次」「TIME_WAIT 為什麼存在」「線上大量 CLOSE_WAIT 怎麼查」等追問。
先認識 TCP header 的關鍵欄位
- Sequence number(seq)
- 這個 segment 第一個 byte 的序號。TCP 以 byte 為單位編號,初始序號(ISN)是隨機產生的(防止預測與舊連線混淆)。
- Acknowledgment number(ack)
- 「我期待收到的下一個 byte 序號」,代表之前的都收到了(累積確認)。
- Flags
SYN(建立連線、同步序號)、ACK、FIN(我沒有資料要送了)、RST(強制重置)、PSH、URG,以及 ECN 相關的ECE/CWR。- Window size
- 接收方還能收多少 bytes(流量控制),搭配 window scale 選項可超過 64KB。
- Options
- MSS、Window Scale、SACK permitted、Timestamps 等,大多在 SYN 時協商。
三次交握(Three-way Handshake)
- Client 送
SYN,帶自己的初始序號x,以及 MSS、window scale、SACK、timestamp 等選項。 - Server 回
SYN+ACK:確認收到 x(ack=x+1,SYN 本身佔一個序號),並帶自己的初始序號y。 - Client 回
ACK(ack=y+1)。此時雙方都進入 ESTABLISHED。
Server 端在 kernel 中有兩個佇列:收到 SYN 後放入 SYN queue(半連線),收到第三個 ACK 後移入 accept queue(全連線),應用呼叫 accept() 才從中取出。注意:交握是 kernel 完成的,應用程式不參與,accept() 只是拿走已完成的連線。
為什麼是三次,不是兩次?
- 雙方都要確認「對方知道我的初始序號」:TCP 是雙向的,每個方向都有自己的序號空間。Client 的 SYN 由 server 的 ACK 確認;server 的 SYN 必須由 client 的第三個 ACK 確認。只有兩次的話,server 無法知道 client 是否收到它的 ISN,也就不確定自己送出的資料能否被正確排序。SYN 與 ACK 合併到同一個 segment,所以「四次」被壓成三次。
- 防止過期的重複 SYN 建立錯誤連線(RFC 793 給的主要理由):假設 client 一個舊的 SYN 在網路中滯留很久才到達 server。若兩次交握就建立連線,server 會為這個早已不存在的請求配置資源並等待資料。三次交握時,client 收到 SYN+ACK 發現 ack 號碼不對應自己目前的連線,會回 RST,錯誤連線就不會建立。
- 確認雙向通道都可用:兩次只能證明 client→server 與 server→client 單向各通一次,但 server 不知道自己的訊息有沒有到達。
反過來說,為什麼不用四次?因為 server 的 ACK 與 SYN 可以合併送出,沒有必要分開。
四次揮手(Four-way Teardown)
為什麼要四次?因為 TCP 是全雙工,兩個方向要分別關閉(half-close)。A 送 FIN 只代表「我不再送資料」,B 可能還有資料要回傳,所以 B 先回 ACK,等自己的應用程式也送完、呼叫 close() 後才送 FIN。若 B 沒有剩餘資料,ACK 與 FIN 可以合併,實際上常看到三個 segment。
TIME_WAIT:為什麼要等 2MSL?
主動關閉的一方在送出最後一個 ACK 後進入 TIME_WAIT,等待 2 × MSL(Maximum Segment Lifetime)。Linux 的 TIME_WAIT 時間固定為 60 秒。兩個理由:
- 確保最後的 ACK 能被對方收到:若最後一個 ACK 遺失,對方會重送 FIN;此時還在 TIME_WAIT 的一方能再回 ACK。若已經 CLOSED,只能回 RST,對方會看到錯誤。
- 讓舊連線的延遲封包在網路中消失:避免相同四元組(src IP、src port、dst IP、dst port)的新連線收到上一個連線遲到的資料而錯亂。
大量 TIME_WAIT 或 CLOSE_WAIT 怎麼辦?
大量 TIME_WAIT
出現在主動關閉方。常見於:服務對下游(DB、Redis、HTTP API)用短連線,每次請求都建立再關閉;或反向代理到後端沒開 keep-alive。TIME_WAIT 本身只佔少量記憶體,真正的問題是佔用 ephemeral port:同一個目標(dst IP + port)在 60 秒內最多只能有約 2.8 萬個(預設 port range),用完就出現 EADDRNOTAVAIL: Cannot assign requested address。
解法優先順序:(1) 使用長連線 / 連線池(HTTP keep-alive、Nginx upstream keepalive、DB pool)—— 治本;(2) 擴大 ip_local_port_range;(3) tcp_tw_reuse=1(只對 outbound 有效);(4) 增加目標 IP / 來源 IP。不要用已移除的 tcp_tw_recycle。若是 server 端主動關閉大量 client 連線造成 TIME_WAIT,因為不佔 server 的 ephemeral port,通常影響不大。
大量 CLOSE_WAIT
出現在被動關閉方:對方已送 FIN,kernel 也回了 ACK,但自己的應用程式遲遲沒有呼叫 close()。這幾乎一定是程式 bug:例外路徑沒關連線、連線池拿出去沒還、讀到 EOF 沒處理、HTTP response body 沒關閉、thread 卡住沒機會關。
CLOSE_WAIT 不會自己消失(沒有 timeout),會持續佔用 fd,最後導致 "Too many open files"。排查:ss -tanp state close-wait 找出 process 與對端,看程式碼中與該對端互動的部分;必要時 py-spy dump / jstack 看 thread 是否卡住。調 kernel 參數沒有用,要修程式。
# 依狀態統計連線數
ss -tan | awk 'NR>1 {s[$1]++} END {for (k in s) print s[k], k}' | sort -rn
ss -s
# TIME_WAIT 都是連到哪裡?(找出沒用連線池的下游)
ss -tan state time-wait | awk 'NR>1 {print $4}' | sort | uniq -c | sort -rn | head
# CLOSE_WAIT 是哪個 process、對端是誰
ss -tanp state close-wait
TCP 狀態機
SYN Flood 與 SYN Cookies
SYN flood 是經典的 L4 DDoS:攻擊者大量送 SYN(通常偽造來源 IP)卻不完成交握,塞滿 server 的 SYN queue,讓正常使用者連不進來;server 還會為每個半連線重送 SYN+ACK(tcp_synack_retries,預設 5 次)。
SYN cookies(net.ipv4.tcp_syncookies=1,Linux 預設在 SYN queue 滿時啟用):server 不在 queue 中保存半連線狀態,而是把連線資訊(時間戳、MSS 編碼、四元組的雜湊加上密鑰)編碼進 SYN+ACK 的初始序號 y。合法 client 回 ACK 時帶 ack=y+1,server 驗證這個值就能重建連線,無需事先保存狀態。代價:部分 TCP 選項(如完整的 window scale、SACK)可能無法保留(有 timestamps 時可以部分編碼)。其他防護:加大 tcp_max_syn_backlog、減少 tcp_synack_retries、前置有清洗能力的 LB / CDN / anti-DDoS 服務。
三次交握的完整回答 = 流程(含 seq/ack 數字)+ 為什麼不是兩次(雙方 ISN 確認 + 舊 SYN 問題)+ server 端的 SYN queue / accept queue + 相關攻擊(SYN flood / SYN cookies)。四次揮手要帶出 half-close、TIME_WAIT 的兩個理由、TIME_WAIT 出現在主動關閉方、CLOSE_WAIT 代表程式沒 close。若能說出「我們線上遇過短連線打 Redis 造成 port 耗盡,改用連線池解決」這類經驗,效果最好。
TCP 可靠傳輸、流量控制、擁塞控制與 UDP 進階
可靠傳輸的機制
- 序號 + 確認:每個 byte 有序號,接收方以累積 ACK 回報;亂序到達的資料先暫存,交給應用時保證順序。
- 重傳:逾時重傳(RTO,根據量測的 RTT 動態計算,Linux 最小值約 200ms)與快速重傳(收到 3 個重複 ACK)。
- SACK(Selective ACK):告訴對方「哪些區段我已經收到」,只重傳真正遺失的部分。
- Checksum:偵測資料毀損。
流量控制(Flow Control):別淹沒接收方
接收方在每個 ACK 中通告 receive window(rwnd),代表自己 buffer 還剩多少空間;傳送方在途(已送未確認)的資料量不得超過 rwnd。這就是滑動視窗(sliding window):隨著 ACK 到達,視窗左緣右移,可以送出新資料。
- 若應用程式讀取太慢,rwnd 縮到 0(zero window),傳送方停止並定期送 window probe。用
ss -tin或 tcpdump 看到win 0,代表瓶頸在接收端應用。 - 原始 header 的 window 欄位只有 16 位元(最多 64KB),透過 window scale 選項可擴大到 1GB,這對高頻寬 × 高延遲(BDP 大)的連線很重要:要跑滿 1Gbps、RTT 100ms 的鏈路,在途資料需要約 12.5MB。
擁塞控制(Congestion Control):別淹沒網路
流量控制保護「接收方」,擁塞控制保護「中間的網路」。傳送方維護 congestion window(cwnd),實際可送的量 = min(cwnd, rwnd)。傳統(Reno / NewReno)演算法的四個階段:
- 慢啟動(Slow Start)
- 新連線的 cwnd 從初始值開始(現代 Linux 為 10 個 MSS,RFC 6928),每收到一個 ACK 增加 1 MSS,效果是每個 RTT 加倍(指數成長),直到達到慢啟動門檻 ssthresh。名字叫「慢」是相對於一開始就全速傳送而言。
- 擁塞避免(Congestion Avoidance)
- 超過 ssthresh 後改為每個 RTT 加 1 MSS(線性成長,AIMD 的 Additive Increase)。
- 快速重傳(Fast Retransmit)
- 收到 3 個重複 ACK(表示後面的資料有到、只是中間某段遺失),不必等 RTO,立即重傳遺失的 segment。
- 快速恢復(Fast Recovery)
- 快速重傳後,把 ssthresh 設為目前 cwnd 的一半,cwnd 也大約減半(Multiplicative Decrease),直接進入擁塞避免,而不是退回慢啟動。只有發生逾時(RTO)這種嚴重情況,才把 cwnd 重設為很小的值並重新慢啟動。
Linux 預設演算法是 CUBIC(cwnd 依距上次丟包的時間以三次函數成長,在高 BDP 網路表現較好)。BBR(Google 提出)走不同路線:不以「丟包」當作擁塞訊號,而是持續估計瓶頸頻寬與最小 RTT,以兩者乘積控制在途資料量與發送速率(pacing),在有隨機丟包的長距離鏈路上吞吐量明顯較好、也較少塞滿中間設備的 buffer(bufferbloat)。
sysctl net.ipv4.tcp_congestion_control # 目前演算法,通常 cubic
sysctl net.ipv4.tcp_available_congestion_control
# 啟用 BBR(kernel 4.9+;搭配 fq qdisc 做 pacing)
sudo sysctl -w net.core.default_qdisc=fq
sudo sysctl -w net.ipv4.tcp_congestion_control=bbr
ss -tin dst 10.0.0.5 # 每條連線的 cwnd、rtt、retrans、所用演算法
慢啟動的含義:新連線一開始送不快。一個 10 個 MSS 的初始視窗約 14KB,回應更大時需要多個 RTT 才能傳完。這是「連線重用」的另一個理由 —— 長連線的 cwnd 已經長大了。若連線閒置後希望保留 cwnd,可評估 tcp_slow_start_after_idle=0。
Nagle 演算法與 TCP_NODELAY
Nagle 演算法:若有已送出但尚未被確認的資料,就把後續的小 segment 累積起來,等 ACK 回來或湊滿一個 MSS 再送,目的是減少大量小封包(例如 telnet 每個按鍵一個封包)的 header 浪費。
問題在於它和接收端的 delayed ACK(接收方延遲一小段時間再送 ACK,以便合併或搭便車,Linux 最短約 40ms)互動時:傳送方在等 ACK 才送下一個小封包,接收方在等更多資料才送 ACK,造成數十毫秒的額外延遲。典型觸發情境是「write header、write body」分兩次寫的請求 / 回應協定。
對延遲敏感的 RPC、資料庫 driver、Redis client、遊戲、RTB 服務幾乎都會設定 TCP_NODELAY 關掉 Nagle(Go 的 net.TCPConn 預設就開啟 NoDelay;Nginx 在 keep-alive 連線上預設 tcp_nodelay on)。更好的做法是應用層自己把一個訊息組好再一次 write(或用 writev)。相反地,TCP_CORK 則是刻意「先塞住、湊滿再送」,適合傳送大檔。
import socket
s = socket.create_connection(("10.0.0.5", 6379))
s.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
TCP Keepalive vs HTTP Keep-Alive
TCP keepalive(L4)
連線閒置一段時間後,kernel 送出探測封包確認對方還活著,用來偵測「半開連線」(對方當機、網路中斷但沒有送 FIN/RST)。Linux 預設:閒置 7200 秒(tcp_keepalive_time)後開始,每 75 秒(tcp_keepalive_intvl)一次,9 次(tcp_keepalive_probes)無回應就關閉。預設值太長,需要時應在 socket 上個別設定(SO_KEEPALIVE、TCP_KEEPIDLE…)。另外 NAT / 防火牆 / 雲端 LB 會清掉閒置連線(例如 AWS NLB 預設閒置逾時 350 秒),長連線服務的 keepalive 間隔要比它短。
HTTP keep-alive(L7)
也叫 persistent connection:一個 TCP 連線上連續傳送多個 HTTP 請求,避免每次都重新交握。HTTP/1.1 預設開啟,除非帶 Connection: close。伺服器有閒置逾時(Nginx keepalive_timeout 預設 75 秒)與每連線最多請求數(keepalive_requests,新版預設 1000)。兩者名字相近但完全不同層,面試常被拿來考混淆。
Head-of-Line Blocking(隊頭阻塞)
HOL blocking 是「排在前面的東西卡住,後面全部跟著等」,在不同層各有一種:
- HTTP/1.1 層:一條連線同時只能處理一個請求-回應(pipelining 理論上可以連送,但回應必須依序返回,前面慢就全部卡住)。瀏覽器只好對同一網域開多條連線(通常 6 條)。
- TCP 層:TCP 保證 byte stream 有序,若一個封包遺失,後面已到達的資料也必須等它重傳後才能交給應用。HTTP/2 雖然在應用層多工,所有 stream 共用一條 TCP 連線,一個封包遺失會讓所有 stream 一起停住;在高丟包網路上 HTTP/2 甚至可能比多條 HTTP/1.1 連線還慢。
- 解法:HTTP/3 用 QUIC,每個 stream 在傳輸層各自保證順序,一個 stream 掉包不影響其他 stream。
UDP 與適用場景
| 面向 | TCP | UDP |
|---|---|---|
| 連線 | 需要交握建立連線 | 無連線,直接送 |
| 可靠性 | 保證送達、有序、不重複 | 不保證,可能遺失、亂序、重複 |
| 資料邊界 | Byte stream,沒有訊息邊界(應用需自行分幀) | 保留 datagram 邊界 |
| 流量 / 擁塞控制 | 有 | 沒有(應用需自律) |
| Header | 20~60 bytes | 8 bytes |
| 適用 | HTTP/1.1、HTTP/2、資料庫、檔案傳輸 | DNS、即時影音、遊戲、VoIP、metrics(StatsD)、QUIC/HTTP/3、DHCP |
選 UDP 的理由:(1) 延遲比完整性重要(即時語音寧可掉一個封包也不要等重傳);(2) 請求-回應很小,一個封包就夠,交握成本不划算(DNS);(3) 想在 user space 自訂傳輸行為(QUIC 把可靠性、擁塞控制做在 user space,可以快速迭代,不必等作業系統 kernel 升級);(4) 廣播 / 多播。像 StatsD 這類 metrics 送出用 UDP,是「掉一點資料可以接受、但絕不能讓業務請求被監控拖慢」的取捨。
「流量控制和擁塞控制的差別?」→ 一個保護接收端(rwnd,由接收方通告),一個保護網路(cwnd,由傳送方根據丟包或延遲估計),實際傳送量取兩者最小值。接著說出慢啟動、擁塞避免、快速重傳、快速恢復,以及 BBR 改以頻寬與 RTT 建模。延伸追問「為什麼 HTTP/2 在弱網路下可能更慢?」→ TCP 層 HOL blocking。
HTTP/1.0 → HTTP/1.1 → HTTP/2 → HTTP/3 高頻
HTTP/1.0 與 HTTP/1.1
- HTTP/1.0:預設每個請求一條新 TCP 連線,回應完就關閉(可用非標準的
Connection: keep-alive延長)。沒有強制的Hostheader,一個 IP 難以服務多個網站。 - HTTP/1.1(1997 起,現行規範為 RFC 9110/9112):
- 持久連線(keep-alive)預設開啟,大幅減少交握成本。
- 強制
Hostheader,實現 virtual hosting。 - Pipelining:可不等回應就連續送多個請求,但回應必須依序返回 → 應用層 HOL blocking,加上中間設備相容性差,瀏覽器實務上都沒有啟用。
- Chunked transfer encoding:
Transfer-Encoding: chunked,回應長度未知時(串流、動態產生)可邊產生邊送,每塊帶自己的長度,以長度 0 的區塊結束。 - 更完整的快取控制(
Cache-Control、ETag、If-None-Match)、range request(206 Partial Content,續傳)、100 Continue。
HTTP/1.1 200 OK
Content-Type: application/x-ndjson
Transfer-Encoding: chunked
1a
{"event":"bid","price":1}
1a
{"event":"win","price":1}
0
(上例中 1a 是 16 進位的區塊長度 26 bytes:25 個字元的 JSON 加上換行。)
HTTP/2(2015)
- 二進位分幀(Binary framing)
- 訊息被切成 frame(HEADERS、DATA、SETTINGS、WINDOW_UPDATE、RST_STREAM、PING、GOAWAY…),每個 frame 標記所屬的 stream ID,解析更有效率、更不易出錯。
- 多工(Multiplexing)與 Stream
- 一條 TCP 連線上可同時有多個雙向 stream(一個請求/回應 = 一個 stream),frame 可交錯傳送,解決 HTTP/1.1 應用層的 HOL blocking,也不再需要開 6 條連線、domain sharding、圖片 sprite 等 hack。併發 stream 數由
SETTINGS_MAX_CONCURRENT_STREAMS限制。 - HPACK header 壓縮
- HTTP header 大量重複(Cookie、User-Agent)。HPACK 用靜態表(常見 header 預先編號)+ 動態表(連線期間已出現的 header,之後只送索引)+ Huffman 編碼。不使用 gzip 是為了避免 CRIME 類的壓縮側通道攻擊。
- Stream 優先權與流量控制
- 每個 stream 與整條連線都有自己的 flow control window。原本的優先權樹設計過於複雜,RFC 9113 已將其淡化,改由較簡單的 Extensible Priorities(RFC 9218)取代。
- Server Push
- 伺服器主動推送 client 尚未請求的資源。實務效果不佳(常推送已在快取中的東西),Chrome 已移除支援,改用
103 Early Hints。 - GOAWAY
- 伺服器通知「不再接受新 stream」,搭配 graceful shutdown / 連線輪替。
瀏覽器上 HTTP/2 實務上只在 TLS 上使用(透過 ALPN 協商 h2);服務對服務的明文 HTTP/2 稱為 h2c,gRPC 就是建立在 HTTP/2 上。
HTTP/3 與 QUIC(2022 標準化)
- QUIC 跑在 UDP 上,在 user space 實作可靠傳輸、擁塞控制與多工。選 UDP 是因為中間設備(防火牆、NAT)只認得 TCP/UDP,新的傳輸協定無法部署;而 TCP 實作在 kernel 與中間設備中,難以演進。
- 解決 TCP 層 HOL blocking:每個 stream 獨立有序,一個封包遺失只影響它所屬的 stream。
- 內建 TLS 1.3:傳輸與加密交握合併,新連線 1 RTT(TCP + TLS 1.3 需要 2 RTT),恢復連線可 0-RTT(第一個封包就帶應用資料;但 0-RTT 資料可能被重放,只適合冪等請求)。
- Connection migration:連線以 Connection ID 識別而非四元組,手機從 Wi-Fi 切到 4G、IP 改變時連線不中斷。
- QPACK:HPACK 的變形,避免 header 壓縮本身又造成 HOL blocking。
- 代價:UDP 在部分企業網路被封鎖(需 fallback 到 HTTP/2)、user space 處理封包的 CPU 成本較高、對 LB 而言需要能依 Connection ID 路由。伺服器透過
Alt-Svc: h3=":443"header 或 DNS HTTPS record 告知 client 支援 HTTP/3。
| 特性 | HTTP/1.1 | HTTP/2 | HTTP/3 |
|---|---|---|---|
| 傳輸層 | TCP | TCP | QUIC(UDP) |
| 格式 | 文字 | 二進位 frame | 二進位 frame |
| 多工 | 無(一連線一請求) | 有,單一 TCP 連線 | 有,stream 互相獨立 |
| HOL blocking | 應用層 + TCP 層 | TCP 層 | 僅單一 stream 內 |
| Header 壓縮 | 無 | HPACK | QPACK |
| 加密 | 可選 | 瀏覽器實務上必須 | 強制(TLS 1.3 內建) |
| 新連線交握 RTT(含 TLS 1.3) | 2 | 2 | 1(恢復時 0) |
| 連線遷移 | 否 | 否 | 是(Connection ID) |
掉一個封包,全部 stream 等待"] end subgraph H3["HTTP/3:QUIC,stream 各自獨立"] C1["stream1 ✓"] C2["stream3 掉包,只有它等"] C3["stream5 ✓"] end H1 --> H2 --> H3
每一代都在解決上一代的瓶頸:1.1 解決每次請求都要建立連線(keep-alive);2 解決一條連線只能一個請求(多工、HPACK);3 解決 TCP 層的 HOL blocking 與交握延遲(QUIC、0-RTT、connection migration)。追問「HTTP/2 還需要多條連線嗎?」→ 通常一條就夠,但在服務間呼叫,單一連線會受 MAX_CONCURRENT_STREAMS 與單一 TCP 的吞吐限制,gRPC client 常需要連線池或多個 subchannel 做負載平衡(L4 LB 無法分散同一條 HTTP/2 連線內的請求)。
HTTP 語意:Methods、Status Codes 與 Headers 高頻
Methods:safe 與 idempotent
- Safe(安全)
- 語意上不改變伺服器狀態(唯讀)。GET、HEAD、OPTIONS、TRACE。
- Idempotent(冪等)
- 執行一次與執行多次,對伺服器狀態的效果相同(回應可以不同)。safe 方法都是冪等的,另外 PUT、DELETE 也是。冪等性決定了「網路逾時後可不可以安全重試」—— LB、client library、proxy 的自動重試只應用在冪等請求上。
| Method | 語意 | Safe | Idempotent | Request body | 可快取 |
|---|---|---|---|---|---|
| GET | 取得資源 | 是 | 是 | 不建議(語意未定義) | 是 |
| HEAD | 同 GET 但只回 header | 是 | 是 | 否 | 是 |
| POST | 建立資源 / 觸發處理 | 否 | 否 | 是 | 極少 |
| PUT | 以完整內容取代(或建立)指定資源 | 否 | 是 | 是 | 否 |
| PATCH | 部分更新 | 否 | 不保證(視 patch 格式;如「加 1」就不冪等) | 是 | 否 |
| DELETE | 刪除 | 否 | 是(第二次可能回 404,但狀態相同) | 可但少用 | 否 |
| OPTIONS | 查詢支援的選項;CORS preflight | 是 | 是 | 可 | 否 |
讓 POST 也能安全重試的做法是 idempotency key:client 產生唯一 key 放在 header(例如 Idempotency-Key),伺服器記錄 key 與結果,重複請求直接回傳先前結果。支付、下單、發送訊息(例如 LINE 推播)都需要。詳見 第 7 章。
Status Codes 重點表
| Code | 名稱 | 何時使用 / 注意事項 |
|---|---|---|
| 200 | OK | 一般成功 |
| 201 | Created | 建立成功,搭配 Location header 指向新資源 |
| 202 | Accepted | 已接受但非同步處理中(例如報表產生、批次匯入) |
| 204 | No Content | 成功但無 body(DELETE、RTB 中的「不出價」回應常用 204) |
| 206 | Partial Content | Range 請求 |
| 301 / 308 | Moved Permanently / Permanent Redirect | 永久轉址;308 保證不改變 method 與 body(301 可能被 client 改成 GET) |
| 302 / 307 | Found / Temporary Redirect | 暫時轉址;307 不改變 method |
| 303 | See Other | POST 完成後導向 GET 結果頁(PRG 模式) |
| 304 | Not Modified | 條件請求命中,client 用快取,不回 body |
| 400 | Bad Request | 格式錯誤、參數驗證失敗 |
| 401 | Unauthorized | 其實是「未認證」:沒帶或帶錯憑證,應回 WWW-Authenticate |
| 403 | Forbidden | 已認證但沒有權限 |
| 404 | Not Found | 資源不存在(有時為了不洩漏存在與否,也用來代替 403) |
| 405 | Method Not Allowed | 需回 Allow header |
| 409 | Conflict | 狀態衝突,例如重複建立、樂觀鎖版本不符 |
| 412 | Precondition Failed | If-Match 不符(樂觀並行控制) |
| 413 / 414 | Content Too Large / URI Too Long | Body 或 URL 太大 |
| 415 | Unsupported Media Type | Content-Type 不支援 |
| 422 | Unprocessable Content | 格式正確但語意驗證失敗(FastAPI 驗證錯誤預設回 422) |
| 429 | Too Many Requests | 限流;可帶 Retry-After |
| 499 | (Nginx 自訂)Client Closed Request | client 在回應前斷線,常代表上游太慢、client 逾時放棄 |
| 500 | Internal Server Error | 未處理的例外 |
| 502 | Bad Gateway | Proxy 從上游收到無效回應:上游 crash、連線被 reset、上游的 keep-alive 逾時比 proxy 短 |
| 503 | Service Unavailable | 暫時無法服務:過載、維護、沒有健康的後端;可帶 Retry-After |
| 504 | Gateway Timeout | Proxy 等上游回應逾時:上游太慢、DB 慢查詢、timeout 設定不一致 |
502 vs 504 是排查高頻題:502 = 上游回了「壞的」東西或連線被斷(看上游是否重啟、OOM、是否關閉了 proxy 正在重用的 keep-alive 連線 —— 上游 idle timeout 應大於 proxy 的 upstream keepalive timeout);504 = 上游「太慢」(看上游延遲、proxy 的 proxy_read_timeout)。
快取相關 headers:Cache-Control 與 ETag
Cache-Control: max-age=3600- 回應在 3600 秒內是新鮮的,可直接用快取。
s-maxage只對共享快取(CDN、proxy)生效。 no-cache- 可以存,但每次使用前都要向伺服器驗證(revalidate)。常被誤以為是「不快取」。
no-store- 完全不可儲存(敏感資料)。
private/publicprivate只能存在瀏覽器(含個人化內容),不能被 CDN 快取。stale-while-revalidate=60- 過期後 60 秒內可先回舊資料,同時背景更新,降低尾端延遲。
ETag/If-None-Match- 伺服器給資源一個版本指紋;client 下次帶
If-None-Match: "abc",未變則回304 Not Modified,省頻寬。Last-Modified/If-Modified-Since是以時間為準的版本。If-Match則用於寫入時的樂觀鎖(不符回 412)。 Vary: Accept-Encoding- 告訴快取:不同的
Accept-Encoding要分開存(gzip 與未壓縮版本)。Vary 設太多(如Vary: Cookie)會讓 CDN 命中率崩潰。
靜態資源最佳實務:檔名加 hash(app.3f9a1c.js)+ Cache-Control: public, max-age=31536000, immutable;HTML 則用 no-cache 確保拿到新版本的引用。
CORS(跨來源資源共享)
瀏覽器的同源政策(scheme + host + port 相同)預設禁止 JS 讀取跨來源回應。CORS 是伺服器用 header 告訴瀏覽器「哪些來源可以讀」。注意:CORS 是瀏覽器的保護機制,curl 或後端呼叫不受影響,它也不是伺服器端的存取控制。
- 簡單請求(GET/HEAD/POST,且 Content-Type 僅限
text/plain、application/x-www-form-urlencoded、multipart/form-data、無自訂 header):直接送出,瀏覽器檢查回應的Access-Control-Allow-Origin。 - Preflight:其他情況(如
Content-Type: application/json、Authorizationheader、PUT/DELETE),瀏覽器先送OPTIONS帶Access-Control-Request-Method/-Headers,伺服器回應允許後才送真正請求。用Access-Control-Max-Age快取 preflight 結果,減少一個 RTT。 - 帶 cookie 的跨來源請求需
Access-Control-Allow-Credentials: true,且此時Allow-Origin不能是*,必須是明確的來源(並加Vary: Origin)。
OPTIONS /v1/events HTTP/1.1
Origin: https://dashboard.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://dashboard.example.com
Access-Control-Allow-Methods: GET, POST
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
Vary: Origin
Cookies 與 SameSite
Set-Cookie: sid=abc123; Path=/; Domain=example.com; Max-Age=86400; Secure; HttpOnly; SameSite=Lax
Secure- 只透過 HTTPS 傳送。
HttpOnly- JS(
document.cookie)讀不到,降低 XSS 偷 session 的風險。 SameSite=Strict- 任何跨站請求都不帶 cookie(從外部連結點進來也不帶,體驗較差)。
SameSite=Lax- 跨站的「頂層導覽 GET」(點連結)會帶,跨站的 POST、iframe、fetch 不帶。主流瀏覽器在未指定時預設以 Lax 處理,是防 CSRF 的第一道防線。
SameSite=None- 跨站也帶,必須同時設
Secure。第三方情境(嵌入式 widget、廣告追蹤)需要。
AdTech 長期依賴第三方 cookie做跨站的使用者識別、frequency capping、轉換歸因(cookie syncing)。Safari(ITP)與 Firefox 已預設封鎖第三方 cookie,Chrome 的政策也數度調整,產業因此轉向第一方資料、server-side tracking、隱私沙盒類 API 與 ML 建模推估。面試時若能說出 SameSite=None; Secure 與第三方 cookie 限制對廣告產業的影響,會是很好的領域加分(詳見 第 14 章)。
HTTPS 與 TLS、即時通訊協定 進階
TLS 提供什麼
- 機密性:對稱加密(AES-GCM、ChaCha20-Poly1305)保護資料。
- 完整性:AEAD 演算法同時提供驗證,資料被竄改會被發現。
- 身分驗證:透過憑證確認「我連到的真的是 example.com」,防止中間人攻擊(MITM)。
核心思路:用非對稱密碼學(ECDHE 金鑰交換 + RSA/ECDSA 簽章)在不安全的通道上安全地協商出一把對稱的 session key,之後的資料都用對稱加密(快很多)。
TLS 1.2 與 TLS 1.3 的交握
| 面向 | TLS 1.2 | TLS 1.3 |
|---|---|---|
| 完整交握 | 2 RTT | 1 RTT |
| 恢復連線 | Session ID / Session Ticket,1 RTT | PSK,1 RTT;或 0-RTT(early data,有重放風險,只能用於冪等請求) |
| 金鑰交換 | 可用 RSA 金鑰交換(無前向保密)或 (EC)DHE | 只允許 (EC)DHE,強制前向保密(forward secrecy) |
| Cipher suites | 數量多、包含不安全的(CBC、RC4、SHA-1 等組合) | 只剩少數 AEAD 套件 |
| 憑證是否加密傳送 | 否,明文 | 是,ServerHello 之後全部加密 |
前向保密(Forward Secrecy):每次連線用臨時的 DH 金鑰產生 session key,即使伺服器私鑰日後外洩,過去錄下來的流量也無法解密。RSA 金鑰交換是用伺服器公鑰直接加密 pre-master secret,私鑰一旦外洩所有歷史流量都能解開,因此 TLS 1.3 移除了它。
憑證鏈(Certificate Chain)
由 Intermediate CA 簽署"] --> I["Intermediate CA 憑證
由 Root CA 簽署"] I --> R["Root CA 憑證(自簽)
內建於 OS / 瀏覽器信任庫"]
- 伺服器送出 leaf 憑證 + 中繼憑證(伺服器必須送完整的中繼鏈;只送 leaf 是常見設定錯誤,部分 client 會驗證失敗)。
- Client 逐層驗證簽章,直到找到自己信任庫中的 root CA。
- 檢查有效期限、網域是否符合(
Subject Alternative Name,含 wildcard*.example.com)、是否被撤銷(OCSP / CRL;OCSP stapling 讓伺服器代為附上撤銷狀態)。 - 驗證伺服器擁有對應私鑰(TLS 1.3 中透過 CertificateVerify 簽章)。
SNI(Server Name Indication):ClientHello 中帶上要連的網域名稱,讓同一個 IP 上的伺服器 / LB 選擇正確的憑證。SNI 在 ClientHello 中是明文(ECH 正在推動加密它)。
# 檢查憑證鏈、有效期限、協商的協定版本
openssl s_client -connect api.example.com:443 -servername api.example.com -showcerts </dev/null
echo | openssl s_client -connect api.example.com:443 -servername api.example.com 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates -ext subjectAltName
curl -sv --tlsv1.3 https://api.example.com -o /dev/null 2>&1 | grep -E 'SSL connection|ALPN|expire'
mTLS(雙向 TLS)
一般 TLS 只有 client 驗證 server;mTLS 中 server 也要求 client 出示憑證(CertificateRequest),雙方互相驗證。用途:
- 服務對服務的零信任通訊:Service mesh(Istio、Linkerd)由 sidecar 自動發放短效憑證並做 mTLS,應用程式不用改。
- B2B API:合作夥伴以 client 憑證認證(某些 ad exchange、金融 API 會要求)。
- 挑戰:憑證發放、輪替、撤銷的管理成本;短效憑證 + 自動化(cert-manager、SPIFFE/SPIRE)是主流做法。
server {
listen 443 ssl;
http2 on; # Nginx 1.25.1+ 寫法;舊版為 listen 443 ssl http2;
server_name api.example.com;
ssl_certificate /etc/nginx/tls/fullchain.pem; # leaf + intermediate
ssl_certificate_key /etc/nginx/tls/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:50m; # session 恢復,降低重複交握成本
ssl_session_timeout 1h;
# mTLS:要求並驗證 client 憑證
ssl_client_certificate /etc/nginx/tls/partner-ca.pem;
ssl_verify_client on;
location / {
proxy_set_header X-Client-DN $ssl_client_s_dn;
proxy_pass http://app;
}
}
「HTTPS 怎麼運作?」→ 非對稱協商、對稱加密;憑證鏈驗證身分防 MITM;TLS 1.3 比 1.2 少一個 RTT、只留前向保密的金鑰交換、憑證加密傳送;0-RTT 的重放風險。追問「TLS 終止(termination)應該放在哪?」→ 通常在 LB / Ingress 終止以集中管理憑證並讓 L7 LB 能看到 HTTP 內容,內網再視需求用 mTLS 或明文;代價是 LB 的 CPU 負擔,以及內網流量是否需要加密的合規考量。
延伸:即時通訊 WebSocket、SSE 與 Long Polling
| 面向 | Short polling | Long polling | SSE(Server-Sent Events) | WebSocket |
|---|---|---|---|---|
| 方向 | Client 拉 | Client 拉(伺服器有事才回) | Server → Client 單向 | 雙向全雙工 |
| 協定 | HTTP | HTTP | HTTP(text/event-stream) | HTTP Upgrade 後換成 WebSocket frame |
| 延遲 | 最多一個輪詢間隔 | 接近即時 | 即時 | 即時 |
| 伺服器成本 | 大量無效請求 | 每個 client 一個掛著的請求,回應後要重新建立 | 長連線 | 長連線,有狀態 |
| 自動重連 | — | 自行實作 | 內建(EventSource + Last-Event-ID) | 自行實作 |
| 資料格式 | 任意 | 任意 | UTF-8 文字 | 文字或二進位 |
| Proxy / LB 相容性 | 最好 | 好(注意 timeout) | 好(要關閉 proxy buffering) | 需支援 Upgrade,長連線影響 LB 平衡與部署 |
| 適用 | 低頻狀態查詢 | 舊環境的通知 | 通知、即時儀表板、LLM 串流輸出、進度 | 聊天、協作編輯、遊戲、雙向互動 |
GET /chat HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
# FastAPI 的 SSE:每秒推送一次即時競價統計
import asyncio, json
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
async def stats_stream():
i = 0
while True:
i += 1
payload = json.dumps({"seq": i, "qps": 12000 + i})
yield f"id: {i}\nevent: stats\ndata: {payload}\n\n" # SSE 格式:空行分隔事件
await asyncio.sleep(1)
@app.get("/stats/stream")
async def stream():
return StreamingResponse(stats_stream(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
長連線類(WebSocket、SSE)在 Nginx 後面要:proxy_http_version 1.1、WebSocket 轉送 Upgrade/Connection header、SSE 關閉 proxy_buffering、把 proxy_read_timeout 調長或定期送心跳。部署時,舊的 Pod 上掛著大量長連線,需要 graceful drain,且 client 重連時要加隨機 jitter,避免所有 client 同時重連造成「重連風暴」。
DNS 細節與 CDN 原理 進階
常見 DNS record types
| Type | 用途 | 範例 |
|---|---|---|
| A / AAAA | 網域 → IPv4 / IPv6 | api.example.com. 60 IN A 203.0.113.10 |
| CNAME | 別名指向另一個網域(不可與其他記錄並存,zone apex 不能用) | www → example.cdn-provider.net. |
| ALIAS / ANAME | 供應商擴充,讓 zone apex 也能「像 CNAME 一樣」指向(如 Route 53 Alias) | example.com → ALB |
| NS | 這個 zone 由哪些權威伺服器負責 | ns1.example.com. |
| SOA | Zone 的起始授權資訊(serial、負向快取 TTL 等) | — |
| MX | 郵件伺服器 | 10 mail.example.com. |
| TXT | 任意文字:SPF、DKIM、網域驗證 | "v=spf1 include:_spf.google.com ~all" |
| SRV | 服務的 host + port(K8s headless service、SIP) | _http._tcp.svc 10 5 8080 host. |
| PTR | 反解:IP → 網域 | — |
| CAA | 限制哪些 CA 可以為此網域發憑證 | 0 issue "letsencrypt.org" |
| HTTPS / SVCB | 宣告支援的協定(如 h3)與連線參數 | — |
DNS 主要走 UDP 53;回應過大(超過 EDNS0 協商的大小時會被截斷,設定 TC 位元)或 zone transfer 時改用 TCP。隱私需求可用 DoH(DNS over HTTPS)/ DoT(DNS over TLS)。
TTL 的取捨
- TTL 短(30~60 秒):切換快(故障轉移、藍綠部署),但查詢量大、解析延遲出現頻率高。
- TTL 長(數小時):快取命中高、對 DNS 服務依賴低,但改 IP 時要等很久才全面生效。
- 實務:計畫遷移前先把 TTL 調短、等舊 TTL 過期,再切換,之後調回。
- 陷阱:有些 client 不遵守 TTL(舊版 JVM 預設在有 security manager 時永久快取、某些 HTTP client 連線池長期持有舊 IP 的連線)。後端 IP 改變後,長連線並不會因 DNS 改變而自動斷開,需要連線有最大存活時間。
- 負向快取:NXDOMAIN 也會依 SOA 的設定被快取,網域剛建好前就被查過的話可能要等一段時間。
DNS-based Load Balancing 與 GeoDNS
- DNS round robin
- 同一網域回傳多個 A 記錄,client 通常取第一個(順序輪替)。簡單但沒有健康檢查,且受快取影響,分配不均、故障節點無法即時摘除。
- GeoDNS / Latency-based routing
- 權威 DNS 依查詢來源(resolver 的 IP,或 EDNS Client Subnet 帶來的 client 子網段)回傳最近 / 延遲最低區域的 IP。例如 Route 53 的 geolocation / latency routing。
- Health-checked failover
- DNS 服務自己做健康檢查,故障區域的記錄自動撤下(仍受 TTL 影響)。
- Anycast
- 同一個 IP 由多個地點透過 BGP 宣告,網路自然把封包送到「路由上最近」的節點。DNS 根伺服器、1.1.1.1、各大 CDN 都用 anycast。與 GeoDNS 不同,它在網路層運作,切換不受 DNS TTL 影響。
CDN 原理
- 導流:透過 CNAME 到 CDN 網域 + GeoDNS,或 anycast IP,把使用者導到最近的 PoP(Point of Presence)。
- 快取:依
Cache-Control/s-maxage、cache key(預設 host + path + query,可自訂忽略某些 query 參數或加入 header)決定是否命中。 - 回源:未命中時向 origin 取得;多層架構(origin shield)讓同一物件只有一個節點回源,避免 origin 被大量 miss 打爆(也可合併同時的相同請求,request collapsing)。
- 失效:等 TTL 過期,或主動 purge / invalidate;最可靠的是檔名帶版本 hash,不需 purge。
- 額外功能:TLS 終止(離使用者近,交握 RTT 短)、HTTP/2/3、壓縮、DDoS 吸收、WAF、edge computing(Cloudflare Workers、Lambda@Edge)。
廣告素材(圖片、影片、HTML5 creative)、追蹤像素、JS SDK 都非常適合由 CDN 提供:全球大量曝光請求若都回源,origin 頻寬與延遲都無法承受。SDK 的版本更新要用版本化路徑或短 TTL 的入口檔,否則使用者瀏覽器與 CDN 會長時間持有舊版。追蹤 / 曝光回報(beacon)則是「寫入型」請求,不能快取,通常由就近的 edge 或區域收集點接收後批次送回資料管線(見 第 12 章)。
負載平衡:L4 vs L7、演算法、健康檢查與 Draining 高頻 深入
L4 與 L7 負載平衡
| 面向 | L4(傳輸層) | L7(應用層) |
|---|---|---|
| 看得到什麼 | IP、port、TCP/UDP | 完整 HTTP:URL、header、cookie、method、body |
| 連線處理 | 轉發封包或 TCP 連線(可 NAT、DSR,不一定終止連線) | 終止 client 連線,另外對後端建立連線(兩段連線) |
| 分流粒度 | 以「連線」為單位 | 以「請求」為單位(同一連線的不同請求可送不同後端) |
| 功能 | 簡單、快、低延遲、協定無關 | 依路徑 / host / header 路由、TLS 終止、改寫 header、重試、快取、壓縮、限流、認證、A/B 分流 |
| 效能成本 | 低,可達極高 PPS(可由 kernel / XDP / 硬體處理) | 較高(解析 HTTP、TLS 加解密) |
| 代表 | LVS/IPVS、AWS NLB、GCP Network LB、HAProxy mode tcp、Maglev、Katran | Nginx、HAProxy mode http、Envoy、AWS ALB、K8s Ingress |
大型系統常見的組合是兩層:最前面 anycast / DNS → L4 LB(扛流量、高可用)→ 一群 L7 proxy(Nginx / Envoy,做路由與 TLS)→ 應用服務。L4 LB 的 DSR(Direct Server Return) 模式讓回應直接從後端回 client,不經過 LB,適合回應遠大於請求的流量。
長連線 + L4 LB = 不平衡。gRPC / HTTP/2 在一條連線上跑所有請求,L4 LB 只在建立連線時選後端,之後新擴容的 Pod 收不到流量。解法:用 L7 LB(Envoy、Linkerd)以請求為單位分配、client-side load balancing,或設定連線最大存活時間(如 gRPC 的 MAX_CONNECTION_AGE)讓 client 定期重連。
負載平衡演算法
- Round Robin / Weighted Round Robin
- 依序輪流;有權重時依比例分配(Nginx 用 smooth weighted RR,避免連續打同一台)。假設每個請求成本相同、後端效能相同,請求成本差異大時會不平衡。
- Least Connections
- 選目前進行中連線(或請求)數最少的後端,自動適應「慢請求」與「慢機器」。適合請求處理時間差異大的服務。
- Random / Power of Two Choices(P2C)
- 隨機挑兩個後端,選負載較小的那個。理論上已能把最大負載從 O(log n / log log n) 降到 O(log log n),而且不需要全域精確狀態,避免「所有 LB 同時把流量丟給同一台最閒的機器」的羊群效應(herd behavior)。Envoy、gRPC、Finagle 都採用。
- EWMA(Peak EWMA)
- 對每個後端的回應延遲做指數加權移動平均(常再乘上進行中請求數),選分數最低的。對延遲敏感的服務很有效,能自動避開 GC 中或負載高的節點。Linkerd、Finagle 使用;常與 P2C 結合(P2C + EWMA)。
- IP Hash / Hash by key
- 依 client IP、user ID、URL 雜湊選後端,同一 key 固定到同一台(session 黏著、快取區域性)。但一般的
hash % N在 N 改變時幾乎所有 key 都會重新分配。 - Consistent Hashing
- 把後端與 key 都雜湊到一個環上,key 順時針找到第一個節點。增減一個節點時只影響約 1/N 的 key。用虛擬節點(每台機器在環上放多個點)改善分佈不均。變形:Ketama(Nginx
hash ... consistent)、Maglev hashing(查表快、分佈均勻,Google L4 LB)、Rendezvous(HRW)hashing、有界負載一致性雜湊(consistent hashing with bounded loads,避免熱點 key 打爆單台)。適用:快取叢集、有狀態服務、依 user 分片。
import bisect
import hashlib
class ConsistentHashRing:
"""含虛擬節點的一致性雜湊環。"""
def __init__(self, nodes: list[str] | None = None, vnodes: int = 160) -> None:
self.vnodes = vnodes
self._keys: list[int] = [] # 排序後的環上位置
self._ring: dict[int, str] = {} # 位置 -> 實體節點
for n in nodes or []:
self.add(n)
@staticmethod
def _hash(s: str) -> int:
return int.from_bytes(hashlib.md5(s.encode()).digest()[:8], "big")
def add(self, node: str) -> None:
for i in range(self.vnodes):
h = self._hash(f"{node}#{i}")
self._ring[h] = node
bisect.insort(self._keys, h)
def remove(self, node: str) -> None:
for i in range(self.vnodes):
h = self._hash(f"{node}#{i}")
del self._ring[h]
self._keys.remove(h) # O(n),節點變動不頻繁時可接受
def get(self, key: str) -> str:
if not self._keys:
raise LookupError("empty ring")
idx = bisect.bisect_right(self._keys, self._hash(key)) % len(self._keys)
return self._ring[self._keys[idx]]
ring = ConsistentHashRing(["cache-a", "cache-b", "cache-c"])
before = {f"user:{i}": ring.get(f"user:{i}") for i in range(10_000)}
ring.add("cache-d")
moved = sum(before[k] != ring.get(k) for k in before)
print(f"moved {moved / len(before):.1%}") # 約 25%(理想值 1/4),而非 hash % N 的約 75%
import random
class P2CBalancer:
"""Power of Two Choices + 延遲 EWMA 的簡化版。"""
def __init__(self, backends: list[str], alpha: float = 0.3) -> None:
self.alpha = alpha
self.ewma = {b: 0.0 for b in backends} # 平均延遲(ms)
self.inflight = {b: 0 for b in backends}
def _score(self, b: str) -> float:
return (self.ewma[b] or 1.0) * (self.inflight[b] + 1)
def pick(self) -> str:
a, b = random.sample(list(self.ewma), 2)
chosen = a if self._score(a) <= self._score(b) else b
self.inflight[chosen] += 1
return chosen
def done(self, backend: str, latency_ms: float) -> None:
self.inflight[backend] -= 1
self.ewma[backend] = self.alpha * latency_ms + (1 - self.alpha) * self.ewma[backend]
健康檢查(Health Check)
主動(Active)
LB 定期對後端發探測(TCP connect、HTTP GET /healthz),連續失敗 N 次(fall)標記為 down,連續成功 M 次(rise)才恢復。可以在沒有流量時也偵測到故障。
被動(Passive / Outlier detection)
觀察真實流量的結果:一段時間內失敗(連線錯誤、5xx、逾時)達門檻就暫時摘除。Nginx 開源版的 max_fails / fail_timeout 屬於此類(主動健康檢查是 Nginx Plus 功能);Envoy 稱為 outlier detection。
- Liveness vs Readiness:liveness 回答「程式是否卡死,需要重啟嗎」,readiness 回答「現在能接流量嗎」(暖機中、依賴的 DB 斷線、正在 draining 時回 not ready)。不要把下游依賴檢查放進 liveness,否則 DB 一抖動全部 Pod 被重啟,引發雪崩。
- 健康檢查端點要便宜且有意義:太淺(只回 200)偵測不到真正問題;太深(每次查 DB)會在高頻探測下自己變成負載。
- Slow start:剛恢復或新加入的節點快取是冷的,逐步提升權重(HAProxy
slowstart、Nginx Plusslow_start)。 - 所有後端都被判定不健康時,有些 LB 會進入 fail-open / panic mode(全部照樣送),以免健康檢查本身的錯誤造成全面停擺。
Session Affinity(Sticky Session)
讓同一使用者的請求固定送到同一後端,方式有:LB 插入 cookie(HAProxy cookie SRV insert、ALB stickiness)、依 source IP hash、依應用 cookie / header hash。
- 優點:伺服器本機的 session / 快取可以重用;WebSocket 等有狀態連線需要。
- 缺點:負載不均(熱門使用者黏在同一台)、節點故障時 session 遺失、擴縮容時無法立即平衡;source IP hash 在大量使用者藏在同一個 NAT / proxy 後面時會嚴重偏斜。
- 首選設計是讓服務無狀態,把 session 放到 Redis 等共享存放處,或使用簽章的 token(JWT),黏著只作為快取最佳化。
Connection Draining(連線排空)
下線一台後端(部署、縮容、維護)時:LB 停止送新連線 / 請求給它,但讓進行中的請求在一段時間內完成,時間到才強制關閉。AWS ALB/NLB 稱為 deregistration delay(預設 300 秒),HAProxy 可將 server 設為 drain 狀態,K8s 則透過 Pod 進入 Terminating、從 Endpoints 移除,加上應用自身的 graceful shutdown(見 第 3 章 signal 小節)。對 HTTP keep-alive 連線,後端在 draining 時應回 Connection: close(HTTP/2 送 GOAWAY),讓 client 換到其他後端。
Reverse Proxy、Forward Proxy 與 API Gateway
| 類型 | 代表誰 | 典型用途 | 範例 |
|---|---|---|---|
| Forward proxy | 代表 client,伺服器看到的是 proxy | 企業出口管控、快取、匿名、繞過地區限制、讓內網服務透過固定出口 IP 呼叫外部 API | Squid、企業 HTTP proxy、NAT gateway(L3/L4 類比) |
| Reverse proxy | 代表 server,client 看到的是 proxy | 負載平衡、TLS 終止、快取、壓縮、隱藏後端拓樸、靜態檔案 | Nginx、HAProxy、Envoy |
| API Gateway | Reverse proxy + API 管理 | 認證授權(API key、JWT、OAuth)、限流與配額、請求轉換、版本路由、聚合多個服務、計量計費、開發者入口 | Kong、AWS API Gateway、Apigee、Envoy Gateway |
| Service mesh sidecar | 每個服務旁的 proxy | 服務間 mTLS、重試、熔斷、可觀測性(east-west 流量) | Envoy(Istio)、Linkerd proxy |
「L4 和 L7 LB 怎麼選?」→ 需要依內容路由、TLS 終止、以請求為單位平衡(HTTP/2、gRPC)時用 L7;需要極致效能、非 HTTP 協定、或作為最前端扛流量時用 L4;大型系統兩者疊用。「演算法怎麼選?」→ 同質短請求用 RR;處理時間差異大用 least connections;延遲敏感用 P2C + EWMA;需要 key 區域性(快取)用一致性雜湊,並說明虛擬節點與增減節點時只搬移約 1/N。
Nginx 與 HAProxy 實戰設定 高頻
Nginx:反向代理、upstream keepalive、限流、gzip、timeouts
user nginx;
worker_processes auto; # 每個 CPU core 一個 worker
worker_rlimit_nofile 200000; # 每個 worker 的 fd 上限
events {
worker_connections 65535; # 每個 worker 最多連線數(含對上游的連線)
use epoll;
multi_accept on;
}
http {
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" "$http_user_agent" '
'$request_time $upstream_response_time $upstream_addr';
access_log /var/log/nginx/access.log main buffer=64k flush=5s;
sendfile on;
tcp_nopush on; # 搭配 sendfile,填滿封包再送
tcp_nodelay on;
# ---- client 端 timeouts ----
keepalive_timeout 65s; # client keep-alive 閒置逾時
keepalive_requests 1000;
client_header_timeout 10s; # 防 slowloris:header 要在時限內送完
client_body_timeout 10s;
send_timeout 10s;
client_max_body_size 5m;
# ---- gzip ----
gzip on;
gzip_comp_level 5; # 1-9,越高越耗 CPU,5 左右是常見平衡點
gzip_min_length 1024; # 太小的回應壓縮不划算
gzip_types application/json application/javascript text/css text/plain image/svg+xml;
gzip_vary on; # 加上 Vary: Accept-Encoding
gzip_proxied any;
# ---- 限流:以 client IP 為 key,每秒 50 個請求 ----
limit_req_zone $binary_remote_addr zone=per_ip:20m rate=50r/s;
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
limit_req_status 429;
# ---- upstream 與連線池 ----
upstream bidder_backend {
least_conn;
server 10.0.1.11:8080 max_fails=3 fail_timeout=10s;
server 10.0.1.12:8080 max_fails=3 fail_timeout=10s;
server 10.0.1.13:8080 max_fails=3 fail_timeout=10s;
server 10.0.1.14:8080 backup; # 其他都掛掉才使用
keepalive 128; # 每個 worker 保留的閒置上游連線數
keepalive_requests 10000;
keepalive_timeout 60s; # 需「小於」上游服務的 idle timeout,避免 502
}
upstream cache_tier {
hash $arg_user_id consistent; # 一致性雜湊:同 user 到同一台,快取命中高
server 10.0.2.21:8080;
server 10.0.2.22:8080;
keepalive 64;
}
server {
listen 80 backlog=65535 reuseport;
server_name api.example.com;
location /healthz {
access_log off;
return 200 "ok\n";
}
location /bid {
limit_req zone=per_ip burst=100 nodelay; # 允許短暫突發 100 個,超過回 429
limit_conn conn_per_ip 200;
proxy_pass http://bidder_backend;
proxy_http_version 1.1; # 上游 keep-alive 必須用 HTTP/1.1
proxy_set_header Connection ""; # 清掉 Connection: close,才能重用連線
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Request-ID $request_id; # 方便追蹤
# RTB 等低延遲服務:timeout 要短且小於整體延遲預算
proxy_connect_timeout 50ms;
proxy_send_timeout 100ms;
proxy_read_timeout 80ms;
# 失敗時換下一台;預設不會重試非冪等方法(POST),除非加上 non_idempotent
proxy_next_upstream error timeout http_502 http_503;
proxy_next_upstream_tries 2;
proxy_next_upstream_timeout 90ms;
proxy_buffering on;
}
location /ws/ {
proxy_pass http://bidder_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; # WebSocket
proxy_set_header Connection "upgrade";
proxy_read_timeout 1h;
}
}
}
limit_req的語意- 以 leaky bucket 實作:
rate=50r/s代表每 20ms 放行一個;burst=100允許最多 100 個超額請求排隊;加上nodelay則 burst 內的請求立即處理而不排隊延遲,但仍佔用 burst 額度。不加nodelay時超額請求會被延遲到符合速率,對低延遲服務通常不適合。 $binary_remote_addr- 以二進位存放 IP(IPv4 4 bytes),比字串的
$remote_addr省記憶體。若 Nginx 前面還有 LB,要用real_ip模組(set_real_ip_from+real_ip_header X-Forwarded-For)取得真實 client IP,否則所有人都被當成 LB 的 IP 一起限流。 - Upstream keepalive 的三件套
keepalive N+proxy_http_version 1.1+proxy_set_header Connection "",缺一不可。漏掉的話 Nginx 對上游每個請求都新建 TCP 連線,造成大量 TIME_WAIT 與額外延遲。
nginx -t # 檢查設定語法
nginx -s reload # 平滑重載:master 用新設定起新 worker,舊 worker 處理完現有連線後退出
nginx -T | less # 印出完整生效設定(含 include)
HAProxy:frontend / backend、健康檢查、stick table
global
maxconn 200000
log /dev/log local0
stats socket /run/haproxy/admin.sock mode 600 level admin # runtime API
nbthread 8
defaults
mode http
log global
option httplog
option http-keep-alive
option forwardfor # 加上 X-Forwarded-For
timeout connect 1s
timeout client 30s
timeout server 5s
timeout http-request 10s # 防 slowloris
timeout http-keep-alive 10s
retries 2
option redispatch # 重試時允許換到其他 server
frontend fe_https
bind :443 ssl crt /etc/haproxy/certs/ alpn h2,http/1.1
# ---- stick table:以來源 IP 追蹤 10 秒內的請求速率 ----
stick-table type ip size 1m expire 30s store http_req_rate(10s),conn_cur
http-request track-sc0 src
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 1000 }
http-request deny deny_status 429 if { sc_conn_cur(0) gt 200 }
acl is_bid path_beg /bid
acl is_admin path_beg /admin
acl office src 10.10.0.0/16
http-request deny if is_admin !office
http-request set-header X-Request-ID %[uuid()]
use_backend be_bidder if is_bid
default_backend be_web
backend be_bidder
balance leastconn
option httpchk
http-check send meth GET uri /healthz
http-check expect status 200
default-server inter 2s fall 3 rise 2 slowstart 30s maxconn 2000
server bid1 10.0.1.11:8080 check
server bid2 10.0.1.12:8080 check
server bid3 10.0.1.13:8080 check
backend be_web
balance roundrobin
cookie SRV insert indirect nocache # sticky session:LB 插入 cookie
server web1 10.0.3.31:8080 check cookie w1
server web2 10.0.3.32:8080 check cookie w2
listen stats
bind :8404
stats enable
stats uri /stats
stats refresh 5s
# 透過 runtime API 做 connection draining:不再給新連線,既有連線繼續完成
echo "set server be_bidder/bid1 state drain" | socat stdio /run/haproxy/admin.sock
echo "show servers state be_bidder" | socat stdio /run/haproxy/admin.sock
echo "show table fe_https" | socat stdio /run/haproxy/admin.sock # 看 stick table 內容
echo "set server be_bidder/bid1 state ready" | socat stdio /run/haproxy/admin.sock
haproxy -c -f /etc/haproxy/haproxy.cfg # 驗證設定
| 面向 | Nginx | HAProxy |
|---|---|---|
| 定位 | Web server + reverse proxy + 靜態檔案 + 快取 | 專精的 L4/L7 load balancer |
| 健康檢查 | 開源版只有被動(max_fails);主動需 Plus | 開源版即有豐富的主動檢查(HTTP、TCP、agent check) |
| 限流 / 追蹤 | limit_req、limit_conn | stick table(可追蹤任意 key 的計數與速率,並在節點間同步) |
| Runtime 管理 | reload 設定 | runtime API 可動態調整權重、drain、加減 server |
| 可觀測性 | stub_status(較陽春) | 內建詳細 stats 頁面與 Prometheus exporter |
RTB 低延遲情境:Keep-alive、連線池與 HTTP/2 深入
Real-time bidding 中,ad exchange 對 DSP 發出 bid request,DSP 必須在約 100ms(含網路往返)內回應出價,否則視同放棄。扣掉跨區網路的 RTT,留給 bidder 內部的時間常只有數十毫秒,其中還要查使用者特徵、跑模型、計算出價。此時「建立連線」本身就可能吃掉大半預算。
長連線" --> LB["L4 LB / Nginx"] LB -- "upstream keepalive 連線池" --> BD["Bidder 服務"] BD -- "連線池 + pipeline" --> RD[("Redis:頻率上限、預算")] BD -- "gRPC(HTTP/2 多工)" --> ML["模型推論服務"] BD -- "連線池" --> FS[("Feature store")] BD -. "非同步送出(UDP / Kafka)" .-> LOG["Bid log / metrics"]
延遲預算的帳
| 步驟 | 新連線的成本 | 重用連線 |
|---|---|---|
| DNS 解析 | 0~數十 ms(快取未命中時) | 0(已解析且連線已建立) |
| TCP 交握 | 1 RTT | 0 |
| TLS 交握 | 1 RTT(1.3)~2 RTT(1.2),外加非對稱運算的 CPU 成本 | 0 |
| TCP 慢啟動 | cwnd 從初始值成長,大回應需多個 RTT | cwnd 已成長 |
| 伺服器端 TIME_WAIT / port | 短連線大量累積 | 幾乎沒有 |
若 exchange 與 bidder 之間 RTT 為 20ms,新建 TLS 1.2 連線光交握就要約 60ms(TCP 1 RTT + TLS 2 RTT),已用掉一大半的 100ms 預算。所以:
- 對外(exchange → bidder):支援並鼓勵 HTTP keep-alive / HTTP/2,keep-alive timeout 與 max requests 設寬鬆,避免頻繁重建;在靠近 exchange 的區域部署 bidder(降低 RTT)。
- 對內(bidder → 下游):所有下游都用連線池(Redis、DB、HTTP client),池大小依「QPS × 平均下游延遲」(Little's Law)估算並設上限;預先暖機建立連線,避免冷啟動時大量同時建連。
- HTTP/2 / gRPC 多工:少量連線即可承載大量併發請求,降低 fd 與交握成本;但注意 L4 LB 無法分散單一連線的請求,需要 client-side LB 或 L7 proxy。
- 嚴格 timeout 與 deadline 傳遞:每個下游呼叫的 timeout 必須小於剩餘預算,逾時就用預設值或放棄出價(no-bid 回 204),而不是讓整個請求超時。避免在時間已用完時還重試。
- TCP 層:
TCP_NODELAY、適當的 backlog 與 somaxconn、監控 ListenOverflows 與重傳。 - 非同步旁路:bid log、metrics 用非同步 / 批次送出,不放在請求的關鍵路徑上。
# Python:共用 httpx.AsyncClient 連線池,並設定總逾時預算
import asyncio
import httpx
limits = httpx.Limits(max_connections=200, max_keepalive_connections=100, keepalive_expiry=60)
timeout = httpx.Timeout(0.03, connect=0.01) # 讀取 30ms、建立連線 10ms
client = httpx.AsyncClient(limits=limits, timeout=timeout, http2=True) # http2 需安裝 httpx[http2]
async def score(features: dict) -> float | None:
try:
r = await client.post("https://model.internal/score", json=features)
r.raise_for_status()
return r.json()["ctr"]
except (httpx.TimeoutException, httpx.HTTPError):
return None # 降級:不出價或使用預設值
async def handle_bid(req: dict) -> dict | None:
try:
async with asyncio.timeout(0.06): # Python 3.11+:整體 60ms 預算
ctr = await score(req["features"])
except TimeoutError:
return None
if ctr is None:
return None
return {"price": round(ctr * 1000 * 0.8, 4)}
# 錯誤示範:在每個請求裡 `async with httpx.AsyncClient() as c:`
# —— 每次都建立新連線池 = 每次都重新做 TCP + TLS 交握
// Go:自訂 Transport,預設 MaxIdleConnsPerHost 只有 2,高併發打同一個 host 時會大量建新連線
var client = &http.Client{
Timeout: 50 * time.Millisecond,
Transport: &http.Transport{
MaxIdleConns: 1000,
MaxIdleConnsPerHost: 200,
IdleConnTimeout: 90 * time.Second,
DialContext: (&net.Dialer{
Timeout: 20 * time.Millisecond,
KeepAlive: 30 * time.Second,
}).DialContext,
ForceAttemptHTTP2: true,
},
}
// 使用後務必讀完並關閉 body,連線才能回到池中重用:
// defer resp.Body.Close(); io.Copy(io.Discard, resp.Body)
以廣告平台常見做法為例:bidder 的 p99 延遲往往由「少數慢的下游呼叫」與「偶發的新建連線」決定,而不是平均值。面試時可以這樣講:「我會先在監控上把延遲拆成 DNS / connect / TLS / TTFB(類似 curl -w 的拆法),確認是否有連線重建;再檢查連線池大小與 Little's Law 是否匹配、timeout 是否層層遞減、是否有在預算用完後的無效重試。」這比單純說「加機器」有說服力得多。
「如何把一個呼叫下游的 API 的延遲降低?」→ 連線重用(keep-alive、連線池、HTTP/2)、就近部署降低 RTT、TLS 1.3 / session resumption、並行呼叫獨立的下游(asyncio.gather)、快取、timeout 與 hedged request(在 p95 時間點對另一台副本發第二個請求,取先回來的)、把非關鍵工作移出關鍵路徑。並說明每一項的代價(連線池佔資源、hedged request 增加下游負載等)。
Socket 程式設計 進階
理解 socket API 能把前面所有概念串起來:listen() 的 backlog 就是 accept queue、connect() 觸發三次交握、close() 送出 FIN、ephemeral port 在 connect() 時分配。
一個多執行緒的 TCP server(含訊息分幀)
TCP 是 byte stream,沒有訊息邊界:一次 send(100 bytes) 對方可能分兩次 recv 才收齊,兩次 send 也可能在一次 recv 中一起到(俗稱「黏包」)。所以應用層協定一定要定義分幀方式:固定長度前綴(length-prefixed)、分隔符號(例如 HTTP header 以 \r\n\r\n 結束、Redis RESP 以 \r\n)、或固定長度訊息。
import socket
import struct
import threading
from concurrent.futures import ThreadPoolExecutor
HEADER = struct.Struct("!I") # 4 bytes 網路位元組序(big-endian)的長度前綴
MAX_MSG = 1 << 20 # 1MB 上限,避免惡意長度造成記憶體爆炸
def recv_exact(conn: socket.socket, n: int) -> bytes:
"""recv 可能只回傳一部分,必須迴圈直到收滿 n bytes。"""
buf = bytearray()
while len(buf) < n:
chunk = conn.recv(n - len(buf))
if not chunk:
raise ConnectionError("peer closed")
buf += chunk
return bytes(buf)
def recv_msg(conn: socket.socket) -> bytes:
(length,) = HEADER.unpack(recv_exact(conn, HEADER.size))
if length > MAX_MSG:
raise ValueError("message too large")
return recv_exact(conn, length)
def send_msg(conn: socket.socket, payload: bytes) -> None:
conn.sendall(HEADER.pack(len(payload)) + payload) # sendall 會處理部分寫入
def handle(conn: socket.socket, addr) -> None:
conn.settimeout(30) # 閒置連線 30 秒後關閉
with conn:
try:
while True:
msg = recv_msg(conn)
send_msg(conn, msg.upper())
except (ConnectionError, socket.timeout, ValueError):
pass # 對端關閉 / 逾時 / 格式錯誤
def serve(host: str = "0.0.0.0", port: int = 9000) -> None:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv:
# 允許重啟時立即重新 bind(即使舊連線還在 TIME_WAIT)
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
srv.bind((host, port))
srv.listen(1024) # backlog:accept queue 長度,實際上限為 min(1024, somaxconn)
# 用 thread pool 限制併發,避免「一連線一 thread」無上限地建立 thread
with ThreadPoolExecutor(max_workers=256) as pool:
while True: # accept loop
conn, addr = srv.accept()
conn.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
pool.submit(handle, conn, addr)
if __name__ == "__main__":
threading.Thread(target=serve, daemon=True).start()
import time; time.sleep(0.2)
with socket.create_connection(("127.0.0.1", 9000), timeout=2) as c:
send_msg(c, b"hello appier")
print(recv_msg(c)) # b'HELLO APPIER'
重要的 socket 選項與細節
backloglisten()的參數,accept queue 長度上限,實際值受net.core.somaxconn截斷。accept 太慢導致佇列滿時,新連線的 ACK 會被丟棄(或回 RST,依tcp_abort_on_overflow),client 端表現為連線延遲或失敗。SO_REUSEADDR- 允許 bind 到仍有 TIME_WAIT 連線的位址,server 重啟必設,否則會遇到
Address already in use。 SO_REUSEPORT- 允許多個 socket(多個 process / thread)bind 同一個 port,由 kernel 以雜湊把新連線分散給它們,減少單一 accept queue 的競爭與驚群。Nginx
listen ... reuseport。 TCP_NODELAY- 關閉 Nagle,低延遲 RPC 必設。
SO_KEEPALIVE+TCP_KEEPIDLE/TCP_KEEPINTVL/TCP_KEEPCNT- 偵測半開連線。
SO_LINGER- 設 linger 為 0 時
close()直接送 RST 而非 FIN,不會進入 TIME_WAIT。可以避免 TIME_WAIT,但會丟棄未送出的資料,一般不建議。 SO_RCVBUF/SO_SNDBUF- 手動設定 buffer 會關閉 kernel 的自動調整,除非確定需要,否則交給 auto-tuning。
這個 thread pool 版本適合理解概念;真正要扛上萬連線,應改用 event loop(asyncio.start_server,底層是 epoll,見 第 3 章)或 Go 的 goroutine-per-connection。另外別忘了 accept() 回傳的每個 socket 都是一個 fd,記得 ulimit -n。
網路排查工具箱與常見攻擊 高頻
curl -w:把延遲拆開
cat > curl-format.txt <<'EOF'
dns_lookup: %{time_namelookup}s\n
tcp_connect: %{time_connect}s\n
tls_handshake: %{time_appconnect}s\n
pre_transfer: %{time_pretransfer}s\n
ttfb: %{time_starttransfer}s\n
total: %{time_total}s\n
http_code: %{http_code}\n
http_version: %{http_version}\n
remote_addr: %{remote_ip}:%{remote_port}\n
EOF
curl -s -o /dev/null -w @curl-format.txt https://api.example.com/v1/health
這些時間都是從請求開始累計的,所以要相減才是各階段耗時:
| 階段 | 計算 | 偏高時懷疑 |
|---|---|---|
| DNS | time_namelookup | resolver 慢、沒有快取、search domain 造成多次查詢(K8s ndots:5 經典問題) |
| TCP 交握 | time_connect - time_namelookup | RTT 高(距離遠)、SYN 被丟(accept queue 滿、防火牆)導致重傳 |
| TLS 交握 | time_appconnect - time_connect | TLS 1.2、憑證鏈長、OCSP 查詢、伺服器 CPU 不足 |
| 伺服器處理(TTFB) | time_starttransfer - time_pretransfer | 應用慢、DB 慢、上游慢 |
| 內容傳輸 | time_total - time_starttransfer | 回應太大、頻寬、慢啟動、沒壓縮 |
curl -v https://api.example.com/ 2>&1 | less # 完整的交握與 header 過程
curl -I https://www.example.com/ # 只看 response header(HEAD)
curl --http2 -sI https://www.example.com/ | head -1 # 確認 HTTP/2
curl --resolve api.example.com:443:10.0.1.11 https://api.example.com/healthz # 繞過 DNS 打指定後端
curl -H 'Host: api.example.com' http://10.0.1.11/healthz # 以 Host header 測 virtual host
for i in $(seq 1 20); do curl -s -o /dev/null -w '%{time_total}\n' https://api.example.com/; done | sort -n
dig:DNS 排查
dig api.example.com # 完整回應:ANSWER、TTL、查詢時間、使用的 server
dig +short api.example.com A
dig api.example.com @1.1.1.1 # 指定 resolver,比較不同 resolver 的結果
dig +trace api.example.com # 從 root 開始逐層迭代,找出哪一層出錯
dig example.com NS # 查其他類型:NS、MX、TXT、CAA…
dig example.com TXT +short
dig -x 203.0.113.10 # 反解
cat /etc/resolv.conf # 確認 nameserver、search、options ndots
tcpdump:看封包說話
# 觀察三次交握與揮手
tcpdump -i any -nn 'host 10.0.1.11 and port 8080 and (tcp[tcpflags] & (tcp-syn|tcp-fin|tcp-rst) != 0)'
# 找出 RST(連線被拒絕或被重置)
tcpdump -i eth0 -nn 'tcp[tcpflags] & tcp-rst != 0'
# 錄下來用 Wireshark 分析(限制大小與數量,避免塞爆磁碟)
tcpdump -i eth0 -nn -s 0 -C 100 -W 5 -w /tmp/cap.pcap port 443
# DNS 查詢
tcpdump -i any -nn port 53
常見判讀:只有 SYN 沒有 SYN-ACK → 對方沒收到或被防火牆擋 / accept queue 滿;立刻收到 RST → port 沒在 listen 或被主動拒絕;大量重傳 → 網路丟包或對端過載;win 0 → 接收方應用讀太慢。
mtr、ss -s 與其他
mtr -rwzc 100 api.example.com # traceroute + ping 的結合,報表模式跑 100 次,看每一跳的丟包與延遲
# 注意:中間某一跳顯示丟包但後面各跳正常,通常只是該路由器限制 ICMP 回應,不是真的丟包
ss -s # 連線總覽:estab、timewait、orphaned 數量
ss -tin state established dst 10.0.1.11 # 每條連線的 rtt、cwnd、retrans
ip -s link show eth0 # 網卡 RX/TX errors、dropped
ethtool -S eth0 | grep -i -E 'drop|err|miss'
nc -zv 10.0.1.11 8080 # 快速測 port 是否可連
ping -c 5 10.0.1.11 # 基本連通與 RTT(ICMP 可能被擋)
排查流程:「API 變慢 / 間歇失敗」
- 界定範圍:所有 client 還是特定區域?所有後端還是特定節點?何時開始?是否有部署或設定變更?
- 拆延遲:用
curl -w或 APM 的 trace 把時間拆成 DNS / connect / TLS / server / transfer。 - 網路層:
mtr看路徑丟包;ss -tin看重傳與 RTT;nstat看 ListenOverflows、TCP 重傳率。 - 連線層:
ss -s看 TIME_WAIT / CLOSE_WAIT 是否異常;連線池是否耗盡(應用端等待取得連線的時間)。 - Proxy 層:Nginx log 的
$request_timevs$upstream_response_time(兩者差距大 = client 慢或 Nginx 本身排隊);502/504/499 比例。 - 應用與下游:profiling、慢查詢、下游延遲。
常見網路攻擊與防護
| 攻擊 | 原理 | 防護 |
|---|---|---|
| DDoS(L3/L4) | 以大量流量(UDP flood、放大攻擊如 DNS/NTP/memcached amplification)或 SYN flood 耗盡頻寬或連線表 | Anycast + CDN / 雲端清洗服務吸收、SYN cookies、上游 ACL、rate limit、關閉不必要的 UDP 服務 |
| DDoS(L7) | 大量看似正常的 HTTP 請求打昂貴的 endpoint;Slowloris 以極慢速度送 header 佔住連線 | WAF、依 IP / token 限流(Nginx limit_req、HAProxy stick table)、challenge、快取、client_header_timeout |
| CSRF(跨站請求偽造) | 使用者已登入 A 站,惡意 B 站誘導瀏覽器對 A 送出請求,瀏覽器自動附上 A 的 cookie | SameSite=Lax/Strict cookie、CSRF token(synchronizer / double submit)、檢查 Origin/Referer、改用 Authorization header 帶 token(瀏覽器不會自動附加)、狀態變更不用 GET |
| XSS(跨站腳本) | 攻擊者的腳本被注入頁面並在受害者瀏覽器執行(stored、reflected、DOM-based),可偷 cookie、冒用身分 | 輸出時依情境 escape(HTML、屬性、JS、URL)、使用自動 escape 的模板與框架、Content-Security-Policy、cookie 設 HttpOnly、不信任任何使用者輸入 |
| SQL Injection | 把使用者輸入直接拼接進 SQL,讓攻擊者改變查詢語意 | 參數化查詢 / prepared statement、ORM、最小權限 DB 帳號、輸入驗證。詳見 第 5 章 |
| SSRF | 誘使伺服器向內部位址發請求(例如雲端 metadata 169.254.169.254) | 白名單目標、阻擋內網與 metadata IP、AWS IMDSv2 |
| MITM | 攔截或竄改明文流量 | 全面 HTTPS、HSTS、憑證驗證不可關閉、內網 mTLS |
廣告產業還有一個獨特的「攻擊」:廣告詐欺(ad fraud),例如機器人流量刷曝光與點擊、偽造 app 安裝。防護會結合網路層訊號(IP 信譽、資料中心 IP、異常請求速率)與行為模型,這也是 ML 在 AdTech 的重要應用。另外接收 SDK / 追蹤像素回報的公開端點本質上是「任何人都能呼叫的寫入 API」,必須有限流、簽章驗證與異常偵測。
面試自測題
請說明 TCP 三次交握的過程,為什麼不是兩次?
Client 送 SYN(seq=x)進入 SYN_SENT;server 回 SYN+ACK(seq=y, ack=x+1)進入 SYN_RCVD,連線放在 SYN queue;client 回 ACK(ack=y+1),雙方 ESTABLISHED,server 端連線移入 accept queue 等應用呼叫 accept。不是兩次的理由:(1) TCP 雙向各有序號,server 的 ISN 也需要被確認,否則 server 不知道 client 是否收到;(2) 防止網路中滯留的舊 SYN 到達後建立無效連線 —— 三次交握時 client 會發現 SYN+ACK 不對應自己的連線而回 RST;(3) 確認雙向通道可用。SYN 與 ACK 合併,所以是三次而非四次。追問方向:SYN queue / accept queue、SYN flood 與 SYN cookies、第三個 ACK 可以帶資料、TCP Fast Open。
四次揮手為什麼需要四次?TIME_WAIT 為什麼要等 2MSL?
TCP 全雙工,兩個方向要分別關閉:主動方送 FIN 只代表自己不再送資料,被動方先回 ACK(進入 CLOSE_WAIT),可能還有資料要送,等應用呼叫 close 才送自己的 FIN(LAST_ACK),主動方回最後 ACK 進入 TIME_WAIT。若被動方沒有資料,ACK 與 FIN 可合併。TIME_WAIT 等 2MSL(Linux 固定 60 秒)的理由:(1) 最後一個 ACK 若遺失,對方會重送 FIN,需要還能回 ACK;(2) 讓舊連線的延遲封包在網路中消失,避免干擾使用相同四元組的新連線。
線上機器出現大量 TIME_WAIT,該怎麼分析和處理?
TIME_WAIT 出現在主動關閉方。先用 ss -tan state time-wait 看對端分佈,通常是服務對某個下游(Redis、DB、HTTP API)使用短連線,或 Nginx 對 upstream 沒開 keepalive。影響主要是 ephemeral port 耗盡(Cannot assign requested address),記憶體影響不大。處理:(1) 治本 —— 連線池 / HTTP keep-alive,Nginx 要 keepalive + proxy_http_version 1.1 + 清空 Connection header;(2) 擴大 ip_local_port_range;(3) tcp_tw_reuse=1(僅 outbound);(4) 不使用已移除且在 NAT 下有害的 tcp_tw_recycle。若 TIME_WAIT 在 server 端且是對外部 client,通常不需處理。
大量 CLOSE_WAIT 代表什麼?
代表對端已經關閉連線(送了 FIN,kernel 已回 ACK),但本機應用程式沒有呼叫 close。CLOSE_WAIT 沒有逾時,會一直佔用 fd,最後造成 Too many open files。這幾乎都是程式 bug:例外路徑沒關連線、連線池借出未歸還、沒處理 EOF、HTTP response body 沒關閉、處理 thread 卡死。排查用 ss -tanp state close-wait 找到 process 與對端,對照程式中與該對端互動的程式碼,必要時 dump thread stack。調 kernel 參數無效。
流量控制和擁塞控制有什麼不同?說明慢啟動、擁塞避免、快速重傳。
流量控制保護接收方:接收方在 ACK 中通告 rwnd,傳送方在途資料不超過它(滑動視窗)。擁塞控制保護網路:傳送方維護 cwnd,實際可送量為 min(cwnd, rwnd)。慢啟動:cwnd 從初始值(Linux 為 10 MSS)開始,每 RTT 加倍直到 ssthresh;擁塞避免:之後每 RTT 加 1 MSS;快速重傳:收到 3 個重複 ACK 不等逾時立即重傳;快速恢復:ssthresh 與 cwnd 減半後繼續擁塞避免;若發生 RTO 逾時則 cwnd 降到很小重新慢啟動。Linux 預設 CUBIC;BBR 以估計瓶頸頻寬與最小 RTT 控制速率,不以丟包為主要訊號。
什麼是 Nagle 演算法?為什麼低延遲服務要設 TCP_NODELAY?
Nagle 在有未確認資料時,把小 segment 累積到收到 ACK 或湊滿 MSS 才送,以減少小封包的 header 浪費。它與接收端的 delayed ACK 互相等待時,會造成數十毫秒的額外延遲,尤其在「分兩次 write 一個訊息」的請求-回應協定。RPC、Redis/DB client、RTB 等延遲敏感服務會設 TCP_NODELAY 關閉 Nagle(Go 預設開啟 NoDelay)。更好的習慣是應用層把整個訊息組好一次寫出或用 writev。相對地,TCP_CORK 用於傳大檔時刻意湊滿封包。
HTTP/1.1、HTTP/2、HTTP/3 的主要差異是什麼?
HTTP/1.1:文字協定、預設 keep-alive、強制 Host、chunked 傳輸;但一條連線同時只能一個請求(pipelining 有 HOL 且沒被採用),瀏覽器開多條連線。HTTP/2:二進位分幀、單一 TCP 連線上多個 stream 多工、HPACK 壓縮 header、per-stream 流量控制;解決應用層 HOL,但 TCP 層掉一個封包會阻塞所有 stream。HTTP/3:建立在 QUIC(UDP)上,stream 在傳輸層獨立,消除 TCP HOL;內建 TLS 1.3,新連線 1 RTT、恢復可 0-RTT;以 Connection ID 支援網路切換時的連線遷移;使用 QPACK。代價是 UDP 可能被擋、CPU 成本較高。
哪些 HTTP method 是冪等的?冪等性在實務上為什麼重要?
GET、HEAD、OPTIONS(同時也是 safe)以及 PUT、DELETE 是冪等的;POST 不是,PATCH 不保證。冪等代表執行多次的效果與一次相同(回應可以不同,例如第二次 DELETE 回 404)。重要性在於重試:網路逾時時無法知道伺服器是否已執行,只有冪等請求能安全地自動重試,因此 LB 與 proxy(例如 Nginx 的 proxy_next_upstream 預設)不會重試 POST。要讓 POST 可安全重試,使用 idempotency key:伺服器記錄 key 與結果,重複請求回傳相同結果,常用於支付、下單、發送訊息。
502、503、504 分別代表什麼?你會如何排查?
502 Bad Gateway:proxy 從上游得到無效回應或連線被斷,常見原因是上游 crash / OOM / 重啟、上游關閉了 proxy 正在重用的 keep-alive 連線(上游 idle timeout 比 proxy 的 upstream keepalive timeout 短)。503 Service Unavailable:服務暫時無法處理,例如過載保護、維護、沒有健康的後端。504 Gateway Timeout:proxy 在 proxy_read_timeout 內沒等到上游回應,原因是上游慢(慢查詢、下游慢、GC、執行緒池耗盡)或 timeout 設定不一致。排查:看 Nginx error log 的具體訊息(connection refused / reset by peer / upstream timed out)、對照 $upstream_response_time、上游的重啟紀錄與資源用量。
Cache-Control 的 no-cache 與 no-store 有什麼不同?ETag 如何運作?
no-cache 是「可以存,但每次使用前必須向伺服器驗證」;no-store 是「完全不准存」,用於敏感資料。ETag 是伺服器給資源的版本識別;client 之後帶 If-None-Match,若版本相同,伺服器回 304 Not Modified 不帶 body,節省頻寬與伺服器運算。If-Match 則用於寫入時的樂觀並行控制,版本不符回 412。其他重點:max-age 與 s-maxage(CDN)、private 不可被共享快取、stale-while-revalidate,以及靜態檔名帶 hash + immutable 的長效快取策略。
什麼是 CORS?preflight 什麼時候會發生?
瀏覽器的同源政策禁止 JS 讀取不同來源(scheme、host、port 任一不同)的回應,CORS 讓伺服器用 header 宣告哪些來源可以存取。非「簡單請求」時(例如 Content-Type 為 application/json、帶 Authorization 或自訂 header、使用 PUT/DELETE),瀏覽器會先送 OPTIONS preflight,伺服器回應 Allow-Origin/Methods/Headers 後才送正式請求,可用 Access-Control-Max-Age 快取結果。帶 cookie 時需 Allow-Credentials: true,且 Allow-Origin 不能是 *。注意 CORS 只是瀏覽器的機制,不能取代伺服器端的認證授權。
TLS 1.3 相較於 TLS 1.2 有哪些改進?什麼是 mTLS?
TLS 1.3 完整交握從 2 RTT 降為 1 RTT(client 在 ClientHello 中就帶 key_share),恢復連線支援 0-RTT(但 early data 可被重放,只能用於冪等請求);移除 RSA 金鑰交換與不安全的演算法,只保留 (EC)DHE,強制前向保密;ServerHello 之後的訊息(包含憑證)都加密。憑證鏈驗證:leaf 由 intermediate 簽、intermediate 由 root 簽,root 在信任庫中,伺服器要送完整中繼鏈,並檢查 SAN、有效期、撤銷狀態。mTLS 是雙方都出示憑證互相驗證,用於服務間零信任通訊(service mesh)與 B2B API,挑戰在憑證發放與輪替的自動化。
L4 與 L7 負載平衡的差異?各在什麼情況下使用?
L4 只看 IP 與 port,以連線為單位轉發,不解析內容,效能高、延遲低、協定無關,可用 DSR,例如 LVS、NLB、HAProxy tcp mode。L7 終止 client 連線並解析 HTTP,以請求為單位分配,可依 path/host/header 路由、TLS 終止、重試、限流、壓縮,例如 Nginx、Envoy、ALB,成本較高。大型系統常 L4 在前扛流量、L7 在後做路由。HTTP/2 / gRPC 長連線在 L4 LB 下會不平衡,需要 L7 或 client-side LB。
說明一致性雜湊(consistent hashing)以及它解決的問題。
傳統 hash(key) % N 在 N 改變時,幾乎所有 key 都會換節點(3 台變 4 台約 75% 搬移),對快取叢集而言等於全面失效。一致性雜湊把節點與 key 都映射到雜湊環,key 順時針找第一個節點;增減一個節點只影響相鄰區段,約 1/N 的 key 搬移。為解決節點少時分佈不均,每台實體節點放多個虛擬節點,也方便依機器能力調整權重。延伸:Ketama(Nginx hash consistent)、Maglev、rendezvous hashing、有界負載版本避免熱點。應用在分散式快取、有狀態服務分片、LB 的 session 區域性。
Nginx 對後端開 keep-alive 需要哪些設定?忘記設定會有什麼後果?
三件套:upstream 區塊內 keepalive N(每個 worker 保留的閒置連線數)、location 內 proxy_http_version 1.1(預設是 1.0,不支援持久連線)、proxy_set_header Connection ""(清除 Connection: close)。另外 upstream 的 keepalive_timeout 要小於後端服務的 idle timeout,否則後端先關連線、Nginx 剛好拿它送請求,會出現間歇性 502。忘記設定的後果:每個請求都新建 TCP 連線,多出一個 RTT 的延遲,Nginx 端累積大量 TIME_WAIT,甚至 ephemeral port 耗盡。
Nginx 的 limit_req 中 rate、burst、nodelay 分別代表什麼?
limit_req_zone $binary_remote_addr zone=per_ip:20m rate=50r/s 定義以 client IP 為 key、每秒 50 個請求(即每 20ms 一個)的 leaky bucket,zone 存放狀態的共享記憶體。burst=100 允許最多 100 個超額請求排隊;不加 nodelay 時這些請求會被延遲到符合速率才處理,加了 nodelay 則立即處理但佔用 burst 額度,額度用完的請求被拒絕(以 limit_req_status 設為 429)。前面有 LB 時需用 real_ip 模組取得真實 client IP,否則所有請求都被算在 LB IP 上。分散式全域限流則需要 Redis 等共享狀態。
WebSocket、SSE、long polling 怎麼選?
只需要伺服器推送(通知、即時儀表板、LLM 串流)選 SSE:基於普通 HTTP、內建自動重連與 Last-Event-ID、proxy 相容性好,但只能文字、單向。需要雙向低延遲互動(聊天、協作、遊戲)選 WebSocket:HTTP Upgrade 後全雙工、支援二進位,但有狀態長連線讓 LB、部署、擴縮容更複雜,需自行處理重連與心跳。Long polling 用於無法使用前兩者的舊環境,每次事件後都要重建請求,伺服器成本高。所有長連線方案都要注意 proxy 的 read timeout、buffering、graceful drain 與重連 jitter。
使用者反映 API 偶爾很慢,你會怎麼用工具定位是哪一段?
先用 curl -w 把時間拆成 DNS、TCP connect、TLS、TTFB、傳輸(注意是累計值要相減)。DNS 慢 → 看 resolver、TTL、K8s ndots;connect 慢或偶發 1/3 秒 → SYN 重傳,查 accept queue 溢出(nstat ListenOverflows、ss -lnt)、conntrack、防火牆;TLS 慢 → 協定版本、session resumption、CPU;TTFB 慢 → 應用 profiling、下游延遲、連線池等待。輔以 mtr 看路徑丟包、ss -tin 看重傳與 RTT、tcpdump 看 SYN/RST/zero window、Nginx log 比對 request_time 與 upstream_response_time。
在 RTB 這種 100ms 內必須回應的場景,你會如何設計連線管理?
(1) 對 exchange:支援 HTTP keep-alive / HTTP/2,寬鬆的 keep-alive timeout,就近部署降低 RTT;(2) 對內部下游:全部使用連線池,依 Little's Law(併發 = QPS × 延遲)設定池大小並暖機;(3) 使用 HTTP/2 / gRPC 多工減少連線數,搭配 client-side 或 L7 LB;(4) TCP_NODELAY、足夠的 backlog、監控 ListenOverflows 與重傳;(5) 每個下游呼叫設定小於剩餘預算的 timeout,傳遞 deadline,逾時就降級或 no-bid(204),避免預算用完後還重試;(6) 必要時使用 hedged request 降低尾端延遲;(7) log 與 metrics 非同步送出。並說明取捨:池太大浪費資源且可能壓垮下游,hedging 增加負載。
CSRF 和 XSS 的差別?分別怎麼防?
CSRF 是利用瀏覽器自動附帶 cookie 的特性,讓使用者在不知情下對已登入的網站送出請求,攻擊者看不到回應但能觸發動作;防護是 SameSite cookie、CSRF token、檢查 Origin/Referer、狀態變更不使用 GET、改用 Authorization header 帶 token。XSS 是攻擊者的腳本在受害網站的頁面中執行,能讀取頁面內容、冒用身分發請求;防護是依輸出情境 escape、使用自動 escape 的框架、CSP、cookie 設 HttpOnly。注意:XSS 存在時 CSRF token 也可能被偷,所以 XSS 更根本。SQL injection 則用參數化查詢防範。