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

95 lines
4.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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()`