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:
@@ -0,0 +1,94 @@
|
||||
## Context
|
||||
|
||||
前端請求回應偏慢,調查後確認三個根本原因:
|
||||
1. **無快取**:所有請求直打 DB,cache 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 Compression(gzip,屬於 nginx 設定)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 決策 1:Cache driver 改為 Redis 而非 Memcached
|
||||
|
||||
**選擇**:Redis
|
||||
|
||||
**理由**:
|
||||
- Docker 環境中加一個 `redis:alpine` service 即可,成本低
|
||||
- Redis 支援 TTL、Pub/Sub、Queue(未來 Job Queue 可複用同一個 Redis)
|
||||
- Memcached 無法用於 Laravel Queue,擴展性較差
|
||||
|
||||
**替代方案**:保留 `database` driver → 拒絕,因為快取本身又增加 DB 負擔,反效果
|
||||
|
||||
---
|
||||
|
||||
### 決策 2:ReviewController::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()`)
|
||||
Reference in New Issue
Block a user