TTechSpecs

效能優化 - 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」。三方各有道理,但誰也拿不出根因

目標:

  1. P99 < 1 秒(目前 11s)
  2. 成本 < +15%(Pre-approved budget)
  3. 不引入 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 ...

根因(不是一個,是三個叠加):

  1. 缺複合 index:(advertiser_id, created_at) 上沒有 index,Postgres 全表掃 4800 萬筆
  2. 過度 JOIN:報表只需要 advertiser_id,卻 JOIN 到底層 conversions(每筆 impression 展開 5-10 筆 conversion,中間結果膨脹)
  3. 缺 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(深度防護)

修完根因後,加多層防護避免退化:

  1. CI 量測:任何 PR 影響 reports aggregate 的,CI 跑 explain,避免 regression
  2. Alerting:P99 > 1s 連續 5 分鐘 → PagerDuty
  3. 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 三個人人都會的工具——重點是不要跳過根因調查。