Chapter 08

並行、效能調校與 Profiling

職缺明寫「profile and performance-tune critical components」與「performance profiling and debugging」。本章從並行原語與 I/O 模型講起,建立效能方法論(USE、RED、Little's law、tail latency),再實戰 Python / Linux / Go 的 profiling 工具、優化手法、壓測陷阱與記憶體洩漏排查,最後用一個「p99 從 50ms 變 800ms」的完整劇本收尾。

預估閱讀 80 分鐘

並行基礎:race condition、同步原語與 deadlock 高頻

Concurrency vs Parallelism

Concurrency(並發)

一種程式結構:多個任務的生命週期重疊,可以在單核上交錯執行。重點是「同時處理很多事」(dealing with lots of things at once)。例:asyncio 單執行緒同時服務一萬個連線。

Parallelism(平行)

一種執行方式:多個運算在同一時刻真的在不同核心上跑。重點是「同時做很多事」(doing lots of things at once)。例:multiprocessing 用 8 核心同時算特徵。

Rob Pike 的說法:concurrency 是關於結構,parallelism 是關於執行;好的並發結構讓平行變得可能,但不保證。CPython 的 threading 有 concurrency 但(因 GIL)沒有 CPU parallelism;Go 的 goroutine 兩者皆有。

Race condition 與 critical section

Race condition:結果取決於多個執行緒操作的交錯順序。最常見兩種型態:

  • Read-modify-write:count += 1 = 讀 → 加 → 寫,兩個 thread 同時讀到 5,各自寫回 6,遺失一次更新。
  • Check-then-act:if key not in cache: cache[key] = load(),兩個 thread 都判斷不存在,各自 load 一次(快取擊穿的小型版本);或「檢查餘額足夠 → 扣款」被並發穿透導致超扣。

Critical section 是存取共享資源、同一時間只能有一個執行者進入的程式區段。原則:臨界區越短越好(不要在持有鎖時做 I/O)、所有存取同一資源的路徑都要用同一把鎖保護。

sequenceDiagram participant A as Thread A participant M as 共享變數 balance=100 participant B as Thread B A->>M: read 100 B->>M: read 100 A->>A: 100 - 80 = 20(檢查 ≥ 80 通過) B->>B: 100 - 80 = 20(檢查 ≥ 80 通過) A->>M: write 20 B->>M: write 20 Note over M: 扣了兩次 80,餘額卻是 20(應拒絕第二筆)
Appier 情境:預算超支(overspend)

廣告預算扣款是典型的 check-then-act。數百台 bidder 同時對同一 campaign 出價,若每台都「讀取剩餘預算 → 判斷足夠 → 出價」,就會集體超支。常見做法:Redis DECRBY/Lua script 做原子扣款、每台 bidder 預先領取一小段預算額度(token bucket / budget pacing)在本地扣、接受小幅超支並在對帳時修正。面試時能說出「完全精準需要全域鎖,代價是延遲;工程上用可控的近似換取吞吐」就很加分。

同步原語一覽

原語語意適用Python / Go
Mutex(互斥鎖)一次只有一個持有者;必須由持有者釋放保護 critical sectionthreading.Lock / sync.Mutex
Reentrant lock同一 thread 可重複取得(計數)遞迴呼叫中要再拿同一把鎖threading.RLock / Go 刻意不提供
RWLock(讀寫鎖)多個 reader 可同時持有,writer 獨佔讀遠多於寫(設定、路由表)標準庫無(可用第三方)/ sync.RWMutex
Semaphore(號誌)計數器,允許最多 N 個同時進入;任何人可 release限制並發數(連線數、外部 API 呼叫數)threading.Semaphore、asyncio.Semaphore / buffered channel
Condition variable搭配 mutex:等待「某條件成立」並被通知喚醒自己實作 bounded queue、等待狀態改變threading.Condition / sync.Cond
Event一次性旗標,等待者阻塞直到被 set啟動完成通知、graceful shutdownthreading.Event / close(ch)
Atomic / CAS硬體保證的不可分割操作,無鎖計數器、旗標、lock-free 結構無公開 API(CPython 靠 GIL)/ sync/atomic
import threading, collections

class BoundedQueue:
    """用 Condition 實作阻塞佇列:面試常要求手寫"""
    def __init__(self, capacity: int):
        self.buf = collections.deque()
        self.cap = capacity
        self.lock = threading.Lock()
        self.not_full = threading.Condition(self.lock)    # 兩個 condition 共用同一把鎖
        self.not_empty = threading.Condition(self.lock)

    def put(self, item):
        with self.not_full:
            while len(self.buf) >= self.cap:     # 一定要用 while,不是 if:防止 spurious wakeup 與被別人搶先
                self.not_full.wait()
            self.buf.append(item)
            self.not_empty.notify()

    def get(self):
        with self.not_empty:
            while not self.buf:
                self.not_empty.wait()
            item = self.buf.popleft()
            self.not_full.notify()
            return item

q = BoundedQueue(2)
t = threading.Thread(target=lambda: [print("got", q.get()) for _ in range(3)])
t.start()
for i in range(3):
    q.put(i)
t.join()

Atomic 操作與 CAS

Compare-And-Swap:「如果記憶體位置目前的值等於 expected,就改成 new,回傳是否成功」,由 CPU 一條指令完成(x86 的 LOCK CMPXCHG)。lock-free 演算法以 CAS 迴圈實作:讀舊值 → 計算新值 → CAS,失敗就重試。

package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var hits atomic.Int64 // Go 1.19+ 型別化 atomic
	var maxLatency atomic.Int64
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func(lat int64) {
			defer wg.Done()
			hits.Add(1)
			for { // CAS 迴圈:更新最大值
				cur := maxLatency.Load()
				if lat <= cur || maxLatency.CompareAndSwap(cur, lat) {
					break
				}
			}
		}(int64(i % 97))
	}
	wg.Wait()
	fmt.Println(hits.Load(), maxLatency.Load()) // 1000 96
}

注意:(1) CAS 在高競爭下大量重試會浪費 CPU;(2) ABA 問題:值從 A 變 B 又變回 A,CAS 誤以為沒變——lock-free stack 中可能導致錯誤,常用版本號(tagged pointer)解決;(3) 分散式版本的 CAS 就是資料庫的 optimistic locking(UPDATE ... SET v = v + 1 WHERE id = ? AND version = ?)與 Redis 的 WATCH/MULTI。

Deadlock:四個必要條件與預防

Coffman 條件,四個同時成立才會死結:

  1. Mutual exclusion:資源一次只能被一個執行者持有。
  2. Hold and wait:持有資源的同時等待其他資源。
  3. No preemption:資源不能被強制搶走,只能由持有者釋放。
  4. Circular wait:存在等待環 A 等 B、B 等 A。

預防 = 打破任一條件:

打破的條件做法實例
Circular wait(最常用)全域固定的加鎖順序轉帳時永遠先鎖 id 小的帳戶;DB 交易依主鍵排序更新多列
Hold and wait一次取得所有需要的鎖,取不到就全部放掉兩階段鎖定前先宣告資源集合
No preemptionacquire(timeout=...) / try_lock,失敗就釋放已持有的鎖並 backoff 重試DB 的 lock wait timeout;InnoDB 偵測到死結時回滾其中一個交易
Mutual exclusion改用不需互斥的設計不可變資料、message passing、單一 owner(actor)
import threading

class Account:
    def __init__(self, aid, balance):
        self.id, self.balance, self.lock = aid, balance, threading.Lock()

def transfer(a: Account, b: Account, amount: int) -> bool:
    first, second = (a, b) if a.id < b.id else (b, a)   # 固定順序,打破 circular wait
    with first.lock, second.lock:
        if a.balance < amount:
            return False
        a.balance -= amount
        b.balance += amount
        return True
Livelock
執行者沒有阻塞、一直在「動」,卻都在互相讓步而沒有進展。例:兩個 thread 都 try_lock 失敗後立刻釋放再重試,步調一致地永遠衝突。解法:隨機化 backoff(jitter)。乙太網路 CSMA/CD 的指數退避就是這個概念。
Starvation(飢餓)
某個執行者長期拿不到資源。例:reader 優先的 RWLock 在讀流量不斷時 writer 永遠等不到;優先權排程中低優先任務永遠不被執行。解法:公平鎖(FIFO)、aging(等越久優先權越高)、writer-preferring RWLock。
Priority inversion
高優先權任務等待被低優先權任務持有的鎖,而低優先權任務又被中優先權任務搶占。解法:priority inheritance。(火星探路者號的經典案例)
面試官想聽到什麼

講完四條件後,舉一個實際遇過或會遇到的例子並說明怎麼偵測:Python 可用 py-spy dump 看所有 thread 卡在哪個 acquire;Go 會在所有 goroutine 都阻塞時 panic「all goroutines are asleep - deadlock!」,部分阻塞則看 goroutine profile;MySQL 用 SHOW ENGINE INNODB STATUS 看 LATEST DETECTED DEADLOCK。再補「預防優先用固定加鎖順序 + 縮小鎖範圍 + 加 timeout」。

Producer-Consumer 與 Thread Pool 大小 進階

Producer-consumer 用一個有界佇列把「產生工作」與「處理工作」解耦:平滑突發流量、讓兩邊用不同並發度、並透過佇列滿時阻塞達成 backpressure(背壓)。無界佇列看似不會阻塞,實際上只是把問題延後成記憶體爆掉與延遲無限增長。

flowchart LR P1["Producer:HTTP handler"] --> Q[["Bounded Queue(maxsize=1000)"]] P2["Producer"] --> Q Q --> C1["Consumer 1:批次寫入 DB"] Q --> C2["Consumer 2"] Q --> C3["Consumer N"] Q -. "滿了 → put 阻塞或回 429/503(背壓)" .-> P1
import queue, threading, time, random

SENTINEL = object()
q: "queue.Queue[object]" = queue.Queue(maxsize=100)   # 有界:提供背壓

def producer(n: int):
    for i in range(n):
        q.put({"event_id": i})            # 佇列滿時阻塞;也可用 put(timeout=0.01) 失敗就丟棄/回錯

def consumer(cid: int, batch_size: int = 20):
    batch = []
    while True:
        item = q.get()
        if item is SENTINEL:
            q.task_done()
            break
        batch.append(item)
        if len(batch) >= batch_size:
            time.sleep(random.uniform(0.005, 0.01))   # 模擬批次寫入 DB:一次 I/O 處理 20 筆
            batch.clear()
        q.task_done()
    if batch:
        pass                                          # flush 剩餘
    print(f"consumer {cid} exit")

consumers = [threading.Thread(target=consumer, args=(i,)) for i in range(4)]
for c in consumers: c.start()
producer(1000)
for _ in consumers: q.put(SENTINEL)                   # 每個 consumer 一個毒丸
for c in consumers: c.join()
import asyncio

async def main():
    q: asyncio.Queue[int] = asyncio.Queue(maxsize=100)

    async def worker(name: str):
        while True:
            item = await q.get()
            try:
                await asyncio.sleep(0.001)     # 處理
            finally:
                q.task_done()

    workers = [asyncio.create_task(worker(f"w{i}")) for i in range(8)]
    for i in range(1000):
        await q.put(i)                         # 滿了會 await,自然背壓
    await q.join()                             # 等所有 item 被 task_done
    for w in workers: w.cancel()
    await asyncio.gather(*workers, return_exceptions=True)

asyncio.run(main())

Thread pool 要開多大?

  • CPU-bound:約等於核心數 N_cores(或 +1 以填補偶發的 page fault 等停頓)。開更多只會增加 context switch 與 cache 失效。Python 要用 process pool 才有意義。
  • IO-bound(Brian Goetz《Java Concurrency in Practice》公式):
    N_threads = N_cores × U_target × (1 + W / C)
    其中 W 是等待時間、C 是計算時間、U_target 是目標 CPU 使用率。例:8 核、每個請求 CPU 計算 5ms、等待下游 45ms,目標使用率 80% → 8 × 0.8 × (1 + 45/5) = 64。
  • 用 Little's law 反推:需要的並發數 = 目標吞吐 × 每個請求的平均延遲。要撐 2,000 RPS、下游延遲 50ms → 至少 2000 × 0.05 = 100 個並發中的請求(thread、coroutine 或連線)。
  • 真正的上限常常不是 CPU 而是下游:DB 連線數、對方 API 的 rate limit。pool 大小要跟連線池大小、下游容量一起設計,否則只是把排隊從 app 移到 DB。
常見陷阱

(1) 用無界佇列的 executor(Python ThreadPoolExecutor 內部的 work queue 是無界的;Java Executors.newFixedThreadPool 也是)在流量高峰時會累積大量待處理工作 → 記憶體成長、所有請求延遲一起變大,應在外層加 semaphore 或有界佇列並做 load shedding。(2) pool 中的任務又同步等待同一個 pool 的其他任務 → thread starvation deadlock。(3) 公式只是起點,最終要壓測找出吞吐不再增加、延遲開始上升的拐點。

I/O 模型:blocking、non-blocking、epoll 與 reactor 高頻

模型行為優缺點
Blocking I/Orecv() 沒資料就讓 thread 睡著直到有資料程式最簡單;一連線一 thread,連線多時記憶體與 context switch 成本爆炸
Non-blocking I/Ofd 設 O_NONBLOCK,沒資料立刻回 EAGAIN不阻塞,但自己輪詢會空轉燒 CPU
I/O multiplexing用 select/poll/epoll 一次等待多個 fd,回傳「哪些就緒」單 thread 管理大量連線;Nginx、Redis、Node.js、asyncio、Go netpoller 的基礎
Signal-driven I/Ofd 就緒時 kernel 送 SIGIO少用
Asynchronous I/O提交請求,kernel 完成整個讀寫(含複製到 user buffer)後通知Linux 的 io_uring(5.1+)、Windows IOCP;真正的 completion-based

select vs poll vs epoll

selectpollepoll(Linux)
fd 數量上限FD_SETSIZE,通常 1024無固定上限無固定上限(受 fd limit)
每次呼叫把整個 fd set 從 user 複製到 kernel同樣傳入整個陣列用 epoll_ctl 註冊一次,kernel 保存興趣列表
找出就緒 fdkernel 與 user 都要 O(n) 掃描O(n)epoll_wait 只回傳就緒的 fd,成本與就緒數成正比
觸發模式level-triggeredlevel-triggeredLT(預設)或 edge-triggered(ET)
可攜性到處都有POSIXLinux 專屬(BSD/macOS 用 kqueue)

Level-triggered:只要 buffer 還有資料,每次 epoll_wait 都會回報。Edge-triggered:只在狀態「變化」時回報一次,所以必須搭配 non-blocking fd 並一直讀到 EAGAIN,否則剩下的資料不會再被通知。ET 減少重複通知,但更難寫對。

import selectors, socket

sel = selectors.DefaultSelector()           # Linux 上自動選 EpollSelector

def accept(server: socket.socket):
    conn, _ = server.accept()
    conn.setblocking(False)
    sel.register(conn, selectors.EVENT_READ, handle)

def handle(conn: socket.socket):
    data = conn.recv(4096)
    if data:
        conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")   # 簡化:假設 send 不會被阻塞
    else:
        sel.unregister(conn)
        conn.close()

server = socket.socket()
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind(("127.0.0.1", 8080)); server.listen(1024); server.setblocking(False)
sel.register(server, selectors.EVENT_READ, accept)

def run_forever():                           # 這就是 reactor:一個 loop + 事件分派到 handler
    while True:
        for key, _ in sel.select(timeout=1):
            callback = key.data
            callback(key.fileobj)
# run_forever()

Reactor vs Proactor

flowchart LR subgraph Reactor["Reactor(就緒通知)"] D["Demultiplexer:epoll_wait"] --> H["Dispatcher"] H --> A1["accept handler"] H --> R1["read handler:自己呼叫 recv"] end subgraph Proactor["Proactor(完成通知)"] S["提交 read 請求:io_uring / IOCP"] --> K["kernel 完成讀取並複製資料"] K --> CB["completion handler:資料已在 buffer"] end
  • Reactor:通知「可以讀了」,由應用程式自己讀。Nginx、Redis(單執行緒 reactor;6.0 起網路 I/O 可多執行緒)、Netty、asyncio、libuv(Node.js)。
  • Multi-reactor:一個 main reactor 負責 accept,多個 sub reactor(每核一個)負責連線讀寫——Nginx 的多 worker process、Netty 的 boss/worker group。
  • Proactor:通知「讀完了」。Windows IOCP、Linux io_uring。

C10K 與 C10M

C10K(Dan Kegel,1999):如何讓一台伺服器同時處理一萬個連線。答案是放棄「一連線一 thread/process」,改用 non-blocking I/O + epoll/kqueue 的事件驅動架構,並調整 OS 參數(fd 上限 ulimit -n、somaxconn、ephemeral port 範圍)。今天 C10K 已是常態。

C10M(Robert Graham,2013):一千萬並發連線。瓶頸變成 kernel 本身(中斷、系統呼叫、kernel 網路堆疊的鎖與複製),解法是kernel bypass(DPDK、user-space TCP stack)、多佇列網卡與 RSS 把封包分散到各核、CPU pinning、zero-copy、hugepages。了解即可,但它說明了一個觀念:規模每上升一個數量級,瓶頸就會移動到下一層。

面試官想聽到什麼

「為什麼 Redis 單執行緒還這麼快?」→ 資料在記憶體、操作多為 O(1)、單執行緒避免鎖與 context switch、用 epoll 的 reactor 模型處理大量連線,瓶頸通常是網路與記憶體頻寬而非 CPU。「epoll 為什麼比 select 快?」→ 註冊一次不必每次複製 fd set、只回傳就緒的 fd 不用 O(n) 掃描、沒有 1024 上限。延伸到 Linux 調校請見第 3 章與第 4 章。

效能方法論:USE、RED、Amdahl、Little's law 與 tail latency 高頻

效能調校最大的錯誤是「憑感覺改程式」。正確流程:定義目標(SLO)→ 量測 → 找瓶頸 → 假設 → 改一個變數 → 再量測。以下是幫你系統化找瓶頸的框架。

flowchart LR A["定義問題與目標
例:p99 小於 50ms @ 5k RPS"] --> B["量測現況
metrics / traces"] B --> C["定位資源或元件
USE / RED"] C --> D["Profiling 找熱點
CPU / 記憶體 / 鎖 / I/O"] D --> E["提出假設並只改一件事"] E --> F["壓測驗證
比較分布,不只平均"] F -->|"未達標"| C F -->|"達標"| G["上線 + 監控回歸"]

USE method(看資源)

Brendan Gregg 提出:對每一個資源(CPU、記憶體、磁碟、網卡、以及軟體資源如連線池、thread pool、鎖)檢查:

Utilization
資源忙碌的時間比例(或使用容量比例)。例:CPU 85%、磁碟 %util 95%。
Saturation
排隊中、無法被服務的工作量。例:CPU run queue 長度(vmstat 的 r 大於核心數)、磁碟 queue size、連線池等待數、swap。
Errors
錯誤事件數。例:網卡 drop、TCP retransmit、磁碟錯誤、OOM kill。

重點是 saturation 往往比 utilization 更早反映問題:CPU 平均 60% 但 run queue 一直很長,代表短時間內有突發;連線池使用率 100% 且有大量等待者,延遲必然上升。

RED method(看服務)

Tom Wilkie 提出,針對每個服務/endpoint:Rate(每秒請求數)、Errors(失敗率)、Duration(延遲分布,看 p50/p90/p99/p999)。Google SRE 的 Four Golden Signals 是 latency、traffic、errors、saturation,等於 RED + saturation。實務上:RED 做服務儀表板與告警,USE 做資源層的根因分析。詳見第 9 章。

Amdahl's law:平行化的上限

若程式中可平行的比例為 p,用 N 個處理器的加速比:

Speedup(N) = 1 / ((1 − p) + p / N)

例:p = 0.9(90% 可平行),N = 8 → 1 / (0.1 + 0.1125) ≈ 4.7 倍;N → ∞ 也最多 10 倍。結論:序列部分決定上限。優化時先找「每個請求都必須經過的序列化點」:全域鎖、單一 DB primary、單執行緒的聚合器、同步的 log flush。Amdahl 也適用於單機優化:某函式佔總時間 20%,就算把它優化到 0,總時間也只降 20%——先 profile 找出佔比最大的部分再動手。(Gustafson's law 從另一角度補充:問題規模隨處理器增加而擴大時,可達到的加速更樂觀。)

Little's law:並發、吞吐與延遲

L = λ × W:系統中平均的工作數 L = 平均到達率 λ × 平均停留時間 W。對穩定系統成立,與分布無關。

  • 服務 1,000 RPS、平均延遲 200ms → 系統中平均有 200 個同時進行中的請求,你的 worker/連線/coroutine 至少要能容納這麼多。
  • 下游 DB 變慢(W 從 5ms 變 50ms),同樣 RPS 下同時佔用的連線數變 10 倍 → 連線池耗盡 → 排隊 → 延遲更高:這就是延遲上升 → 並發上升 → 資源耗盡的連鎖反應。
  • 反推容量:一個 worker 同時只能處理 1 個請求、延遲 50ms → 每 worker 上限 20 RPS;要 2,000 RPS 需要 100 個 worker。

Latency vs Throughput

Latency(延遲)

單一請求從送出到完成所花的時間。使用者感受到的是延遲。用分布(percentile)描述,不要只看平均。

Throughput(吞吐量)

單位時間完成的工作量(RPS、MB/s)。代表系統容量。批次化、pipelining 常以增加單筆延遲換取吞吐。

兩者的關係由排隊理論決定:使用率接近 100% 時,排隊時間會急遽上升(M/M/1 模型中平均等待時間與 ρ / (1 − ρ) 成正比,ρ 為使用率;ρ 從 50% 到 90%,該項從 1 變成 9)。所以延遲敏感的服務要保留 headroom,常見目標是 CPU 平均使用率 50–70%,而不是榨到 95%。

Tail latency:為什麼 p99 比平均重要

  • 平均值會騙人:99 個請求 10ms、1 個請求 2 秒,平均 30ms 看起來很好,但那 1% 的使用者等了 2 秒。
  • 重度使用者更常遇到 tail:一個頁面載入發出 50 個請求,至少一個碰到 p99 的機率是 1 − 0.9950 ≈ 39%。
  • Fan-out 放大(Dean & Barroso,《The Tail at Scale》):一個請求要等 N 個後端都回應才能完成,整體延遲由最慢的那個決定。若每個後端有 1% 機率超過 1 秒,fan-out 到 100 個後端時,1 − 0.99100 ≈ 63% 的請求會超過 1 秒。
import random, statistics

def simulate(fanout: int, n: int = 100_000) -> float:
    """每個後端:99.5% 在 5~15ms,0.5% 是 200ms 的慢請求;回傳整體 p99"""
    def backend():
        return 200.0 if random.random() < 0.005 else random.uniform(5, 15)
    totals = sorted(max(backend() for _ in range(fanout)) for _ in range(n // fanout + 1000))
    return totals[int(len(totals) * 0.99)]

for f in (1, 10, 50, 100):
    print(f"fan-out={f:3d}  整體 p99 ≈ {simulate(f):.0f} ms  慢請求比例 ≈ {1 - 0.995 ** f:.1%}")
# fan-out=1 時 p99 仍在 15ms 左右;fan-out=10 起 p99 就是 200ms

對抗 tail latency 的手法:

  • Hedged requests:送出請求後若超過 p95 時間仍未回應,就對另一個副本再送一次,取先回來的結果(只多約 5% 負載)。
  • Tied requests:同時送給兩個副本並互相告知,一方開始處理就取消另一方。
  • 嚴格的 timeout + 部分結果:超時的分片直接放棄,用已有結果回應(降級)。
  • 減少變異來源:GC 停頓、背景任務(compaction、cron)、noisy neighbor、CPU throttling(K8s CPU limit 的 CFS quota 造成的週期性卡頓是常見 p99 殺手)、排隊。
Appier 情境

以 RTB 為例:ad exchange 對 bidder 有硬性的回應期限(常見在 100ms 左右,含網路);逾時等於棄權,量大時還可能被 exchange 降低流量配額。bidder 內部若對特徵庫、預算服務、模型服務 fan-out,每一個下游的 tail 都會被放大。因此設計時會給每個下游呼叫明確的子預算(例如特徵查詢 10ms)、超時就用預設值、監控 p99/p999 而非平均。

Latency numbers every programmer should know 高頻

源自 Jeff Dean 與 Peter Norvig 的整理,數值隨硬體世代變動,以下為現代伺服器的數量級近似值。面試重點不是背精確數字,而是知道各層之間差幾個數量級,用來做 back-of-the-envelope 估算。

操作約略時間換算直覺
L1 cache 存取~1 ns基準
分支預測失敗~3–5 ns
L2 cache 存取~4–7 ns
無競爭的 mutex lock/unlock~15–25 ns
主記憶體(DRAM)存取~100 nsL1 的 100 倍
簡單系統呼叫~100 ns – 1 µs依呼叫與緩解措施而定
Context switch(thread/process)~1–5 µs含 cache 污染的間接成本更高
用 Snappy 壓縮 1 KB~2 µs
透過 1 Gbps 網路傳送 1 KB~10 µs10 Gbps 約 1 µs
從記憶體循序讀 1 MB~10–100 µs單核心可達約 10–20 GB/s
NVMe SSD 隨機讀 4 KB~20–100 µsDRAM 的數百倍
同一資料中心內網路來回(RTT)~0.1–0.5 msRedis GET 的下限大約在這裡
從 SSD 循序讀 1 MB~0.2–1 ms
HDD 磁碟搜尋(seek)~2–10 ms這就是為什麼 HDD 怕隨機 I/O
從 HDD 循序讀 1 MB~5 ms
同區域(region)跨可用區 RTT~1–2 ms
台灣 ↔ 日本 RTT~30–50 ms光速與路由限制
美西 ↔ 歐洲 RTT~150 msDean 原表的 CA→Netherlands→CA
TCP + TLS 1.3 新連線建立2 RTT(TCP 1 + TLS 1)這就是連線池與 keep-alive 的價值
實戰提示:用這張表做估算

「一個請求要查 Redis 10 次,循序做要多久?」→ 同機房 RTT 約 0.2–0.5ms,10 次循序就是 2–5ms;改用 pipeline / MGET 變成 1 次 RTT。「每個請求做一次 TLS 握手到下游」→ 至少多 2 個 RTT,跨區就是數十毫秒,必須用連線池。「Python 解析一個 5KB 的 JSON」→ 數十微秒等級,10,000 RPS 下就是每秒數百毫秒的 CPU,換 orjson 可能省下一顆核心。

Python Profiling 工具實戰 高頻

先選對工具。Profiler 分兩大類:deterministic / tracing(每次函式呼叫都記錄,精確但 overhead 大,會扭曲結果)與 sampling(每隔固定時間抓一次 call stack,overhead 低、可用於 production,但對極短函式不精確)。

工具類型看什麼OverheadProduction 可用
timeit微基準一小段程式碼的執行時間—否(開發用)
cProfile + pstatsdeterministic(函式層級)每個函式的呼叫次數、自身時間、累計時間高(可使總時間膨脹數倍)不建議
line_profilerdeterministic(行層級)指定函式中每一行的時間很高,只對標記的函式否
py-spysampling(外部 process 讀記憶體)CPU 熱點、火焰圖、即時 top、當下 stack dump極低,不需改程式、不需重啟是
scalenesamplingCPU(區分 Python vs native)、記憶體、GPU,行層級低有限度
tracemalloc(標準庫)記憶體配置追蹤哪一行配置了多少 Python 記憶體、snapshot 比較中高(深 traceback 更高)短時間開啟
memray記憶體配置追蹤含 C 擴充(native)配置、火焰圖、洩漏報告中可 attach(有限制)

timeit:比較兩種寫法

python -m timeit -s "d = {i: i for i in range(1000)}" "999 in d"
# 50000000 loops, best of 5: 8.35 nsec per loop
python -m timeit -s "l = list(range(1000))" "999 in l"
# 100000 loops, best of 5: 3.09 usec per loop     ← list 線性掃描,慢了約 370 倍

timeit 自動選擇迴圈次數、取 best of N(排除系統雜訊),並暫時關閉 GC。只適合微基準;整體服務的效能要用 profiler 與壓測看,不要因為 microbenchmark 快了 20% 就以為服務會快 20%(Amdahl)。

cProfile + pstats

範例:一個「彙整廣告事件」的報表函式很慢。

# slow_report.py
import json, random

def load_events(n=200_000):
    random.seed(1)
    return [json.dumps({"user_id": f"u{random.randint(1, 5000)}", "campaign_id": f"c{random.randint(1, 50)}",
                        "type": random.choice(["view", "click"]), "price": random.random()}) for _ in range(n)]

def parse(lines):
    return [json.loads(l) for l in lines]

def is_valid(e, blocked):
    return e["user_id"] not in blocked          # blocked 是 list → O(n)

def aggregate(events):
    blocked = [f"u{i}" for i in range(0, 5000, 2)]    # 2500 個被封鎖 user(list!)
    stats = {}
    for e in events:
        if not is_valid(e, blocked):
            continue
        key = e["campaign_id"]
        s = stats.setdefault(key, {"views": 0, "clicks": 0, "spend": 0.0})
        if e["type"] == "click":
            s["clicks"] += 1
        else:
            s["views"] += 1
        s["spend"] += e["price"]
    return stats
# 方法 1:命令列  python -m cProfile -s cumulative slow_report.py
# 方法 2:只 profile 關心的區段(避免把啟動、資料準備算進來)
import cProfile, pstats
from slow_report import load_events, parse, aggregate

lines = load_events()
with cProfile.Profile() as pr:                  # 3.8+ 支援 context manager
    stats = aggregate(parse(lines))
ps = pstats.Stats(pr).strip_dirs().sort_stats("cumulative")
ps.print_stats(8)                               # 前 8 名
ps.sort_stats("tottime").print_stats(5)
ps.print_callers("is_valid")                    # 誰呼叫了它
pr.dump_stats("report.prof")                    # 存檔,可用 snakeviz report.prof 視覺化
         2300178 function calls in 2.484 seconds

   Ordered by: cumulative time
   List reduced from 14 to 8 due to restriction <8>

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
        1    0.053    0.053    1.959    1.959 slow_report.py:14(aggregate)
   200000    1.896    0.000    1.896    0.000 slow_report.py:11(is_valid)
        1    0.033    0.033    0.525    0.525 slow_report.py:8(parse)
   200000    0.082    0.000    0.492    0.000 __init__.py:304(loads)
   200000    0.145    0.000    0.382    0.000 decoder.py:340(decode)
   200000    0.136    0.000    0.136    0.000 decoder.py:351(raw_decode)
   400000    0.062    0.000    0.062    0.000 {method 'match' of 're.Pattern' objects}
ncalls
呼叫次數;遞迴函式會顯示成 7/1(總次數 / 非遞迴的原始呼叫次數)。
tottime
函式自身花的時間,不含它呼叫的子函式。找「真正在燒 CPU 的地方」看這欄。
cumtime
累計時間,含所有子函式。找「哪條呼叫路徑最貴」看這欄。
percall
第一個是 tottime/ncalls,第二個是 cumtime/原始呼叫次數。

怎麼讀:aggregate 的 cumtime 1.959s 佔了大部分,但它的 tottime 只有 0.053s,表示時間都花在子函式;往下看 is_valid 的 tottime 高達 1.896s、被呼叫 20 萬次——一個只做 in 判斷的函式不該這麼貴,原因是 blocked 是 list(O(n) 掃描 2,500 個元素)。修正:blocked = {...} 改成 set,is_valid 降到數十毫秒等級。

cProfile 的盲點

(1) Overhead 會扭曲比例:每次函式呼叫都被攔截,「很多小函式呼叫」的程式碼會被不成比例地放大。實測同一支程式,cProfile 顯示 load_events 花 1.4 秒,用 sampling 的 py-spy 量只佔約 0.2 秒。(2) 只到函式層級,看不到是函式中哪一行。(3) 只 profile 呼叫它的那個 thread(其他 thread 要各自啟用);對 asyncio 程式,時間會被 event loop 的 select 吸走,看不出哪個 coroutine 在等。(4) 看不到 C 擴充內部。

line_profiler:定位到行

uv pip install line_profiler
# 在要分析的函式上加 @profile(kernprof 執行時會注入這個名字),然後:
kernprof -l -v slow_report.py
Timer unit: 1e-06 s

Total time: 2.20776 s
Function: aggregate at line 14

Line #      Hits         Time  Per Hit   % Time  Line Contents
==============================================================
    14                                           @profile
    15                                           def aggregate(events):
    16         1        149.1    149.1      0.0      blocked = [f"u{i}" for i in range(0, 5000, 2)]
    17         1          0.2      0.2      0.0      stats = {}
    18    200001      31507.4      0.2      1.4      for e in events:
    19    200000    2059735.8     10.3     93.3          if not is_valid(e, blocked):
    20     99826      15441.5      0.2      0.7              continue
    21    100174      17255.8      0.2      0.8          key = e["campaign_id"]
    22    100174      25331.0      0.3      1.1          s = stats.setdefault(key, {"views": 0, ...})
    ...

一眼看出第 19 行佔 93.3%、每次 10.3µs(其他行約 0.2µs)。line_profiler 適合在 cProfile 已經縮小到某個函式之後使用。

py-spy:production 的首選

py-spy 用 Rust 寫成,從外部讀取目標 process 的記憶體來重建 Python call stack(Linux 用 process_vm_readv),不需要修改程式、不需要重啟、幾乎不拖慢目標 process。需要 ptrace 權限:root、或 CAP_SYS_PTRACE(容器中要加這個 capability)、或 kernel.yama.ptrace_scope=0。

# 1) top:即時看哪些函式最耗 CPU(像 Linux top 但是看 Python 函式)
sudo py-spy top --pid 12345

# 2) record:取樣 30 秒並輸出火焰圖(SVG),--subprocesses 一併取樣 gunicorn 的 worker
sudo py-spy record -o profile.svg --pid 12345 --duration 30 --subprocesses
#    --native        連 C 擴充(NumPy、orjson)內部的 stack 也顯示
#    --idle          連等待中(idle)的 thread 也取樣:排查「慢但 CPU 不高」時很有用
#    --gil           只取樣持有 GIL 的 thread
#    --format speedscope   輸出給 https://www.speedscope.app 互動檢視
#    --rate 100      取樣頻率(預設 100 Hz)

# 3) dump:印出「此刻」每個 thread 的 stack。process 卡住 / 死結 / CPU 100% 時第一個要做的事
sudo py-spy dump --pid 12345 --locals

# 也可以直接啟動程式並取樣
py-spy record -o profile.svg -- python slow_report.py
$ py-spy dump --pid 12345
Process 12345: /usr/bin/python3 -m gunicorn app:app
Python v3.12.3 (/usr/bin/python3.12)

Thread 12345 (idle): "MainThread"
    wait (threading.py:355)
    acquire (app/budget.py:41)
    reserve_budget (app/budget.py:58)
    handle_bid (app/bid.py:102)
Thread 12360 (active): "ThreadPoolExecutor-0_3"
    is_valid (app/filters.py:12)
    aggregate (app/report.py:18)

讀法:每個 thread 由上到下是「最內層 → 最外層」的呼叫。上例主執行緒卡在 budget.py:41 的鎖上,另一個 thread 正在跑 is_valid——若多次 dump 都看到同樣畫面,就有了鎖競爭或死結的強烈證據。

scalene

uv pip install scalene
scalene --cpu --memory --cli slow_report.py      # 也可產生 HTML 報告

scalene 的特色是逐行區分 Python 時間 vs native 時間 vs system 時間,以及每行的記憶體成長與「copy volume」(Python 與 native 之間的資料複製量)。若某行 Python 時間比例很高,代表值得向量化或改寫;若主要是 native 時間,代表已經在 C 裡跑,再優化 Python 層效益有限。

tracemalloc:找記憶體成長點

import tracemalloc, json

_cache = {}                                   # 沒有上限的「快取」→ 洩漏

def handle_request(req_id: int) -> str:
    payload = {"id": req_id, "features": [0.1] * 200}
    body = json.dumps(payload)
    _cache[req_id] = body                     # 每個 request_id 都不同,永遠不會命中也不會淘汰
    return body

tracemalloc.start(25)                         # 保存 25 層 traceback
for i in range(1_000): handle_request(i)      # 暖機
snap1 = tracemalloc.take_snapshot()
for i in range(1_000, 21_000): handle_request(i)
snap2 = tracemalloc.take_snapshot()

for stat in snap2.compare_to(snap1, "lineno")[:3]:
    print(stat)
top = snap2.compare_to(snap1, "traceback")[0]
for line in top.traceback.format(limit=4, most_recent_first=True):
    print(line)
/usr/lib/python3.14/json/encoder.py:263: size=21.4 MiB (+20.4 MiB), count=21001 (+20000), average=1067 B
leak.py:14: size=624 KiB (+624 KiB), count=19984 (+19984), average=32 B
leak.py:8: size=576 KiB (+540 KiB), count=1 (+0), average=576 KiB
  File "/usr/lib/python3.14/json/encoder.py", line 263
    return _iterencode(o, 0)
  ...
  File "leak.py", line 7
    body = json.dumps(payload)

讀法:在兩個 snapshot 之間,json/encoder.py:263 配置的記憶體增加了 20.4 MiB、多了 20,000 個物件(剛好等於請求數)——每個請求留下一個約 1KB 的字串沒被釋放。lineno 只告訴你「在哪裡配置」(標準庫內部),要用 traceback 模式往上追到我們的程式 leak.py:7,再看那個值被誰持有(_cache,第 8 行的 dict 本身也在成長)。

memray

uv pip install memray
memray run -o out.bin app.py            # 追蹤所有配置(含 C 擴充、numpy)
memray flamegraph out.bin               # 產生 HTML 火焰圖:寬度 = 該路徑在峰值時持有的記憶體
memray flamegraph --leaks out.bin       # 只看結束時仍未釋放的配置(建議搭配 PYTHONMALLOC=malloc)
memray summary out.bin                  # 終端機表格:各函式的 total / own memory
memray attach 12345                     # 附加到執行中的 process(需要 gdb/lldb 與權限)

tracemalloc 只看得到 Python 層透過 PyMem 的配置;memray 攔截 malloc/mmap,連 C 擴充的配置都看得到,適合「RSS 一直漲但 tracemalloc 看不出來」的情境。

火焰圖(flame graph)怎麼讀

flowchart TB subgraph FG["火焰圖示意(由下往上是呼叫方向)"] direction TB T1["is_valid(很寬、頂端平 → 熱點)"] T2["loads"] A1["aggregate(寬)"] --- P1["parse"] M["main(100% 寬)"] end T1 --> A1 T2 --> P1 A1 --> M P1 --> M
  • Y 軸是 call stack 深度:最底下是進入點(main),往上是被呼叫的函式。(Icicle graph 則是上下顛倒。)
  • X 軸寬度 = 該函式(含其子呼叫)出現在多少比例的樣本中 ≈ 佔用的時間比例。X 軸不是時間軸,同層的框依字母排序,左右位置沒有意義。
  • 找「寬的高原」:頂端寬而平的框代表函式自身花很多時間(self time 高)——真正的熱點。底部寬但上面被許多子框切碎,代表時間分散在子呼叫。
  • 顏色通常沒有意義(隨機暖色),部分工具用顏色區分 Python / native / kernel。
  • Differential flame graph:比較優化前後兩份 profile,紅色變寬、藍色變窄,用來驗證優化與找回歸。
  • Off-CPU flame graph:取樣的是「在等待」的時間(鎖、I/O、sleep),用來解釋「CPU 不高但很慢」。py-spy 的 --idle 就是類似概念。
# py-spy --format raw 輸出的 collapsed stack(火焰圖的原始資料),最後一個數字是樣本數
<module> (slow_report.py:36);main (slow_report.py:32);aggregate (slow_report.py:18);is_valid (slow_report.py:12) 180
<module> (slow_report.py:36);main (slow_report.py:31);parse (slow_report.py:9);loads (json/__init__.py:352);... 17
<module> (slow_report.py:36);main (slow_report.py:30);load_events (slow_report.py:5);dumps (json/__init__.py:235);... 17
# 總樣本 243(100Hz ≈ 2.4 秒),is_valid 佔 180/243 ≈ 74%
面試官想聽到什麼

「線上服務 CPU 突然飆高,你怎麼找原因?」→ 先看 metrics 確認是哪些 instance、何時開始、與部署/流量是否相關;登入其中一台用 top / pidstat 確認是哪個 process、是 user 還是 sys time;Python 服務直接 py-spy top / py-spy record --duration 30 產生火焰圖(不需重啟、overhead 低),找最寬的高原;若 sys time 高則用 perf / strace -c 看系統呼叫。找到熱點後在本機用 cProfile/line_profiler 與 benchmark 驗證修正。強調「production 用 sampling profiler,開發環境才用 tracing profiler」。

Linux 層的效能工具 進階

應用層 profiler 看不到 kernel、排程、磁碟與網路。Brendan Gregg 的「Linux 效能分析 60 秒」清單是很好的起手式(細節見第 3 章):

uptime                 # load average 1/5/15 分鐘:趨勢上升還是下降
dmesg -T | tail        # OOM killer、TCP drop、硬體錯誤
vmstat 1               # 整體 CPU / 記憶體 / swap / run queue
mpstat -P ALL 1        # 每顆 CPU 的使用率:是否有單核被打滿(單執行緒瓶頸、中斷不均)
pidstat 1              # 每個 process 的 CPU
iostat -xz 1           # 磁碟
free -m                # 記憶體(看 available,不是 free)
sar -n DEV 1           # 網卡流量
sar -n TCP,ETCP 1      # TCP 連線建立速率、重傳
top                    # 最後總覽

vmstat / iostat / pidstat 重點欄位

$ vmstat 1
procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu-----
 r  b   swpd   free   buff  cache   si   so    bi    bo   in   cs us sy id wa st
12  0      0 812340  20480 4096000    0    0     0    48 9800 45000 78 15  7  0  0
欄位意義警訊
r可執行(running + 等 CPU)的 thread 數持續大於 CPU 核心數 → CPU saturation
b不可中斷睡眠(通常在等磁碟 I/O)持續 > 0 → I/O 瓶頸
si / soswap in / out非 0 → 記憶體不足,延遲會劇烈惡化
cs每秒 context switch異常高 → thread 太多、鎖競爭
us / syuser / kernel CPUsy 高 → 大量系統呼叫、小封包、鎖(futex)
waCPU 閒置且在等 I/O高 → 磁碟或網路儲存慢
ststeal:被 hypervisor 拿去給其他 VM雲端上高 → noisy neighbor,考慮換機型
iostat -xz 1      # 看 r/s w/s(IOPS)、rkB/s wkB/s(吞吐)、r_await w_await(每個 I/O 平均延遲 ms)、
                  # aqu-sz(平均佇列長度,saturation)、%util(對 SSD/NVMe 這種可平行的裝置,100% 不代表飽和)
pidstat -u -r -d -w -p 12345 1
                  # -u CPU、-r 記憶體與 page fault、-d 磁碟 I/O、
                  # -w cswch/s(自願切換:在等 I/O 或鎖)與 nvcswch/s(非自願:時間片用完 → CPU 不夠)
top -H -p 12345   # 看該 process 內每個 thread 的 CPU(找出哪個 thread 在燒)

perf、strace、ltrace

# perf:kernel 內建的 sampling profiler(硬體計數器 + call stack)
perf top -p 12345                         # 即時熱點函式(含 kernel)
perf record -F 99 -g -p 12345 -- sleep 30 # 以 99Hz 取樣 30 秒並記錄 call graph
perf report                               # 互動式檢視
perf stat -p 12345 -- sleep 10            # cycles、instructions、IPC、cache-misses、context-switches
# Python 3.12+ 支援 perf trampoline,讓 perf 看得到 Python 函式名稱:
python -X perf app.py                     # 或設定環境變數 PYTHONPERFSUPPORT=1

# strace:追蹤系統呼叫(基於 ptrace,overhead 非常大,production 只能短時間用)
strace -c -f -p 12345                     # 統計摘要:每種 syscall 的次數、總時間、錯誤數;Ctrl-C 結束
strace -f -tt -T -e trace=network -p 12345   # 每次網路 syscall 的時間戳與耗時
# ltrace:追蹤動態函式庫呼叫(malloc、SSL_read...),同樣 overhead 大
ltrace -c -p 12345
$ strace -c -f -p 12345        (示意輸出)
% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 62.10    1.204512          60     20034           write
 21.33    0.413760          20     20601      3120 recvfrom
  9.87    0.191400          95      2015           epoll_wait
  4.02    0.077900          38      2030           futex

讀法範例:write 被呼叫 2 萬次、佔 62% 的 syscall 時間——可能每筆 log 都直接 write 沒有緩衝、或回應被拆成很多小塊寫出;recvfrom 有大量 errors 通常是 non-blocking socket 的 EAGAIN(正常);futex 很多代表鎖競爭。eBPF 工具(bcc / bpftrace,例如 offcputime、biolatency、tcpretrans)是 overhead 更低的現代替代方案。

常見陷阱

在容器中:(1) top/free 看到的是宿主機的 CPU 與記憶體,要看 cgroup 的限制(/sys/fs/cgroup/cpu.max、memory.max);(2) CPU limit 造成的 throttling 在 cpu.stat 的 nr_throttled/throttled_usec,平均 CPU 不高但 p99 很差時要優先檢查;(3) py-spy、perf、strace 需要 SYS_PTRACE / SYS_ADMIN 等 capability,常用 kubectl debug 起一個 ephemeral container 來做。

Go 的 pprof 與 benchmark 進階

Go 把 profiling 內建在 runtime 與工具鏈中,是它在後端效能工作上的一大優勢。

package main

import (
	"log"
	"net/http"
	_ "net/http/pprof" // 註冊 /debug/pprof/* handler 到 DefaultServeMux
	"runtime"
)

func main() {
	runtime.SetBlockProfileRate(10_000)   // 開啟 block profile(單位 ns:平均每阻塞 10µs 取樣一次,超過的事件必定記錄),預設關閉
	runtime.SetMutexProfileFraction(100)  // 開啟 mutex 競爭 profile(每 100 次競爭取樣 1 次),預設關閉
	go func() {
		// 只綁 localhost 或內網,切勿對外暴露
		log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
	}()
	select {} // 主服務邏輯...
}
# CPU profile:取樣 30 秒
go tool pprof http://127.0.0.1:6060/debug/pprof/profile?seconds=30
# Heap:inuse_space(目前持有,找洩漏)vs alloc_space(累計配置,找 GC 壓力來源)
go tool pprof -sample_index=alloc_space http://127.0.0.1:6060/debug/pprof/heap
# Goroutine:數量一直漲 → goroutine leak;debug=2 印出每個 goroutine 的完整 stack
curl -s http://127.0.0.1:6060/debug/pprof/goroutine?debug=2 | head -50
# Block / Mutex:在 channel、select、鎖上等待的時間
go tool pprof http://127.0.0.1:6060/debug/pprof/block
go tool pprof http://127.0.0.1:6060/debug/pprof/mutex
# 開啟 Web UI(含火焰圖、source view)
go tool pprof -http=:8081 cpu.prof
# 比較兩份 profile(優化前後)
go tool pprof -diff_base=before.prof after.prof
# 執行追蹤:排程延遲、GC 停頓、goroutine 在各狀態的時間
curl -o trace.out http://127.0.0.1:6060/debug/pprof/trace?seconds=5 && go tool trace trace.out

go test -bench

package benchdemo

import (
	"fmt"
	"strconv"
	"strings"
	"testing"
)

func KeySprintf(campaign, user string, slot int) string {
	return fmt.Sprintf("%s:%s:%d", campaign, user, slot)
}

func KeyBuilder(campaign, user string, slot int) string {
	var b strings.Builder
	b.Grow(len(campaign) + len(user) + 12) // 預先配置,避免多次擴容
	b.WriteString(campaign)
	b.WriteByte(':')
	b.WriteString(user)
	b.WriteByte(':')
	b.WriteString(strconv.Itoa(slot))
	return b.String()
}

func BenchmarkKeySprintf(b *testing.B) {
	for i := 0; i < b.N; i++ { // Go 1.24+ 也可寫 for b.Loop() { ... }
		_ = KeySprintf("c123", "u456789", i)
	}
}

func BenchmarkKeyBuilder(b *testing.B) {
	for i := 0; i < b.N; i++ {
		_ = KeyBuilder("c123", "u456789", i)
	}
}
go test -bench=. -benchmem -count=10 -cpuprofile cpu.out | tee new.txt
benchstat old.txt new.txt          # 統計比較兩組結果(含信賴區間),避免被雜訊誤導
go tool pprof -top -nodecount=8 cpu.out
BenchmarkKeySprintf-24    	12948900	        91.28 ns/op	      32 B/op	       2 allocs/op
BenchmarkKeyBuilder-24    	28105770	        42.85 ns/op	      31 B/op	       1 allocs/op

      flat  flat%   sum%        cum   cum%
     250ms  8.47%  8.47%      250ms  8.47%  runtime.nextFreeFast (inline)
     220ms  7.46% 15.93%      360ms 12.20%  runtime.mallocgcTiny
     210ms  7.12% 23.05%      210ms  7.12%  internal/strconv.formatBase10
     190ms  6.44% 36.27%      680ms 23.05%  fmt.(*pp).doPrintf
     120ms  4.07% 45.42%     1010ms 34.24%  runtime.mallocgc

讀法:-24 是 GOMAXPROCS;第二欄是迭代次數;ns/op 每次操作時間;B/op、allocs/op 是每次操作的 heap 配置(配置次數往往比 CPU 指令更影響效能,因為會增加 GC 壓力)。pprof 中 flat 對應 cProfile 的 tottime、cum 對應 cumtime。這裡 runtime.mallocgc 的 cum 佔 34%,表示記憶體配置本身就是主要成本;用 go build -gcflags=-m 可看哪些變數 escape 到 heap。

實戰提示:Go 的 GC 調校

GOGC(預設 100:heap 成長到上次 GC 後存活量的 2 倍時觸發)調高可減少 GC 頻率、增加記憶體用量;GOMEMLIMIT(1.19+)設定軟性記憶體上限,在容器中搭配使用可避免 OOM 又不過度 GC。熱路徑上用 sync.Pool 重用 buffer、預先配置 slice 容量、避免 interface 轉換與閉包造成的 escape。

常見效能優化手法 高頻

優先順序大致是:不做 > 少做 > 批次做 > 換更快的方式做 > 平行做。先做架構與演算法層級的改善,最後才是微優化。

1. 演算法與資料結構

上面的 profiling 範例就是典型:list 換 set,O(n) → O(1),效益遠大於任何微優化。其他常見:巢狀迴圈 join 換成 dict 索引(O(N×M) → O(N+M))、重複排序換成 heap、反覆字串串接換成 join、在迴圈中重複計算不變量。

2. 批次化(batching)與減少往返

import redis
r = redis.Redis()

user_ids = [f"u{i}" for i in range(100)]
# 差:100 次網路往返(RTT 0.3ms → 至少 30ms)
# feats = [r.get(f"feat:{u}") for u in user_ids]

# 好:1 次往返
feats = r.mget([f"feat:{u}" for u in user_ids])

# 好:多種指令用 pipeline 一次送出(非 transaction 可設 transaction=False)
with r.pipeline(transaction=False) as p:
    for u in user_ids:
        p.hgetall(f"profile:{u}")
        p.incr(f"freq:{u}")
    results = p.execute()

同樣概念:SQL 用 executemany/多值 INSERT、Kafka producer 的 linger.ms + batch.size、把逐筆 HTTP 呼叫改成批次 API。取捨:批次增加單筆延遲(要等湊滿或等 linger 時間)與失敗時的影響範圍。

3. 快取

多層快取:process 內(lru_cache、cachetools.TTLCache,最快但每個 worker 一份、一致性差)→ 分散式(Redis/Memcached,一次網路 RTT)→ CDN。要設計 TTL、失效策略、並防範 cache stampede(熱 key 過期瞬間大量請求同時回源:用 single-flight / 鎖、提前非同步刷新、TTL 加隨機抖動)。詳見第 6 章。

4. 連線池(connection pooling)

import httpx

# 差:每個請求建立新 client → 每次都做 DNS + TCP + TLS 握手
# async def call(): async with httpx.AsyncClient() as c: return await c.get(URL)

# 好:整個 process 共用一個 client(內建連線池 + keep-alive)
client = httpx.AsyncClient(
    timeout=httpx.Timeout(0.05, connect=0.02),
    limits=httpx.Limits(max_connections=200, max_keepalive_connections=50),
)
# 在 app shutdown 時 await client.aclose()

DB 連線池(SQLAlchemy pool_size/max_overflow、pgbouncer)同理。池太小 → 請求排隊等連線(看「等待取得連線的時間」指標);池太大 → DB 端連線過多、記憶體與 context switch 增加反而變慢。

5. 降低序列化成本

在高 QPS 服務中,JSON 編解碼常常是 CPU 熱點前幾名。以下是一個約 0.5KB 的 bid-request 風格 dict 在本機的實測(Python 3.13,數值僅供數量級參考):

import json, timeit
import orjson, msgpack          # uv pip install orjson msgpack

doc = {"id": "req-123",
       "imp": [{"id": str(i), "bidfloor": 0.5 + i, "w": 300, "h": 250} for i in range(5)],
       "user": {"id": "u-99", "segments": list(range(50))},
       "device": {"ua": "Mozilla/5.0 ...", "ip": "1.2.3.4"}}
n = 100_000
for name, enc, dec in [("json", json.dumps, json.loads),
                       ("orjson", orjson.dumps, orjson.loads),      # 回傳 bytes
                       ("msgpack", msgpack.packb, msgpack.unpackb)]:
    b = enc(doc)
    te = timeit.timeit(lambda: enc(doc), number=n) / n * 1e6
    td = timeit.timeit(lambda: dec(b), number=n) / n * 1e6
    print(f"{name:8s} size={len(b):4d}B  encode={te:5.2f}µs  decode={td:5.2f}µs")
格式大小encodedecode特性
json(標準庫)558 B4.2 µs3.8 µs人類可讀、通用;預設輸出含空白
orjson457 B0.54 µs1.2 µsRust 實作,相容 JSON;原生支援 dataclass、datetime、numpy;回傳 bytes
msgpack296 B2.1 µs1.5 µs二進位、無 schema、比 JSON 小
Protobuf通常最小依實作(upb/C++ 後端較快)有 schema(.proto)、強型別、向前/向後相容、gRPC 標準;OpenRTB 也有 protobuf 版本

選擇原則:對外公開 API 用 JSON(相容性)但換成 orjson 實作;內部服務間高流量用 Protobuf(schema 演進 + 體積小);快取值或佇列訊息可用 msgpack。也要算上網路與壓縮成本,不只是 CPU。

6. 避免 N+1 查詢

# N+1:1 次查 campaigns + N 次查各自的 creatives
# for c in session.query(Campaign).all():
#     print(c.creatives)          # lazy load:每個 campaign 再打一次 DB

# 解法:eager loading(SQLAlchemy)
from sqlalchemy.orm import selectinload
# campaigns = session.scalars(select(Campaign).options(selectinload(Campaign.creatives))).all()
# → 2 次查詢:SELECT campaigns; SELECT creatives WHERE campaign_id IN (...)

偵測:在測試中計算每個請求的 SQL 數量、在 APM trace 中看到同一個 span 重複幾十次。GraphQL 的 resolver 特別容易產生 N+1,用 DataLoader 批次化(見第 7 章)。

7. 預先計算(precompute)與把工作移出熱路徑

能離線算的就不要在請求中算:預先彙整報表(rollup table、materialized view)、預先計算使用者特徵存入 key-value store、啟動時把設定編譯成查找表(例如把 targeting 規則轉成 bitmap / 倒排索引)。不需要同步完成的工作(寫 log、更新統計、發通知)丟到佇列非同步處理。

8. 向量化(NumPy)

import numpy as np, time

bids = np.random.rand(1_000_000)
weights = np.random.rand(1_000_000)

t = time.perf_counter()
s = 0.0
for b, w in zip(bids, weights):        # Python 迴圈:每個元素都要 dispatch、boxing
    s += b * w
print(f"loop   {time.perf_counter() - t:.3f}s")

t = time.perf_counter()
s2 = float(np.dot(bids, weights))      # 一次呼叫進入 C/BLAS,連續記憶體 + SIMD
print(f"numpy  {time.perf_counter() - t:.4f}s")   # 通常快 2–3 個數量級

9. 原生擴充:Cython、Rust(PyO3)、C

當熱點是無法向量化的 Python 迴圈(例如複雜的逐筆規則判斷):Cython 加型別宣告編譯成 C;Rust + PyO3/maturin 寫擴充(orjson、pydantic-core、polars、ruff 都是這條路);或直接用現成的 C 實作函式庫。記得在長時間運算中釋放 GIL(Cython 的 with nogil:、PyO3 的 allow_threads),否則仍無法多執行緒平行。

10. Object pooling、減少配置與 GC 壓力

  • Python:大量小物件用 __slots__ / tuple / array;避免在熱路徑建立大量暫時物件(例如每個請求複製大 dict);啟動後 gc.freeze();調整 gc.set_threshold。
  • Go:sync.Pool 重用 bytes.Buffer、預先配置 make([]T, 0, n)、避免不必要的 []byte↔string 轉換、用 escape analysis 讓變數留在 stack。
  • Java:減少短命物件、選擇合適的 GC(G1、ZGC)。
  • Pooling 的代價:物件歸還時要重設狀態(否則資料外洩到下一個請求)、池本身的同步成本。

11. 壓縮

壓縮用 CPU 換頻寬與儲存。對外 HTTP 回應用 gzip/brotli(Nginx 層處理);服務間大批次資料或 Kafka 訊息用 LZ4 / Snappy / zstd(速度快,壓縮比適中);冷資料儲存用 zstd 高等級或 gzip。小訊息(幾百 bytes)壓縮常常得不償失。

Appier 情境:在高 QPS 下每微秒都重要

假設一個服務 50,000 QPS、每個請求省下 20µs 的 CPU,等於每秒省 1 CPU-second——整整一顆核心。乘上數十個 region / cluster 的副本,就是可觀的機器成本。這就是為什麼廣告平台會在意 JSON 解析器的選擇、log 的格式化成本、甚至 dict 的複製。但也要記得 Amdahl:先 profile 確認這些微秒在總成本中的比例。

負載測試與 benchmark 設計 進階

工具語言 / 腳本負載模型適合
wrkC,Lua 腳本closed(固定連線數)單一 endpoint 的極限吞吐;單機就能打出很高 RPS
wrk2C固定速率(-R),修正 coordinated omission在指定 RPS 下量測真實延遲分布
heyGoclosed(-c 並發),可用 -q 限速快速簡單的測試
k6Go 引擎,JavaScript 腳本open(constant-arrival-rate)或 closed(VU)皆可多步驟情境、門檻(thresholds)、CI 整合
locustPythonclosed(模擬使用者),分散式用 Python 描述複雜使用者行為
vegetaGo固定速率constant-rate 測試與報表
wrk -t4 -c200 -d60s --latency http://10.0.0.5:8000/bid      # 4 threads、200 連線、60 秒
wrk2 -t4 -c200 -d60s -R 5000 --latency http://10.0.0.5:8000/bid   # 固定 5000 RPS
hey -z 60s -c 100 -m POST -T application/json -D req.json http://10.0.0.5:8000/bid
Running 1m test @ http://10.0.0.5:8000/bid        (示意輸出)
  4 threads and 200 connections
  Thread Stats   Avg      Stdev     Max   +/- Stdev
    Latency    12.31ms    8.02ms 310.55ms   91.20%
    Req/Sec     4.12k   350.11     5.02k    70.50%
  Latency Distribution
     50%   10.80ms
     75%   13.90ms
     90%   18.70ms
     99%   45.10ms
  983412 requests in 1.00m, 160.12MB read
Requests/sec:  16390.20
// k6:open model(固定到達率),並設定 SLO 門檻讓 CI 自動判定失敗
import http from "k6/http";
import { check } from "k6";

export const options = {
  scenarios: {
    steady: {
      executor: "constant-arrival-rate",
      rate: 2000, timeUnit: "1s",      // 每秒 2000 個請求,不受回應速度影響
      duration: "5m",
      preAllocatedVUs: 200, maxVUs: 1000,
    },
  },
  thresholds: {
    http_req_duration: ["p(99)<50"],   // p99 小於 50ms
    http_req_failed: ["rate<0.001"],
  },
};

export default function () {
  const res = http.post("http://10.0.0.5:8000/bid", JSON.stringify({ request_id: "r1", user_id: "u1", floor_price: 0.5, slots: ["s1"] }),
    { headers: { "Content-Type": "application/json" } });
  check(res, { "status 200": (r) => r.status === 200 });
}
# locustfile.py   執行:locust -f locustfile.py --host http://10.0.0.5:8000
from locust import HttpUser, task, between

class Advertiser(HttpUser):
    wait_time = between(0.5, 2)

    @task(5)
    def report(self):
        self.client.get("/reports?campaign_id=c1&range=7d", name="/reports")

    @task(1)
    def update_budget(self):
        self.client.patch("/campaigns/c1", json={"daily_budget": 1000})

Coordinated omission:為什麼你的壓測延遲太好看

Gil Tene 提出的問題。Closed-loop 壓測工具(每個連線「等上一個回應回來才送下一個」)在伺服器卡住 1 秒的期間,不會送出原本應該送的請求。結果只有少數幾個請求記錄到 1 秒延遲,而現實中這 1 秒內持續到達的大量使用者請求全都會被延遲,卻沒有被量到——p99 被嚴重低估。

sequenceDiagram participant LG as 壓測工具(closed loop) participant S as Server LG->>S: req 1(t=0ms) S-->>LG: 1ms LG->>S: req 2(t=1ms) Note over S: GC 停頓 / 卡住 1000ms S-->>LG: 1000ms(只記錄到這一筆慢) Note over LG: 這 1 秒內本該每 1ms 送一筆
約 1000 筆延遲 1~1000ms 的請求沒被記錄 LG->>S: req 3(t=1001ms) S-->>LG: 1ms

解法:使用 open model / 固定到達率的工具(wrk2 -R、k6 constant-arrival-rate、vegeta),並以「預定送出時間」而非「實際送出時間」計算延遲;HdrHistogram 也有校正功能。另一個意涵:真實流量是 open model(使用者不會因為你慢就不來),所以 closed model 的結果會過度樂觀。

如何設計一個可信的 benchmark

  1. 明確的問題與 SLO:「在 p99 小於 50ms 的前提下,單 Pod 最大可承受 RPS 是多少?」而不是「它多快?」
  2. 貼近 production 的環境:相同機型、CPU/memory limit、版本、設定(關掉 debug log)、資料量(空 DB 的 index 都在記憶體裡,結果沒有意義)、真實的請求分布(熱點 key、payload 大小)。
  3. 壓測端不能成為瓶頸:監控 load generator 的 CPU 與網路,必要時分散式施壓;不要跟受測服務跑在同一台機器。
  4. 暖機:JIT、連線池、快取、Python 的 specializing interpreter 都需要暖機,丟掉前段數據。
  5. 階梯式增壓:逐步提高 RPS,畫出「吞吐 vs 延遲」曲線,找出延遲開始陡升的拐點(knee),容量規劃取拐點以下並保留 headroom。
  6. 看分布不看平均:記錄 p50/p90/p99/p999 與 max、錯誤率;同時收集伺服器端的 USE 指標,才能解釋瓶頸在哪。
  7. 可重現與統計:每組跑多次、只改一個變數、用 benchstat / 信賴區間判斷差異是否顯著。
  8. Soak test:中等負載長時間(數小時)跑,才看得到記憶體洩漏、連線洩漏、log 塞滿磁碟。
面試官想聽到什麼

「你怎麼知道一台機器能撐多少流量?」→ 描述上面的流程,並主動提到 coordinated omission 與 open vs closed model,這是區分「跑過壓測」與「懂壓測」的關鍵。再補上用 Little's law 檢查結果是否自洽(RPS × 平均延遲 ≈ 觀察到的並發數)。

記憶體洩漏排查流程 深入

flowchart TB A["告警:RSS 持續上升 / OOMKilled"] --> B{"隨時間或請求數線性成長?
還是成長到某值後持平?"} B -->|"持平"| C["可能是快取暖機、碎片化、
正常工作集 → 調整 limit 或快取上限"] B -->|"持續成長"| D{"與流量相關?"} D --> E["本機或 staging 重現:
固定請求反覆打 + 記錄 RSS"] E --> F["tracemalloc snapshot 比較
找 Python 層成長點"] F -->|"找得到"| G["追 traceback + gc.get_referrers
找出誰持有參考"] F -->|"Python 層沒成長但 RSS 漲"| H["memray --native / valgrind
查 C 擴充或 malloc 碎片"] G --> I["修正 + soak test 驗證"] H --> I
  1. 確認是洩漏:看長時間的記憶體曲線。持續、與請求數成比例地上升才是洩漏;一開始上升後持平可能只是快取暖機。也要分辨 RSS 與 Python heap:CPython 的 pymalloc 以 arena 管理小物件,只要 arena 中還有一個存活物件就無法歸還 OS,所以「峰值後 RSS 沒有下降」不一定是洩漏(碎片化)。
  2. 重現:在 staging 用固定的請求反覆打,記錄每 N 個請求後的 RSS,找出是哪個 endpoint 造成。
  3. 定位配置點:tracemalloc 前後 snapshot 比較(見前節);或用 gc.get_objects() 依型別計數,看哪種型別數量一直增加。
  4. 找出持有者:gc.get_referrers(obj) 或 objgraph.show_backrefs() 畫出參考鏈,找到是哪個全域結構抓著不放。
  5. Native 層:Python 層沒成長但 RSS 漲,用 memray(含 native stack)、PYTHONMALLOC=malloc 搭配 valgrind/heaptrack,或檢查 C 擴充版本的已知問題。glibc malloc 的多 arena 碎片可用 MALLOC_ARENA_MAX=2 或改用 jemalloc 緩解。
  6. 修正與驗證:修正後跑 soak test 確認曲線變平。短期止血:gunicorn --max-requests 定期回收 worker、K8s 設 memory limit 讓 Pod 被重啟(但要了解這只是緩解)。
import gc, collections

def top_types(limit=10):
    """定期呼叫並比較:哪種型別的物件數量一直增加?"""
    counts = collections.Counter(type(o).__name__ for o in gc.get_objects())
    return counts.most_common(limit)

class Session:
    def __init__(self, uid): self.uid = uid

before = dict(top_types(50))
leaky = [Session(i) for i in range(10_000)]   # 模擬洩漏
after = dict(top_types(50))
print({k: after[k] - before.get(k, 0) for k in after if after[k] - before.get(k, 0) > 1000})
# {'Session': 10000}
# 注意:gc.get_objects() 只回傳被 GC 追蹤的容器物件;只含 int/str 的 dict、tuple 會被「取消追蹤」,不會出現在這裡
常見洩漏來源症狀修正
無上限的全域快取 / dict(key 是 request id、user id)與請求數線性成長LRU/TTL 上限、改用 Redis
lru_cache 掛在 instance methodinstance 永不回收cached_property 或模組層級函式
事件 listener / callback 註冊後未移除handler 物件累積明確 unregister、用 weakref.WeakMethod
asyncio task 建立後沒 await、沒取消pending task 數一直漲TaskGroup、追蹤並取消
例外 traceback 被保存(frame 持有所有 local 變數)大型物件留在記憶體不要長期保存 exception 物件或 sys.exc_info()
Logging handler 中保存 record / 無界佇列流量高峰後不回落有界佇列、丟棄策略
Go:goroutine 卡在 channel 上永不結束goroutine 數一直漲context 取消、pprof goroutine profile
Go:從大 slice 切出小 slice 長期保存整個底層陣列無法回收copy 出需要的部分

實戰劇本:某 API p99 從 50ms 變成 800ms,怎麼排查? 高頻

這是面試中非常典型的開放題。評分重點是有沒有系統化的思路:先止血、再定位、從外到內、用數據排除假設,而不是一開始就猜「是 DB 慢」。

flowchart TB S["告警:/v1/recommend p99 50ms → 800ms"] --> M["0. 先止血:回滾最近部署 / 擴容 / 降級"] M --> Q1{"1. 範圍?全部 endpoint 還是單一?
所有 instance 還是少數?"} Q1 -->|"少數 instance"| H["單機問題:noisy neighbor、CPU throttling、
壞掉的節點、GC、磁碟"] Q1 -->|"全部"| Q2{"2. 時間點對應什麼變更?
部署、設定、流量、資料量、依賴"} Q2 --> Q3["3. 分段:trace 看時間花在哪個 span
app 內部 vs 下游 vs 排隊"] Q3 -->|"下游慢"| D["DB 慢查詢 / 鎖 / Redis 大 key / 外部 API"] Q3 -->|"app 內部"| P["py-spy / pprof:CPU 熱點、
event loop 阻塞、鎖競爭、GC"] Q3 -->|"排隊"| R["連線池耗盡、worker 不足、
backlog:Little's law"] D --> F["4. 修正 → 驗證 → 寫 postmortem 與防回歸"] P --> F R --> F H --> F
  1. 確認與止血(先恢復服務,再找原因):確認告警是真的(不是監控本身的問題),評估影響範圍與 SLO 燃燒速度。若時間點與部署吻合,先回滾;若是流量暴增則擴容或開啟降級(關閉非必要功能、用快取的舊結果、對低優先流量 load shedding)。
  2. 縮小範圍:只有 p99 變差、還是 p50 也變差?(只有 tail 變差 → 偏向偶發事件:GC、鎖、少數慢查詢、少數壞節點、重試;整體都變差 → 偏向資源飽和或程式路徑變慢)。所有 endpoint 還是單一 endpoint?所有 instance 還是少數 instance / 某個 AZ?錯誤率與 timeout 有沒有一起上升?
  3. 對齊時間線找變更:部署、設定變更、feature flag、依賴服務部署、資料量成長(某個表剛好跨過記憶體大小、某客戶資料突然變大)、流量模式改變(新客戶、爬蟲、重試風暴)、基礎設施事件(節點遷移、雲端事件)。
  4. 用 distributed tracing 分段:挑幾個慢的 trace,看時間花在哪一段——
    • 下游 span 變長:DB 看 slow query log、EXPLAIN(是否有 index 失效、統計資訊過期導致執行計畫改變)、鎖等待、連線數;Redis 看 SLOWLOG、大 key、熱 key、KEYS 指令;外部 API 看對方狀態。
    • span 之間有空白(沒被任何 span 涵蓋的時間):在 app 內部排隊或被阻塞——event loop 被同步程式卡住、等連線池、GIL 競爭、GC 停頓。
    • 請求進來前就在排隊:比較 LB 記錄的延遲與 app 自身記錄的延遲,差值就是排隊時間(worker 全忙、accept backlog)。
  5. 看資源(USE):CPU 使用率與 throttling(nr_throttled)、run queue、記憶體與 swap、GC 頻率與停頓、連線池使用率與等待數、thread/goroutine 數、網路重傳、檔案描述符。
  6. Profiling:在慢的 instance 上 py-spy record --idle(同時看 on-CPU 與 off-CPU)或 py-spy dump 數次看 thread 卡在哪;Go 用 pprof 的 CPU、block、mutex profile。
  7. 提出假設並驗證:一次只改一個變數,在 staging 重現或在一個 canary 上驗證,確認 p99 回到基線。
  8. 收尾:寫 blameless postmortem;補上缺少的監控(例如連線池等待時間)、告警、壓測案例與防回歸(CI benchmark)。

常見根因清單(依經驗出現頻率)

根因典型證據修正
新部署引入慢路徑(N+1、同步呼叫、O(n²))時間點吻合部署;火焰圖出現新熱點回滾、修正
DB 執行計畫改變 / index 失效slow query log、EXPLAIN 從 index scan 變 full scan加 index、ANALYZE、改寫查詢
連線池耗盡「等待取得連線」時間上升、DB 端連線數滿修正連線洩漏、調整池大小、加快下游
event loop 被阻塞(Python async)所有請求一起變慢;asyncio debug 出現 slow callback;py-spy 看到同步呼叫改 async library、to_thread
CPU throttling(K8s CPU limit)平均 CPU 不高但 nr_throttled 增加,p99 呈現約 100ms 週期的延遲調高 / 移除 CPU limit、減少 thread 數、設定正確的 GOMAXPROCS
GC 停頓延遲尖峰與 GC 事件吻合;heap 變大減少配置、gc.freeze、調 GOGC/GOMEMLIMIT
重試風暴下游 RPS 是上游的數倍、錯誤與延遲互相放大重試預算、exponential backoff + jitter、circuit breaker
熱 key / 大 key(Redis)單一 Redis 節點 CPU 高、SLOWLOG拆 key、本地快取、讀副本
Noisy neighbor / 壞節點只有特定節點變慢;st(steal)升高驅逐該節點、反親和性排程
DNS / TLS 每次重建連線trace 中 connect 時間長連線池、keep-alive、DNS 快取
面試官想聽到什麼

答題結構:「先止血(回滾/擴容/降級)→ 縮小範圍(哪些 endpoint、instance、只有 tail 還是全部)→ 對齊時間線找變更 → trace 分段定位是下游、app 內部還是排隊 → 用 USE 指標與 profiler 確認 → 單變數驗證 → postmortem 與防回歸」。每一步講出用什麼工具、看什麼數字。最後補一句「p99 惡化但 p50 正常通常是偶發因素(GC、鎖、少數慢請求、CPU throttling),而 p50 也惡化通常是整體飽和或程式路徑變慢」,展現你對分布的理解。

Appier 情境:在 100ms 預算內完成競價 深入

以廣告平台常見做法為例(不代表 Appier 實際架構):RTB 流程中,ad exchange 發出 bid request,DSP 的 bidder 必須在期限內(常見約 100ms,含網路往返)回應出價。扣掉跨網路的時間,bidder 內部可用的時間往往只有數十毫秒,且要在極高 QPS 下維持穩定的 tail latency。

flowchart LR EX["Ad Exchange"] -->|"bid request(約 100ms 期限)"| LB["LB"] LB --> B["Bidder"] B --> F["特徵查詢
本地快取 / Redis(子預算 ~5–10ms)"] B --> T["Targeting 過濾
記憶體內索引(~1ms)"] B --> M["CTR / CVR 模型推論
(子預算 ~10–20ms)"] B --> BG["預算 / 頻次檢查
本地額度 + 非同步同步"] B -->|"bid response 或 no-bid"| EX B -.->|"非同步"| K["Kafka:bid / win / impression log"]

延遲預算分配(示意)

階段示意預算手法
網路(exchange ↔ bidder 往返)依地理距離,可能佔一半以上多區域部署靠近 exchange、keep-alive 長連線
解析 request< 1ms高效 JSON parser 或 protobuf;只解析需要的欄位
Targeting / 候選篩選~1–2ms啟動時把 campaign 規則預先編譯成倒排索引 / bitmap,全在記憶體
特徵查詢~5–10ms批次 MGET、本地 LRU 快取熱門使用者、逾時用預設特徵
模型推論~10–20ms批次推論、模型輕量化、限制候選數量、專用推論服務
預算 / 頻次控制< 1ms本地預先領取的額度扣減,避免每次同步呼叫中央服務
Buffer剩餘部分對抗 tail:GC、排程、網路抖動

設計原則

  • Deadline propagation:從收到請求的時間點計算剩餘預算,傳給每個下游呼叫(Go 的 context.WithDeadline、gRPC deadline、Python 的 asyncio.timeout);預算用完就放棄並回 no-bid,遲到的出價毫無價值。
  • 熱路徑上不做阻塞 I/O 以外的同步工作:logging、指標、bid log 一律非同步(記憶體 buffer → 背景 flush → Kafka)。
  • 以記憶體換延遲:campaign 設定、targeting 索引、黑名單常駐記憶體,透過訂閱變更事件或定期 reload 更新(double buffering:建好新版本再原子替換參考)。
  • 控制 tail:限制每個請求評估的候選數、對慢的下游設 timeout 與 circuit breaker、hedged request、避免大型 GC 停頓、K8s 上避免 CPU throttling。
  • Load shedding:過載時主動丟棄一部分請求(快速回 no-bid)比讓所有請求都超時更好——超時的請求浪費了全部資源卻沒產生價值。
  • 可觀測性:每個階段的延遲直方圖(p50/p99/p999)、no-bid 原因分類、timeout 率、win rate;在 Go 服務常駐 pprof endpoint、在 Python 服務可隨時 py-spy。
import asyncio, time

BUDGET_S = 0.060                      # bidder 內部可用時間(示意)

async def get_features(uid):  await asyncio.sleep(0.004); return {"ctr_hist": 0.02}
async def predict(feats):     await asyncio.sleep(0.008); return 0.031
async def check_budget(cid):  return True

async def handle_bid(req: dict) -> dict:
    start = time.monotonic()
    deadline = start + BUDGET_S

    def remaining() -> float:
        return max(0.0, deadline - time.monotonic())

    try:
        async with asyncio.timeout(min(0.010, remaining())):         # 特徵查詢子預算
            feats = await get_features(req["user_id"])
    except TimeoutError:
        feats = {"ctr_hist": 0.01}                                  # 降級:使用預設特徵,而不是放棄

    try:
        async with asyncio.timeout(min(0.020, remaining())):        # 模型推論子預算
            ctr = await predict(feats)
    except TimeoutError:
        return {"id": req["id"], "nobid": "model_timeout"}           # 沒有分數就不出價

    if not await check_budget(req["campaign_id"]):
        return {"id": req["id"], "nobid": "budget"}
    price = ctr * 1000 * 0.8                                        # 示意:eCPM 的一部分
    return {"id": req["id"], "price": round(price, 4),
            "elapsed_ms": round((time.monotonic() - start) * 1000, 2)}

print(asyncio.run(handle_bid({"id": "r1", "user_id": "u1", "campaign_id": "c1"})))
Appier 情境:面試中可以怎麼講

「在 RTB 這種有硬性期限的系統裡,我會把延遲當成預算來管理:每個階段有子預算、deadline 往下傳、超時就降級或 no-bid;用 p99/p999 而非平均做 SLO;熱路徑全在記憶體並避免配置與阻塞;所有非關鍵工作非同步化。效能優化前一定先 profile——在高 QPS 下省下的每一微秒都是機器成本,但只有在熱點上才值得投資。」

面試自測題

Concurrency 和 parallelism 有什麼不同?CPython 的 threading 屬於哪一種?

Concurrency 是程式結構:多個任務生命週期重疊、交錯推進,單核也能做到;parallelism 是執行方式:多個運算在同一時刻於多個核心上執行。CPython threading 有 concurrency,但因 GIL,同時只有一個 thread 執行 bytecode,所以沒有 CPU parallelism(I/O 等待與釋放 GIL 的 C 擴充除外)。asyncio 是單執行緒的 concurrency;multiprocessing 和 Go goroutine 能做到 parallelism。

什麼是 race condition?舉一個後端常見的例子並說明怎麼解。

結果取決於多個執行者操作的交錯順序。常見型態是 read-modify-write 與 check-then-act。例:庫存/預算扣款「先查餘額足夠再扣」,兩個請求同時通過檢查導致超扣。解法依範圍而定:單一 process 內用 mutex;跨 process 用資料庫的原子操作(UPDATE ... SET balance = balance - ? WHERE id = ? AND balance >= ? 並檢查影響列數)、樂觀鎖(version 欄位)、悲觀鎖(SELECT ... FOR UPDATE)、Redis 的 DECRBY 或 Lua script;高 QPS 下可用預先分配額度在本地扣以避免中央熱點。

Mutex、Semaphore、Condition variable 各自的用途?

Mutex:互斥,一次一個持有者,保護 critical section,必須由持有者釋放。Semaphore:計數器,允許最多 N 個同時進入,用來限制並發(例如同時最多 10 個對外連線),binary semaphore 類似 mutex 但沒有擁有者概念。Condition variable:搭配 mutex,讓 thread 等待某個條件成立並被通知(例如阻塞佇列的 not_empty / not_full),等待時會原子地釋放鎖,被喚醒後重新取得;必須用 while 迴圈重新檢查條件以處理 spurious wakeup。RWLock 則讓多個 reader 並行、writer 獨佔。

Deadlock 的四個必要條件是什麼?實務上怎麼預防?

Mutual exclusion、hold and wait、no preemption、circular wait,四者同時成立才會死結。預防就是破壞其一:最常用的是固定全域的加鎖順序(破壞 circular wait,例如轉帳時總是先鎖 id 小的帳戶);用 try_lock / acquire timeout,失敗就釋放已持有的鎖並隨機 backoff(破壞 no preemption);一次取得所有資源(破壞 hold and wait);用不可變資料或 message passing 避免共享。另外要縮小鎖的範圍、不要在持有鎖時呼叫外部程式碼或 I/O。偵測:py-spy dump、Go 的 goroutine profile、InnoDB 的死結日誌。

Livelock 和 starvation 跟 deadlock 差在哪?

Deadlock 是大家都阻塞、沒人能前進。Livelock 是大家都在執行(沒有阻塞),但不斷互相讓步或重試而沒有進展,例如兩方同時 try_lock 失敗、同時釋放、同時重試;解法是隨機化退避。Starvation 是系統整體有進展,但某個執行者長期拿不到資源,例如 reader 優先的讀寫鎖讓 writer 一直等;解法是公平排隊、aging、writer 優先策略。

CAS 是什麼?有什麼問題?在資料庫層對應到什麼?

Compare-And-Swap:若記憶體位置的值等於預期值就更新為新值,由 CPU 一條原子指令完成,是 lock-free 演算法的基礎(讀 → 計算 → CAS,失敗重試)。問題:高競爭下大量重試浪費 CPU;ABA 問題(值 A→B→A,CAS 誤判沒變),用版本號解決;只能原子更新單一位置。資料庫層對應樂觀鎖:UPDATE ... WHERE id = ? AND version = ?,影響列數為 0 表示被別人改過要重試;Redis 對應 WATCH/MULTI/EXEC。

Thread pool 應該開多大?

CPU-bound 約等於核心數(Python 要用 process pool)。IO-bound 可用 N_cores × 目標 CPU 使用率 × (1 + 等待時間/計算時間),例如 8 核、計算 5ms、等待 45ms、目標 80% → 64。也可以用 Little's law:需要的並發數 = 目標吞吐 × 平均延遲。但實際上限常由下游決定(DB 連線數、對方 rate limit),pool 要與連線池一起設計,並用壓測找出吞吐不再增加、延遲開始上升的拐點。另外要用有界佇列做背壓,避免無界佇列在高峰時把延遲與記憶體無限推高。

select、poll、epoll 有什麼差別?什麼是 edge-triggered?

三者都是 I/O multiplexing。select 有 FD_SETSIZE(通常 1024)上限,每次呼叫都要把整個 fd set 複製進 kernel,並 O(n) 掃描找就緒者;poll 移除數量上限但仍是 O(n);epoll 用 epoll_ctl 註冊一次,kernel 維護興趣列表與就緒列表,epoll_wait 只回傳就緒的 fd,成本與就緒數成正比,適合大量連線、少數活躍的場景。Level-triggered 只要還有資料就持續通知;edge-triggered 只在狀態變化時通知一次,必須用 non-blocking fd 並一直讀到 EAGAIN,否則剩餘資料不會再被通知。Nginx、Redis、asyncio、Go netpoller 都建立在 epoll 之上。

解釋 USE method 與 RED method,什麼時候用哪個?

USE(Brendan Gregg)針對每個資源檢查 Utilization、Saturation、Errors,適合找資源層瓶頸:CPU 使用率與 run queue、記憶體與 swap、磁碟 %util 與佇列、網卡 drop、連線池使用率與等待數。RED(Tom Wilkie)針對每個服務檢查 Rate、Errors、Duration,適合服務層儀表板與告警。實務上用 RED 發現「哪個服務的使用者體驗變差」,再用 USE 找「是哪個資源造成」。Saturation 通常比 utilization 更早反映問題。Google 的四大黃金訊號(latency、traffic、errors、saturation)是兩者的結合。

用 Amdahl's law 說明為什麼加機器不一定變快。

加速比 = 1 / ((1 − p) + p/N),p 是可平行的比例。p = 0.9 時,8 核心約 4.7 倍,無限多核心也只有 10 倍——序列部分決定上限。系統中的序列部分像是全域鎖、單一 DB primary 寫入、單執行緒聚合器、需要全域協調的步驟。所以要擴展先找出並消除序列化點(分片、去中心化、非同步化)。同理,單機優化時一個只佔 20% 時間的函式,就算優化到 0 也只省 20%,所以要先 profile。

Little's law 是什麼?怎麼用在容量規劃?

L = λW:系統中平均的工作數 = 到達率 × 平均停留時間,對穩定系統普遍成立。應用:1,000 RPS、平均延遲 200ms → 平均 200 個並發中的請求,worker、連線池、coroutine 容量要足以容納。它也解釋了連鎖反應:下游變慢使 W 上升,同樣 RPS 下並發數上升,耗盡連線池而開始排隊,延遲進一步上升。也可用來檢查壓測結果是否自洽,或反推每個 worker 的吞吐上限(1/延遲)。

為什麼要看 p99 而不是平均延遲?什麼是 fan-out 放大?

平均值會被大量快速請求掩蓋少數極慢請求,而這些慢請求正是使用者感受最差的部分,且重度使用者(發出更多請求)更容易遇到。Fan-out 放大:一個請求要等 N 個後端都回應,整體延遲取決於最慢的那個;若每個後端 1% 機率很慢,fan-out 100 時約 63% 的請求會遇到慢後端(1 − 0.99^100)。對策:hedged / tied requests、嚴格 timeout 並回傳部分結果、減少延遲變異來源(GC、背景任務、CPU throttling、排隊)、保留 headroom 不要把使用率推太高。

cProfile 的 tottime 與 cumtime 差在哪?為什麼 production 不用 cProfile?

tottime 是函式本身花的時間(不含子呼叫),用來找真正燒 CPU 的函式;cumtime 含所有子呼叫,用來找最貴的呼叫路徑。cProfile 是 deterministic profiler,攔截每次函式呼叫,overhead 大且會扭曲比例(大量小函式呼叫被放大),只看單一 thread、看不到 C 擴充內部、在 asyncio 中時間會被 event loop 吸走。Production 用 sampling profiler:py-spy 從外部讀取 process 記憶體取樣,不需修改或重啟、overhead 極低,可產生火焰圖、即時 top、dump 當下所有 thread 的 stack。

怎麼讀火焰圖?

Y 軸是 call stack 深度,最底層是進入點,往上是被呼叫者;X 軸寬度代表該函式(含子呼叫)出現在樣本中的比例,約等於時間佔比;X 軸不是時間序列,同層依名稱排序。找頂端寬而平的「高原」,那是 self time 高的熱點;底部寬但上方被切碎的代表時間分散在子呼叫。顏色通常無意義。進階:differential flame graph 比較前後兩份 profile;off-CPU flame graph 看等待時間(鎖、I/O),用來解釋 CPU 不高卻很慢的情況。

什麼是 coordinated omission?怎麼避免?

Closed-loop 的壓測工具在等待回應時不會送出新請求,所以當伺服器卡住時,本該在這段時間到達的大量請求沒有被送出、也沒有被記錄延遲,只有少數請求記到高延遲,導致 p99 被嚴重低估。真實流量是 open model,使用者不會因為服務慢就不來。避免方式:使用固定到達率(open model)的工具,例如 wrk2 的 -R、k6 的 constant-arrival-rate、vegeta,並以預定發送時間計算延遲(HdrHistogram 校正)。

Python 服務記憶體一直漲,你的排查步驟?

先確認是洩漏:看長時間曲線是否與請求數成比例持續上升,排除快取暖機與 pymalloc arena 碎片化造成的 RSS 不回落。在 staging 用固定請求重現並找出是哪個 endpoint。用 tracemalloc 在前後各取 snapshot 比較,找成長最多的配置點並用 traceback 模式追到自己的程式碼;用 gc.get_objects 依型別計數、gc.get_referrers 或 objgraph 找出持有者。若 Python 層沒成長但 RSS 漲,用 memray 的 native 模式查 C 擴充或 malloc 碎片(MALLOC_ARENA_MAX、jemalloc)。常見原因:無上限的全域快取、method 上的 lru_cache、未移除的 listener、未完成的 asyncio task、保存的 traceback。修正後 soak test 驗證;短期可用 gunicorn --max-requests 止血。

某 API 的 p99 從 50ms 變 800ms,但 p50 沒什麼變化,你會優先懷疑什麼?

只有 tail 變差代表大部分請求的正常路徑沒問題,而是偶發因素影響少數請求:GC 停頓、鎖競爭、CPU throttling(K8s CFS quota 造成的週期性停頓)、少數慢查詢(特定使用者資料量特別大、執行計畫在某些參數下變差)、少數壞節點或特定 AZ、連線池偶爾耗盡而排隊、重試、cache miss 回源。排查:先看是否與部署時間吻合(是就回滾);依 instance / AZ / endpoint / 使用者切分 p99 看是否集中;挑慢的 trace 看時間在哪個 span 或 span 之間的空白;檢查 nr_throttled、GC 指標、連線池等待時間;在慢的 instance 上用 py-spy --idle 或 pprof 的 block/mutex profile 確認。

列舉五種常見的後端效能優化手法並說明取捨。

(1) 演算法與資料結構:list 換 set、巢狀迴圈換 hash join,效益最大、通常無代價。(2) 批次化:Redis MGET/pipeline、SQL 批次寫入,減少 RTT,代價是單筆延遲與失敗範圍。(3) 快取:降低延遲與下游負載,代價是一致性、失效複雜度與 stampede 風險。(4) 連線池與 keep-alive:省去 TCP/TLS 握手,需調整池大小避免排隊或壓垮 DB。(5) 降低序列化成本:orjson、protobuf,代價是相容性或 schema 管理。其他:預先計算、非同步化非關鍵工作、向量化、原生擴充、減少配置與 GC 壓力、壓縮(CPU 換頻寬)。原則是先 profile 找熱點,以 Amdahl 判斷值不值得。

Go 服務要找 CPU 熱點、goroutine 洩漏、鎖競爭,分別用什麼?

匯入 net/http/pprof 開一個只綁內網的 debug port。CPU 熱點:go tool pprof .../debug/pprof/profile?seconds=30,看 top(flat/cum)與火焰圖。記憶體:heap profile 的 inuse_space 找洩漏、alloc_space 找 GC 壓力來源。Goroutine 洩漏:/debug/pprof/goroutine?debug=2 看數量是否持續增加與卡在哪個 channel 操作。鎖競爭與阻塞:先用 runtime.SetMutexProfileFraction、SetBlockProfileRate 開啟,再看 mutex 與 block profile。排程與 GC 停頓用 go tool trace。微基準用 go test -bench -benchmem 搭配 benchstat,注意 allocs/op。

在 RTB bidder 這種 100ms 期限的系統,你會怎麼設計以確保延遲?

把延遲當預算管理:扣掉網路後為每個階段(解析、targeting、特徵、模型、預算檢查)分配子預算,從收到請求起計算 deadline 並往下傳;任何下游超時就降級(預設特徵)或直接 no-bid,因為遲到的出價沒有價值。熱路徑全在記憶體:targeting 規則預編譯成索引、campaign 設定常駐並以 double buffering 更新、特徵用本地快取加批次查詢。非關鍵工作(log、統計)非同步送 Kafka。控制 tail:限制候選數、hedged request、circuit breaker、避免 GC 停頓與 CPU throttling、過載時 load shedding。監控每階段的 p99/p999、timeout 率、no-bid 原因。語言上 Go/Java/C++ 常用於這類熱路徑。