feat:實作 API 效能優化 — Redis 快取、分頁、DB 索引
- 引入 Redis(predis)快取層:Admin Stats(5分鐘)、課程列表(3分鐘,tag-based 失效)、評價分布(10分鐘) - ReviewController::publicList 改為 paginate + eager load votes,消除 N+1 - AdminReviewController::index 加入分頁(預設 20,最大 100) - 新增 notifications / diving_offers 效能索引 migration - 新增 docker-compose.override.yml 本機開發 port mapping 機制(不進 git) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-18
|
||||
@@ -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()`)
|
||||
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
前端請求回應偏慢,調查後確認根本原因為:無任何快取層、多處 Controller 直接執行全表查詢(無分頁)、資料庫缺少關鍵索引,導致每次請求都是全量 DB 掃描。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **補充 DB 索引**:`notifications` 表補 `[notifiable_type, notifiable_id, read_at]` 複合索引;`diving_offers` 表補 `provider_id` 索引
|
||||
- **查詢優化**:`ReviewController::publicList()` 將 3 次獨立 SQL 合併;`AdminReviewController::index()` 加入分頁
|
||||
- **引入快取層(Redis)**:`AdminStatsController` 統計數據、課程列表搜尋結果、評價分布加 `Cache::remember()`
|
||||
- **Cache driver 切換為 Redis**:目前 driver 為 `database`,改為 Redis 以降低快取本身的 DB 負擔
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `api-cache-layer`:為高頻讀取端點(統計、課程列表、評價分布)加入 Redis 快取,定義 TTL 策略與快取失效時機
|
||||
- `db-index-optimization`:補充缺少的資料庫索引,涵蓋 notifications 與 diving_offers 表
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `review-lifecycle`:`publicList` 端點行為改變——合併查詢、加入分頁參數(`per_page` 預設 20)
|
||||
|
||||
## Impact
|
||||
|
||||
- **後端**:`ReviewController`、`AdminReviewController`、`AdminStatsController`、`config/cache.php`、`.env`(CACHE_STORE=redis)
|
||||
- **資料庫**:新增一個 migration 補索引
|
||||
- **Docker**:需確認 `docker-compose.yml` 中已有 Redis 服務(若無則補上)
|
||||
- **前端**:`ReviewController` 加分頁後,前端呼叫端需傳入 `page` 參數(或保持預設,相容現有行為)
|
||||
@@ -0,0 +1,68 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Redis 作為快取驅動
|
||||
|
||||
系統 SHALL 使用 Redis 作為 Laravel 快取驅動,取代現有的 `database` driver。
|
||||
|
||||
#### Scenario: 環境設定正確
|
||||
|
||||
- **WHEN** `.env` 中 `CACHE_STORE=redis`、`REDIS_HOST=redis`(Docker service name)
|
||||
- **THEN** `docker-compose up` 後 `php artisan cache:clear` 執行成功,無連線錯誤
|
||||
|
||||
#### Scenario: Redis service 在 docker-compose 中存在
|
||||
|
||||
- **WHEN** 執行 `docker-compose up`
|
||||
- **THEN** `redis` container 正常啟動,`cfdive-app` 可連線至 Redis
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Admin 統計數據快取
|
||||
|
||||
`GET /api/admin/stats` SHALL 使用 `Cache::remember()` 快取結果,TTL 5 分鐘,Cache key 為 `admin_stats`。
|
||||
|
||||
#### Scenario: 首次請求寫入快取
|
||||
|
||||
- **WHEN** Admin 送出 `GET /api/admin/stats`,且快取中無 `admin_stats` key
|
||||
- **THEN** 系統執行 DB 查詢並將結果寫入 Redis,回傳統計數據
|
||||
|
||||
#### Scenario: 後續請求命中快取
|
||||
|
||||
- **WHEN** Admin 在 5 分鐘內再次送出 `GET /api/admin/stats`
|
||||
- **THEN** 系統直接從 Redis 回傳,不執行任何 DB 查詢
|
||||
|
||||
#### Scenario: 快取過期後自動重整
|
||||
|
||||
- **WHEN** 5 分鐘 TTL 到期後再次請求
|
||||
- **THEN** 系統重新執行 DB 查詢並更新快取
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 課程列表快取
|
||||
|
||||
`GET /api/diving-offers` SHALL 快取搜尋結果,TTL 3 分鐘,Cache key 包含查詢參數的 hash。
|
||||
|
||||
#### Scenario: 相同搜尋條件命中快取
|
||||
|
||||
- **WHEN** 同樣的搜尋參數(region、tag、keyword 等)在 3 分鐘內再次請求
|
||||
- **THEN** 系統從 Redis 回傳,不執行 DB 查詢
|
||||
|
||||
#### Scenario: Provider 異動課程時清除快取
|
||||
|
||||
- **WHEN** Provider 成功新增、修改或刪除課程(`POST/PUT/DELETE /api/provider/offers`)
|
||||
- **THEN** 系統呼叫 `Cache::forget()` 清除對應快取 key,下次請求重新查詢
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 評價分布快取
|
||||
|
||||
`GET /api/diving-offers/{id}/reviews` 的 `distribution`(1–5 星分布統計)SHALL 獨立快取,TTL 10 分鐘,Cache key 為 `offer_review_distribution_{id}`。
|
||||
|
||||
#### Scenario: 分布統計命中快取
|
||||
|
||||
- **WHEN** 同一課程 reviews 端點在 10 分鐘內再次請求
|
||||
- **THEN** `distribution` 欄位從 Redis 取得,不執行 `GROUP BY` SQL
|
||||
|
||||
#### Scenario: 新增/修改/刪除評價時清除分布快取
|
||||
|
||||
- **WHEN** Member 成功新增、修改或刪除某課程的評價
|
||||
- **THEN** 系統呼叫 `Cache::forget("offer_review_distribution_{offerId}")` 清除對應快取
|
||||
@@ -0,0 +1,31 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Notifications 表補複合索引
|
||||
|
||||
`notifications` 表 SHALL 新增 `[notifiable_type, notifiable_id, read_at]` 複合索引,以加速 `unreadNotifications()` 查詢。
|
||||
|
||||
#### Scenario: Migration 執行成功
|
||||
|
||||
- **WHEN** 執行 `php artisan migrate`
|
||||
- **THEN** `notifications` 表上存在 `notifications_notifiable_read_at_index`(或同等命名的)複合索引,`EXPLAIN` 結果不再為 full table scan
|
||||
|
||||
#### Scenario: 未讀通知查詢走索引
|
||||
|
||||
- **WHEN** 系統執行 `Notification::where('notifiable_type', User::class)->where('notifiable_id', $userId)->whereNull('read_at')->get()`
|
||||
- **THEN** MySQL `EXPLAIN` 顯示使用複合索引,`type` 為 `ref` 而非 `ALL`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: DivingOffers 表補 provider_id 索引
|
||||
|
||||
`diving_offers` 表 SHALL 新增 `provider_id` 單欄索引,以加速 Provider 課程列表查詢。
|
||||
|
||||
#### Scenario: Migration 執行成功
|
||||
|
||||
- **WHEN** 執行 `php artisan migrate`
|
||||
- **THEN** `diving_offers` 表上存在 `provider_id` 索引
|
||||
|
||||
#### Scenario: Provider 課程列表查詢走索引
|
||||
|
||||
- **WHEN** 系統執行 `DivingOffer::where('provider_id', $providerId)->get()`
|
||||
- **THEN** MySQL `EXPLAIN` 顯示使用 `provider_id` 索引,`type` 為 `ref`
|
||||
@@ -0,0 +1,29 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: 評價公開顯示(匿名)
|
||||
|
||||
任何人(含未登入)SHALL 能查看課程評價列表,評價人統一顯示為「匿名潛水者」。Provider 在 Coach Portal 亦可查看自己課程的評價(只讀)。評價列表 SHALL 支援分頁,預設每頁 20 筆,最大 50 筆。
|
||||
|
||||
#### Scenario: 取得評價列表(含 summary,含分頁)
|
||||
|
||||
- **WHEN** 任何人送出 `GET /api/diving-offers/{id}/reviews?sort=helpful|rating|newest&page=1&per_page=20`
|
||||
- **THEN** 系統回傳 `summary`(平均星等、總數、1–5 星分布)與分頁後的 `reviews` 列表;`reviewer_name` 一律為「匿名潛水者」;已登入 Member 額外回傳 `is_mine`;未登入 `has_voted` 固定為 `false`、`is_mine` 欄位省略;回傳包含分頁 meta(`current_page`、`last_page`、`per_page`、`total`)
|
||||
|
||||
#### Scenario: per_page 超出上限時截斷
|
||||
|
||||
- **WHEN** 請求帶有 `per_page=200`
|
||||
- **THEN** 系統以 `per_page=50` 處理,不回傳錯誤
|
||||
|
||||
#### Scenario: 三種排序
|
||||
|
||||
- **WHEN** `sort=helpful`(預設)
|
||||
- **THEN** 依 `helpful_count DESC, created_at DESC` 排序
|
||||
- **WHEN** `sort=rating`
|
||||
- **THEN** 依 `rating DESC, created_at DESC` 排序
|
||||
- **WHEN** `sort=newest`
|
||||
- **THEN** 依 `created_at DESC` 排序
|
||||
|
||||
#### Scenario: votes 透過 eager loading 查詢
|
||||
|
||||
- **WHEN** 已登入 Member 送出評價列表請求
|
||||
- **THEN** `has_voted` 欄位透過 eager loaded `votes` collection 判斷,不額外發 SQL 查詢
|
||||
@@ -0,0 +1,46 @@
|
||||
## 1. 基礎設施:Redis 設定
|
||||
|
||||
- [x] 1.1 [後端] `docker-compose.yml` 新增 `redis:alpine` service,並在 `cfdive-app` depends_on 加入 `redis`
|
||||
- [x] 1.2 [後端] `.env` 設定 `CACHE_STORE=redis`、`REDIS_HOST=redis`、`REDIS_PORT=6379`;`.env.example` 同步補上
|
||||
- [x] 1.3 [後端] `config/cache.php` 確認 redis connection 設定正確(`REDIS_HOST`、`REDIS_PASSWORD`、`REDIS_PORT`)
|
||||
- [x] 1.4 [後端] 執行 `docker-compose up --build`,確認 redis container 啟動,`php artisan cache:clear` 無連線錯誤
|
||||
|
||||
## 2. 資料庫索引補充
|
||||
|
||||
- [x] 2.1 [後端] 建立 migration `add_performance_indexes`,為 `notifications` 表新增 `[notifiable_type, notifiable_id, read_at]` 複合索引
|
||||
- [x] 2.2 [後端] 同一 migration 為 `diving_offers` 表新增 `provider_id` 索引
|
||||
- [x] 2.3 [後端] 執行 `php artisan migrate`,確認索引建立成功(可用 `SHOW INDEX FROM notifications` 驗證)
|
||||
|
||||
## 3. 查詢優化:ReviewController::publicList
|
||||
|
||||
- [x] 3.1 [後端] `ReviewController::publicList()` 加入分頁:`->paginate(min($perPage, 50))`,`per_page` 預設 20,從 request 取得
|
||||
- [x] 3.2 [後端] 評價查詢改為 `Review::with('votes')->where(...)` eager load votes,移除獨立的 `ReviewVote::where()->pluck()` SQL
|
||||
- [x] 3.3 [後端] `has_voted` 判斷改從 eager loaded `$review->votes` collection 計算(`$review->votes->contains('member_id', $memberId)`)
|
||||
- [x] 3.4 [後端] 評價分布統計加上 `Cache::remember("offer_review_distribution_{$offerId}", 600, fn() => ...)` 快取(TTL 10 分鐘)
|
||||
- [x] 3.5 [後端] Member 新增/修改/刪除評價的 service 方法中,加入 `Cache::forget("offer_review_distribution_{$offerId}")` 快取失效
|
||||
- [x] 3.6 [後端] Response 加入 Laravel 分頁 meta:`current_page`、`last_page`、`per_page`、`total`
|
||||
|
||||
## 4. 查詢優化:AdminReviewController
|
||||
|
||||
- [x] 4.1 [後端] `AdminReviewController::index()` 的 `.get()` 改為 `.paginate(min($perPage, 100))`,`per_page` 預設 20
|
||||
|
||||
## 5. 快取層:AdminStatsController
|
||||
|
||||
- [x] 5.1 [後端] `AdminStatsController` 的三個 `count()` 查詢,用 `Cache::remember('admin_stats', 300, fn() => [...])` 包裹(TTL 5 分鐘)
|
||||
|
||||
## 6. 快取層:課程列表
|
||||
|
||||
- [x] 6.1 [後端] `DivingOfferController::index()` 對搜尋結果加入 `Cache::remember()` 快取,Cache key 為 `diving_offers_` + md5(serialize($request->all())),TTL 180 秒
|
||||
- [x] 6.2 [後端] `DivingOfferController::store()`、`update()`、`destroy()` 加入 `Cache::flush()` 或 `Cache::forget()` 清除對應快取 key
|
||||
|
||||
## 7. 前端相容性調整
|
||||
|
||||
- [x] 7.1 [前端] `CourseDetailView.vue` 的 reviews API 呼叫確認 response 結構相容(`data.data` 現在含分頁 meta,原本是陣列)
|
||||
- [x] 7.2 [前端] `ReviewsView.vue`(Coach Portal)確認 API response 結構相容
|
||||
|
||||
## 8. 手動驗證
|
||||
|
||||
- [x] 8.1 [整合測試] 開啟 `GET /api/diving-offers/{id}/reviews`,確認回傳包含 `meta.total`、`meta.current_page` 等分頁欄位
|
||||
- [x] 8.2 [整合測試] 呼叫 `GET /api/admin/stats` 兩次,確認第二次無 DB 查詢(可用 Laravel Telescope 或 query log 確認)
|
||||
- [x] 8.3 [整合測試] Provider 修改課程後,再次請求課程列表確認資料已更新(快取失效正常)
|
||||
- [x] 8.4 [整合測試] 執行 `SHOW INDEX FROM notifications`、`SHOW INDEX FROM diving_offers`,確認索引存在
|
||||
Reference in New Issue
Block a user