Chapter 04

網路、HTTP 與負載平衡

Appier 面試實際問過 TCP 三次交握,而職缺加分項列了 Nginx / HAProxy 與 Linux 高效能網路服務。本章從分層模型、「輸入 URL 之後發生什麼事」開始,深入 TCP 狀態機、擁塞控制、HTTP/1.1 → HTTP/2 → HTTP/3、TLS、DNS 與 CDN,再到 L4/L7 負載平衡演算法與 Nginx、HAProxy 實戰設定,最後是 socket 程式設計與網路排查。對於 RTB 這種 100ms 內必須回應的業務,連線管理就是延遲管理。

預估閱讀 80 分鐘

OSI 七層與 TCP/IP 四層模型 基礎

分層的目的是關注點分離:每一層只依賴下一層提供的服務、為上一層提供抽象。面試不太會要你背七層名稱,但會用「L4 vs L7 負載平衡」「這是 L3 問題還是 L7 問題」這類術語,所以要能對應。

OSI 層TCP/IP 層代表協定 / 技術資料單位相關設備 / 元件
7 應用層(Application)應用層HTTP、gRPC、DNS、SMTP、WebSocketMessageL7 LB(Nginx、HAProxy http mode、ALB)、API Gateway、WAF
6 表現層(Presentation)TLS(常被歸在這附近)、編碼、壓縮—
5 會議層(Session)(TCP/IP 中沒有獨立對應)—
4 傳輸層(Transport)傳輸層TCP、UDP、QUIC(建立在 UDP 上)Segment / DatagramL4 LB(LVS、NLB、HAProxy tcp mode)、port
3 網路層(Network)網際網路層IP、ICMP、路由協定(BGP、OSPF)PacketRouter、L3 switch
2 資料連結層(Data Link)網路存取層Ethernet、ARP、VLAN、Wi-FiFrameSwitch、網卡 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 分鐘。好的回答是先給完整骨架,再依面試官興趣往某一段深入。

sequenceDiagram participant B as Browser participant R as Recursive Resolver participant A as Root / TLD / 權威 DNS participant C as CDN Edge participant O as Origin(LB + App) B->>B: 解析 URL、查 HSTS、查瀏覽器 / OS DNS 快取 B->>R: DNS 查詢 www.example.com(遞迴查詢) R->>A: 迭代查詢:Root → .com TLD → 權威 DNS A-->>R: CNAME 到 CDN 網域,再解析出最近的 Edge IP R-->>B: A / AAAA 記錄(含 TTL) B->>C: TCP 三次交握(1 RTT) B->>C: TLS 1.3 交握(1 RTT) B->>C: HTTP GET /(HTTP/2) alt Cache hit C-->>B: 200 + 快取內容 else Cache miss C->>O: 回源請求(通常是保持中的長連線) O-->>C: 200 + Cache-Control C-->>B: 200(並依規則快取) end B->>B: 解析 HTML、建立 DOM/CSSOM、下載子資源、執行 JS、Layout、Paint
  1. URL 解析:判斷 scheme、host、port、path;若輸入的不是 URL 就丟給搜尋引擎。檢查 HSTS 清單,若該網域要求 HTTPS,直接改用 https(避免第一次明文請求被劫持)。
  2. 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 快取。
  3. 建立 TCP 連線:三次交握,花 1 RTT。若有可重用的 keep-alive 連線則跳過。
  4. TLS 交握:TLS 1.3 為 1 RTT(恢復連線可 0-RTT),TLS 1.2 為 2 RTT。協商加密套件、驗證憑證鏈、建立 session key。HTTP/2 透過 TLS 的 ALPN 擴充協商;HTTP/3 則跑在 QUIC 上,傳輸與加密交握合併。
  5. 送出 HTTP 請求:method、path、headers(Host、Cookie、Accept-Encoding、User-Agent…)。
  6. CDN / LB / 應用:請求先到最近的 CDN edge;未命中則回源(origin)。在 origin 端經過 L4/L7 LB → reverse proxy(Nginx)→ 應用伺服器 → 快取 / 資料庫,產生回應。
  7. 回應:status code、headers(Content-Type、Cache-Control、Set-Cookie、Content-Encoding)、body(可能 gzip/br 壓縮、chunked 傳輸)。
  8. 瀏覽器渲染:解析 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)

sequenceDiagram participant C as Client participant S as Server Note over S: LISTEN(已呼叫 listen) C->>S: SYN, seq=x Note over C: SYN_SENT Note over S: SYN_RCVD(放入 SYN queue) S->>C: SYN+ACK, seq=y, ack=x+1 C->>S: ACK, seq=x+1, ack=y+1 Note over C: ESTABLISHED Note over S: ESTABLISHED(移入 accept queue,等待 accept) C->>S: 資料(第三個 ACK 也可攜帶資料)
  1. Client 送 SYN,帶自己的初始序號 x,以及 MSS、window scale、SACK、timestamp 等選項。
  2. Server 回 SYN+ACK:確認收到 x(ack=x+1,SYN 本身佔一個序號),並帶自己的初始序號 y。
  3. Client 回 ACK(ack=y+1)。此時雙方都進入 ESTABLISHED。

Server 端在 kernel 中有兩個佇列:收到 SYN 後放入 SYN queue(半連線),收到第三個 ACK 後移入 accept queue(全連線),應用呼叫 accept() 才從中取出。注意:交握是 kernel 完成的,應用程式不參與,accept() 只是拿走已完成的連線。

為什麼是三次,不是兩次?

  1. 雙方都要確認「對方知道我的初始序號」:TCP 是雙向的,每個方向都有自己的序號空間。Client 的 SYN 由 server 的 ACK 確認;server 的 SYN 必須由 client 的第三個 ACK 確認。只有兩次的話,server 無法知道 client 是否收到它的 ISN,也就不確定自己送出的資料能否被正確排序。SYN 與 ACK 合併到同一個 segment,所以「四次」被壓成三次。
  2. 防止過期的重複 SYN 建立錯誤連線(RFC 793 給的主要理由):假設 client 一個舊的 SYN 在網路中滯留很久才到達 server。若兩次交握就建立連線,server 會為這個早已不存在的請求配置資源並等待資料。三次交握時,client 收到 SYN+ACK 發現 ack 號碼不對應自己目前的連線,會回 RST,錯誤連線就不會建立。
  3. 確認雙向通道都可用:兩次只能證明 client→server 與 server→client 單向各通一次,但 server 不知道自己的訊息有沒有到達。

反過來說,為什麼不用四次?因為 server 的 ACK 與 SYN 可以合併送出,沒有必要分開。

四次揮手(Four-way Teardown)

sequenceDiagram participant A as 主動關閉方(例如 Client) participant B as 被動關閉方(例如 Server) A->>B: FIN, seq=u Note over A: FIN_WAIT_1 B->>A: ACK, ack=u+1 Note over B: CLOSE_WAIT(應用程式該呼叫 close 了) Note over A: FIN_WAIT_2 B-->>A: (仍可繼續傳送剩餘資料) B->>A: FIN, seq=v Note over B: LAST_ACK A->>B: ACK, ack=v+1 Note over A: TIME_WAIT(等待 2MSL) Note over B: CLOSED Note over A: CLOSED

為什麼要四次?因為 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 秒。兩個理由:

  1. 確保最後的 ACK 能被對方收到:若最後一個 ACK 遺失,對方會重送 FIN;此時還在 TIME_WAIT 的一方能再回 ACK。若已經 CLOSED,只能回 RST,對方會看到錯誤。
  2. 讓舊連線的延遲封包在網路中消失:避免相同四元組(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 狀態機

stateDiagram-v2 [*] --> CLOSED CLOSED --> LISTEN: 被動開啟 listen() CLOSED --> SYN_SENT: 主動開啟 connect() / 送 SYN LISTEN --> SYN_RCVD: 收 SYN / 送 SYN+ACK SYN_SENT --> ESTABLISHED: 收 SYN+ACK / 送 ACK SYN_RCVD --> ESTABLISHED: 收 ACK ESTABLISHED --> FIN_WAIT_1: close() / 送 FIN ESTABLISHED --> CLOSE_WAIT: 收 FIN / 送 ACK FIN_WAIT_1 --> FIN_WAIT_2: 收 ACK FIN_WAIT_1 --> CLOSING: 收 FIN / 送 ACK(同時關閉) FIN_WAIT_2 --> TIME_WAIT: 收 FIN / 送 ACK CLOSING --> TIME_WAIT: 收 ACK CLOSE_WAIT --> LAST_ACK: close() / 送 FIN LAST_ACK --> CLOSED: 收 ACK TIME_WAIT --> CLOSED: 等待 2MSL

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 與適用場景

面向TCPUDP
連線需要交握建立連線無連線,直接送
可靠性保證送達、有序、不重複不保證,可能遺失、亂序、重複
資料邊界Byte stream,沒有訊息邊界(應用需自行分幀)保留 datagram 邊界
流量 / 擁塞控制有沒有(應用需自律)
Header20~60 bytes8 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 延長)。沒有強制的 Host header,一個 IP 難以服務多個網站。
  • HTTP/1.1(1997 起,現行規範為 RFC 9110/9112):
    • 持久連線(keep-alive)預設開啟,大幅減少交握成本。
    • 強制 Host header,實現 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.1HTTP/2HTTP/3
傳輸層TCPTCPQUIC(UDP)
格式文字二進位 frame二進位 frame
多工無(一連線一請求)有,單一 TCP 連線有,stream 互相獨立
HOL blocking應用層 + TCP 層TCP 層僅單一 stream 內
Header 壓縮無HPACKQPACK
加密可選瀏覽器實務上必須強制(TLS 1.3 內建)
新連線交握 RTT(含 TLS 1.3)221(恢復時 0)
連線遷移否否是(Connection ID)
flowchart LR subgraph H1["HTTP/1.1:多條連線,各自排隊"] A1["conn1: req1 → resp1 → req4"] A2["conn2: req2 → resp2"] A3["conn3: req3 → resp3"] end subgraph H2["HTTP/2:一條 TCP,多個 stream 交錯"] B1["TCP: s1 s3 s5 s1 s3 ...
掉一個封包,全部 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語意SafeIdempotentRequest 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名稱何時使用 / 注意事項
200OK一般成功
201Created建立成功,搭配 Location header 指向新資源
202Accepted已接受但非同步處理中(例如報表產生、批次匯入)
204No Content成功但無 body(DELETE、RTB 中的「不出價」回應常用 204)
206Partial ContentRange 請求
301 / 308Moved Permanently / Permanent Redirect永久轉址;308 保證不改變 method 與 body(301 可能被 client 改成 GET)
302 / 307Found / Temporary Redirect暫時轉址;307 不改變 method
303See OtherPOST 完成後導向 GET 結果頁(PRG 模式)
304Not Modified條件請求命中,client 用快取,不回 body
400Bad Request格式錯誤、參數驗證失敗
401Unauthorized其實是「未認證」:沒帶或帶錯憑證,應回 WWW-Authenticate
403Forbidden已認證但沒有權限
404Not Found資源不存在(有時為了不洩漏存在與否,也用來代替 403)
405Method Not Allowed需回 Allow header
409Conflict狀態衝突,例如重複建立、樂觀鎖版本不符
412Precondition FailedIf-Match 不符(樂觀並行控制)
413 / 414Content Too Large / URI Too LongBody 或 URL 太大
415Unsupported Media TypeContent-Type 不支援
422Unprocessable Content格式正確但語意驗證失敗(FastAPI 驗證錯誤預設回 422)
429Too Many Requests限流;可帶 Retry-After
499(Nginx 自訂)Client Closed Requestclient 在回應前斷線,常代表上游太慢、client 逾時放棄
500Internal Server Error未處理的例外
502Bad GatewayProxy 從上游收到無效回應:上游 crash、連線被 reset、上游的 keep-alive 逾時比 proxy 短
503Service Unavailable暫時無法服務:過載、維護、沒有健康的後端;可帶 Retry-After
504Gateway TimeoutProxy 等上游回應逾時:上游太慢、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 / public
private 只能存在瀏覽器(含個人化內容),不能被 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、Authorization header、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、廣告追蹤)需要。
Appier 情境

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 的交握

sequenceDiagram participant C as Client participant S as Server Note over C,S: TLS 1.2(完整交握 2 RTT) C->>S: ClientHello(支援的 cipher suites、client random) S->>C: ServerHello + Certificate + ServerKeyExchange + ServerHelloDone C->>S: ClientKeyExchange + ChangeCipherSpec + Finished S->>C: ChangeCipherSpec + Finished C->>S: 應用資料(第 3 個 RTT 才開始) Note over C,S: TLS 1.3(完整交握 1 RTT) C->>S: ClientHello + key_share(直接猜測金鑰交換參數) S->>C: ServerHello + key_share + 加密的 Certificate、CertificateVerify、Finished C->>S: Finished + 應用資料(第 2 個 RTT 就能送)
面向TLS 1.2TLS 1.3
完整交握2 RTT1 RTT
恢復連線Session ID / Session Ticket,1 RTTPSK,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)

flowchart BT L["Leaf 憑證:api.example.com
由 Intermediate CA 簽署"] --> I["Intermediate CA 憑證
由 Root CA 簽署"] I --> R["Root CA 憑證(自簽)
內建於 OS / 瀏覽器信任庫"]
  1. 伺服器送出 leaf 憑證 + 中繼憑證(伺服器必須送完整的中繼鏈;只送 leaf 是常見設定錯誤,部分 client 會驗證失敗)。
  2. Client 逐層驗證簽章,直到找到自己信任庫中的 root CA。
  3. 檢查有效期限、網域是否符合(Subject Alternative Name,含 wildcard *.example.com)、是否被撤銷(OCSP / CRL;OCSP stapling 讓伺服器代為附上撤銷狀態)。
  4. 驗證伺服器擁有對應私鑰(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 pollingLong pollingSSE(Server-Sent Events)WebSocket
方向Client 拉Client 拉(伺服器有事才回)Server → Client 單向雙向全雙工
協定HTTPHTTPHTTP(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 / IPv6api.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.
SOAZone 的起始授權資訊(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 原理

flowchart LR U1["台北使用者"] --> E1["CDN Edge(台北 PoP)"] U2["東京使用者"] --> E2["CDN Edge(東京 PoP)"] E1 -- "miss" --> M["Mid-tier / Origin Shield"] E2 -- "miss" --> M M -- "miss" --> O["Origin(LB + App + 物件儲存 S3/GCS)"]
  1. 導流:透過 CNAME 到 CDN 網域 + GeoDNS,或 anycast IP,把使用者導到最近的 PoP(Point of Presence)。
  2. 快取:依 Cache-Control / s-maxage、cache key(預設 host + path + query,可自訂忽略某些 query 參數或加入 header)決定是否命中。
  3. 回源:未命中時向 origin 取得;多層架構(origin shield)讓同一物件只有一個節點回源,避免 origin 被大量 miss 打爆(也可合併同時的相同請求,request collapsing)。
  4. 失效:等 TTL 過期,或主動 purge / invalidate;最可靠的是檔名帶版本 hash,不需 purge。
  5. 額外功能:TLS 終止(離使用者近,交握 RTT 短)、HTTP/2/3、壓縮、DDoS 吸收、WAF、edge computing(Cloudflare Workers、Lambda@Edge)。
Appier 情境

廣告素材(圖片、影片、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、KatranNginx、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 Plus slow_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 呼叫外部 APISquid、企業 HTTP proxy、NAT gateway(L3/L4 類比)
Reverse proxy代表 server,client 看到的是 proxy負載平衡、TLS 終止、快取、壓縮、隱藏後端拓樸、靜態檔案Nginx、HAProxy、Envoy
API GatewayReverse 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       # 驗證設定
面向NginxHAProxy
定位Web server + reverse proxy + 靜態檔案 + 快取專精的 L4/L7 load balancer
健康檢查開源版只有被動(max_fails);主動需 Plus開源版即有豐富的主動檢查(HTTP、TCP、agent check)
限流 / 追蹤limit_req、limit_connstick 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 內部的時間常只有數十毫秒,其中還要查使用者特徵、跑模型、計算出價。此時「建立連線」本身就可能吃掉大半預算。

flowchart LR EX["Ad Exchange"] -- "HTTP keep-alive / HTTP/2
長連線" --> 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 RTT0
TLS 交握1 RTT(1.3)~2 RTT(1.2),外加非對稱運算的 CPU 成本0
TCP 慢啟動cwnd 從初始值成長,大回應需多個 RTTcwnd 已成長
伺服器端 TIME_WAIT / port短連線大量累積幾乎沒有

若 exchange 與 bidder 之間 RTT 為 20ms,新建 TLS 1.2 連線光交握就要約 60ms(TCP 1 RTT + TLS 2 RTT),已用掉一大半的 100ms 預算。所以:

  1. 對外(exchange → bidder):支援並鼓勵 HTTP keep-alive / HTTP/2,keep-alive timeout 與 max requests 設寬鬆,避免頻繁重建;在靠近 exchange 的區域部署 bidder(降低 RTT)。
  2. 對內(bidder → 下游):所有下游都用連線池(Redis、DB、HTTP client),池大小依「QPS × 平均下游延遲」(Little's Law)估算並設上限;預先暖機建立連線,避免冷啟動時大量同時建連。
  3. HTTP/2 / gRPC 多工:少量連線即可承載大量併發請求,降低 fd 與交握成本;但注意 L4 LB 無法分散單一連線的請求,需要 client-side LB 或 L7 proxy。
  4. 嚴格 timeout 與 deadline 傳遞:每個下游呼叫的 timeout 必須小於剩餘預算,逾時就用預設值或放棄出價(no-bid 回 204),而不是讓整個請求超時。避免在時間已用完時還重試。
  5. TCP 層:TCP_NODELAY、適當的 backlog 與 somaxconn、監控 ListenOverflows 與重傳。
  6. 非同步旁路: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)
Appier 情境

以廣告平台常見做法為例: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() 時分配。

sequenceDiagram participant S as Server participant C as Client S->>S: socket() → bind(0.0.0.0:9000) → listen(backlog) C->>C: socket() C->>S: connect():kernel 完成三次交握 S->>S: accept() 從 accept queue 取出新的已連線 socket C->>S: send() S->>S: recv() → 處理 S->>C: send() C->>S: close()(送 FIN) S->>S: recv() 回傳空 bytes 代表 EOF → close()

一個多執行緒的 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 選項與細節

backlog
listen() 的參數,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

這些時間都是從請求開始累計的,所以要相減才是各階段耗時:

階段計算偏高時懷疑
DNStime_namelookupresolver 慢、沒有快取、search domain 造成多次查詢(K8s ndots:5 經典問題)
TCP 交握time_connect - time_namelookupRTT 高(距離遠)、SYN 被丟(accept queue 滿、防火牆)導致重傳
TLS 交握time_appconnect - time_connectTLS 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 變慢 / 間歇失敗」

  1. 界定範圍:所有 client 還是特定區域?所有後端還是特定節點?何時開始?是否有部署或設定變更?
  2. 拆延遲:用 curl -w 或 APM 的 trace 把時間拆成 DNS / connect / TLS / server / transfer。
  3. 網路層:mtr 看路徑丟包;ss -tin 看重傳與 RTT;nstat 看 ListenOverflows、TCP 重傳率。
  4. 連線層:ss -s 看 TIME_WAIT / CLOSE_WAIT 是否異常;連線池是否耗盡(應用端等待取得連線的時間)。
  5. Proxy 層:Nginx log 的 $request_time vs $upstream_response_time(兩者差距大 = client 慢或 Nginx 本身排隊);502/504/499 比例。
  6. 應用與下游: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 的 cookieSameSite=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
Appier 情境

廣告產業還有一個獨特的「攻擊」:廣告詐欺(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 則用參數化查詢防範。