bda6533105
- 移至 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>
4.3 KiB
4.3 KiB
Context
前端請求回應偏慢,調查後確認三個根本原因:
- 無快取:所有請求直打 DB,cache driver 設為
database(等於又多一次 DB 查詢) - 全表查詢:
AdminReviewController::index()與ReviewController::publicList()直接.get()無分頁,資料量增長後線性惡化 - 缺少索引:
notifications與diving_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 Compression(gzip,屬於 nginx 設定)
Decisions
決策 1:Cache driver 改為 Redis 而非 Memcached
選擇:Redis
理由:
- Docker 環境中加一個
redis:alpineservice 即可,成本低 - Redis 支援 TTL、Pub/Sub、Queue(未來 Job Queue 可複用同一個 Redis)
- Memcached 無法用於 Laravel Queue,擴展性較差
替代方案:保留 database driver → 拒絕,因為快取本身又增加 DB 負擔,反效果
決策 2:ReviewController::publicList 合併查詢策略
目前狀況:3 次獨立 SQL
Review::where()->get()— 撈評價列表ReviewVote::where()->pluck()— 撈已投票 IDReview::selectRaw()->groupBy()->pluck()— 統計分布
選擇:合併為 2 次 SQL
- SQL 1:
Review::with('votes')->where()->paginate()— 評價列表(含 votes eager load) - SQL 2:
Review::selectRaw()->groupBy()->pluck()— 統計分布(保留,邏輯獨立) has_voted改從 eager loadedvotescollection 中判斷,不再發第 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,最大 50AdminReviewController::index():per_page預設 20,最大 100- 相容性:前端目前不傳
page,Laravelpaginate()預設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
- 執行
php artisan migrate(補索引 migration) docker-compose.yml加入 Redis service.env設定CACHE_STORE=redis、REDIS_HOST=redis- 部署後重啟
cfdive-appcontainer
Rollback:
- 索引可
DROP INDEX(不影響資料) - 快取失效直接
CACHE_STORE=database回退,行為與現在相同
Open Questions
- Redis 是否需要設定
maxmemory-policy(防止記憶體溢出)?建議allkeys-lru,但需確認 Docker 記憶體限制 - 課程列表的快取 key 是否要包含搜尋參數?若包含,失效邏輯需改為 tag-based invalidation(
Cache::tags())