Files
CFDivePlatform/openspec/changes/archive/2026-05-25-api-performance-optimization/design.md
T
a620906209 bda6533105 chore:歸檔 api-performance-optimization,同步主規格
- 移至 openspec/changes/archive/2026-05-25-api-performance-optimization/
- 新增主規格:api-cache-layer、db-index-optimization
- 更新 review-lifecycle 規格(補分頁、eager load)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 00:45:08 +08:00

4.3 KiB
Raw Blame History

Context

前端請求回應偏慢,調查後確認三個根本原因:

  1. 無快取:所有請求直打 DBcache driver 設為 database(等於又多一次 DB 查詢)
  2. 全表查詢AdminReviewController::index()ReviewController::publicList() 直接 .get() 無分頁,資料量增長後線性惡化
  3. 缺少索引notificationsdiving_offers 表缺少 WHERE clause 中常用欄位的索引,導致全表掃描

Goals / Non-Goals

Goals:

  • 補全缺少的 DB 索引,消除 notifications / diving_offers 的全表掃描
  • ReviewController::publicList() 的 3 次 SQL 合併,並加入分頁
  • AdminReviewController::index() 加入分頁(預設 20 筆)
  • 引入 Redis 快取層,覆蓋 Admin Stats、課程列表、評價分布三個高頻端點
  • CACHE_STORE 切換為 redis

Non-Goals:

  • 前端 HTTP 層級的 CDN / HTTP Cache-Control header 優化
  • Elasticsearch 全文搜尋(課程 LIKE 搜尋的長期方案)
  • 資料庫連線池調整(屬於 DevOps 層級)
  • API Response Compressiongzip,屬於 nginx 設定)

Decisions

決策 1Cache driver 改為 Redis 而非 Memcached

選擇Redis

理由

  • Docker 環境中加一個 redis:alpine service 即可,成本低
  • Redis 支援 TTL、Pub/Sub、Queue(未來 Job Queue 可複用同一個 Redis
  • Memcached 無法用於 Laravel Queue,擴展性較差

替代方案:保留 database driver → 拒絕,因為快取本身又增加 DB 負擔,反效果


決策 2ReviewController::publicList 合併查詢策略

目前狀況3 次獨立 SQL

  1. Review::where()->get() — 撈評價列表
  2. ReviewVote::where()->pluck() — 撈已投票 ID
  3. Review::selectRaw()->groupBy()->pluck() — 統計分布

選擇:合併為 2 次 SQL

  • SQL 1Review::with('votes')->where()->paginate() — 評價列表(含 votes eager load
  • SQL 2Review::selectRaw()->groupBy()->pluck() — 統計分布(保留,邏輯獨立)
  • has_voted 改從 eager loaded votes collection 中判斷,不再發第 3 次 SQL

理由:分布統計邏輯與列表查詢職責不同,維持獨立 SQL 可讀性更高;votes 用 eager load 解決 N+1

替代方案:全部合一次 SQL(subquery)→ 拒絕,可讀性差,維護困難


決策 3:快取 TTL 策略

端點 TTL 失效時機
Admin Stats(總數統計) 5 分鐘 手動清除(可接受少許延遲)
課程列表(GET /api/diving-offers 3 分鐘 Provider 新增/修改/刪除課程時 Cache::forget()
評價分布(per offer 10 分鐘 Member 新增/修改/刪除評價時 Cache::forget()

理由Stats 為 Admin 用,接受延遲;課程列表對 SEO 重要,失效時機明確;評價分布計算成本高,TTL 稍長


決策 4:分頁預設值

  • ReviewController::publicList()per_page 預設 20,最大 50
  • AdminReviewController::index()per_page 預設 20,最大 100
  • 相容性:前端目前不傳 pageLaravel paginate() 預設 page=1,現有行為不破壞

Risks / Trade-offs

  • [Risk] Redis 服務故障CACHE_STORE=redis 時 Redis 掛掉會拋出 Exception → Mitigation:在 .env.example 補 fallback 說明;生產環境考慮 CACHE_STORE 動態切換
  • [Risk] 快取資料與 DB 短暫不一致 → 課程列表快取 3 分鐘內不即時 → Mitigation:寫入操作(新增/修改/刪除課程、新增評價)主動 Cache::forget(),只有統計類允許延遲
  • [Risk] Migration 補索引在生產環境加鎖 → MySQL 8 ADD INDEX 為 online DDL,不鎖表 → 低風險

Migration Plan

  1. 執行 php artisan migrate(補索引 migration
  2. docker-compose.yml 加入 Redis service
  3. .env 設定 CACHE_STORE=redisREDIS_HOST=redis
  4. 部署後重啟 cfdive-app container

Rollback

  • 索引可 DROP INDEX(不影響資料)
  • 快取失效直接 CACHE_STORE=database 回退,行為與現在相同

Open Questions

  • Redis 是否需要設定 maxmemory-policy(防止記憶體溢出)?建議 allkeys-lru,但需確認 Docker 記憶體限制
  • 課程列表的快取 key 是否要包含搜尋參數?若包含,失效邏輯需改為 tag-based invalidationCache::tags()