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>
This commit is contained in:
2026-05-25 00:45:08 +08:00
parent efb1f22be3
commit bda6533105
10 changed files with 107 additions and 4 deletions
@@ -0,0 +1,94 @@
## Context
前端請求回應偏慢,調查後確認三個根本原因:
1. **無快取**:所有請求直打 DBcache driver 設為 `database`(等於又多一次 DB 查詢)
2. **全表查詢**`AdminReviewController::index()``ReviewController::publicList()` 直接 `.get()` 無分頁,資料量增長後線性惡化
3. **缺少索引**`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 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 1`Review::with('votes')->where()->paginate()` — 評價列表(含 votes eager load
- SQL 2`Review::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
- **相容性**:前端目前不傳 `page`Laravel `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=redis``REDIS_HOST=redis`
4. 部署後重啟 `cfdive-app` container
**Rollback**
- 索引可 `DROP INDEX`(不影響資料)
- 快取失效直接 `CACHE_STORE=database` 回退,行為與現在相同
## Open Questions
- Redis 是否需要設定 `maxmemory-policy`(防止記憶體溢出)?建議 `allkeys-lru`,但需確認 Docker 記憶體限制
- 課程列表的快取 key 是否要包含搜尋參數?若包含,失效邏輯需改為 tag-based invalidation`Cache::tags()`