100 Day Engineer Challenge
Phase 01 — Backend / CS Fundamentals(Day 1–10)
這份逐日教材依循內部規劃文件的範圍與排序展開1。
本檔案由兩個任務接力完成:本段(T-019)涵蓋 Day 1–5:Networking →DNS → HTTP;Day 6–10(API Design / Reliability / Observability)由 T-020 接續寫在本檔案後半段,不另開新檔。
Day 1–5 的順序依 prerequisite chain2 排列:Networking 是 DNS 與 HTTP 兩條分支共同的前置知識,必須先教;DNS 與 HTTP 之間彼此沒有依賴,但請求生命週期上 DNS 解析先於 HTTP 連線建立,故排在 HTTP 之前。
每個知識點依內部規劃文件的分類標準3標註分類(Core Fundamentals / Supporting Topics / Awareness Topics)與對應的目標精熟等級,並涵蓋 Why / Mechanism / Trade-off / Failure mode / Backend 連結五段式內容4(依 content-standard 精簡合併,但五段不省略)。
Day 1 — Networking I:IP / Port / TCP / UDP / TCP Handshake
學習目標
看完今天內容後,能夠:
- 說明一個封包從應用層資料變成能在網路上傳送的位元組,中間 IP 與 Port 各自負責定位哪一層。
- 畫出 TCP 三次交握的完整流程,並說明每個步驟在防止什麼問題。
- 對照 TCP/UDP 的設計取捨,判斷一個新的應用場景該選哪個協定,並說出理由。
教材大綱
IP(Internet Protocol)
Supporting TopicsLv.3IP 負責「這個封包該送到網路上的哪一台主機」,是網路層的定址與路由協定。
如果沒有一個全網路統一的定址方式,兩台機器之間的資料就無法知道該往哪個方向轉發——IP 存在的目的就是讓每一台連上網路的主機都有一個(在其網段內)唯一的位址,讓路由器可以依這個位址逐跳把封包轉發到目的地。
每個 IP 封包(datagram)都帶有 source IP 與 destination IP header。封包從主機發出後,會先送到本機的 default gateway(路由器),路由器查自己的路由表,找出「往哪個 next hop 可以更接近目的地」,一路轉發直到抵達目的網段,最後一跳的路由器再把封包送到目的主機。IPv4 位址是 32-bit(例:192.168.1.10),IPv6 是 128-bit——這是因為 IPv4 位址空間(約 43 億個)在裝置數量爆炸後已經不夠用,IPv6 用更大的位址空間解決這個問題。私有網段(如 10.0.0.0/8、192.168.0.0/16)配合 NAT(Network Address Translation)讓一個公開 IP 可以代表內網多台機器對外通訊——這也是為什麼你的筆電在家用網路裡的 IP 和外部網站看到的 IP 不一樣。
IP 只保證「盡力而為(best-effort)地嘗試轉發」,不保證封包一定送達、不保證順序、也不保證不重複——這個設計把「可靠傳輸」的責任下放給更上層的協定(TCP),讓 IP 本身可以做得簡單、快、且能在任何底層實體網路(乙太網路、Wi-Fi、衛星鏈路)上運作。代價是應用開發者不能假設「送出去的東西一定會照原樣到達」,必須倚賴上層協定或自己處理。
路由表設錯(route black hole)會讓封包送到一半就被丟棄,卻沒有任何明確錯誤訊息回來,只會觀察到 timeout;NAT 表項目過期或滿載,會讓原本活著的連線突然斷線;IP 位址衝突(同網段兩台機器用同一個位址)會造成間歇性、難以重現的封包遺失。
Kubernetes 裡每個 Pod 拿到一個獨立 IP、Service 用 ClusterIP 做內部負載平衡,都是直接建立在 IP 定址與路由之上;診斷「服務之間連不到」的問題時,第一步永遠是確認 IP 層是否連通(ping、traceroute),再往上查 TCP、應用層。
在自己的機器上執行 traceroute 到一個外部網域(例如traceroute example.com),寫下輸出中每一跳的 IP 是公網位址還是私有位址,並解釋為什麼中間會出現私有位址(家用路由器、ISP 內部網段)。
Port
Supporting TopicsLv.3Port 負責「送到這台主機之後,交給哪一個應用程式」,是傳輸層用來區分同一台主機上多個服務/連線的編號。
一台主機上同時可能跑著 web server(80/443)、資料庫(5432)、SSH(22)等多個服務,光靠 IP 只能定位到主機,還需要一個機制在同一個 IP 之下區分「這個封包是要給哪個服務」——Port 就是這個區分依據。
Port 是 16-bit 數字(0–65535)。0–1023 是 well-known ports(由 IANA 保留給標準服務,如 HTTP=80、HTTPS=443、SSH=22),1024–49151 是 registered ports,49152–65535 是動態/私有 port,通常由作業系統隨機配給 client 端連線使用。一個 TCP 連線實際上是由(source IP, source port, destination IP, destination port) 四元組唯一識別的,這也是為什麼同一台主機可以同時對同一個 server 開很多條連線——每條連線的 source port 不同。
Port 數量有限(65535 個),高並發、大量對外連線的服務(例如反向代理對後端建立大量 outbound 連線)可能把本機的可用 port 耗盡(ephemeral port exhaustion),需要靠連線重用(keep-alive/連線池)或調整作業系統參數緩解。
Port 已被佔用時嘗試 bind 會直接失敗(address already in use);防火牆規則只開特定 port 卻忘記更新,會讓服務「主機可連通、但連不上服務」,容易被誤判為應用程式崩潰。
設計微服務時決定每個服務監聽哪個 port、docker 容器的 port mapping(-p 8080:80)、以及 Kubernetes Service 的targetPort,都是直接操作這一層的概念。
用 ss -tan 或 netstat -an 列出本機所有處於 LISTEN狀態的 port,對照 lsof -i -P -n | grep LISTEN(或針對單一 port 用lsof -i :<port>)找出各自對應的行程;接著對同一個網站開兩個瀏覽器分頁,用 ss -tan | grep :443 觀察兩條連線的 source port 是否不同,驗證「同一台主機到同一個 server 的多條連線,靠 source port 互相區分」這件事。
TCP(Transmission Control Protocol)
Core FundamentalsLv.4TCP 是一個建立在 IP 之上、提供可靠、有序、位元組流傳輸的傳輸層協定。
IP 只保證「盡力而為」,不保證封包送達、順序、不重複——但大多數應用(HTTP、資料庫連線)需要「資料一定會到、而且順序不能亂」,TCP 存在的目的就是在不可靠的 IP 之上,補上這一層可靠性保證,讓應用層可以把網路當成一條穩定的位元組流來用,不用自己處理封包遺失、亂序、重複的問題。
TCP 用以下機制達成可靠傳輸:(1) Sequence number:每個位元組都有序號,接收端可以偵測遺漏與重複、並依序重組;(2)ACK(Acknowledgement):接收端確認收到的位元組範圍,未被確認的資料會在 timeout 後由發送端重傳;(3) Sliding window / Flow control:接收端用 window size 告訴發送端「我還能接收多少資料」,避免發送端灌爆接收端的 buffer;(4) Congestion control(如 slow start、congestion avoidance):發送端根據網路壅塞程度動態調整發送速率,避免整個網路被灌爆。連線建立需要先做三次交握(見下一個知識點),結束時用四次揮手(FIN/ACK 各兩次)確保雙方都各自結束後才釋放資源。
可靠性與順序保證的代價是額外的 overhead——header 較大、需要維護連線狀態(sequence number、window、timer)、重傳與壅塞控制在高延遲或不穩定網路下會明顯拖慢傳輸速度。這也是為什麼即時性比可靠性更重要的場景(視訊通話、線上遊戲)會選 UDP 而不是 TCP。
網路壅塞或封包遺失會觸發 TCP 重傳,重傳期間應用層會感受到延遲飆高甚至暫時卡住;長時間閒置又沒有 keep-alive 的連線可能被中間的 NAT/防火牆悄悄斷開,應用層卻毫無所覺,直到下次送資料才會發現連線已經死了(進而觸發 timeout 或 RST)。
資料庫連線、gRPC、大多數內部服務間通訊都建立在 TCP 之上;理解 TCP 的重傳與壅塞控制機制,是診斷「網路正常但延遲忽高忽低」這類問題的基礎。
一個服務在流量正常的白天延遲穩定在 5ms,但每天凌晨備份排程跑的時候,同一個服務對同一個下游的呼叫延遲飆到 300ms 以上,CPU/記憶體使用率都正常。用 TCP 的機制,你會怎麼推導可能原因、怎麼驗證?(提示:備份排程通常會有大量、長時間的資料傳輸佔滿頻寬——對照 TCP 的壅塞控制機制,思考「頻寬被大量佔用」對其他連線的 congestion window 與重傳行為會造成什麼影響;用 ss -i 或封包擷取工具確認是否有異常高的重傳率可以驗證這個假設。)
UDP(User Datagram Protocol)
Supporting TopicsLv.3UDP 是一個不保證送達、不保證順序、沒有連線狀態的傳輸層協定,換取最低的傳輸 overhead 與延遲。
不是所有應用都需要 TCP 的可靠性保證——有些場景(DNS 查詢、視訊串流、線上遊戲的狀態同步)更在乎「盡快送到」而不是「一定送到」,TCP 的重傳與壅塞控制反而在這些場景裡是不必要的延遲來源,UDP 存在就是為了服務這種場景。
UDP 封包(datagram)只有極簡的 header(source port、destination port、length、checksum),沒有連線建立的握手、沒有 sequence number 排序、沒有重傳機制、沒有壅塞控制——送出去之後 UDP 完全不追蹤這個封包後續發生什麼事,全部丟給應用層自己決定要不要處理遺失或亂序。
換到的是極低的延遲與 overhead(沒有握手、沒有重傳造成的等待),但可靠性(送達、順序、去重)全部要由應用層自己實作,如果應用不處理,資料遺失或亂序就會直接反映在使用者體感上(畫面卡格、語音斷斷續續)。
在會遺失封包的網路環境下,沒有自行實作重傳/FEC(前向錯誤更正)的 UDP 應用會直接體現為資料遺失(畫面花掉、語音斷句)而不是變慢;另外 UDP 沒有連線狀態,容易被用來偽造來源位址發動反射式攻擊,這也是為什麼許多防火牆對非必要的 UDP 流量會更嚴格限制。
DNS 查詢(見 Day 3)預設使用 UDP(回應通常很小,不需要 TCP 的連線開銷;訊息過大時會退回 TCP);QUIC(HTTP/3 的傳輸層,見 Day 5)也是建立在 UDP 之上,自己在應用層重新實作可靠性與壅塞控制,以繞開 TCP head-of-line blocking 的限制。
用 dig +tcp example.com 與 dig example.com(預設走 UDP)分別查詢,比較兩者在封包大小與往返次數上的差異,並解釋為什麼一般 DNS 查詢預設選 UDP 而不是 TCP。
TCP Handshake(三次交握)
Core FundamentalsLv.4TCP 連線建立前,雙方先透過三個訊息(SYN → SYN-ACK →ACK)確認彼此都能收發資料、並同步初始 sequence number。
TCP 要保證可靠傳輸,雙方都必須先知道對方的初始 sequence number(之後才能用 sequence number 判斷遺漏/重複),而且要雙向確認「我能收到你、你也能收到我」——只送一次或兩次都無法同時確認雙向可達性,三次交握是同時滿足「雙向確認可達性」與「雙向同步初始 sequence number」的最少步驟數。
(1) Client 送出 SYN,帶上自己選的初始 sequence number(seq=x);(2) Server 收到後回覆 SYN-ACK,同時帶上ack=x+1(確認收到 client 的 SYN)與自己選的初始 sequence number(seq=y);(3) Client 收到後回覆 ACK,帶上 ack=y+1(確認收到 server 的 SYN)。三個訊息之後,雙方都確認了「我發的東西你收得到」、也都知道了對方的起始 sequence number,連線正式進入 ESTABLISHED狀態,可以開始傳輸資料。
三次交握需要至少一個完整的 RTT(Round-Trip Time)才能完成,這代表每一條新的 TCP 連線在傳送第一個位元組資料之前,就已經付出了至少一個 RTT 的延遲——這也是為什麼 HTTP 連線重用(Keep-alive,見 Day 2)與連線池(Connection Pool,見 Day 2)對效能很重要:避免每次請求都重新付這筆交握成本。
Server 收到大量偽造來源位址的 SYN、卻從不完成第三次交握,會讓 server 端累積大量處於 SYN_RECEIVED 狀態的半開連線,耗盡連線資源——這就是 SYN flood 攻擊;正常情況下如果三次交握遲遲無法完成(例如中間路徑封包遺失),呼叫端會看到連線逾時(connection timeout),而不是明確的錯誤訊息,容易被誤判為服務沒有回應。
每一次新建立的資料庫連線、每一次沒有走 keep-alive 的 HTTP 請求,都要先付一次三次交握的 RTT 成本;連線池(Day 2)、HTTP keep-alive(Day 2)、gRPC 的長連線設計,本質上都是在避免重複付出這個成本。
一個服務把資料庫連線池的最大連線數從 50 調小到 10 之後,在流量尖峰時段觀察到大量請求延遲增加,但資料庫本身 CPU 與連線數都遠低於上限。用三次交握與連線建立成本的概念,解釋可能發生了什麼事、你會怎麼驗證。(提示:連線池太小時,多出來的請求必須等前面的請求釋放連線才能拿到連線去用,如果服務退化成頻繁地建立新連線而非重用,每個新連線都要多付一次三次交握的 RTT;先確認是「排隊等連線池」還是「真的頻繁新建連線」,可以從連線池的等待佇列長度與資料庫端新連線建立速率兩個指標分別驗證。)
練習(Day 1 綜合)
用 Go 寫一個最小的 TCP echo server(net.Listen("tcp", ...))與對應的 client,並用 tcpdump 或 Wireshark 擷取兩者之間的封包,指出擷取結果中哪三個封包是三次交握、哪些欄位對應到 SYN/ACK flag 與 sequence number。
Day 1 過關標準(DoD)
- Understand:能用自己的話解釋 IP 定址主機、Port 定址服務、TCP 在 IP 之上加了什麼保證。
- Recall:不看資料,畫出三次交握的完整流程圖(含每個訊息的 flag 與 seq/ack 欄位)。
- Apply:完成上面的 TCP echo server 練習,並從封包擷取中正確指出三次交握對應的三個封包。
- Explain:能對照「陌生題」情境(延遲異常 debug),說出你會查哪個指標、為什麼。
- Implement(TCP 為 Core Fundamentals,額外要求):echo server 程式碼能正確處理至少一個 client 的連續多次讀寫,不只是單次 request-response。
Day 2 — Networking II:Connection / Keep-alive / Timeout / Connection Pool / Latency / Throughput
學習目標
看完今天內容後,能夠:
- 說明一個 TCP 連線從建立到關閉,狀態怎麼變化,以及為什麼「保持連線存活」對效能重要。
- 為一個服務設定合理的 timeout 與連線池大小,並說出設定依據。
- 精確區分 Latency 與 Throughput 兩個指標,並判斷一個效能問題該從哪一個切入。
教材大綱
Connection
Core FundamentalsLv.4Connection 是 TCP 交握後雙方維護的一段有狀態的通道,在其存續期間雙方都記著彼此的 sequence number、window size 等狀態。
如果每次傳輸資料都要重新確認雙方狀態,效率會很差——Connection 讓雙方在交握完成後,把這些狀態記在記憶體裡(作業系統的 TCP control block),後續的資料傳輸可以直接依賴這個已知狀態運作,不用每次都重新協商。
一個 TCP 連線在其生命週期中會經過一系列狀態(如SYN_SENT → ESTABLISHED → FIN_WAIT → TIME_WAIT → CLOSED),作業系統核心會為每個連線維護一份 control block,記錄 sequence number、未確認的資料、window size 等。應用層看到的「一個 socket」,底層就是核心裡的這份狀態。
維護連線狀態需要記憶體與作業系統資源(file descriptor、control block),連線數量越多,這些資源消耗越大——這是為什麼高並發服務要關注「同時開多少連線」而不是無限制地開新連線。
連線沒有正常關閉(例如程式崩潰、忘記呼叫close())會讓連線卡在某個中間狀態(如大量 CLOSE_WAIT),持續佔用檔案描述符,最終可能耗盡 ulimit 上限、讓服務無法再接受新連線。
netstat/ss 觀察到大量 CLOSE_WAIT 或TIME_WAIT,是排查連線洩漏(connection leak)最直接的入口。
用 ss -tan 觀察本機目前各狀態的 TCP 連線數量,找出至少一個 ESTABLISHED 的連線,並用 lsof -i 對照是哪個行程持有它。
一個服務跑了三天後開始間歇性回傳「connect: cannot assign requested address」,或無法再對某個下游服務建立新連線,但重啟服務後問題暫時消失、幾天後又復發。用 Connection 的機制解釋可能發生了什麼、你會怎麼驗證。(提示:檢查是否有連線沒有正常關閉、持續累積在某個中間狀態(例如 CLOSE_WAIT)——每個連線都佔用一個 file descriptor、並讓作業系統持續維護一份 control block,長時間累積會耗盡 ulimit 或本機可用的 ephemeral port;用ss -tan | awk '{print $1}' | sort | uniq -c 統計各狀態的連線數量,找出是否有某個狀態的連線數持續異常增長來驗證。)
Keep-alive
Supporting TopicsLv.3Keep-alive 讓一個 TCP 連線在完成一次請求後不立刻關閉,留著給後續請求重複使用,省下重新交握的成本。
Day 1 提到三次交握至少要付一個 RTT,如果每個 HTTP 請求都重新建立一次連線,這筆成本會被重複付很多次;Keep-alive 讓連線在完成一次請求-回應後維持開啟,之後的請求可以直接重用同一條連線。
HTTP/1.1 預設啟用 Connection: keep-alive,連線在完成一次交易後不會立刻關閉,而是等待一段時間(idle timeout)看有沒有下一個請求,沒有的話才關閉。TCP 層也有自己的 keep-alive 機制(週期性送探測封包確認對方還活著),與 HTTP 層的 keep-alive 是兩回事,不要混為一談。
保持連線存活代表兩端都要維護這條連線的資源(見上一個知識點),連線數量一多、閒置時間設太長,會佔用大量原本可以釋放的資源;設太短又會讓 keep-alive 幾乎沒有作用,退化成每次都重新交握。
Client 與 Server 對 idle timeout 的認知不一致時(例如 server 5 秒後主動關閉,client 以為連線還活著繼續使用)會出現間歇性的「連線已被對方關閉」錯誤,這類問題常見於 client 與負載平衡器之間 idle timeout 設定不一致的場景。
反向代理(Nginx)、負載平衡器與後端服務之間的 keep-alive 設定不一致,是生產環境常見的間歇性 502/504 錯誤來源。
用 curl -v 對同一個支援 keep-alive 的伺服器連續發送兩個請求(curl -v --next 或用同一個連線發兩次),從輸出中確認第二個請求是否重用了同一條連線(沒有重新交握)。
Timeout
Core FundamentalsLv.4Timeout 是「等待對方回應的最長時間」,超過就主動放棄等待並回報失敗,避免無限期卡住。
網路不保證每個請求都會收到回應(對方可能真的沒回應、也可能是回應本身遺失了)——如果沒有 timeout,呼叫端會無限期等待,資源(連線、thread、記憶體)永遠不會被釋放,一個下游的問題會直接拖垮呼叫端。
Timeout 通常分好幾層設定:connect timeout(建立連線的等待上限)、read timeout(等待資料的等待上限)、request/overall timeout(整個請求從開始到結束的總上限)。應用層在送出請求時啟動一個計時器,時間到還沒收到預期的回應就主動中斷這次呼叫、釋放資源、回報 timeout 錯誤給呼叫方。
Timeout 設太短,正常但稍慢的請求會被誤判為失敗(false positive,可能觸發不必要的重試,反而加重下游負擔);設太長,真正卡住的請求會長時間佔用資源,拖慢整個呼叫鏈甚至引發資源耗盡的連鎖故障。合理的 timeout 需要參考該呼叫在正常情況下的延遲分布(例如 p99 延遲的合理倍數),而不是憑感覺設一個數字。
整個呼叫鏈中,如果上游服務的 timeout 設得比下游服務更短(或反過來,下游沒有 timeout 但上游有),會出現「上游已經放棄、下游還在處理」的資源浪費,甚至「上游重試、下游又收到一個重複請求」的問題(這正是 Idempotency 要解決的問題,見 Day 5)。
一次線上事故排查中,「這個服務為什麼一直不回應」往往能追到「呼叫的下游沒有設 timeout,卡住的連線一路把 thread pool 或連線池耗盡」——這是 Reliability(Day 6–10)Circuit Breaker 存在的直接動機之一。
一個服務的 thread pool 大小是 100,平常 CPU 使用率 30%,某次下游資料庫變慢(單一 query 從 10ms 變成 5 秒),十分鐘後這個服務完全無法回應任何請求,即使是不需要查資料庫的健康檢查 endpoint。用 Timeout 與資源耗盡的概念解釋這個現象。(提示:如果對下游的呼叫沒有設 timeout 或 timeout 設得很長,每個處理中的請求會一路佔用一個 thread 直到下游真的回應;下游變慢會讓大量請求同時卡在「等下游」的狀態,逐漸吃光 thread pool,連完全無關的健康檢查請求也會因為排不到 thread 而無法處理——這是 thread pool 資源耗盡導致的「一個下游拖垮整個服務」,也是 Circuit Breaker/隔離設計要解決的問題。)
Connection Pool
Supporting TopicsLv.3Connection Pool 預先建立並維護一批可重複使用的連線,需要時從池子裡拿一條來用,用完歸還,避免每次都重新建立連線。
每次都重新建立連線要付三次交握的 RTT 成本(Day 1),對於高頻率呼叫同一個下游(尤其是資料庫)的服務,這個成本累積起來相當可觀;Connection Pool 用「預先建好、重複借用」取代「每次現建現拆」。
Pool 維護一組已建立好的連線,應用需要呼叫下游時,向 pool 要一條空閒連線;沒有空閒連線時,依設定要嘛新建一條(在池子上限內)、要嘛等待其他使用者歸還。用完後連線歸還池子(而不是關閉),供下一次請求重複使用。Pool 通常有最小連線數(min idle)、最大連線數(max size)、取得連線的等待逾時(acquire timeout)等參數。
Pool 太小,高並發時大量請求要排隊等連線可用,直接拖慢延遲(如上面 TCP handshake 的陌生題);Pool 太大,可能超過下游(例如資料庫)能同時處理的連線數上限,反而拖累下游效能,甚至把下游壓垮。
連線借出後沒有正確歸還(程式邏輯漏了close()/release(),尤其在例外狀況的路徑上)會造成池子逐漸枯竭,最終所有請求都卡在「等待可用連線」;多個服務實例各自開一個大 pool 打向同一個資料庫,加總起來的連線數可能遠超資料庫的max_connections 上限。
資料庫連線池(如 Go 的 database/sqlSetMaxOpenConns)、HTTP client 的連線池,都是同一個模式的不同應用場景;調整 pool 大小是後端效能調校最常見的手段之一。
找一個你熟悉語言的資料庫連線池設定(例如 Go 的db.SetMaxOpenConns(n)),把它設成 1,並用多個並行請求觀察延遲如何隨著並行數增加而變差,說明背後發生的排隊現象。
Latency
Core FundamentalsLv.4Latency 是「完成一次操作需要的時間」,衡量的是單次請求的快慢,不是系統整體的吞吐能力。
使用者體感(頁面多久出現、API 多久回應)直接由 Latency 決定;沒有一個清楚定義的 Latency 指標,就無法量化「這個服務快不快」或「這次優化有沒有效」。
Latency 通常不是看單一數字,而是看分布——p50(中位數)、p95、p99(尾端延遲)。p50 反映「典型使用者體驗」,p99 反映「最慢的 1% 使用者」——後者往往更能暴露系統的隱藏問題(例如 GC 停頓、鎖競爭、少數慢查詢),因為平均值會被大多數快速請求稀釋掉。一次請求的總 Latency 通常可以拆解成:網路傳輸時間+佇列等待時間+實際處理時間,拆解後才能定位瓶頸在哪一段。
降低 p99 延遲往往需要犧牲一些吞吐量或增加資源成本(例如提高冗餘、預先計算、犧牲一致性換取更快的讀取路徑),不存在「不花代價就同時把延遲和吞吐都做到最好」的免費午餐。
只看平均延遲會掩蓋尾端延遲的問題——平均 20ms 可能來自 99% 請求 10ms、1% 請求 1000ms,這 1% 在高流量下代表大量使用者實際體驗很差,卻完全反映不在平均值上。
SLO/SLA 幾乎都是用 p95/p99 延遲定義,而不是平均值;效能優化前一定要先量測延遲分布,否則優化方向可能對絕大多數請求毫無幫助。
一個 API 的平均延遲從 50ms 上升到 80ms,團隊排查後發現 p50 其實沒有變化,只有 p99 從 200ms 惡化到 2000ms。用 Latency 分布的概念,解釋為什麼「只看平均值」會讓這次問題的訊號被稀釋,並說明你會從哪個方向繼續排查(提示:p50 沒變、p99 大幅惡化,代表問題只影響少部分請求而非全部——優先檢視是否有特定條件(特定資料大小、特定使用者、特定時段)的請求才會走到慢路徑,而不是假設整個系統普遍變慢)。
Throughput
Supporting TopicsLv.3Throughput 是「單位時間內系統能處理多少請求(或位元組)」,衡量的是系統整體的承載能力,不是單次請求的快慢。
一個系統即使單次延遲很低,如果同時能處理的請求量很小,遇到流量尖峰還是會崩潰;Throughput 存在的意義就是回答「這個系統撐得住多大的量」這個和 Latency 不同維度的問題。
Throughput 通常以 QPS(queries per second)或 RPS(requests per second)衡量,也可以是資料傳輸的 bytes/second(例如網路頻寬)。Throughput 受限於系統中最慢的那個環節(bottleneck)——CPU、資料庫連線數、網路頻寬、鎖競爭等任何一個環節先達到上限,整體 Throughput 就會被那個環節卡住,即使其他資源還有餘裕。
Latency 與 Throughput 有時互相牽制:透過 batching(把多個小請求合併成一次處理)可以提高 Throughput,但單一請求要等到湊夠一批才處理,會拉高該請求的 Latency;反過來,為了讓單次延遲最低而不做任何 batching/併發控制,可能無法把系統整體 Throughput 推到最高。
只優化 Throughput 而忽略 Latency 分布,可能讓系統整體「跑得動很大的量」,但個別使用者體感很差(例如透過大量排隊/batching 換取高 Throughput,代價是個別請求的等待時間變長)。
容量規劃(capacity planning)本質上是在回答「這個系統的 Throughput 上限在哪、瓶頸是什麼」;負載測試(load testing)就是刻意把流量推到系統的 Throughput 上限,觀察哪個資源先撐不住。
用任何簡單的負載測試工具(例如 hey、ab、或自己寫的併發呼叫腳本)對本機上 Day 1 寫的 TCP echo server 打不同並行數的請求,記錄 Throughput(每秒完成請求數)如何隨並行數變化,找出大約在哪個並行數之後 Throughput 不再上升(瓶頸出現的訊號)。
練習(Day 2 綜合)
針對 Day 1 寫的 TCP echo server,加上一個可設定的「模擬慢下游」開關(例如收到特定訊息時故意 sleep(5s) 才回應),並用一個沒有設定 timeout 的 client 呼叫它,觀察 client 端行為;再加上 3 秒 timeout 後重跑一次,比較兩次的差異,寫下你觀察到的現象與背後原因。
Day 2 過關標準(DoD)
- Understand:能解釋 Connection、Keep-alive、Connection Pool 三者如何共同減少「重複付出交握成本」,以及 Timeout 為何是資源保護機制而非單純的「等久一點/等短一點」。
- Recall:不看資料,說出 Latency 用 p50/p95/p99 衡量、Throughput 用 QPS/bytes-per-second 衡量的原因與差別。
- Apply:完成上面「模擬慢下游+有無 timeout」的練習,並能指出沒有 timeout 時 client 端資源(連線/thread)被佔用的具體現象。
- Explain:能對照兩個「陌生題」情境(thread pool 耗盡、p99 忽然惡化),清楚說出你會查哪個指標、為什麼。
- Implement(Timeout、Latency 為 Core Fundamentals,額外要求):練習中的 client 需真的實作 timeout 邏輯(不是用外部工具硬砍連線),並能印出量測到的請求延遲數字佐證行為。
Day 3 — DNS:Resolver / Root / TLD / Authoritative Server / Record / TTL / Cache
學習目標
看完今天內容後,能夠:
- 畫出一次完整的 DNS 遞迴查詢流程,從 client 到 Root、TLD、Authoritative Server。
- 解釋 TTL 與 Cache 如何共同減少查詢次數,並說出設定 TTL 時的取捨。
- 針對「DNS 解析異常」的情境,判斷問題可能出在哪一層。
教材大綱
Resolver
Core FundamentalsLv.4Resolver 是代替 client 執行整個 DNS 查詢流程的角色,通常是作業系統或 ISP 提供的遞迴解析伺服器(recursive resolver)。
應用程式(瀏覽器、後端服務)不應該自己知道去哪裡問 Root、TLD、Authoritative Server 這一整套流程——Resolver 把這個複雜度包起來,應用只要問 Resolver「這個網域的 IP 是什麼」,Resolver 負責把答案問回來。
Client 向設定好的 Resolver(例如 8.8.8.8、ISP 提供的預設 DNS)發出查詢;如果 Resolver 快取裡沒有答案,它會依序去問 Root Server「.com 的 TLD Server 在哪」、再問 TLD Server「example.com 的 Authoritative Server 在哪」、最後去問 Authoritative Server「example.com 的 A record 是什麼」,拿到答案後回傳給 client,同時依 TTL 把答案快取起來(見下方 TTL/Cache)。這整個過程對 client 而言是一次查詢,但 Resolver 內部可能發出好幾次查詢(遞迴解析)。
把解析邏輯集中在 Resolver,換來的是 client 端簡單、且大量使用者共用同一個 Resolver 時可以受益於共享快取(見 Cache);代價是所有查詢都要經過這個 Resolver,一旦它變慢或故障,會直接拖累所有依賴它的 client。
Resolver 本身故障或回應變慢,會讓所有依賴它的服務出現「連不上任何外部服務」的假象,容易被誤判為目的服務本身的問題;Resolver 快取了錯誤或過期的答案(快取污染/未即時更新),會讓 client 持續連到錯誤或已下線的位址,直到快取過期。
後端服務裡「服務發現(service discovery)」本質上是同一個模式——用一個中介角色把「名字」解析成「位址」,Resolver 快取失效的問題與服務發現裡「拿到過期的服務位址」的問題是同一類故障。
一個服務一路正常運作,某天開始間歇性地出現「呼叫下游服務逾時」,但下游服務本身的監控顯示完全健康、流量正常。用 Resolver 的機制,你會怎麼推導可能原因、怎麼驗證?(提示:先確認問題是「連不到」還是「連到了但慢」——如果是前者,檢查 DNS 解析本身是否正常(dig/nslookup 直接查、比對 client 端快取的 IP 是否還有效),排除是本機 Resolver 快取了已經失效或變動過的 IP。)
Root(根伺服器)
Awareness TopicsLv.2Root Server 是 DNS 階層架構最頂端的伺服器,知道「每個 TLD(如 .com、.org)該去問哪個 TLD Server」。
DNS 是一個階層式的分散式資料庫,需要一個所有查詢都能共同信任的起點——Root Server 就是這個起點,讓任何一個 Resolver 都能從同一個地方開始,逐層往下找到最終答案。
全球目前有 13 組 Root Server 位址(實際運作上用 Anycast 技術,同一個位址在世界各地有多個實體節點分散流量),Resolver 遞迴解析時第一步(若快取沒有答案)就是查詢 Root Server,取得「這個 TLD 的 TLD Server 在哪」的答案。
把整個 DNS 體系的信任起點集中在少數 Root Server 上,換取「所有人都有一致的起點」,代價是這些節點必須有極高的可用性與抗攻擊能力(因此普遍採用 Anycast 與大量備援)。
實務上因為有快取與 Anycast 備援,Root Server 本身故障導致終端使用者無法解析網域的情況極為罕見;真正常見的問題幾乎都出在更下游(Resolver 快取、Authoritative Server)。
作為後端工程師,不太需要直接與 Root Server 互動,但理解它在階層中的位置,有助於判斷「DNS 問題到底可能出在哪一層」——Root 幾乎不會是問題來源,優先懷疑更下游的環節。
TLD(Top-Level Domain)
Awareness TopicsLv.2TLD 是網域名稱最後一段(如 .com、.org、.tw),TLD Server 知道「這個 TLD 底下每個網域該去問哪個 Authoritative Server」。
Root Server 不可能記住全世界每一個網域的位址,DNS 用階層分工把「知道每個網域答案」的責任下放給各個 TLD 底下的 Authoritative Server,TLD Server 只需要知道下一層該去哪裡問。
Resolver 拿到 Root Server 的回應後,得知「.com 的 TLD Server 位址」,接著去問 TLD Server「example.com的 Authoritative Server 在哪」,取得答案後繼續往下一層查詢。
分層讓每一層只需要維護「自己這一層負責範圍」的資訊,不需要任何單一節點記住全世界的網域資料,換取整個系統的可擴展性;代價是一次完整查詢需要多次往返(雖然實務上大量被 Cache 省略掉)。
註冊網域時 TLD 層的設定(例如網域到期未續約)沒有正確處理,會讓 TLD Server 找不到對應的 Authoritative Server 資訊,使用者端會看到「網域不存在」的解析錯誤。
網域到期或 nameserver 設定錯誤是「服務忽然完全無法連線」的常見肇因之一,排查時應確認網域註冊狀態與 nameserver 設定,而不是只檢查應用本身。
Authoritative Server
Supporting TopicsLv.3Authoritative Server 是真正持有某個網域最終答案(DNS record)的伺服器,是整個查詢鏈的終點。
Root 與 TLD Server 只負責「指路」,真正的答案(這個網域對應的 IP 是什麼)必須由網域擁有者自己管理的伺服器提供——Authoritative Server 就是這個角色,通常由網域註冊商或雲端 DNS 服務(如 Route 53、Cloudflare DNS)代管。
Resolver 依序問過 Root、TLD 之後,最後向該網域的 Authoritative Server 查詢實際的 record(見下一個知識點),取得最終答案。網域擁有者透過 DNS 管理介面新增/修改/刪除 record 時,改動的就是這裡的資料。
把「真正的答案」放在網域擁有者自己可控的 Authoritative Server,換取網域擁有者對自己網域完整的控制權(可以隨時改 IP、加新的子網域),代價是如果這台伺服器本身故障,即使 Root/TLD 都正常,整個網域也無法被解析。
Authoritative Server 故障或設定錯誤(例如 record 打錯、nameserver 委派設定不一致)會讓該網域完全無法解析,且通常不會有清楚的錯誤訊息,只會看到查詢逾時或 NXDOMAIN。
許多雲端服務把 Authoritative DNS 外包給 Route 53、Cloudflare 等託管服務,正是因為自己維護一台高可用的 Authoritative Server 有相當的營運複雜度。
用 dig +trace example.com 觀察一次完整的遞迴解析過程,指出輸出中哪一段是在查 Root、哪一段是查 TLD、哪一段是查 Authoritative Server。
Record
Supporting TopicsLv.3Record 是 DNS 裡實際儲存的一筆一筆資料,最常見的是把網域名稱對應到 IP(A/AAAA record)或另一個名稱(CNAME record)。
DNS 不是只能查「網域對應的 IP」,還需要支援多種不同用途的對應關係(郵件伺服器在哪、這個名稱其實是另一個名稱的別名等),Record 的型別就是為了區分這些不同用途的資料。
常見的 Record 型別包括:A(網域對應 IPv4 位址)、AAAA(網域對應 IPv6 位址)、CNAME(把一個名稱指向另一個名稱,由查詢端再對目標名稱做一次解析)、MX(該網域收信用的郵件伺服器)、TXT(任意文字資料,常用於網域驗證)、NS(指出該網域委派給哪些 nameserver)。每筆 Record 都帶有 TTL(見下一個知識點),決定這筆資料可以被快取多久。
CNAME 讓維運者可以把多個名稱統一指向一個可變動的目標(例如都指向 CDN 提供的網域),只要改一個地方就能同時更新所有別名的實際目標,但代價是多一層解析(查完 CNAME 還要再查一次目標名稱的 A record),增加一點查詢延遲。
CNAME 與其他 Record 型別在同一個名稱上共存會違反 DNS 規範(CNAME 必須是該名稱唯一的 Record),常見的設定錯誤是在根網域(apex domain)上誤用 CNAME;Record 設定錯誤或忘記更新(例如伺服器換了 IP,A record 沒同步更新),會讓使用者連到舊的、可能已經沒有服務在運作的位址。
切換雲端服務商、更換伺服器 IP 時,DNS Record 更新的時機與 TTL 設定(見下一個知識點)直接決定切換過程中會有多久的「部分使用者連到舊位址」的過渡期。
用 dig example.com A、dig example.com MX、dig example.com NS 分別查詢同一個網域的三種 Record 型別,比較ANSWER SECTION 裡回傳資料格式的差異(例如 A 回傳一個 IP,MX回傳郵件伺服器主機名與優先權數字,NS 回傳一組 nameserver 主機名),並記錄各自的 TTL 值是否相同。
TTL(Time To Live)
Core FundamentalsLv.4TTL 是一筆 DNS Record 可以被快取多久(秒數),時間到了快取失效,下次查詢要重新問 Authoritative Server。
如果每次查詢都要重新走一次完整的遞迴解析(Root → TLD→ Authoritative),DNS 系統的查詢量與延遲都會非常高——TTL 讓中間的 Resolver 可以把答案暫存一段時間,同一段時間內的重複查詢可以直接用快取回答,大幅減少查詢次數。
Authoritative Server 回應查詢時,會在 Record 上帶一個 TTL 值(例如 300 秒)。Resolver 收到答案後,把它連同「還剩多少秒失效」一起快取;在 TTL 到期前,同一個名稱的查詢都直接從快取回答,不再往上游查詢;TTL 到期後,下一次查詢會重新走一次完整解析,並拿到新的 TTL。
TTL 設得長,快取命中率高、查詢延遲低、對 Authoritative Server 的負載也低,但代價是如果這筆 Record 的內容改變了(例如換 IP),所有已經快取舊答案的 Resolver 要等到 TTL 到期才會看到新答案,這段期間內流量仍然會打到舊位址。TTL 設得短,換來變更能更快生效,但查詢頻率與延遲都會上升。
在計畫性的伺服器遷移或 DNS 切換前,如果沒有提前把 TTL 調低(讓舊設定先過期、縮短切換過渡期),正式切換當下仍會有大量流量因為快取著舊答案而繼續打到舊伺服器,這是 DNS 切換事故中最常見的根因之一。
任何需要換 IP/換服務商的維運計畫,標準做法是提前(至少一個舊 TTL 週期以上)把該 Record 的 TTL 調低,等確認大部分快取都已經用短 TTL 之後,再執行實際切換,最後再視情況把 TTL 調回原本的值。
團隊計畫把某個網域從 Server A 遷移到 Server B,把 A record 從 A 的 IP 改成 B 的 IP,但完全沒有調整或考慮 TTL。切換後觀察到有些使用者立刻連到新伺服器,有些使用者卻在長達一小時內持續連到已經下線的舊伺服器。用 TTL/Cache 的機制解釋這個現象,並說出下次遷移前你會怎麼做。(提示:不同 Resolver 在切換當下持有的舊快取「剩餘存活時間」不同,剩餘時間長的 Resolver 會繼續回傳舊 IP 直到自己的快取到期;下次遷移應提前(至少一個原本 TTL 週期前)把該 Record 的 TTL 調低,讓切換當下大部分快取已經是短週期、能更快过期更新。)
Cache
Supporting TopicsLv.3Cache 是 Resolver(以及作業系統、瀏覽器)暫存 DNS 查詢結果的地方,讓同一筆查詢在 TTL 到期前不需要重新走完整解析流程。
DNS 查詢如果每次都要走 Root → TLD → Authoritative 的完整流程,延遲與整體系統負載都會過高——Cache 是讓大量重複查詢可以用「查一次、後續直接複用」取代「每次都重新查」的手段,這個概念與 Day 2 的 Connection Pool、後續會出現的各種應用層 Cache 是同一類設計思路。
DNS 查詢結果會在多個層級被快取:作業系統層級(如 systemd-resolved、macOS 的 DNS cache)、應用程式或執行環境層級(部分程式語言的 runtime 會自己快取 DNS 查詢結果)、以及 ISP/公司提供的遞迴 Resolver 層級。每一層快取都依循前面提到的 TTL 決定何時失效。
多層快取疊加起來,查詢效能非常好,但也代表「一筆 DNS 變更要多久才會被所有使用者看到」變得更難預測——同一個變更,不同使用者因為走過不同層級的快取,實際生效時間可能差到超過原始 TTL 的設定值。
應用程式本身(尤其是長時間運作、很少重啟的行程)在啟動時把 DNS 結果快取住、卻沒有依照 TTL 定期刷新,會讓它在 DNS 已經更新後仍然持續連到舊位址——這是「明明 DNS 已經改了,但某個服務還是連到舊主機」的常見肇因,通常出現在忽略 TTL、自己在應用層做了一層不會過期的 DNS 快取的程式。
長連線、長壽命的服務行程如果內部快取了 DNS 結果,必須確保依照 TTL 過期或提供手動刷新機制,否則會在下游 IP 變更時出現「服務本身正常、但連到錯誤目的地」的隱性故障。
用 dig example.com 連續查詢兩次同一個網域,觀察第二次回應中的 TTL 數字是否比第一次小(代表你的本機/ISP Resolver 快取生效了,剩餘存活時間在遞減)。
練習(Day 3 綜合)
畫出一張完整流程圖:從你的筆電打開瀏覽器輸入https://example.com 開始,到瀏覽器拿到 IP 位址為止,標出每一步經過 Resolver、Cache、Root、TLD、Authoritative Server 的順序,並標註「如果這一步的答案被快取住了,流程會在哪裡提前結束」。
Day 3 過關標準(DoD)
- Understand:能用自己的話解釋 Root/TLD/Authoritative Server 三層分工的理由,以及 TTL 與 Cache 如何共同減少查詢次數。
- Recall:不看資料,畫出完整的遞迴 DNS 查詢流程圖。
- Apply:完成
dig +trace練習,正確指出輸出中對應 Root/TLD/Authoritative Server 的段落。 - Explain:能對照兩個「陌生題」情境(下游忽然連不到、DNS 遷移切換不完全),說出問題可能出在哪一層、怎麼驗證。
- Implement(Resolver、TTL 為 Core Fundamentals,額外要求):完成「Day 3 綜合」流程圖練習,且流程圖需明確標出快取命中會在哪一步提前結束整個查詢鏈。
Day 4 — HTTP I:Request/Response / Methods / Status Codes / Headers / Cookies / Safe-Unsafe Methods
學習目標
看完今天內容後,能夠:
- 畫出一次 HTTP request/response 的完整結構(起始行、headers、body)。
- 為任一個 API 操作正確選擇 HTTP method,並說明依據(Safe/Unsafe、語意是否符合)。
- 解釋 Cookie 如何讓「無狀態」的 HTTP 支援有狀態的使用者體驗,以及這帶來的安全考量。
教材大綱
HTTP Request / Response
Supporting TopicsLv.3HTTP 是一個建立在 TCP(或 QUIC,見 Day 5)之上的應用層協定,通訊模式是 client 送一個 request、server 回一個 response,一問一答。
TCP 只提供「可靠的位元組流」,但應用程式之間需要一個共同理解的「訊息格式」才能互相溝通——HTTP 定義了這個格式:request 要包含什麼(要做什麼操作、對哪個資源)、response 要包含什麼(結果如何、附帶什麼資料),讓不同語言、不同框架寫的 client 與 server 可以互通。
一個 HTTP request 由起始行(method + path +HTTP 版本,如 GET /users/1 HTTP/1.1)、一組 headers(key-value 形式的中繼資料,如 Content-Type: application/json)、以及可選的 body(實際傳輸的資料)組成。Response 則由狀態行(HTTP 版本 +status code + 說明文字,如 HTTP/1.1 200 OK)、headers、以及可選的 body 組成。HTTP 本身是無狀態(stateless)的——每個 request 都是獨立的,server 預設不會記得上一個 request 的任何資訊(狀態需要靠 Cookie 等機制額外維護,見下方)。
無狀態的設計讓 HTTP server 可以更容易水平擴展(任何一台 server 處理任何一個 request 都一樣,不需要「這個使用者的狀態存在哪台機器上」),代價是需要有狀態的場景(登入狀態、購物車)必須額外設計機制(Cookie、Session、Token)來彌補。
假設 HTTP 本身會記得狀態(例如以為同一個 client 連續兩次呼叫,server 會自動記得第一次呼叫的內容)是常見的初學者誤解,會導致設計出「必須依賴特定 server 實例」的架構,在多實例、負載平衡的環境下出現「這次登入了,下次卻被當成未登入」的不一致行為。
REST API 設計、負載平衡策略、Session 存放位置(是否要用集中式的 Redis 而非單機記憶體)的決策,根本上都建立在「HTTP 本身無狀態」這個前提之上。
用 curl -v https://example.com 送出一個請求,從輸出中找出屬於 request 的起始行與 headers(> 開頭的行)、屬於 response 的狀態行與 headers(< 開頭的行),並確認這次輸出裡 request、response 各自是否帶有 body。
HTTP Methods
Core FundamentalsLv.4HTTP Method 表達「這個 request 想對資源做什麼操作」的語意(讀取、建立、覆寫、刪除、部分修改等)。
如果每個操作都只用同一個 method(例如全部用 POST),中間的代理伺服器、快取、瀏覽器都無法從 method 本身判斷這個操作是不是安全可重試的——HTTP 定義多種 method、賦予每種明確的語意,讓整個生態系(快取、重試邏輯、瀏覽器行為)可以依照這個共同語意做出正確的行為。
常見 method 與語意:GET(讀取資源,不應有副作用)、POST(建立新資源或觸發一個不保證重複安全的操作)、PUT(用提供的內容完整覆寫某個資源,若不存在則建立)、PATCH(對資源做部分修改)、DELETE(刪除指定資源)、HEAD(與 GET 相同但只要 headers、不要 body,常用於檢查資源是否存在或取得 metadata)、OPTIONS(查詢該資源支援哪些 method,常用於 CORS 預檢請求)。
嚴格遵守 method 語意的代價是設計 API 時需要多想一層「這個操作到底該用哪個 method」,但換來的是瀏覽器、CDN、代理伺服器都能依照標準語意做出正確判斷(例如安全地快取 GET、安全地重試 PUT),如果為了方便把所有操作都塞進 POST,會讓這些中間層失去判斷依據,喪失可以自動獲得的效能與可靠性紅利。
用 GET 實作有副作用的操作(例如GET /delete-user?id=1)會讓瀏覽器的預先載入(prefetch)、爬蟲、代理伺服器的快取行為意外觸發這個副作用,是常見卻嚴重的設計錯誤;用 POST 做本質上是 PUT/DELETE 的操作,會讓呼叫端無法安全地自動重試(因為不確定重複執行是否安全)。
API Design(Day 6–10)裡的 RESTful 設計原則,本質上就是要求開發者精確地把「操作意圖」對應到正確的 HTTP method,讓 API 的行為可以被整個 HTTP 生態系正確理解與運用。
一個團隊把「刪除使用者」實作成GET /users/delete?id=123,上線後發現有使用者反應「我什麼都沒按,帳號卻被刪除了」。用 HTTP Method 語意的概念,解釋最可能的原因是什麼。(提示:GET 依語意被視為安全、無副作用,瀏覽器的連結預先載入、企業防毒軟體的連結掃描、搜尋引擎爬蟲都可能在使用者不知情的情況下對這類連結發出 GET 請求;正確做法是把刪除操作改成 DELETE,並要求使用者主動觸發不會被自動預先載入的動作。)
Status Codes
Supporting TopicsLv.3Status Code 是 response 裡的三位數代碼,用一個標準化的分類告訴呼叫端「這次請求的結果屬於哪一大類」。
如果每個 server 都用自己發明的方式表達「成功」「失敗」「需要重新導向」,client、代理伺服器、監控系統都無法用統一邏輯處理回應——Status Code 提供一套跨語言、跨框架都通用的分類標準。
依第一位數字分類:1xx(Informational,處理中,較少見於一般 API)、2xx(Success,如 200 OK、201 Created、204 No Content)、3xx(Redirection,如 301 Moved Permanently、304 Not Modified)、4xx(Client Error,呼叫端的請求有問題,如 400 Bad Request、401 Unauthorized、403 Forbidden、404 Not Found)、5xx(Server Error,伺服器端處理時發生問題,如 500 Internal Server Error、503 Service Unavailable)。這個分類本身就帶有語意:呼叫端看到4xx 應該檢討自己的請求(重試同樣的請求不會成功),看到 5xx則可能是暫時性問題(視情況可以重試)。
精確地選對 status code 需要多花心思判斷(例如分清楚 401 未認證與 403 已認證但無權限),但換來的是監控系統可以依 4xx/5xx 的比例分別判斷「是使用端行為異常」還是「服務端真的出問題」,重試邏輯也能依此決定該不該重試。
把所有錯誤都回 200 OK(把錯誤資訊塞在 body 裡)會讓依賴 status code 判斷成敗的中介層(負載平衡器健康檢查、監控告警、自動重試邏輯)完全失效,因為它們看到的永遠是「成功」;反過來把使用者輸入錯誤(本該是 4xx)誤回5xx,會讓監控系統誤判為服務端故障,觸發不必要的告警或自動擴容。
SLO 通常用 5xx 比例定義服務的可用性(error rate),選錯 status code 會直接讓這類監控指標失真。
用 curl -i 對一個公開 API(例如https://httpstat.us/404)發送請求,觀察回傳的 status line,並解釋為什麼是 4xx 而不是 5xx。
Headers
Supporting TopicsLv.3Headers 是附加在 request/response 上的 key-value 中繼資料,描述訊息本身(格式、大小、快取規則、認證資訊等),而不是訊息的實際內容。
Body 只承載實際資料,但傳輸過程中還有很多「關於這份資料」的資訊需要傳達(這是什麼格式、可以被快取多久、身份是什麼)——Headers 提供一個獨立於 body 的地方放這些中繼資料,讓中間的代理伺服器不需要解析 body 內容,光看 headers 就能做出路由、快取、壓縮等決策。
常見 headers 包括:Content-Type(描述 body 的資料格式,如 application/json)、Content-Length(body 的位元組長度)、Authorization(攜帶認證資訊,如Bearer <token>)、Cache-Control(快取策略指示,如max-age=3600)、Accept(client 期望 server 回傳的資料格式)。
把中繼資料獨立於 body 之外,讓代理伺服器、CDN 可以不解析 body 就做出決策(效能好),代價是設計 API 時需要判斷「這個資訊該放 header 還是 body」,放錯位置(例如把應該公開快取的資訊藏在只有應用層看得懂的 body 裡)會讓中間層無法發揮應有的作用。
Content-Length 與實際 body 長度不一致會讓接收端錯誤地截斷或等待資料;忘記設定或設錯 Cache-Control會讓本不該被快取的內容(例如帶有個人資料的回應)被瀏覽器或 CDN 快取住,造成資料外洩風險。
API Gateway、CDN 的路由與快取規則幾乎都是依 headers 做決策;Authorization header 的設計與驗證,是 Day 76–78(Auth)的基礎。
用 curl -I https://example.com(只取 headers,不下載 body)觀察實際回傳哪些 headers,找出其中的 Content-Type 與Cache-Control(若有回傳),並解釋這兩個 header 分別會讓瀏覽器或中間的代理伺服器做出什麼決策。
Cookies
Supporting TopicsLv.3Cookie 是 server 要求瀏覽器儲存、並在後續每次請求自動附帶回來的一小段資料,是彌補 HTTP 無狀態特性、實作「登入狀態」等有狀態行為的主要機制。
HTTP 本身無狀態(見上方),但很多應用場景(登入後記得使用者身份、購物車內容)需要跨多次 request 維持狀態——Cookie 讓 server 可以把一個識別碼(例如 Session ID)交給瀏覽器保管,瀏覽器在後續每次請求自動附上,server 就能依此識別「這是同一個使用者的連續請求」。
Server 透過 response 的 Set-Cookie header 要求瀏覽器儲存一組 key-value;瀏覽器之後對同一個網域發出的每個 request,會自動在 Cookie header 帶上這些資料。Cookie 可以設定 Expires/Max-Age(存活多久)、HttpOnly(禁止 JavaScript 讀取,防止部分 XSS 竊取)、Secure(只在 HTTPS 連線下傳送)、SameSite(限制跨網站請求時是否附帶這個 Cookie,防止部分 CSRF 攻擊)等屬性。
Cookie 讓「有狀態的使用者體驗」建立在「無狀態的 HTTP」之上,換取開發簡便性,但代價是每個相關屬性(HttpOnly、Secure、SameSite)都需要正確設定,否則會直接開啟資安漏洞;另外每次請求都要帶上 Cookie,也代表每個 request 都多了一點 overhead。
忘記設定 HttpOnly,讓儲存 Session 識別碼的 Cookie 可以被頁面上的惡意 JavaScript 讀取(若網站有 XSS 漏洞,攻擊者可藉此竊取 Session);忘記設定 Secure,讓 Cookie 可能在不安全的 HTTP 連線上以明文傳輸被中間人竊聽;SameSite設定不當會讓網站暴露在 CSRF 風險下(攻擊者的網頁誘導使用者瀏覽器對目標網站發出帶有 Cookie 的請求)。
Session-based 認證機制的安全性幾乎完全建立在正確設定 Cookie 屬性之上;這也是 Day 76–78(Auth)要深入處理的主題之一。
打開瀏覽器開發者工具,登入任一個你熟悉的網站,檢查它設定的 Cookie 屬性(HttpOnly/Secure/SameSite),記錄下來並解釋每個屬性在防禦哪一種攻擊。
Safe / Unsafe Methods
Core FundamentalsLv.4Safe method 承諾「不會對伺服器狀態造成任何改變」(如 GET),Unsafe method 則可能造成狀態改變(如POST、PUT、DELETE)。
瀏覽器、爬蟲、代理伺服器、快取系統需要知道「哪些請求可以放心地重複發送、預先載入、或自動重試,不會有副作用」——Safe/Unsafe 的區分就是給這些自動化行為一個明確的判斷依據,而不是每個系統各自猜測。
依 HTTP 規範,GET、HEAD、OPTIONS 是 Safe method——語意上保證不會改變伺服器狀態,因此可以被瀏覽器安全地預先載入、被爬蟲安全地大量存取、被代理伺服器安全地快取。POST、PUT、PATCH、DELETE 是 Unsafe method——語意上可能改變伺服器狀態,因此瀏覽器不會自動預先載入這些請求,爬蟲也不應主動觸發。這個分類與 Idempotency(見 Day 5)是兩個相關但不同的概念:Safe 談的是「有沒有副作用」,Idempotency 談的是「重複執行結果是否一樣」——GET 同時 Safe 且 Idempotent;PUT Unsafe(會改變狀態)但 Idempotent(重複 PUT 同樣內容,結果一樣);POST 通常 Unsafe 且非 Idempotent(重複 POST 可能建立多筆重複資料)。
嚴格遵守 Safe method 不改變狀態的約定,換來瀏覽器/代理伺服器/爬蟲可以放心自動化處理這些請求,但要求開發者在設計每個 endpoint 時都誠實地判斷「這真的沒有副作用嗎」——偷懶把有副作用的操作包裝成 GET(如上一個知識點的陌生題),會讓整個依賴 Safe 語意運作的生態系(預先載入、快取、爬蟲)變成安全隱患。
見上一個知識點「HTTP Methods」的陌生題——用GET 實作有副作用的操作,是 Safe method 語意被違反最常見、後果也最嚴重的錯誤。
CDN、瀏覽器快取、爬蟲行為的預設假設都建立在「Safe method 不會有副作用」之上;設計 API 時是否遵守這個約定,直接影響整個系統能不能安全地享受這些基礎設施提供的自動化效能紅利。
一個團隊的重試邏輯這樣寫:「只要不是 GET,就不重試,避免造成重複副作用」,結果在使用者網路不穩定的環境下,很多合法的PUT/DELETE 請求因為逾時而永遠不會自動重試,使用者體驗變差。用 Safe/Unsafe 與 Idempotency 的差異,指出這個重試邏輯的判斷依據錯在哪裡、應該怎麼改。(提示:能不能安全重試,取決於這個操作是不是 Idempotent(重複執行結果一樣),不是是不是 Safe(有沒有副作用)——PUT/DELETE 雖然 Unsafe(會改變狀態),但通常是 Idempotent 的,逾時後重試通常安全;只有非 Idempotent 的操作(例如建立資源的POST)重試才真的有「重複造成副作用」的風險,判斷依據應該換成 Idempotency,而不是 Safe。)
練習(Day 4 綜合)
用 Go 寫一個最小的 HTTP server,實作GET /items/:id(讀取)、POST /items(建立)、PUT /items/:id(覆寫)、DELETE /items/:id(刪除)四個 endpoint,並用 curl 分別呼叫,記錄每個呼叫實際回傳的 status code,解釋為什麼選擇這個 status code(例如建立成功回201 Created 而非 200 OK)。
Day 4 過關標準(DoD)
- Understand:能解釋 HTTP 無狀態的設計、Cookie 如何補上狀態、以及 Method/Status Code 存在的共同理由(讓中間層可以依標準語意做決策)。
- Recall:不看資料,列出至少 6 個 HTTP method 與其語意、以及 5 個常見 status code 分類與各自代表的意思。
- Apply:完成上面四個 endpoint 的練習,並正確選用 status code。
- Explain:能對照「陌生題」(誤用 GET 做刪除)解釋 Safe method 被違反會造成什麼實際後果。
- Implement(Methods、Safe/Unsafe 為 Core Fundamentals,額外要求):練習中的四個 endpoint 需真的對應到正確的 HTTP method 與語意,不能為了方便全部用同一個 method 實作。
Day 5 — HTTP II:HTTP/1.1 / HTTP/2 / HTTP/3 / REST / Idempotency
學習目標
看完今天內容後,能夠:
- 說明 HTTP/1.1 → HTTP/2 → HTTP/3 各自解決了前一版本的什麼問題。
- 判斷一個 API 設計是否符合 REST 風格,並說出理由。
- 精確定義 Idempotency 在「單一 request/response」層級的範圍,並知道這個定義在哪裡會不夠用。
教材大綱
HTTP/1.1
Supporting TopicsLv.3HTTP/1.1 是沿用最廣泛的 HTTP 版本,引入 Keep-alive(見 Day 2)與 pipelining,但每條連線同一時間仍只能有一個請求在等待回應。
HTTP/1.0 每次請求都要新開一條 TCP 連線(付一次三次交握成本),在網頁需要載入大量資源(圖片、CSS、JS)的時代效率很差——HTTP/1.1 引入預設的持久連線(Keep-alive),讓多個請求可以共用同一條 TCP 連線,減少重複交握的成本。
HTTP/1.1 在同一條連線上依序處理請求——雖然理論上支援 pipelining(不等前一個回應就送下一個請求),但回應仍必須按照請求送出的順序依序回來,且大多數瀏覽器與伺服器實務上並未真正啟用 pipelining(因為隊首阻塞問題,見下方)。瀏覽器因此普遍用「對同一個網域開多條並行連線(通常 6 條)」的方式繞過單一連線的序列限制。
多開並行連線緩解了單一連線的序列限制,但每條連線都要各自付出連線建立與維護的資源成本,而且瀏覽器對同一網域的並行連線數有上限,資源多的頁面仍然會受限於此。
HTTP/1.1 的隊首阻塞(Head-of-Line Blocking)——同一條連線上,如果前一個請求的回應還沒回來,後面的請求即使已經處理完也要排隊等前面的先回傳——會讓單一一個慢請求拖慢同一條連線上所有排在它後面的請求。
這個限制正是 HTTP/2 多工設計要解決的問題(見下一個知識點)。
打開瀏覽器開發者工具的 Network 分頁,載入一個有多張圖片或多個小資源、且仍走 HTTP/1.1 的網頁,從 waterfall 圖觀察同一時間對同一網域最多有幾條請求在並行傳輸,驗證「瀏覽器對同一網域的並行連線數有上限(通常 6 條)」這件事,並找出頁面上是否有請求因為排隊等連線而明顯延後開始。
HTTP/2
Core FundamentalsLv.4HTTP/2 引入多工(multiplexing),讓同一條 TCP 連線可以同時處理多個請求/回應,不需要依序排隊,解決 HTTP/1.1 的隊首阻塞問題。
HTTP/1.1 靠「多開連線」繞過隊首阻塞,但每條連線都有建立與維護成本,且連線數有上限——HTTP/2 從協定層面直接解決「一條連線只能序列處理」的根本限制,讓一條連線就能達到過去需要多條連線才能達到的並行效果。
HTTP/2 把每個 request/response 拆成多個 frame,每個 frame 帶有 stream ID,同一條 TCP 連線上可以交錯傳送屬於不同 stream 的 frame,接收端依 stream ID 重新組回完整的請求或回應——這讓多個請求可以「同時在路上」,不需要排隊等前一個完成。HTTP/2 另外引入 header 壓縮(HPACK,減少重複 header 的傳輸量)與 server push(伺服器可以主動推送 client 尚未請求但預期會需要的資源,實務上因效益與快取行為複雜性,後續已較少使用)。
多工解決了應用層(HTTP 訊息層級)的隊首阻塞,但底層仍然是單一 TCP 連線——如果這條 TCP 連線本身發生封包遺失,TCP 為了保證位元組流順序,會讓這條連線上所有 stream 的資料都要等遺失的封包重傳完成才能繼續,也就是說隊首阻塞問題被「從應用層搬到了傳輸層」,並沒有完全消失(這正是 HTTP/3 要解決的問題,見下一個知識點)。
在封包遺失率較高的網路環境(例如不穩定的行動網路)下,HTTP/2 的多工優勢會因為 TCP 層的隊首阻塞而大打折扣,效能可能反而不如多開幾條 HTTP/1.1 連線(因為 HTTP/1.1 的多條連線各自獨立,一條連線的封包遺失不會拖累其他連線)。
多數現代 API Gateway、CDN 都預設啟用 HTTP/2;理解「應用層多工」與「傳輸層仍是單一位元組流」的差異,是理解 HTTP/3 為何要換掉 TCP、改用 UDP-based 的 QUIC 的關鍵前提。
一個網站啟用 HTTP/2 後,在辦公室穩定的網路環境下頁面載入明顯變快,但部分使用者在網路品質較差的行動網路環境下反應「載入變慢了」。用 HTTP/2 多工與 TCP 隊首阻塞的關係,解釋這個現象可能的原因。(提示:HTTP/2 解決的是應用層的隊首阻塞,但底層仍是單一 TCP 連線;在封包遺失率高的網路下,這條唯一的 TCP 連線一旦遺失封包,該連線上所有 stream 都要等重傳完成,反而比 HTTP/1.1 用多條獨立連線分散風險的效果差,這正是促成 HTTP/3 改用 QUIC/UDP 的動機。)
HTTP/3
Supporting TopicsLv.3HTTP/3 把傳輸層從 TCP 換成建立在 UDP 之上的 QUIC 協定,讓每個 stream 的封包遺失不再拖累其他 stream,徹底解決傳輸層的隊首阻塞。
HTTP/2 的多工優勢被底層 TCP 的隊首阻塞打折——問題的根源在於 TCP 本身把整條連線視為一個單一、必須嚴格排序的位元組流。HTTP/3 的解法是換掉傳輸層:用 QUIC(建立在 UDP 之上,自己在應用層重新實作可靠傳輸與壅塞控制)讓每個 stream 各自獨立管理可靠性,一個 stream 的封包遺失只影響它自己,不會拖累其他 stream。
QUIC 在 UDP 之上重新實作了 TCP 原本提供的可靠性(確認、重傳)與壅塞控制,但用「每個 stream 各自的 sequence 空間」取代 TCP 全連線共用一個位元組流順序的設計,讓多個 stream 的資料可以獨立地遺失、獨立地重傳,互不影響。QUIC 也把 TLS 加密直接整合進交握流程(不像 TCP+TLS 是兩層各自握手),減少建立一條新連線需要的往返次數。
換成 UDP-based 的 QUIC 讓 HTTP/3 徹底解決傳輸層隊首阻塞、也縮短連線建立的延遲,但代價是需要在使用者空間(user space)重新實作原本作業系統核心已經高度最佳化的 TCP 可靠性機制,實作與除錯複雜度較高;另外部分企業防火牆/網路設備對大量 UDP 流量的處理不如 TCP 成熟,可能出現相容性問題。
某些網路環境(嚴格的企業防火牆)會限制或封鎖 UDP 流量,使得走 QUIC 的 HTTP/3 連線無法建立,這時 client 需要能夠 fallback 回 HTTP/2(走 TCP),如果沒有正確實作這個 fallback,會讓部分使用者完全無法連線而非優雅降級。
CDN 與大型網站逐步預設支援 HTTP/3,但通常仍需保留 HTTP/1.1/HTTP/2 作為 fallback;作為後端工程師不需要自己實作 QUIC,但需要理解它解決的問題,才能判斷「網站變慢是不是跟協定版本的降級有關」。
用瀏覽器開發者工具的 Network 分頁,檢查你常用的一個大型網站(例如 Google、Cloudflare 提供的網站)目前實際使用的 HTTP 協定版本(欄位通常標示 h2 或 h3),確認它是否已經啟用 HTTP/3。
REST
Core FundamentalsLv.4REST(Representational State Transfer)是一套設計 API 的風格原則,核心是把系統功能表達成「對資源(resource)的操作」,並充分運用 HTTP 既有的語意(method、status code、無狀態)。
如果每個 API 都自創一套規則(例如所有操作都用 POST、把操作結果塞進統一的 200 回應 body),呼叫端就無法運用 HTTP 生態系已經提供的能力(快取、重試安全性判斷、標準化的錯誤處理)——REST 存在的目的就是讓 API 設計「回歸」HTTP 原本的語意,而不是把 HTTP 只當作傳輸層、在其上重新發明一套語意。
RESTful 設計的核心原則包括:(1) 以資源(名詞,如 /users、/orders/123)而非動作(動詞,如/getUser)作為 URL 的核心;(2) 用 HTTP method 表達對資源的操作(GET /users/1 讀取、PUT /users/1 覆寫、DELETE /users/1 刪除),而不是把動作塞進 URL 或都用同一個 method;(3) 無狀態——每個 request 都攜帶處理它所需的全部資訊,server 不依賴前一個 request 留下的狀態;(4) 統一介面——同一類資源的操作方式一致,呼叫端不需要為每個資源學一套不同的慣例;(5) 善用 HTTP 既有機制表達結果(用 status code 表達成敗、用 Location header 回傳新建資源的位置)。
嚴格遵守 REST 原則需要在設計階段多花心思把每個操作對應到正確的資源與 method(尤其是不是所有操作都能乾淨地對應到 CRUD,例如「觸發批次結算」這種動作型操作),換來的是 API 具有一致、可預期的行為,並能充分運用 HTTP 生態系(快取、代理、重試邏輯)已經提供的能力,不需要每個 client 都重新學一套自訂規則。
把所有操作都塞進 POST(RPC 風格披著 REST 外皮,俗稱 "REST-ish")、或是把應該用 PUT/PATCH 表達的更新操作用 POST /updateUser 這種動詞 URL 實作,會讓 API 喪失前面提到的所有語意紅利(無法安全重試、無法被正確快取),也讓呼叫端必須逐一查文件才知道每個 endpoint 的行為,而不能依標準慣例推測。
API Design(Day 6–10)會在 REST 原則之上進一步討論 Pagination、Versioning、Error design 等更細緻的設計決策,都是建立在本日建立的 REST 基礎原則之上。
一個團隊的 API 幾乎所有操作都設計成POST /api/doAction,body 裡帶一個 action 欄位(如{"action": "deleteUser", "id": 123})決定實際要做什麼。用 REST 原則,指出這個設計違反了哪些原則、會失去哪些 HTTP 生態系原本能提供的能力。(提示:URL 用動作而非資源命名、所有操作統一用 POST 而不區分語意,代表呼叫端無法從 method 判斷這個操作是否安全可重試、代理伺服器無法依標準規則判斷是否可以快取、監控系統也無法單純從 method+path 區分不同操作的獨立指標,全部都退化成需要解析 body 內容才能理解語意。)
Idempotency
Core FundamentalsLv.4Idempotency 是「同一個操作重複執行多次,結果與只執行一次時一樣」的性質;在本日這個層級,談的是 HTTP method 語意上是否承諾這個性質。
呼叫端有時候不確定一個請求是否已經成功送達並處理完(例如網路逾時,不知道 server 有沒有收到),如果知道這個操作是 Idempotent 的,呼叫端就可以放心地直接重試,不用擔心重試會造成「執行了兩次」的副作用(例如重複扣款、重複建立資料);反之非 Idempotent 的操作,貿然重試可能造成資料重複或狀態錯誤,呼叫端必須更謹慎處理。
依 HTTP 規範,GET、HEAD、PUT、DELETE、OPTIONS 是 Idempotent method——GET 重複讀取結果一樣(且本身無副作用);PUT 用同樣內容重複覆寫,最終狀態與只做一次相同;DELETE 重複刪除同一個已不存在的資源,最終狀態(該資源不存在)也相同。POST 通常不是 Idempotent——例如「建立一筆訂單」的 POST,重複呼叫兩次語意上會建立兩筆訂單,結果並不相同;PATCH 依實作方式而定,可能 Idempotent(例如「把某欄位設為某值」)也可能不是(例如「把某欄位的值加 1」,重複執行結果每次都不同)。
把一個操作設計成 Idempotent(例如用 PUT以完整內容覆寫而非用會累加的 PATCH),換來呼叫端可以安全地自動重試而不需要額外機制判斷「這個操作到底做過沒」,但代價是有些操作的語意本質上就是「新增一筆」(如建立訂單),沒有天生 Idempotent 的實作方式,需要額外設計(例如引入 idempotency key,見下方的範圍界定)才能讓它在重試情境下安全。
把一個非 Idempotent 的操作(如 POST 建立訂單)當作可以放心重試,會在網路不穩定、逾時後自動重試的情境下造成重複下單、重複扣款等資料錯誤——這正是為什麼呼叫端在決定「逾時後能不能重試」之前,必須先確認這個操作是否 Idempotent。
Reliability(Day 6–10)的 Retry/Backoff 機制,是否可以安全地自動觸發,第一個要檢查的前提就是這裡定義的 Idempotency;這也是為什麼 API 設計時會刻意優先選擇 Idempotent 的 method(能用 PUT 就不隨便用 POST)。
範圍界定(重要):這裡定義的 Idempotency,指的是「在一次 client 手動重試、request 本身沒有遺失或重複送達的前提下,同一個操作重複做結果要一樣」——這在單一 client-server request/response 的層級是完整、自洽的,足以說明為什麼PUT/DELETE可以安全重試、POST預設不行。它不解決「網路本身讓同一個訊息送達兩次、而系統要怎麼知道這是重複」的問題——當重試不是使用者手動點兩下,而是系統自動重試、且訊息可能因為網路本身的行為被送達不只一次時,光靠 method 語意還不足以保證安全,需要額外的機制(如 idempotency key、去重存儲)才能確保「這個操作到底有沒有真的執行過」——那是 Phase 06(Distributed Systems,Day 51–60)Retry/Failure 情境下要處理的問題,屆時 Idempotency 這個詞彙會在更深的層級(分散式重試安全)重新出現,不是重新定義,而是在今天這個「HTTP method 語意層級」的定義之上疊加新的變數1。
一個 client 呼叫 PUT /accounts/1/balance(body 帶目標餘額)在網路逾時後自動重試了一次,兩次呼叫最終伺服器上的餘額是否可能不一致?如果把同樣的重試邏輯套用在POST /accounts/1/transactions(body 帶「轉入 100 元」這筆交易紀錄)呢?分別用今天對 Idempotency 的定義解釋兩者結果為何不同。(提示:PUT 覆寫成同一個目標餘額,不論執行一次或重複執行兩次,最終餘額都一樣,是 Idempotent 的;POST 建立一筆新的交易紀錄,重複呼叫會建立兩筆各自獨立的「轉入 100 元」紀錄,等同於多轉了一次,不是 Idempotent 的——這說明了為什麼「使用 PUT 更新餘額」與「使用 POST 建立交易紀錄」在能否安全重試這件事上有本質差異。)
練習(Day 5 綜合)
延續 Day 4 寫的 HTTP server,把 PUT /items/:id 與POST /items 各自呼叫兩次(模擬網路重試),記錄兩者重複呼叫後系統最終狀態的差異(PUT 是否得到相同結果、POST 是否建立了兩筆資料),並在程式碼註解寫下這個差異對應到今天哪一段 Idempotency 的定義。
Day 5 過關標準(DoD)
- Understand:能解釋 HTTP/1.1 → HTTP/2 → HTTP/3 每一版解決了前一版的什麼問題(連線重用 → 應用層多工 → 傳輸層隊首阻塞)。
- Recall:不看資料,說出哪些 HTTP method 是 Idempotent、哪些不是,並說出 REST 的至少 3 個核心原則。
- Apply:完成上面「Day 5 綜合」練習,實際觀察並記錄
PUT與POST重複呼叫的結果差異。 - Explain:能對照兩個「陌生題」情境(HTTP/2 在差網路下變慢、REST-ish 全部用 POST 的設計)解釋語意選錯會失去什麼能力;並能完整覆誦本日 Idempotency 定義的範圍界定(這裡的定義解決什麼、不解決什麼、留給哪個 Phase 處理)。
- Implement(HTTP/2、REST、Idempotency 為 Core Fundamentals,額外要求):Day 5 綜合練習需真的能從程式行為觀察到
PUT與POST重試結果的差異,不能只是文字描述。
- 依 00-knowledge-dependency-graph.md 第 3.7 節「具體解法」第 1 點,此段對照該節深度遞增表格第 1 層 ↩
Day 1–5 階段性檢查
第 5 章1的整體驗收要求能回答「一個 request 從 Browser 發出,到 Application 取得 PostgreSQL 資料,中間發生什麼,以及每一層可能失敗在哪」——這需要 Day 6–10(API Design/Reliability/Observability)補完後才算完整,Day 1–5 完成後,應該已經能回答其中前半段:
Browser 輸入網址 → DNS 解析拿到 IP(Day 3)→ 與該 IP 建立 TCP 連線,經過三次交握(Day 1)→ 視協定版本決定走 HTTP/1.1 的多連線或 HTTP/2/HTTP/3 的多工(Day 5)→ 送出 HTTP request(帶正確的 method、headers、視需要帶 Cookie,Day 4)→ 中間可能經過的每一層(Resolver 快取、TCP 連線狀態、HTTP 版本協商)各自可能在哪裡失敗(Day 1–5 各知識點的 Failure mode 段落已逐一列出)。
Day 6–10(API Design/Reliability/Observability)由 T-020 接續,補完「Application 端收到 request 之後到取得 PostgreSQL 資料」的後半段。
- 依 00-master-curriculum.md 第 5 章「驗收」 ↩
Day 6 — API Design I:REST 資源設計深化 / Pagination / Cursor Pagination / Filtering
學習目標
看完今天內容後,能夠:
- 針對一個真實資料模型,設計出巢狀資源、集合/單一資源、非 CRUD 動作三種情境各自正確的 URL 與方法。
- 針對「清單會隨資料量增長」的分頁需求,判斷該用 offset 分頁還是 cursor 分頁,並說出具體會壞掉的情境。
- 設計 filtering 參數時,避免語意模糊與全表掃描的效能陷阱。
教材大綱
REST 資源設計深化
Supporting TopicsLv.3在 Day 4 建立的 REST 原則之上,今天處理「怎麼把一個真實系統的資料模型映射成一組乾淨的資源與 URL」這個更具體的設計問題。
Day 4 講的是「用資源與方法表達操作」的原則層級;實務上常遇到的難題是「巢狀資源該巢多深」「一個操作明明不是 CRUD,該怎麼表達」,這些原則本身不會直接給答案,需要具體的設計慣例。
(1) 巢狀資源:子資源脫離父資源就沒有獨立意義時(例如 /orders/123/items,訂單項目離開訂單沒有意義),用巢狀 URL 表達所屬關係;子資源本身是獨立可定址的實體時(例如使用者),改用扁平 URL+body 帶關聯 ID(例如 POST /orders 的 body 帶customer_id),巢狀通常不超過 2 層,超過就代表資料模型或 API 邊界需要重新考慮。(2) 集合 vs 單一資源:/orders(collection,GET 回傳陣列、POST 建立新資源)與 /orders/123(single resource,GET/PUT/PATCH/DELETE 對單一資源操作)要嚴格區分,不讓同一個 URL 同時代表兩種語意。(3) 非 CRUD 動作:像「取消訂單」「觸發結算」這種不乾淨對應到 CRUD 的操作,慣例是把動作本身建模成一個資源(POST /orders/123/cancellations 建立一筆「取消」紀錄),維持「URL 是名詞、方法是動詞」的一致性;沒有需要保留歷程的簡單動作,也可以退而求其次用明確標示為動作的 sub-resource(POST /orders/123/actions/cancel),但這是取捨後的妥協寫法。
嚴格把動作也建模成資源,換來 API 全站一致、每個 endpoint 都可預期(例如天然就能查詢「這次取消目前狀態」);代價是設計時要多想一層「這個動作的名詞形式是什麼」,有時顯得繞口。
巢狀資源沒有邊界會一路巢狀下去(/companies/1/departments/2/teams/3/members/4/tasks/5),呼叫端組 URL 越來越困難,每一層都要重複驗證上層 ID 是否存在、是否有權限;動作型 endpoint 隨意命名(/doSomething),會讓 API 慢慢退化成 Day 4 陌生題描述的 RPC-over-POST。
訂單系統的「取消」「退款」在真實後端 API 中極常見,設計成資源(POST /orders/123/refunds)而非動作動詞,能自然支援「查詢這筆退款目前狀態」這類後續需求,也讓 Day 7 的 Idempotency API 設計有一個明確掛載欄位的地方。
挑一個你熟悉的網站功能(例如購物車「結帳」),分別用「動作動詞」(POST /checkout)與「資源化動作」(POST /orders,body 帶購物車內容)兩種方式設計 API,寫下兩種設計在「查詢這次結帳狀態」「同一個請求重試兩次會發生什麼」這兩個問題上的差異。
Pagination
Core FundamentalsLv.4Pagination 是把一個可能很長的資料集合,切成一頁一頁回傳給呼叫端的機制,最常見的實作方式是 offset-based(LIMIT/OFFSET)分頁。
如果一個 endpoint 回傳的資源集合沒有上限(例如GET /orders 回傳一個公司全部歷史訂單),單次回應可能是幾十萬筆資料,會拖垮伺服器記憶體、網路傳輸時間、以及呼叫端的處理能力——Pagination 讓呼叫端可以分批取得資料,每次只處理可控的資料量。
Offset-based 分頁用 ?limit=20&offset=40 這類參數,資料庫端對應 SELECT ... ORDER BY id LIMIT 20 OFFSET 40,語意是「跳過前 40 筆,取接下來 20 筆」。回應通常附帶 total(總筆數)與has_more/下一頁的連結,讓呼叫端知道是否還有更多資料、以及怎麼取得下一頁。
Offset 分頁實作簡單、呼叫端可以直接跳到任意頁(例如「跳到第 50 頁」),符合使用者「頁碼」直覺;代價是資料庫執行OFFSET N 時,即使只要第 N+1 筆之後的資料,仍然要先掃過前 N 筆再丟棄,N 越大這個代價越高,深分頁(deep pagination,例如OFFSET 100000)在大資料表上會明顯變慢。
資料在分頁查詢之間被新增或刪除時,offset 分頁會出現「跳頁」或「重複」——例如使用者在瀏覽第 2 頁時,如果第 1 頁範圍內被刪除了一筆資料,後續所有資料的 offset 位置往前移一位,使用者翻到第 2 頁時會漏看原本該在第 2 頁開頭的那一筆(因為它現在落在第 1 頁末端);反過來新增資料則可能造成同一筆資料在連續兩頁都出現一次。
後台管理介面(admin panel)這種允許使用者跳頁、資料變動頻率低、資料量通常不會超過幾萬筆的場景,offset 分頁的簡單與「可以跳頁」的優點通常勝過它的缺點,是實務上仍然常見的選擇。
一個 API 的分頁查詢在資料量還小的時候(幾千筆)回應時間穩定在 20ms,資料成長到幾百萬筆之後,使用者反應「越往後翻頁面卡得越久」,但第一頁始終很快。用 offset 分頁的機制,解釋這個現象、並說出你會怎麼驗證。(提示:OFFSET N 需要先掃描並丟棄前 N 筆才能取得目標資料,N 越大掃描量越大,第一頁 OFFSET 0 不用掃描任何資料所以維持快速;可以用 EXPLAIN ANALYZE 對比 OFFSET 0 與OFFSET 500000 兩個查詢的實際掃描列數與耗時,驗證耗時是否隨OFFSET 值線性增加。)
用任一個你有的資料表(或造一份幾萬筆的測試資料),分別執行 OFFSET 0 與 OFFSET 50000 的分頁查詢並記錄耗時,觀察兩者差異;如果資料庫支援,額外用 EXPLAIN 觀察兩個查詢的執行計畫差異。
Cursor Pagination
Core FundamentalsLv.4Cursor pagination 用「上一頁最後一筆資料的某個排序欄位值」當作下一頁查詢的起點,取代 offset 分頁用「跳過幾筆」定位下一頁。
Pagination 這個知識點已經指出 offset 分頁在深分頁與資料異動時的兩個問題(變慢、跳頁/重複)——Cursor pagination 存在的目的就是同時解決這兩個問題,代價是放棄「使用者可以任意跳頁」這個能力。
以 ORDER BY created_at, id 的清單為例,第一頁查詢SELECT ... ORDER BY created_at, id LIMIT 20,回應除了資料本身,還附帶一個 cursor(通常是最後一筆的 created_at 與 id 編碼後的字串,例如 ?cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0wMSJ9);下一頁查詢改成SELECT ... WHERE (created_at, id) > (上一頁最後一筆的 created_at, id) ORDER BY created_at, id LIMIT 20,資料庫可以直接用排序欄位上的 index 定位到起點,不需要先掃過並丟棄前面的資料,也因為條件是「比上一筆更晚」而非「第幾筆」,資料在中途被新增或刪除不會造成跳頁或重複。
Cursor 分頁換來穩定的效能(不隨深度變慢)與資料異動下的正確性,代價是失去「跳到第 N 頁」的能力(cursor 只能往前一頁、往後一頁,不能直接跳到「第 50 頁」,因為沒有中途頁的 cursor 值)、也不容易顯示「總共幾頁」(算總筆數本身仍是一次全表掃描或需要額外維護計數器)。
如果排序欄位不是唯一的(例如只用 created_at排序,但同一秒可能有多筆資料),cursor 定位會漏掉或重複同一秒內的資料——這正是上面 Mechanism 用 (created_at, id) 複合排序、而不是只用 created_at 的原因,id 保證了排序的唯一性,補上單一欄位可能重複的漏洞。
像 Twitter/X 的時間軸、聊天訊息的「載入更多」都是 cursor 分頁的典型場景——使用者只會往下捲動載入更多,不需要跳頁,資料又持續在新增,正是 cursor 分頁設計初衷要解決的場景。
一個新聞 feed API 原本用 offset 分頁,使用者反應「往下滑動載入更多時,偶爾會看到重複的新聞,偶爾又會漏看某幾則」。你會建議改用什麼分頁方式?並解釋原本 offset 分頁的問題根源是什麼。(提示:Feed 資料持續有新內容加入,offset 分頁用「第幾筆」定位,資料一旦在使用者瀏覽過程中新增,後續所有資料的 offset 位置就會偏移,造成漏看或重複;改用 cursor 分頁後,定位依據是「比上次看到的最後一筆更舊/更晚」而非位置,不受中途新增資料影響。)
把上一題 Pagination 練習用的資料表,改寫成 cursor 分頁查詢(WHERE (sort_col, id) > (上次的值)),在查詢過程中故意對表插入一筆新資料,比較 offset 分頁與 cursor 分頁在「插入後下一頁結果是否受影響」上的差異。
Filtering
Supporting TopicsLv.3Filtering 是讓呼叫端透過 query 參數縮小回傳集合範圍的機制(例如 GET /orders?status=paid&created_after=2026-01-01)。
呼叫端往往只需要資料集合裡符合特定條件的子集,如果每次都回傳整個集合再讓呼叫端自己過濾,會浪費頻寬、拖慢回應,也讓伺服器做了不必要的查詢與序列化工作。
Filtering 參數設計上要決定:(1) 參數命名慣例,通常對應資料欄位名稱(status=paid);範圍條件常用created_after/created_before 或 created_at[gte]/created_at[lte] 這類慣例表達「大於等於」「小於等於」;(2)多值篩選的表達方式(例如 status=paid,shipped 用逗號分隔,或status[]=paid&status[]=shipped 用重複參數)需要在文件裡明確定義,不能讓呼叫端自己猜;(3) 伺服器端把這些參數轉譯成資料庫查詢的WHERE 條件,篩選欄位如果沒有對應的 index,會退化成全表掃描。
開放越多欄位可篩選,API 對呼叫端越彈性、越好用;代價是每一個可篩選欄位都可能需要對應的資料庫 index 才能維持效能,欄位開得越多、需要維護的 index 也越多,也擴大了「呼叫端組出昂貴查詢」的風險面(例如允許對一個沒有 index 的長文字欄位做LIKE '%xxx%' 篩選)。
沒有限制篩選欄位是否有 index,會讓呼叫端無意間(或惡意地)組出一個全表掃描的昂貴查詢,在資料量大時直接拖慢整個資料庫;篩選參數的語意如果沒有明確定義(例如 status=paid 到底是「等於」還是「包含」),不同開發者會做出不一致的實作,前後端對不上而產生錯誤結果。
後台報表、訂單列表這類查詢頁面幾乎都需要 filtering;設計時同步檢查每個開放篩選的欄位是否已經有資料庫 index(或是否該加一個),是 API 設計與資料庫設計必須同步考慮的一個環節。
針對一個你熟悉的資料表,設計 3 個 filtering 參數(至少包含一個等值篩選、一個範圍篩選),寫出對應的 SQL WHERE子句,並檢查每個篩選欄位目前是否有 index(用 EXPLAIN 確認查詢是走 index scan 還是 sequential scan)。
練習(Day 6 綜合)
以「訂單系統」為題,設計一個 GET /orders endpoint:支援status、created_after/created_before 兩種 filtering,支援 cursor 分頁(附帶 next_cursor),並用第 1 節的資源設計原則決定「取消訂單」要用什麼 URL/方法。寫出完整的 request/response 範例(含分頁 metadata),並標註每個設計選擇對應到今天哪一個知識點。
Day 6 過關標準(DoD)
- Understand:能解釋巢狀資源何時該巢、何時該扁平化,以及非 CRUD 動作該怎麼建模成資源。
- Recall:不看資料,說出 offset 分頁與 cursor 分頁各自的查詢條件寫法,以及 cursor 分頁為什麼需要複合排序鍵。
- Apply:完成「Day 6 綜合」練習,產出完整的 endpoint 設計與 request/response 範例。
- Explain:能對照兩個「陌生題」情境(深分頁變慢、feed 分頁漏看/重複),說出你會查哪個指標、為什麼、以及該換成哪種分頁方式。
- Implement(Pagination、Cursor Pagination 為 Core Fundamentals,額外要求):對一份真實或造的資料表,實際跑過 offset 與 cursor 兩種分頁查詢並比較耗時/正確性差異,不能只是紙上推導。
Day 7 — API Design II:Sorting / Versioning / Idempotency(API 設計層級)/ Error Design
學習目標
看完今天內容後,能夠:
- 設計 sorting 參數時避免常見的語意與效能陷阱。
- 判斷一個 API 變更是否需要 Versioning,並選擇合適的版本化策略。
- 在 API 設計層級(不是重新定義 Method 語意)把 Day 4–5 的 Idempotency 落實成具體的介面設計決策。
- 設計一致的 error response 格式,讓呼叫端可以穩定地判斷錯誤類型並決定下一步行為。
教材大綱
Sorting
Supporting TopicsLv.3Sorting 是讓呼叫端透過 query 參數指定回傳集合排序依據的機制(例如 GET /orders?sort=-created_at)。
不同呼叫端對同一個集合可能需要不同的檢視順序(最新優先、金額由大到小),伺服器如果固定寫死排序方式,呼叫端就必須自己在取得全部資料後再排序,等於白白浪費了伺服器已經算好順序(或資料庫已經有 index)的優勢。
常見慣例是 sort=field 表示升冪、sort=-field(加負號)表示降冪,多重排序用逗號分隔(sort=-created_at,id,id 通常作為 tie-breaker 確保排序穩定)。伺服器端把這個參數轉譯成ORDER BY 子句,如果同時也支援 Day 6 的 cursor 分頁,排序欄位與 cursor 使用的排序鍵必須是同一組,否則 cursor 分頁的正確性(見 Day 6 Cursor Pagination 的 Mechanism)會被破壞。
開放呼叫端自訂排序欄位很方便,但每一個可排序欄位都可能需要對應的 index(尤其配合 cursor 分頁時,index 必須完全對應排序鍵的順序),開放越多可排序欄位,維運成本越高;限制成只能用固定幾種預先定義好的排序(例如只能選 newest/price_asc/price_desc),可以確保每一種都有對應 index,犧牲一點彈性換取可控的效能。
允許排序任意欄位、卻沒有對應 index,會讓資料庫執行 ORDER BY 時退化成全表排序(in-memory sort 或臨時檔案排序),在大資料量下明顯拖慢查詢;排序鍵不唯一(例如只用 created_at排序、同一秒有多筆資料)在搭配分頁時會產生同一筆資料在不同頁重複出現的問題,跟 Day 6 Cursor Pagination 的 Failure mode 是同一個根因。
電商列表頁「依價格排序」「依上架時間排序」都是典型場景;設計 API 時開放的排序選項,應該直接對應資料庫已經建好 index 的欄位組合,而不是先開放介面、事後才發現需要臨時補 index。
針對一個你熟悉的資料表,設計 sort=-created_at(降冪)與 sort=price,created_at(多重排序)兩種查詢,寫出對應 SQL,並用EXPLAIN 確認是否有可用的 index 可以直接滿足這個排序(避免額外的排序步驟)。
Versioning
Supporting TopicsLv.3Versioning 是讓 API 在需要做出破壞性變更(breaking change)時,能同時服務舊版與新版呼叫端的機制,最常見的做法是把版本號放進 URL(/v1/orders)或 header(Accept: application/vnd.myapi.v2+json)。
API 一旦有外部呼叫端在用,任何破壞性變更(改欄位名稱、改回傳格式、移除欄位)都會直接讓既有呼叫端壞掉;Versioning 讓 API 提供者可以推出新版本、同時繼續服務還沒遷移的舊版呼叫端,給雙方一個平順遷移的過渡期。
常見策略有三種:(1) URL 版本化(/v1/orders、/v2/orders):最直觀、呼叫端一看 URL 就知道用哪個版本,但同一個資源的不同版本 URL 完全不同,語意上不太乾淨;(2) Header 版本化(Accept header 帶版本資訊):URL 保持乾淨、資源語意不變,但版本資訊藏在 header 裡,除錯與文件呈現上不如 URL 直觀;(3) 欄位層級的向下相容變更(新增可選欄位、不刪除或改變既有欄位語意):完全不需要版本化,是應該優先追求的做法——只有真的無法向下相容時,才需要走到 (1) 或 (2)。
版本化讓破壞性變更有安全的推出路徑,代價是維運成本——每多維護一個版本,就要多一份程式碼路徑或轉譯邏輯,版本數量一多容易變成技術債;優先用向下相容的方式演進 API(只加欄位、不刪改),可以完全避免這個成本,但不是所有變更都能做到向下相容(例如需要改變一個欄位的資料型別)。
沒有版本化策略、直接對正式環境的既有欄位做破壞性變更,會讓所有還在用舊格式的呼叫端立即壞掉,且通常沒有預警(呼叫端只會看到自己的請求開始失敗或收到非預期格式);版本維護太久沒有淘汰計畫,會讓程式庫裡同時堆積好幾個版本的邏輯,越來越難維護。
對外開放的公開 API(例如金流服務商的 API)幾乎一定需要 Versioning,因為呼叫端是外部第三方、遷移步調不受你控制;內部微服務之間如果雙方都由同一團隊控制部署節奏,有時可以用「同步部署、不需要版本化」的方式簡化,但仍需要謹慎評估。
挑一個假設的破壞性變更(例如把 order.total 從整數分改成字串金額),分別設計「用 /v2/orders 提供新格式」與「新增order.total_formatted 欄位、保留舊欄位不動」兩種方案,比較兩者在「舊呼叫端是否需要改動」「維運成本」上的差異。
Idempotency(API 設計層級)
Core FundamentalsLv.4在 Day 4–5 已經定義的「哪些 HTTP method 語意上承諾重複安全」之上,今天處理的是「API 介面設計上,怎麼讓呼叫端能安全重試那些天生不 Idempotent 的操作(如建立訂單)」。
範圍界定(延續 Day 5):這裡談的仍然是 Day 5 定義的同一個深度層級1——深度遞增表格把 Phase 01(Day 1–10,HTTP +API Design)整段列為同一層「Method/API 語意層級」,不是更深一層。今天新增的是介面設計決策(呼叫端要傳什麼、伺服器的 API 合約長什麼樣子),不包含伺服器內部怎麼儲存、比對、判斷「這個 key 是否已經處理過」的具體實作機制——那個去重存儲與分散式一致性的問題,屬於 Phase 06(Distributed Systems,Day 51–60)Retry/Failure 情境下的「分散式重試安全層級」,會在那裡重新出現並加深。
POST /orders 這類建立資源的操作,語意上天生不是 Idempotent(見 Day 5)——但呼叫端仍然常常需要在網路逾時、不確定上一次是否成功的情況下安全重試。API 設計必須提供一個介面機制,讓呼叫端能表達「這次重試,跟上一次是同一個意圖,不要重複建立」。
常見的介面設計是讓呼叫端在請求中帶一個Idempotency-Key header(呼叫端自己產生的唯一字串,例如一個 UUID,代表「這個操作的意圖」),同一個 key 的重複請求,伺服器(在 Phase 06 會學到的去重機制之上)回傳與第一次相同的結果,而不是建立第二筆資源。API 文件需要明確定義:這個 key 的有效期限是多久、如果同一個 key 但 request body 不同該怎麼處理(通常視為錯誤,回傳衝突狀態)。
提供 Idempotency-Key 介面,讓呼叫端可以對「建立」這類操作安全重試,換來呼叫端整合重試邏輯時不需要自己額外實作「先查詢是否已存在再決定要不要送」這種脆弱的變通方案;代價是呼叫端也要負擔正確使用這個機制的責任(要記得帶 key、同一個意圖要用同一個 key),設計不良的呼叫端(例如每次重試都重新產生新 key)會讓這個機制完全失效。
如果 API 沒有提供任何 idempotency 機制,呼叫端在網路逾時後只能「賭」——要嘛冒重複建立的風險重試,要嘛不重試但可能漏掉真正需要送出的操作;提供了機制但呼叫端誤用(每次重試用新 key),一樣會造成重複建立,這通常出現在呼叫端把 key 綁定在「這次 HTTP 呼叫」而非「這個操作的原始意圖」上的設計錯誤。
金流、訂單建立這類「重複執行代價很高」的 API,幾乎都會提供 Idempotency-Key 介面(Stripe 的 API 是這個模式的知名範例);今天學的是這個介面存在的原因與設計方式,它背後的去重存儲怎麼實作、在多節點下怎麼保持一致,留給 Phase 06 處理。
一個支付 API 只提供 POST /payments(不支援 Idempotency-Key),呼叫端在網路逾時後不確定上一次請求是否已經扣款成功。用今天的 API 設計層級知識,你會建議這個 API 加上什麼介面設計?加上之後,呼叫端的重試邏輯應該怎麼寫(提示:key 要在第一次嘗試時就產生、並在所有重試中重複使用同一個 key,而不是每次重試都重新產生)?(這一題只要求你設計介面與呼叫端的正確使用方式,不要求你設計伺服器內部的去重存儲機制——那是 Day 56–60 的範圍。)
為 Day 6 綜合練習設計的 POST /orders(如果 Day 6 沒有設計建立訂單的 endpoint,這裡另外設計一個),加上Idempotency-Key header 的介面設計,寫出:呼叫端第一次呼叫、網路逾時後用同一個 key 重試、以及用不同 key 重試三種情境下,API 文件應該怎麼描述預期行為。
Error Design
Core FundamentalsLv.4Error Design 是設計一致的錯誤回應格式與分類,讓呼叫端可以穩定地用程式判斷「這個錯誤是什麼類型、我該怎麼處理」,而不是每個 endpoint 各自發明一套錯誤格式。
如果每個 endpoint 的錯誤回應格式都不一樣(有的用{"error": "..."},有的用 {"message": "..."},有的直接回傳純文字),呼叫端就無法寫出通用的錯誤處理邏輯,必須為每個 endpoint 各自處理,也難以區分「這個錯誤呼叫端能自己修正後重試」還是「這是伺服器問題,重試也沒用」。
一致的錯誤設計通常包含:(1) 正確使用 HTTP status code 分類錯誤大類——4xx 代表呼叫端的問題(請求本身有誤,重送同樣的請求不會成功,除非呼叫端先修正),5xx 代表伺服器端的問題(呼叫端的請求可能是對的,但伺服器處理失敗,可能可以重試);(2) 結構化的 error body,至少包含機器可判讀的錯誤代碼(error.code,例如 "INSUFFICIENT_BALANCE",讓呼叫端可以用程式比對而不必解析人類語言的訊息)、給人看的說明文字(error.message)、以及在驗證錯誤時指出具體是哪個欄位有問題(error.field);(3) 錯誤代碼與訊息應該在整個 API 裡保持一致的命名慣例與詳細程度,不因為不同 endpoint 是不同工程師寫的而有不同風格。
投入心力設計一致的錯誤格式與完整的錯誤代碼表,換來呼叫端可以寫出穩定、可維護的錯誤處理邏輯(例如「看到RATE_LIMITED 就退避重試,看到 VALIDATION_ERROR 就不重試、直接顯示給使用者修正」),也讓 Day 8–9 的 Retry/Circuit Breaker 邏輯有明確依據可以判斷「這個失敗能不能安全重試」;代價是設計階段要多花時間枚舉可能的錯誤情境並保持一致命名,不能想到什麼錯誤就臨時加一個格式不同的欄位。
只回傳籠統的 {"error": "something went wrong"},呼叫端完全無法判斷是驗證錯誤、權限問題還是伺服器內部錯誤,只能一律當成失敗處理(可能不必要地放棄重試,也可能不安全地盲目重試);用 200 OK 搭配 body 裡的 {"success": false} 表達錯誤(而不是用正確的 4xx/5xx status code),會讓所有依賴 HTTP status code 判斷成敗的中介層(代理伺服器、監控系統、Day 9 的 Circuit Breaker)完全失效,因為它們看到的是「成功」。
Day 9 的 Circuit Breaker 需要靠 status code/錯誤分類判斷「下游是不是真的壞了」,Day 8 的 Retry 需要靠錯誤分類判斷「這個失敗能不能安全重試」——如果 Error Design 沒做好、status code 用錯,這兩個 Reliability 機制的判斷依據就是錯的,會做出錯誤的重試或斷路決策。
一個 API 把所有錯誤(包含使用者輸入驗證失敗、資料庫連線逾時、權限不足)全部回傳 500 Internal Server Error 加上{"error": "failed"}。呼叫端實作了「看到 500 就自動重試 3 次」的邏輯。這個設計會導致什麼問題?(提示:驗證錯誤與權限不足是呼叫端的問題,重試同樣的請求絕對不會成功,只會浪費資源、甚至在高流量下對伺服器造成不必要的額外負擔;只有真正的伺服器暫時性問題才適合自動重試,把所有錯誤混在同一個 status code 底下,讓呼叫端無法區分這個關鍵差異。)
為 Day 6 綜合練習的 GET /orders(或 POST /orders)設計至少 3 種錯誤情境(例如驗證失敗、資源不存在、伺服器內部錯誤),分別寫出對應的 status code 與結構化 error body,並標註呼叫端看到每一種錯誤時「該不該重試」。
練習(Day 7 綜合)
延續 Day 6 的訂單系統設計:(1) 為 GET /orders 加上 sort參數(至少支援依建立時間、依金額排序);(2) 假設要把 order.total從整數分改成字串金額,設計一個不需要 Versioning 的向下相容方案;(3) 為 POST /orders 加上 Idempotency-Key 設計;(4) 為整個 API 設計一份錯誤代碼表(至少 5 種錯誤,含 status code、error.code、是否可重試)。
Day 7 過關標準(DoD)
- Understand:能解釋 Sorting 為什麼要限制在有 index 支援的欄位、Versioning 為什麼應該優先用向下相容取代版本號、Error Design 為什麼要用 status code 分類而不是統一回 200。
- Recall:不看資料,覆誦今天 Idempotency 段落的範圍界定(這裡新增的是介面設計、不含去重存儲實作,那部分留給哪個 Phase)。
- Apply:完成「Day 7 綜合」練習,產出完整的 sort 設計、向下相容方案、Idempotency-Key 介面設計、錯誤代碼表。
- Explain:能對照兩個「陌生題」情境(無 Idempotency-Key 的支付 API、所有錯誤都回 500 導致誤重試),說出設計缺陷在哪、該怎麼修正。
- Implement(Idempotency、Error Design 為 Core Fundamentals,額外要求):練習 (3)(4) 需要是可以直接放進 API 文件的具體規格(欄位名稱、status code、錯誤代碼字串都要明確寫出),不能只是文字描述「應該要有一個 key」。
- 依 00-knowledge-dependency-graph.md 第 3.7 節的深度遞增表格 ↩
Day 8 — Reliability I:Timeout(系統層級)/ Retry / Backoff
學習目標
看完今天內容後,能夠:
- 區分 Day 2 學過的「單次呼叫的 timeout」與今天「一條呼叫鏈上 timeout 預算怎麼分配」的差異。
- 判斷一個失敗是否適合自動重試,並依 Day 7 的 Error Design 知識找出判斷依據。
- 說明 Backoff(尤其是指數退避+Jitter)為什麼比固定間隔重試更安全,並能推導其設計。
教材大綱
Timeout(系統層級)
Core FundamentalsLv.4今天把 Timeout 從 Day 2 的「單一呼叫該等多久」,延伸到「一條呼叫鏈(A 呼叫 B、B 呼叫 C)上,各節點的 timeout 該怎麼分配,才不會出現互相矛盾的等待行為」。
Day 2 已經講過單一呼叫為什麼需要 timeout;但真實系統很少只有一次呼叫——一個對外請求往往會觸發一連串內部呼叫(A→B→C),如果每一層各自設定 timeout、彼此沒有協調,會出現 Day 2 Timeout Failure mode 提到的「上游已經放棄、下游還在處理」這類資源浪費,甚至讓整條鏈的總等待時間超過使用者能接受的範圍。
呼叫鏈的 timeout 設計原則是越往呼叫鏈上游,timeout 應該越長,且上游的 timeout 必須大於它呼叫的下游 timeout 加上自己的處理時間——例如使用者請求給 A 的總預算是 3 秒,A 呼叫 B 應該只分配比 3 秒少(例如 2.5 秒,留 0.5 秒給 A 自己的處理與網路往返),B 呼叫 C 又要比 B 拿到的預算更短。實務上常見的具體作法是使用「deadline 傳遞」:A 一開始就算出這個請求的絕對截止時間(例如 now + 3s),把這個截止時間(而不是固定秒數)往下傳給 B、C,每一層用「截止時間減去目前時間」動態算出自己還剩多少預算可以等待下游,這樣不論呼叫鏈多深,總等待時間都不會超過最上層設定的預算。
明確設計 timeout 預算分配(尤其是 deadline 傳遞),換來整條呼叫鏈的總延遲有上限、不會因為某一層 timeout 設太長而讓使用者等待遠超預期;代價是需要在服務間傳遞的呼叫(例如 gRPC context、HTTP header)額外攜帶這個截止時間資訊,並要求每一層都遵守這個約定,只要有一層沒有正確實作 deadline 傳遞,整個機制就會失效。
如果下游的 timeout 設定比上游還長(例如 A 給自己 3 秒預算,但呼叫的 B 卻允許自己等 5 秒),A 會先放棄(觸發自己的 timeout、回應使用者失敗),但 B 內部的處理仍在繼續進行,形成資源浪費,且如果 B 完成後才觸發某些有副作用的操作(例如發送通知),會出現「使用者看到失敗,但操作其實後來成功了」的不一致現象。
分散式追蹤(Day 10 的 Traces)能直接看到一條呼叫鏈上每一層實際花了多少時間,是驗證「timeout 預算分配是否合理」最直接的工具——沒有 Traces,很難察覺是哪一層的 timeout 設定跟其他層互相矛盾。
一個服務 A(總 timeout 3 秒)呼叫服務 B(B 的 timeout 設 4 秒),B 內部又呼叫資料庫(無 timeout)。使用者回報偶爾看到請求失敗,但相同的操作在資料庫慢查詢監控上顯示「查詢其實在 3.5 秒後就完成了」。用今天的機制解釋這個落差,並說出你會怎麼修正三層的 timeout 設定。(提示:A 在 3 秒時已經放棄並回應失敗給使用者,但 B 與資料庫的呼叫沒有因此被中斷,仍然繼續跑到 3.5 秒才完成——這是典型的「上游放棄、下游還在做白工」;修正方式是讓 B 的 timeout 小於 A 分配給它的預算、資料庫查詢也該有一個明確小於 B 剩餘預算的 timeout,並確保 A 放棄時能透過 context 取消(cancellation)真正終止 B 與資料庫端還在進行的工作,而不只是自己不再等待。)
設計一個三層呼叫鏈(A→B→C)的 timeout 預算分配表,給定使用者能接受的總延遲上限(例如 2 秒),寫出每一層各自應該設定的 timeout 數字與理由。
Retry
Core FundamentalsLv.4Retry 是呼叫失敗後,自動重新發送同一個請求的機制,目的是讓暫時性的失敗(網路抖動、下游短暫過載)不需要人工介入就能自我恢復。
分散式系統裡的失敗大多是暫時性的(網路封包遺失、下游正在重啟、短暫過載),如果每一次暫時性失敗都直接回報使用者或觸發告警,系統會顯得非常脆弱;Retry 讓這類暫時性問題有機會在極短時間內自我恢復,不需要整個呼叫鏈都因為一次偶發失敗而失敗。
實作 Retry 前必須先回答兩個問題:(1) 這個操作是否 Idempotent(Day 5、Day 7 已定義)——只有 Idempotent 或已經有 Idempotency-Key 保護的操作才能安全自動重試,否則重試可能造成重複扣款、重複建立等副作用;(2) 這個錯誤是否值得重試——依 Day 7 的 Error Design,5xx/網路逾時這類代表「可能是暫時性問題」的錯誤才適合重試,4xx(驗證錯誤、權限不足)代表請求本身有問題,重試不會成功,只會浪費資源。滿足這兩個條件後,才進入下一節 Backoff 決定「多久重試一次、最多重試幾次」。
Retry 讓暫時性失敗對使用者透明(使用者感受不到背後其實失敗過一次),換來更高的可用性;代價是重試本身會增加下游的請求量——如果下游正處於過載狀態,大量呼叫端同時重試反而會讓過載更嚴重(見下方 Failure mode),這也是 Backoff 與 Day 9 Circuit Breaker 存在的原因。
對非 Idempotent 操作盲目重試會造成重複副作用(見上方 Mechanism);對已經過載的下游持續重試,會讓下游的負載不減反增,形成「重試放大故障」(retry storm)——下游越慢,呼叫端等 timeout 等得越久、重試觸發得越頻繁,形成惡性循環,這正是 Day 9 Circuit Breaker 要解決的問題。
資料庫連線失敗、下游服務短暫的503 Service Unavailable,是實務上最常見會加上 Retry 的場景;決定要不要重試前,先查 Day 7 設計的 error code 是否標示為「可重試」,是把今天知識點直接落地到程式碼的方式。
一個服務對 POST /orders/123/refunds(見 Day 6 資源設計,建立一筆退款紀錄)套用了「收到 5xx 就自動重試 3 次」的通用規則,某次因為下游逾時觸發了重試,結果同一筆退款被建立了兩次。用今天 Retry 的兩個判斷條件,指出這個設計漏掉了哪一步、該怎麼修正。(提示:POST 建立退款本身不是 Idempotent(Day 5 定義),漏掉了「這個操作是否 Idempotent/有無 Idempotency-Key 保護」這個判斷條件,只看了「錯誤類型是否可重試」;修正方式是先依 Day 7 幫這個 endpoint 加上 Idempotency-Key 介面設計,讓重試安全,或者這個操作若不支援 Idempotency-Key,就不該對它套用自動重試,只能交給呼叫端手動確認後再決定是否重送。)
針對 Day 7 設計的錯誤代碼表,逐條標註「這個錯誤該不該自動重試」,並說明每一條的判斷依據(是 Idempotency 的問題,還是 Error 類型本身不適合重試)。
Backoff
Core FundamentalsLv.4Backoff 是決定「重試之間該等多久」的策略,最常見的是指數退避(exponential backoff)搭配 Jitter(隨機抖動)。
如果 Retry 用固定間隔(例如每次都等 1 秒後重試),在下游真的過載的情況下,大量呼叫端會幾乎同時、以相同節奏重複打向同一個下游,讓過載狀態難以恢復;Backoff 讓重試間隔隨失敗次數拉長,並用隨機性打散多個呼叫端的重試時間點,給下游真正恢復的空間。
指數退避的等待時間通常是base_delay * 2^attempt(例如 base_delay=100ms,第 1 次重試等 100ms、第 2 次等 200ms、第 3 次等 400ms),失敗越多次、等待越久,避免持續高頻打向一個尚未恢復的下游。單純的指數退避仍有問題:如果同時有 1000 個呼叫端在同一秒因為同一次下游故障而觸發第 1 次重試,它們會在完全相同的時間點(都等了 100ms)再次同時打向下游,造成「同步重試」的尖峰——Jitter 在計算出的退避時間上加一個隨機範圍(例如random(0, base_delay * 2^attempt)),讓 1000 個呼叫端的重試時間點被打散在一個區間內,而不是全部疊在同一個時間點。通常也會設定最大重試次數與最大退避上限(例如最多重試 5 次、單次退避不超過 10 秒),避免無限期重試。
指數退避+Jitter 讓重試行為對下游更友善、更能避開「重試風暴」,換來系統整體更容易從暫時性故障恢復;代價是單一請求如果真的需要重試到後面幾次,使用者等待的總時間會拉得更長(相較固定間隔的簡單重試),需要搭配上一節的呼叫鏈 timeout 預算一起設計,確保重試不會讓整體回應時間超過使用者能接受的上限。
只做指數退避、沒有 Jitter,在大量呼叫端因為同一次故障同時觸發重試時,仍然會出現「同步重試尖峰」——所有呼叫端雖然間隔拉長了,但拉長的時間點是一致的,本質上還是一起打向下游;退避上限設太高或重試次數設太多,會讓單一請求的總延遲遠超使用者可接受範圍,甚至超過上一節的呼叫鏈 timeout 預算,讓整條鏈提早被上游放棄,重試變成純粹浪費資源。
主流的 HTTP client library(如 gRPC 的內建 retry policy)與 message queue 的 consumer 通常都內建指數退避+Jitter 的重試機制,理解其原理才能正確設定 base_delay、max_attempts、max_backoff 這些參數,而不是照抄預設值。
一個下游服務短暫故障 5 秒後恢復,但因為 1000 個呼叫端幾乎在同一秒收到失敗、都用固定 1 秒間隔重試,下游服務「恢復」後立刻又被這 1000 個呼叫端同時打進來的重試請求打垮,再次進入故障狀態,如此反覆好幾輪才真正穩定。用 Backoff/Jitter 的機制解釋這個現象的根因,並說出你會怎麼修正重試策略。(提示:固定間隔重試會讓所有呼叫端的重試時間點完全同步,下游剛恢復的瞬間承受的是「全部呼叫端同時重試」的尖峰負載,而不是逐漸回升的負載;改用指數退避+Jitter 可以把這 1000 個呼叫端的重試時間點打散在一段區間內,讓下游承受的是逐漸回升、而非瞬間尖峰的負載。)
寫一段程式碼(任何語言)實作指數退避+Jitter 的重試邏輯(base_delay、max_attempts 可自訂),對一個故意設計成「前 2 次失敗、第 3 次成功」的模擬函式呼叫,印出每次重試前實際等待的時間,確認等待時間隨失敗次數增長、且加了隨機抖動。
練習(Day 8 綜合)
延續 Day 6–7 的訂單系統:假設 POST /orders 呼叫鏈是「API Gateway → Order Service → Payment Service」三層,(1) 給定使用者總延遲預算 2.5 秒,設計三層的 timeout 分配;(2) 針對 Payment Service 呼叫失敗,依 Day 7 錯誤代碼表判斷哪些錯誤適合 Order Service 自動重試;(3) 為適合重試的錯誤設計指數退避+Jitter 參數(base_delay、max_attempts、max_backoff),並確認總重試時間不會超過 (1) 分配給 Order Service 的 timeout 預算。
Day 8 過關標準(DoD)
- Understand:能解釋呼叫鏈 timeout 為什麼要用 deadline 傳遞而非各層各自設定固定秒數,以及 Backoff 為什麼需要 Jitter 而不只是指數增長。
- Recall:不看資料,說出決定「這個失敗該不該重試」的兩個判斷條件(Idempotent、錯誤類型)。
- Apply:完成「Day 8 綜合」練習,產出三層 timeout 分配表、可重試錯誤清單、退避參數設計。
- Explain:能對照兩個「陌生題」情境(上游放棄下游仍在做白工、重試風暴讓剛恢復的下游再次倒下),說出根因與修正方式。
- Implement(Timeout、Retry、Backoff 皆為 Core Fundamentals,額外要求):完成上面 Backoff 的程式碼練習,並能印出實際測得的等待時間數字佐證指數增長與隨機抖動都確實生效。
Day 9 — Reliability II:Circuit Breaker / Graceful Degradation / Rate Limiting
學習目標
看完今天內容後,能夠:
- 解釋 Circuit Breaker 三種狀態如何防止 Retry 造成的重試風暴持續打向一個已知故障的下游。
- 針對一個功能,設計至少一種 Graceful Degradation 方案。
- 說明 Rate Limiting 在 Reliability(本日)層級要解決的問題,以及它與 Phase 05/07 更深入的實作範圍差異。
教材大綱
Circuit Breaker
Core FundamentalsLv.4Circuit Breaker(斷路器)在偵測到下游持續失敗後,暫時直接讓後續呼叫快速失敗、不再真的發送請求給下游,給下游恢復的空間,一段時間後才嘗試放行少量請求確認下游是否已恢復。
Day 8 的 Retry+Backoff 解決了「單次暫時性失敗該怎麼安全重試」,但沒有解決「下游已經持續故障一段時間、不是暫時性問題」的情境——這種情況下繼續重試(即使有退避)仍然是在浪費資源、拖慢呼叫端自己的回應時間,甚至讓下游持續承受本來就處理不了的負載,永遠沒有恢復的機會。Circuit Breaker 存在的目的就是在「持續故障」被偵測到之後,主動停止繼續打向下游,讓下游有機會在沒有外部負載的情況下恢復。
Circuit Breaker 有三種狀態:(1) Closed(閉合,正常狀態):請求正常放行給下游,同時統計失敗率;當失敗率在一段時間窗口內超過設定的門檻(例如 10 秒內失敗率超過 50%),狀態轉為 Open。(2) Open(斷開):後續請求不再真的發送給下游,直接快速回傳失敗(fail fast),呼叫端不需要等待 timeout 就能立刻得知失敗、決定下一步(見下方 Graceful Degradation);經過一段冷卻時間(例如 30 秒)後,狀態轉為 Half-Open。(3)Half-Open(半開):只放行少量(例如 1 個)試探性請求給下游,如果成功,代表下游可能已恢復,狀態轉回 Closed,恢復正常放行;如果失敗,代表下游還沒恢復,狀態轉回 Open,重新開始冷卻計時。
Circuit Breaker 讓呼叫端在下游持續故障時能快速失敗(不用等 timeout),也保護下游不被持續的重試流量淹沒,換來更快的失敗回應與更快的系統恢復;代價是斷路門檻與冷卻時間需要仔細調校——門檻設太敏感(例如短時間內少數幾次失敗就斷路),會在下游只是短暫抖動時就誤判斷路,讓原本可以正常處理的請求也被擋下;冷卻時間設太長,下游明明已經恢復,呼叫端卻還要多等一段時間才會嘗試放行。
門檻設定不合理導致「誤斷路」(下游正常,卻被判定故障、大量請求被擋下)或「斷路太慢」(下游已經持續故障很久,Circuit Breaker 卻遲遲沒有偵測到、任由 Retry 持續轟炸下游);Half-Open 狀態如果一次放行太多試探請求(而非少量),可能在下游其實還沒完全恢復時就又送入大量流量,把剛開始恢復的下游再次打垮。
Circuit Breaker 通常搭配 Day 10 的 Metrics/Traces 一起運作——斷路器狀態轉換本身就是一個重要的監控指標(「這個下游現在是不是被斷路了」),也是排查「使用者看到快速失敗、但下游其實一直沒被打到」這類現象時的第一個檢查點。
一個服務對下游的呼叫全部套用了 Retry+指數退避,但沒有加 Circuit Breaker。下游因為自己的資料庫故障而完全無法回應,持續了 20 分鐘。這 20 分鐘內,呼叫端服務的觀察是什麼?加上 Circuit Breaker 之後,同樣的故障情境下,呼叫端的行為與資源使用會有什麼不同?(提示:沒有 Circuit Breaker 時,即使有退避,呼叫端仍會持續嘗試呼叫下游(只是頻率降低),每次呼叫都要等到 timeout 才失敗,持續佔用呼叫端自己的執行緒/連線資源長達 20 分鐘;加上 Circuit Breaker 後,偵測到持續失敗後很快進入 Open 狀態,後續請求直接快速失敗,不再消耗等待 timeout 的資源,只有 Half-Open 狀態下的少量試探請求還在真正呼叫下游。)
實作一個簡化版 Circuit Breaker(Closed/Open/Half-Open 三種狀態,可設定失敗率門檻與冷卻時間),對一個模擬函式(可以設定它「持續失敗」或「持續成功」)套用,觀察並記錄狀態轉換的時間點與觸發條件。
Graceful Degradation
Supporting TopicsLv.3Graceful Degradation(優雅降級)是當某個非核心依賴失敗或被 Circuit Breaker 斷路時,系統仍然提供一個功能較少但可用的結果,而不是讓整個請求直接失敗。
不是系統裡所有依賴都同等重要——如果一個「推薦商品」服務故障,導致整個商品頁面(含價格、庫存等核心資訊)都無法顯示,是不成比例的代價;Graceful Degradation 讓非核心功能的故障只影響它自己(例如推薦區塊留空或顯示預設內容),不拖累核心功能。
實作上通常是把一個請求依賴的多個下游呼叫,明確分類成「核心(必須成功才能回應)」與「非核心(失敗時可以省略或用預設值取代)」;對非核心依賴的呼叫包一層錯誤處理,失敗或被 Circuit Breaker 斷路時,回傳一個預先定義好的 fallback 結果(例如空陣列、快取的舊資料、靜態預設值),而不是讓例外往上傳播、拖垮整個 request。
Graceful Degradation 讓系統在部分依賴故障時仍能提供核心價值,大幅提升整體可用性;代價是需要在設計階段就明確劃分「什麼是核心、什麼可以降級」,這個判斷本身有主觀性(例如「個人化推薦」對某些商業模式可能其實是核心),劃分錯誤會讓真正重要的功能被錯誤地標記成可降級。
把實際上重要的依賴誤判為非核心而套用降級邏輯,會讓使用者在關鍵時刻拿到不完整卻看似正常的結果而不自知(例如結帳頁把「庫存檢查」誤判為非核心、降級成「假設有貨」,導致賣超);反過來,如果核心與非核心的邊界沒有清楚定義在程式碼與監控裡,即使降級邏輯本身正確,也很難在事後追蹤「這次請求是不是降級過的」,難以評估降級功能造成的實際影響範圍。
電商首頁在推薦系統故障時仍能正常瀏覽商品、社群平台在「已讀」狀態服務故障時訊息仍能正常送達,都是 Graceful Degradation 的實務案例;設計時應該同步記錄「這次回應是否經過降級」(例如回應 header 或 log 標記),供 Day 10 的 Observability 追蹤降級發生的頻率與影響範圍。
針對 Day 6–7 的訂單系統,列出 GET /orders/123(訂單詳情頁)可能依賴的多個下游(例如訂單本身、物流追蹤資訊、個人化優惠推薦),分類哪些是核心、哪些可以降級,並為每個可降級的依賴設計一個 fallback 結果。
Rate Limiting
Supporting TopicsLv.3Rate Limiting 是限制單一呼叫端(或整個系統)在一段時間內能發出的請求數量,超過限制的請求被拒絕或延後,保護系統不被過量流量壓垮。
不論是惡意的濫用(單一使用者狂打某個 endpoint)還是非惡意的異常流量(呼叫端程式錯誤造成無限迴圈重試),系統都需要一個機制限制單一來源能佔用的資源上限,避免少數來源獨佔資源、拖累其他正常使用者。
Rate Limiting 在今天(Reliability,概念層級)只需要理解它「為什麼是 Reliability 的一環」——它與 Timeout/Retry/Circuit Breaker 一樣,都是在保護系統資源不被單一來源(不論是持續失敗的下游,還是過量請求的呼叫端)耗盡;具體的演算法設計(Token Bucket/Sliding Window 等)與如何在分散式多節點下維護一致的計數狀態,屬於 Phase 05(Concurrency,Day 41–50)與 Phase 07(System Design,Day 61–70)的範圍,那裡會分別處理「單機上怎麼正確實作」與「多節點共享狀態下怎麼實作」這兩個更深的問題1。今天只需要知道:超過限制的請求通常回傳 429 Too Many Requests,並搭配 Retry-After header 告訴呼叫端該等多久再試——這正好呼應 Day 8 的 Retry/Backoff:呼叫端收到 429 時,應該尊重 Retry-After 的指示,而不是立刻重試。
Rate Limiting 保護系統資源、防止少數來源獨佔,換來整體系統對多數使用者的公平性與穩定性;代價是門檻設定需要拿捏——設太嚴格會誤傷正常但流量本來就比較大的使用場景(例如批次匯入資料的呼叫端),設太寬鬆則起不到保護作用。
完全沒有 Rate Limiting 的 API,一個寫錯重試邏輯(例如沒有 Backoff、失敗後立刻無限重試)的呼叫端可以在短時間內把伺服器資源耗盡,波及所有其他正常使用者;429 回應沒有附帶Retry-After,呼叫端只能自己猜測該等多久,容易猜太短而持續撞上限制。
公開 API 幾乎都會有某種形式的 Rate Limiting(依 API Key 或 IP 限制請求頻率),今天理解的是「它作為 Reliability 手段存在的原因」,具體怎麼在單機正確實作(Phase 05)、怎麼在多節點間共享限流狀態(Phase 07)留待之後處理。
針對 Day 6–7 的訂單系統,設計一個簡單的 Rate Limiting 規則(例如「同一個 API Key 每分鐘最多 100 次請求」),寫出超過限制時的 HTTP status code、response body、以及 Retry-Afterheader 該怎麼設定。
練習(Day 9 綜合)
延續 Day 8 的「API Gateway → Order Service → Payment Service」呼叫鏈:(1) 為 Order Service 呼叫 Payment Service 這段加上 Circuit Breaker(寫出門檻與冷卻時間的設計理由);(2) 針對 Payment Service 被斷路的情境,判斷 POST /orders 這個操作能不能 Graceful Degradation(提示:付款是否為核心依賴,不能隨意降級,說明理由);(3) 為 API Gateway 加上 Rate Limiting,並說明它與(1) 的 Circuit Breaker 保護的是系統的哪一端(呼叫端過量請求 vs 下游持續故障)。
Day 9 過關標準(DoD)
- Understand:能解釋 Circuit Breaker 三種狀態各自的用途、Graceful Degradation 如何劃分核心/非核心依賴、Rate Limiting 在今天的概念層級解決什麼問題(不涉及演算法實作)。
- Recall:不看資料,畫出 Circuit Breaker 的狀態轉換圖(Closed→Open→Half-Open→Closed/Open)。
- Apply:完成「Day 9 綜合」練習,產出 Circuit Breaker 設計、Graceful Degradation 判斷與理由、Rate Limiting 規則設計。
- Explain:能對照「陌生題」情境(沒有 Circuit Breaker 時持續故障 20 分鐘的資源消耗),說出加上 Circuit Breaker 後行為的具體差異。
- Implement(Circuit Breaker 為 Core Fundamentals,額外要求):完成上面的簡化版 Circuit Breaker 程式碼練習,並印出狀態轉換紀錄佐證行為正確。
- 依 00-knowledge-dependency-graph.md 第 3.4 節的深度遞增表格 ↩
Day 10 — Observability + Phase 01 收尾
學習目標
看完今天內容後,能夠:
- 區分 Logs/Metrics/Traces 三種 Observability 資料各自回答什麼問題,以及該在什麼情境下查哪一種。
- 說明 Error Rate/Saturation 作為系統健康指標的意義與計算方式。
- 完整回答 Phase 01 的驗收問題:一個 request 從 Browser 發出到 Application 取得 PostgreSQL 資料,中間發生什麼、每一層可能失敗在哪。
教材大綱
Logs
Supporting TopicsLv.3Logs 是系統在執行過程中,針對個別事件輸出的時間序列文字紀錄,用來回答「這一次、這一個請求,具體發生了什麼」。
系統出問題時,最常見的第一個問題是「剛剛到底發生了什麼事」——Logs 提供最細緻的事件層級紀錄(哪個使用者、哪個請求、幾點幾分、發生了什麼錯誤訊息),是排查具體單一事件最直接的資料來源。
良好的 Log 實踐包括:(1) 結構化輸出(JSON 格式而非純文字),讓 log 可以被程式化查詢與過濾,而不是只能用眼睛掃描;(2) 一致的欄位,每條 log 至少帶時間戳、severity(info/warn/error)、以及能對應到 Day 10 下面 Traces 段落的 request_id/trace_id,讓同一個請求觸發的多條 log 可以被串起來看;(3) 適當的詳細程度,過少的 log(只在真正出錯時才記)會讓正常流程完全沒有可觀察性,過多的 log(每一行程式都印)會讓真正重要的訊息被淹沒、也拖慢系統效能與增加儲存成本。
Log 越詳細,排查具體事件越容易,但代價是儲存與處理成本隨 log 量線性增加,過量的 log 反而讓「找到真正需要的那一行」變得更難;這也是為什麼大型系統通常會依 severity 分層(debug 等級只在需要時開啟,error 等級永遠記錄)。
Log 沒有帶 request_id 這類關聯欄位,當一個請求觸發了 A、B、C 三個服務各自的 log,事後完全無法知道哪幾條 log 屬於同一次請求,排查分散式系統問題時如同大海撈針;Log 內容包含敏感資料(密碼、完整信用卡卡號)沒有遮罩,會造成資料外洩風險。
線上事故排查的第一步幾乎都是「查對應時間點、對應 request_id 的 log」;設計 API 時預先規劃好每個請求都會產生一個唯一 request_id(見 Day 10 Traces),是讓 Log 真正好用的前提。
在 Day 6 設計的 POST /orders endpoint 上,設計至少 3 條會輸出的結構化 log(收到請求、驗證失敗、成功建立),每條都包含時間戳、severity、request_id、以及該事件必要的欄位。
Metrics
Core FundamentalsLv.4Metrics 是系統狀態隨時間變化的數值型量測(例如每分鐘請求數、平均延遲),用來回答「系統整體現在健不健康、趨勢是變好還是變壞」,而不是「某一次請求發生了什麼」。
Logs 適合查單一事件,但沒辦法一眼看出「過去一小時系統整體延遲是不是在惡化」——如果要靠人工掃描成千上萬條 log 才能發現趨勢,反應速度會太慢;Metrics 把大量事件聚合成少數幾個隨時間變化的數字,讓趨勢與異常一眼可見,也是自動告警的資料基礎。
Metrics 通常分三種型別:(1) Counter(只增不減的累計值,如「累計處理過的請求總數」,搭配時間窗口可以算出 QPS);(2) Gauge(某個時間點的瞬時值,如「目前使用中的連線數」,可以升可以降);(3) Histogram(把量測值分布到多個桶裡,用來算出 Day 2 學過的 p50/p95/p99——見下方 Latency/Throughput 段落)。Metrics 系統通常會定期(例如每 15 秒)從各服務抓取這些數值,累積成時間序列資料,畫成儀表板或設定告警規則(例如「p99 延遲連續 5 分鐘超過 500ms 就通知」)。
Metrics 讓系統健康狀態一目了然、可以設定自動告警,換來遠比逐條翻 Log 更快的問題發現速度;代價是 Metrics 本身是聚合後的數字,沒辦法回答「是哪一個使用者的哪一次請求」這種具體事件層級的問題(那要靠 Logs/Traces),三者要一起搭配使用,不能只靠其中一種。
只監控平均值而不是分布(呼應 Day 2 的 Latency Failure mode),會讓少數請求的嚴重問題被平均值稀釋、看不出來;Metrics 抓取頻率太低(例如每 5 分鐘才抓一次),會讓短暫但劇烈的異常(例如幾十秒的流量尖峰)在圖表上完全看不到。
Day 9 的 Circuit Breaker 狀態轉換、Day 8 的重試次數,都應該同時輸出成 Metrics(而不是只寫進 Log),才能在儀表板上直接看到「這個下游最近斷路了幾次」「重試率是不是在上升」這類趨勢。
一個服務的 Metrics 儀表板顯示「平均延遲」一直穩定在 30ms,看起來很健康,但客服陸續收到少量使用者反應「頁面偶爾卡住好幾秒」。用今天 Metrics 型別的知識,解釋這個落差可能的原因,並說出你會改看哪個 Metrics(提示:平均值會被大量快速請求稀釋掉少數極端慢的請求,這正是 Day 2 提過的問題;應該改看 Histogram 算出的 p99,甚至 p999,才能看到這些被平均值掩蓋的極端案例。)
為 Day 6 的 POST /orders endpoint 設計 3 個 Metrics:一個 Counter(請求總數)、一個 Gauge(目前處理中的請求數)、一個 Histogram(請求延遲分布),並寫出各自的抓取/計算方式。
Traces
Core FundamentalsLv.4Traces(分散式追蹤)記錄一個請求在跨越多個服務的整條呼叫鏈上,每一段花了多少時間、經過哪些服務,把 Day 8 討論過的「呼叫鏈」變成可以實際觀察的資料。
Day 8 的 timeout 預算分配、Day 9 的 Circuit Breaker,都建立在「一個請求會經過一條呼叫鏈」這個事實之上,但光靠 Logs(各服務各自獨立輸出)很難重建「這條鏈到底長什麼樣、哪一段花了最多時間」——Traces 存在的目的就是把整條鏈的時間軸攤開來,直接回答「這個請求慢在哪一段」。
一個 Trace 由多個 Span 組成,每個 Span 代表呼叫鏈上的一段工作(例如「API Gateway 處理這個請求」是一個 Span,它呼叫 Order Service 又是一個子 Span,Order Service 呼叫 Payment Service 又是更深一層的子 Span)。整條鏈共用同一個trace_id(呼應上面 Logs 段落提到的 request_id,實務上兩者經常是同一個或有明確對應關係),每個 Span 各自有開始/結束時間、以及指向父 Span 的關聯,串起來後可以畫成一張「瀑布圖」,一眼看出整個請求的總耗時中,每一段各佔多久。
Traces 讓跨服務的延遲問題定位變得非常直接(不用再靠人工比對多個服務各自的 log 時間戳去猜測),換來的是排查分散式系統延遲問題的效率大幅提升;代價是需要每個服務都正確實作「把 trace context 往下游傳遞」這件事(類似 Day 8 的 deadline 傳遞),只要有一個服務沒有正確傳遞或建立 Span,這條鏈在那一段就會斷掉,看不到後續的資訊。
某個服務忘記把 trace_id 傳給它呼叫的下一個服務,會讓那段呼叫變成一個新的、無關聯的獨立 trace,整條鏈的視覺化在那裡斷掉,排查時只能看到「A 呼叫了某個東西、花了 200 ms」,卻看不到那 200ms 裡下游實際在做什麼;Span 的父子關係建錯(例如把兩個平行呼叫誤標成序列關係),會讓瀑布圖呈現的耗時分佈失真,得出錯誤的瓶頸判斷。
Day 8 陌生題描述的「A 3 秒放棄、B 卻跑到 3.5 秒才完成」,如果有正確實作的 Traces,會直接在瀑布圖上看到「A 的 Span 在 3 秒處結束(標記逾時),但 B 的子 Span 一路延續到 3.5 秒」,比單靠對照兩個服務各自的 log 時間戳快得多、也更不容易出錯。
一個請求經過 API Gateway → Order Service → Payment Service 三層,使用者回報「這個請求花了 4 秒」,但 API Gateway 自己的 Log 顯示「呼叫 Order Service 花了 4 秒」、Order Service 自己的 Log 顯示「呼叫 Payment Service 只花了 0.3 秒」——兩邊 Log 對不起來,團隊懷疑是 Order Service 自己的處理邏輯慢,但翻遍 Order Service 的程式碼找不到明顯的效能問題。用 Traces 的機制,你會怎麼定位問題、預期會看到什麼?(提示:先確認三個服務是否都正確傳遞了同一個 trace_id/父子 Span 關係——如果 Order Service 呼叫 Payment Service 之前,還有一段自己的驗證或資料庫查詢邏輯沒有被記錄成獨立的 Span,Log 裡「花了 4 秒」與「呼叫下游只花 0.3 秒」的落差,很可能就落在這段沒有被追蹤到的自身處理時間;把這段邏輯也包成一個 Span 後,瀑布圖會直接顯示這 3.7 秒的落差發生在 Order Service 自己的哪一段程式碼,不用再靠人工比對多個服務的 log 時間戳去猜測。)
為 Day 8 的「API Gateway → Order Service → Payment Service」呼叫鏈設計 Trace 結構:畫出這條鏈對應的 Span 巢狀關係(哪個是哪個的子 Span),並標註每個 Span 需要記錄的欄位(開始時間、結束時間、trace_id、span_id、父 span_id)。
Latency/Throughput 在 Observability 中的呈現
Awareness TopicsLv.2Latency 與 Throughput 的定義在 Day 2(Networking II)已經完整建立,今天只處理「這兩個指標在 Observability 系統裡具體怎麼被量測與呈現」,不重新定義概念本身1。
這一段要知道的新東西:Latency 的 p50/p95/p99(Day 2 已定義其意義)在 Metrics 系統裡是透過上面第 2 節的 Histogram 型別實際計算出來的——每一次請求的耗時被放進對應的桶(bucket),系統定期把所有桶的資料匯總、算出各分位數;Throughput(Day 2 已定義的 QPS/bytes-per-second)在 Metrics 系統裡通常是用Counter 型別(例如「累計請求數」)搭配時間窗口做差分算出(例如「這 15 秒內 Counter 增加了多少,除以 15 秒」)。這也是為什麼上面第 2 節要先理解 Counter/Gauge/Histogram 三種型別——Latency 與 Throughput 這兩個 Day 2 定義過的指標,正是這兩種型別在實務上最常見的應用案例。
對 Day 10 第 2 節設計的 Histogram(請求延遲分布)與 Counter(請求總數),寫出「如何從這兩個原始 Metrics 計算出 p99 延遲」與「如何算出每分鐘 QPS」的具體公式或虛擬碼。
Error Rate
Core FundamentalsLv.4Error Rate 是一段時間窗口內,失敗請求數占總請求數的比例,是判斷「系統現在是不是在正常運作」最直接的單一指標。
單看「有沒有錯誤」意義不大(任何系統多少都會有零星錯誤),真正有意義的是「錯誤發生的比例是不是異常升高」——Error Rate 把 Day 7 定義好的錯誤(尤其是可歸類為系統問題的5xx)量化成一個可以設定告警門檻、可以畫成趨勢圖的指標。
Error Rate 通常用 Day 10 第 2 節的兩個 Counter 算出——error_count / total_count(例如過去 5 分鐘內5xx 回應數除以總請求數)。要正確計算,前提是 Day 7 的 Error Design 已經把錯誤正確分類到 status code——如果所有錯誤都混在同一個籠統分類底下(呼應 Day 7 的 Failure mode),Error Rate 會把「呼叫端自己的驗證錯誤」也算進「系統故障」,得出失真的指標。SLO(Service Level Objective)通常直接用 Error Rate 定義(例如「99.9% 的請求應該成功」,代表 Error Rate 的目標上限是 0.1%)。
Error Rate 是一個簡單、單一數字的健康指標,容易設定告警、容易跟團隊溝通「現在系統健不健康」;代價是它是一個聚合指標,同樣會掩蓋「是不是只有特定一小群使用者受影響」這種細節(例如整體 Error Rate 只有 0.5%,但其實是某一個特定商家的全部請求都在失敗,只是這個商家的請求量占比很小),需要能夠依維度(例如依 API Key、依 endpoint)切分 Error Rate 才能發現這類局部問題。
只看全域 Error Rate、不能依維度切分,會讓「影響範圍小但對特定使用者是 100% 失敗」的問題被淹沒在健康的全域數字裡;Error Rate 計算把可重試的暫時性錯誤(例如 Day 8 Retry 邏輯最終還是成功了的那些請求)也算成「失敗」,會讓指標過度悲觀,實務上通常只計算「重試後仍然失敗」的最終結果。
Day 9 的 Circuit Breaker 判斷是否要斷路,本質上就是在監控一個局部的 Error Rate(對特定下游的失敗率);今天學的全域/可切分維度的 Error Rate,是同一個概念在系統整體可觀察性層級的應用。
一個系統的全域 Error Rate 穩定在 0.2%(遠低於 SLO 門檻 1%),團隊因此判斷系統健康,但某天收到一個大客戶投訴「這三天我們的所有請求都失敗」。用今天的機制解釋這個落差可能的原因,並說出你會怎麼調整監控方式。(提示:全域 Error Rate 是所有使用者請求的聚合平均,如果這個大客戶的請求量占全站總量很小比例,即使他們 100% 失敗,反映在全域數字上也可能只占小數點後幾位、不足以突破告警門檻;應該額外針對關鍵維度(例如依 API Key/客戶)切分 Error Rate,才能發現「整體健康、但某個重要客戶 100% 失敗」這種局部異常。)
針對 Day 10 第 2 節設計的請求 Counter,額外設計一個error_count Counter(依 status code 分類),寫出全域 Error Rate 的計算公式,並說明如果要依 API Key 切分 Error Rate,Metrics 需要多帶哪個標籤(label/tag)欄位。
Saturation
Supporting TopicsLv.3Saturation(飽和度)是系統資源(CPU、記憶體、連線池、佇列)目前使用量占上限的比例,用來回答「系統還有多少餘裕,會不會快撐不住了」。
Error Rate 反映的是「已經發生的失敗」,是落後指標(lagging indicator);Saturation 反映的是「資源還剩多少餘裕」,往往在錯誤真正大量發生之前就會先升高,是更早期的預警訊號——例如 Day 2 的 Connection Pool 使用率逼近上限時,還沒有請求真的失敗,但已經是「快要出問題」的訊號。
Saturation 通常用 Day 10 第 2 節的 Gauge 型別量測,例如「目前使用中的連線數 / 連線池上限」「目前佇列長度 /佇列容量上限」「CPU 使用率」。這類指標的告警門檻通常設在遠低於 100% 的位置(例如 70%),因為資源使用率越接近上限,系統對突發流量的緩衝空間越小,等到真正到 100% 才反應通常已經來不及。
監控 Saturation 讓團隊能在問題真正發生(Error Rate 升高)之前就先介入(擴容、清查資源洩漏),換來更早的預警時間;代價是需要為每一種資源都建立對應的 Saturation 指標與合理門檻,覆蓋不到的資源(例如忘記監控的某個內部佇列)仍然可能在毫無預警下突然耗盡。
只監控 Error Rate、不監控 Saturation,團隊只能在問題已經發生後才知道;Day 2 提過的 Connection Pool 洩漏(連線借出後沒有歸還)如果有對應的 Saturation 指標,會在連線使用率持續攀升時提早被發現,而不必等到連線池真正耗盡、開始出現請求失敗才被注意到。
容量規劃(capacity planning,Day 2 的 Throughput 段落提過)本質上就是在追蹤各種資源的 Saturation 趨勢,決定「什麼時候該擴容」;今天把它正式納入 Observability 的四個核心訊號(連同 Latency/Traffic/Throughput/Errors,即業界常提的「Four Golden Signals」)之一。
為 Day 6–9 訂單系統呼叫鏈上會用到的三種資源(HTTP Server 的 thread pool、資料庫連線池、Day 9 的 Rate Limiting 計數器)各自設計一個 Saturation Gauge 與告警門檻,並說明每個門檻設定的理由。
Phase 01 完整驗收(Day 1–10)
延續本檔案 Day 5 之後的「階段性檢查」,現在可以完整回答第 5 章2的驗收問題:
一個 request 從 Browser 發出,到 Application 取得 PostgreSQL 資料,中間發生什麼?每一層可能失敗在哪裡?
完整流程:
- DNS 解析(Day 3):Browser 向 Resolver 查詢網域對應的 IP,若 Cache 命中直接回答,否則走 Root → TLD → Authoritative Server 的遞迴解析。*可能失敗*:Resolver 故障或回應變慢(看起來像「連不上任何服務」);快取住過期或錯誤的 IP。
- 建立 TCP 連線(Day 1):與解析到的 IP 進行三次交握(SYN → SYN-ACK → ACK)。*可能失敗*:中間路徑封包遺失導致交握逾時;大量偽造 SYN 造成 server 端資源耗盡(SYN flood)。
- 協定版本協商與連線重用(Day 5、Day 2):依伺服器支援情況決定走 HTTP/1.1(可能開多條連線或靠 Keep-alive 重用單一連線)、HTTP/2(單一 TCP 連線多工)或 HTTP/3(QUIC,UDP 之上各自獨立的 stream)。*可能失敗*:差網路下 HTTP/2 的 TCP 隊首阻塞讓多工優勢失效;企業防火牆封鎖 UDP 導致 HTTP/3 連線失敗,需要 fallback。
- 送出 HTTP Request(Day 4):帶正確的 method(依 Day 4 Safe/Unsafe、Day 5 Idempotency 語意選擇)、headers、視需要帶 Cookie。*可能失敗*:method 選錯導致操作在快取/代理層有非預期行為;Idempotency 語意誤判導致重試造成重複副作用(Day 5 範圍界定)。
- 請求抵達 Application,依 API Design 慣例被路由與解析(Day 6–7):URL 對應到正確的資源/動作、query 參數依 filtering/sorting/pagination 慣例解析。*可能失敗*:非核心 CRUD 動作命名不一致導致路由邏輯混亂;分頁參數在資料量大時(offset)或資料異動時造成錯誤結果。
- Application 執行商業邏輯,可能呼叫下游服務(Day 8–9):每一段呼叫都在 Day 8 設計的 timeout 預算內進行,失敗時依 Retry/Backoff 判斷是否安全重試,持續故障的下游會被 Day 9 的 Circuit Breaker 斷開、視情況觸發 Graceful Degradation。*可能失敗*:timeout 預算分配不當造成「上游放棄、下游做白工」;沒有 Circuit Breaker 導致重試風暴;非核心依賴未正確降級拖垮整個請求。
- Application 透過 Connection Pool 向 PostgreSQL 查詢資料(Day 2):從連線池取得(或等待)一條可用連線,送出查詢。*可能失敗*:連線池過小造成請求排隊、延遲飆升(Day 1–2 陌生題已示範);連線池過大反而壓垮資料庫的
max_connections。 - 回應沿原路徑送回 Browser,途中若違反 Rate Limiting 會提早被拒絕(Day 9):超過限制的請求在到達 Application 商業邏輯前就被擋下,回傳
429與Retry-After(Day 9)。 - 回應格式依 Error Design 慣例呈現成敗(Day 7):成功回傳對應資料與 status code;失敗時回傳結構化 error body,讓呼叫端(或前面提到的 Retry 邏輯)能判斷這個失敗的類型與能不能重試。
- 整個過程中,每一層都在產生 Observability 訊號(Day 10):Logs 記錄個別事件、Metrics 累積成趨勢(含 Day 2 定義的 Latency/Throughput 在這裡被實際量測)、Traces 把跨服務的完整耗時攤開成瀑布圖;Error Rate 與 Saturation 分別是「已發生的失敗」與「即將發生失敗的預警」兩種訊號,共同構成排查上述任何一個環節出問題時的資料基礎。
Day 6–10 完成後,本 Phase 01(Day 1–10)對第 5 章驗收問題的回答已完整涵蓋 Networking/HTTP/DNS/API Design/Reliability/Observability 六個必須理解的分類,Phase 02(Data Structures)由 T-021 接續。