效能優化 - Performance Optimization 實戰範例
「Slack 上有人抱怨:『報表頁要等 11 秒才能開』。你打開 Prod Grafana,看到 P99 把天花板撐開了。」這是工程團隊最常被交辦、也最容易做出問題的任務。
效能優化的最大反模式是 「先 Optimise 再說」:沒量測就動手改、憑直覺加大 pool、thread 化、加 cache、加 index——結果成本翻倍、複雜度暴增、P99 沒改善。
本範例示範如何用 Superpowers 的除錯與驗證 skills 把效能優化做成根因導向的工程而非直覺賭博。
專案概述
情境:Go 後端的「廣告投放報表 API」。近年廣告主數成長 30x,Dashboard /reports/aggregate 從 200ms 退化到 11 秒。
PM 說「加 Redis 就好」,Backend 說「SQL 加 index」,DevOps 說「機器 scale up」。三方各有道理,但誰也拿不出根因。
目標:
- P99 < 1 秒(目前 11s)
- 成本 < +15%(Pre-approved budget)
- 不引入 Redis(避免維護負擔倍增)
技術棧:Go 1.22 + Gin + PostgreSQL 16 + pgxpool
核心挑戰:
- Aggregate query 跨 4 張表 JOIN
- 每廣告主資料量差異大(從 100 筆到 5000 萬筆)
- 卡 timeout 的恰好是大廣告主
- 黑箱程度高:誰也說不清為什麼慢
完整開發流程
第 1 步:Systematic Debugging 找根因
鐵律:沒找根因不許優化。憑直覺的優化是工程罪惡。
階段 1:穩定復現 + 量化基線
不能用 prod 試,需要 staging 環境用 prod 資料 復現:
# 從 prod dump 一個月資料(匿名化)到 staging
pg_dump --table=campaigns --table=impressions --table=clicks --table=conversions \
--data-only prod | psql staging
# 找到大廣告主 ID
psql staging -c "SELECT advertiser_id, COUNT(*) FROM impressions GROUP BY 1 ORDER BY 2 DESC LIMIT 5;"
# 12345 → 4800 萬筆 impressions
# 用 wrk 在 staging 量測基線
$ wrk -t4 -c16 -d30s 'https://staging.example.com/reports/aggregate?advertiser_id=12345&days=30'
Running 30s test @ https://staging.example.com/reports/aggregate...
Latency 11.82s (P50), 12.41s (P99) ★ 完美復現
Requests/sec: 1.3
基線:P99 = 12.41s。這是修復前的對照組,任何優化都要對照它。
階段 2:多層證據收集
系統有「API → Query → DB」多層,先確定斷點在哪,別在三層都改:
// app/handlers/reports.go (加診断埋點)
func AggregateReports(c *gin.Context) {
start := time.Now()
defer func() {
log.WithFields(log.Fields{
"total_ms": time.Since(start).Milliseconds(),
// ...
}).Info("aggregate")
}()
// L1: schema 驗證
params := parseParams(c)
t1 := time.Now()
// ... 驗證省略
metrics.Report.RecordDuration("aggregate.validation", time.Since(t1))
// L2: SQL query
t2 := time.Now()
rows, err := db.QueryContext(c, sqlStr, params.Args...)
metrics.Report.RecordDuration("aggregate.sql", time.Since(t2))
// L3: row scan + aggregate in app
t3 := time.Now()
result := reduceRows(rows)
metrics.Report.RecordDuration("aggregate.reduce", time.Since(t3))
}
跑一次,看各層數值:
total_ms=11820 validation=2 sql=11750 reduce=68
↑ SQL 佔 99% 時間
斷點確認:SQL 層占 99% 時間,不要動 Go code、不要動 cache、不要動 reduce 函式。
階段 3:在 SQL 層深入 — EXPLAIN ANALYZE
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON)
SELECT c.name, SUM(i.count), SUM(cl.count), SUM(cv.count)
FROM campaigns c
JOIN impressions i ON i.campaign_id = c.id
JOIN clicks cl ON cl.impression_id = i.id
JOIN conversions cv ON cv.click_id = cl.id
WHERE c.advertiser_id = 12345
AND i.created_at >= NOW() - INTERVAL '30 days'
GROUP BY c.name;
關鍵發現:
-> Seq Scan on impressions (cost=0.00..982121.33 rows=47823119)
Filter: (created_at >= (now() - '30 days'::interval))
Rows Removed by Filter: 250000 ← 掃了 4800 萬,只有少數被過濾
Buffers: shared hit=12345678 read=987654
-> Hash Join (rows=47823119)
Hash Cond: (cl.impression_id = i.id)
Buffers: shared hit=9823411 read=512345 ...
根因(不是一個,是三個叠加):
- 缺複合 index:
(advertiser_id, created_at)上沒有 index,Postgres 全表掃 4800 萬筆 - 過度 JOIN:報表只需要
advertiser_id,卻 JOIN 到底層 conversions(每筆 impression 展開 5-10 筆 conversion,中間結果膨脹) - 缺 materialized view:同一份結果每天被算數百次
第 2 步:Writing Plans 拆解優化計畫
觸發:
使用 writing-plans skill 把發現的根因轉成有驗收標準的優化計畫
計畫:
階段 1(0.5 天):加複合 index → 預期 P99 < 8s
- [ ] migration: impressions(advertiser_id, created_at) DESC
- [ ] staging EXPLAIN 重跑,Seq Scan → Index Scan
- [ ] wrk 重測 P99,驗收 < 8s
階段 2(1.5 天):拆 JOIN,改兩段式查詢 → 預期 < 3s
- [ ] 第一段:用 impressions 為主查,算 imp_count
- [ ] 第二段:用 GROUP BY 後的小集合(數十筆)去 JOIN clicks/conversions
- [ ] wrk 重測,驗收 < 3s
階段 3(1 天):Materialized View(每日夜間刷新)→ 預期 < 500ms
- [ ] CREATE MATERIALIZED VIEW advertiser_daily_summary
- [ ] cron 02:00 REFRESH CONCURRENTLY
- [ ] API 改打 materialized view,加 fallback 當日實時算
- [ ] wrk 重測,驗收 < 500ms
驗收標準(三階段匯總):
- [ ] P99 < 1s
- [ ] DB CPU < +20%(目前 +1500% in aggregate)
- [ ] 不引入 Redis
- [ ] 既有報表行為不變(數值 diff = 0)
每階段都有量化驗收標準——這是 Writing Plans 的價值:強迫你定義「成功是什麼」,而不是「我覺得變快了」。
第 3 步:TDD 實作階段 1(Index)
紅燈:為「該走 Index Scan」寫測試
// app/handlers/reports_test.go
func TestAggregateReports_UsesIndexScan(t *testing.T) {
if testing.Short() {
t.Skip("需要真實 PG")
}
// EXPLAIN 結果應該不包含 "Seq Scan on impressions"
plan := explainQuery(t, testDB, `
SELECT 1 FROM impressions
WHERE advertiser_id = $1 AND created_at >= NOW() - INTERVAL '30 days'`,
defaultAdvertiserID)
if strings.Contains(plan, "Seq Scan on impressions") {
t.Errorf("imp 仍走 Seq Scan,缺 index:\n%s", plan)
}
}
跑 → 紅燈。
綠燈:加 index migration
// db/migrations/20250716000001_add_impressions_index.go
func Up(tx *sql.Tx) error {
_, err := tx.Exec(`
CREATE INDEX CONCURRENTLY idx_impressions_adv_date
ON impressions (advertiser_id, created_at DESC)
`)
return err
}
跑測試 → 綠燈。重新跑 wrk → P99 由 12.4s 降到 7.8s(驗收 < 8s ✓)。
第 4 步:Executing Plans 階段 2 + 3
依序做 JOIN 拆解、materialized view。每階段都驗收後才進下一階段(skill 強調「一次只驗證一個變數」):
階段 1: 12.4s → 7.8s ✓ (index)
階段 2: 7.8s → 2.9s ✓ (JOIN 拆解)
階段 3: 2.9s → 380ms ✓ (materialized view)
最終: 12.4s → 380ms,降幅 97%
第 5 步:Verification 全面驗收
觸發:
使用 verification-before-completion skill 驗證效能優化結果
驗收清單(定量 + 定性):
✅ 效能指標(P99 < 1s 目標)
-staging P99:380ms ✓ (達標,預留 2.6x 餘裕)
-prod P99(灰度 5%):415ms ✓
-prod P99(灰度 50%):392ms ✓
✅ 成本(< +15%)
-DB CPU:-40%(因為 index + materialized view 大幅降 CPU)
-Staging 跑成本:每月節省 ~$200(估算)
-✓ 遠低於 +15% budget
✅ 行為一致性(避免「變快但數值錯」)
-staging 與 prod 同參數比對:數值 diff = 0 ✓
-報表 contract test 全綠 ✓
-跑了一週「新舊並行 + diff alert」:零差異 ✓
✅ 維運可持續
-materialized view REFRESH 失敗的 fallback 機制 ✓
-daily REFRESH 監控告警 ✓
-若 materialized view 落後 30 分鐘,API 自動降級用即時計算 ✓
✅ 沒引入 Redis(明確禁止)
-證明根因不需要 Redis ✓
-避免新增維護負擔 ✓
第 6 步:Defense in Depth(深度防護)
修完根因後,加多層防護避免退化:
- CI 量測:任何 PR 影響 reports aggregate 的,CI 跑 explain,避免 regression
- Alerting:P99 > 1s 連續 5 分鐘 → PagerDuty
- Cogsyload test:CI 定期跑 wrk 對 aggregate endpoint,長期追蹤 P99 趨勢
關鍵學習點
1. 沒量測不要動手(No Measurement, No Optimization)
最致命反模式是「PM 說加 Redis / 你感覺 SQL 慢 / DevOps 說 scale up」就動手。根因可能恰好是因為 cache、SQL、scaling 三者都不是主因。三層 timing 埋點 1 小時投入,換來 3 週的正確工程。
2. 證據驅動 > 直覺驅動
ex:
直覺:「加 Redis」
證據:99% 時間在 SQL → Redis 解決不了 SQL
直覺:"加 index"
證據:EXPLAIN 顯示 3 個根因 → index 只解 1 個
每一步都用 EXPLAIN、metrics、wrk 量化,禁止「我覺得有比較快」。
3. 階段式驗收,每階段只改一個變數
階段 1(index)驗收完才做階段 2(JOIN)。否則無法隔離哪個優化真的生效——這是 Systematic Debugging skill 的核心。
4. 防止退化比修好更難
修好密集用 3 天,但 3 個月後有人改回 N+1 query 又退化。Defense in depth(CI explain + alerting + load test)保護成果不流失。
5. 「不引入 Redis」是正確選擇
本研究證明根因不在 cache。引入 Redis 會:
- 增加維護負擔(部署、監控、一致性)
- 解錯問題(延後 SQL 根因爆發)
- 成本增加 ~30%(額外 instance)
YAGNI 適用於效能優化——別為了「不知道根因」而上工具。
工具使用摘要
| Skill | 用途 | 在本例的作用 |
|---|---|---|
| Systematic Debugging | 根因調查 | 多層 timing → SQL 層 → EXPLAIN |
| Writing Plans | 拆解計畫 | 3 階段 + 每階段量化驗收 |
| Test-Driven Development | 紅→綠迴圈 | 為「該走 Index」寫測試 |
| Executing Plans | 階段式執行 | 每階段只改一變數,逐次驗收 |
| Verification Before Completion | 全面驗收 | 效能、成本、行為、維運 |
| Defense in Depth (輔助) | 防退化 | CI alerting + load test |
總結
效能優化的核心反模式是 “Optimization by Vibe”:憑直覺、靠熱情、看心情。Superpowers skills 把它變成工程:
- 不量測,不優化(Systematic Debugging)
- 不訂驗收,不開工(Writing Plans)
- 不寫測試,不宣稱修好(TDD + Verification)
- 不防退化,不算完成(Defense in Depth)
最关键的: 效能問題的根因往往不是「大家說的那個」。11 秒到 380ms 的路上,我們跳過了 Redis、並沒有 scale up,只用了 index、SQL 拆解、materialized view 三個人人都會的工具——重點是不要跳過根因調查。