效能調校 FAQ
一份實用、不綁語言的筆記:怎麼找出並修掉效能問題,最後再附上 Java 專屬的技巧。
總覽
黃金守則
- **要量測,不要猜。**先做 profiling;人對瓶頸的直覺通常是錯的。
- **只優化熱路徑。**90% 的時間花在一小部分程式碼上(Amdahl 定律)。
- 先修最大的瓶頸,然後重新量測 —— 瓶頸會搬家。
- 設一個目標。「夠快」是一個數字(例如 p99 < 200ms),不是一種感覺。
- 小心微優化:犧牲可讀性換來微不足道的收益。
什麼時候才該調校
等你有了 (1) 可重現的工作負載、(2) 一個指標、(3) 一條基準線之後再說。
1) Profiling 的做法
一個可以反覆執行的循環:
text
1. Reproduce → capture a realistic, repeatable workload
2. Measure → get a baseline (latency, throughput, CPU, memory)
3. Locate → profile to find the hot spot
4. Fix → change ONE thing
5. Re-measure → confirm improvement, watch for regressions elsewhere
6. Repeat → until target is met
Profiling 的種類
| 種類 | 回答什麼問題 | 工具(舉例) |
|---|---|---|
| CPU/取樣 | 時間花在哪? | perf、async-profiler、py-spy |
| 配置(allocation) | 是誰在製造垃圾? | JFR、記憶體 profiler |
| 牆鐘時間/追蹤 | 一個請求在哪裡等? | 分散式追蹤、span |
| Heap dump | 是誰抓著記憶體不放? | heap 分析工具 |
延遲的百分位數
永遠不要只看平均值。要追蹤 p50 / p95 / p99 / p99.9。 使用者感受到的慢、以及 SLA 被打破,通常都是尾端延遲(p99)造成的。
2) 常見的瓶頸
CPU 密集
- 症狀:CPU 使用率高、等待時間低。
- 成因:演算法沒效率(大
n上的O(n^2))、過多的序列化/正規表示式、 緊湊迴圈、重複計算。 - 解法:換更好的演算法/資料結構、memoization/快取、把工作批次化、 平行化(只有在核心還閒著時才有用)。
記憶體密集
- 症狀:GC 時間長、開始換頁(swap)、OOM。
- 成因:洩漏(沒有上限的快取或集合)、龐大的物件圖、裝箱。
- 解法:快取設上限、用串流取代把全部載進記憶體、重複使用緩衝區、 修掉洩漏(不小心讓物件一直可達)。
I/O 密集(磁碟/檔案)
- 症狀:CPU 低、等待時間高。
- 解法:讀寫加緩衝、順序存取優於隨機存取、加大批次、 非同步/非阻塞 I/O、用壓縮拿 CPU 換 I/O。
資料庫密集
- 通常是現實世界裡的第一名瓶頸。見第 4 節。
網路密集
- 症狀:延遲主要由來回次數決定。
- 成因:話太多的 API(N+1 次呼叫)、payload 太大、沒有重用連線。
- 解法:把請求批次化、減少來回、連線池/keep-alive、 壓縮、把運算搬到離資料近的地方、靜態資源用 CDN。
3) 快取
快取是拿記憶體與資料新鮮度去換速度。等你確定讀取佔多數、而且資料會被重複用到, 再加它。
層次(用戶端 → 伺服器)
- 瀏覽器/用戶端快取
- CDN/邊緣快取(靜態資源與可快取的回應)
- 應用程式的記憶體內快取(最快,但每個實例各一份)
- 分散式快取(各實例共用)
- 資料庫的緩衝區/查詢快取
幾個關鍵決定
- 淘汰策略:LRU(最常見)、LFU、以 TTL 為準。
- 失效:真正難的部分。做法有:TTL 過期、write-through、 write-behind、寫入時明確失效。
- Cache-aside 模式(最常見):text
read: check cache → miss → read DB → populate cache → return write: write DB → invalidate/update cache
常見陷阱
- 快取擊穿/驚群:熱門 key 一過期,大量 miss 同時打到資料庫。 用鎖、請求合併,或把 TTL 加上抖動來緩解。
- 資料過舊:明確決定你能容忍多舊的資料。
4) 資料庫查詢與索引調校
找出慢查詢
- 用資料庫的
EXPLAIN/EXPLAIN ANALYZE看查詢計畫。 - 特別留意那些本來該走索引、卻變成全表掃描的地方。
索引
- 在
WHERE、JOIN、ORDER BY用到的欄位上建索引。 - 複合索引的欄位順序很重要 —— 最左前綴原則。
- 覆蓋索引:把所有要 select 的欄位都包進去,資料庫就完全不用回表。
- 取捨:索引讓讀變快,但讓寫變慢、也佔空間。不要建過頭。
常見修法
| 問題 | 修法 |
|---|---|
| N+1 查詢 | 批次/join/eager fetch |
SELECT * |
只選需要的欄位 |
| 缺索引 | 建合適的索引 |
| offset 很大的分頁 | 改用 keyset(seek)分頁 |
| 鎖競爭 | 縮短交易、選對隔離級別 |
| 反覆的重量級讀取 | 快取、讀取複本 |
- 用連線池 —— 建立連線很貴。
- 讀取為主的分析場景,可以反正規化或預先算好(materialized view)。
5) JVM 調校基礎(Java)
程式碼和資料庫都調完了,才輪到 JVM 旗標。
Heap
-Xms/-Xmx:設定初始與最大 heap;伺服器上把兩者設成一樣, 避免調整大小造成的停頓。- 太小 → GC 太頻繁或 OOM。太大 → 停頓變長、白白佔記憶體。
垃圾收集器
- G1(現代 JDK 的預設):均衡、以 region 為單位,通用的好選擇。
- ZGC / Shenandoah:低停頓收集器,適合大 heap 或對延遲敏感的應用。
- 目標:把 GC 停頓時間與 GC 佔用比例壓到最小。
先觀察再調
- 打開 GC log,並使用 JDK Flight Recorder(JFR) 與 Mission Control。
- 要看的數字:配置速率、停頓時間、晉升到老年代的量、full GC 之後的 heap 大小。
Java 程式碼層級的技巧
- 迴圈裡串字串用
StringBuilder,不要用+。 - 集合先開好大小(
new ArrayList<>(expectedSize)),避免擴容。 - 熱迴圈裡避免自動裝箱(用 primitive 或 primitive stream)。
- 重複使用建立成本高、且執行緒安全的物件:
Pattern、DateTimeFormatter。 - 優先用串流/延遲處理,而不是把一大坨集合都實體化出來。
6) 熱路徑的 Big-O
演算法上最大的一筆收益,通常來自降低複雜度等級:
| 從 | 到 | 怎麼做 |
|---|---|---|
O(n^2) 巢狀掃描 |
O(n) |
用 hash map 查找取代內層迴圈 |
O(n) 的反覆查找 |
O(1) |
用 Set/Map 取代 List |
O(n log n) 重複排序 |
攤銷 O(1) |
讓資料保持有序/用 heap |
| 反覆重算 | 每次 O(1) |
Memoize/快取 |
熱路徑的檢查清單:
- 輸入大小
n是多少?它會怎麼成長? - 目前的複雜度是多少?有沒有不必要的巢狀迭代?
- 用對資料結構了嗎(查找用 hash、top-k 用 heap……)?
- 有沒有重複做的工作可以快取或預先算好?
- 能不能改成增量計算,而不是每次從頭來過?
經驗法則:在大輸入上把
O(n^2)改成O(n),勝過任何程度的常數項 JVM 調校。