feat: /scan 採 stale-while-revalidate,閒置時零呼叫

- /scan 永遠秒回當前 Redis 快取,若 TTL < STALE_THRESHOLD 或 key 不存在,用 daemon thread 呼叫 radar_fetcher.main() 非同步更新
- 以 Redis SET NX EX 當互斥 lock(pokemon:radar:fetching,TTL 180s)避免並發 fetcher
- 新增設定:STALE_THRESHOLD=120、FETCH_LOCK_KEY、FETCH_LOCK_TTL=180
- 原本需要 cron 定期跑 fetcher;現在 API 端會自動觸發,沒人打就不動

驗證:
- cold start /scan: 26ms 回空、同時搶 lock
- 背景 fetcher 跑完(~16s Function Server)後寫入 Redis 並釋放 lock
- 下一次 /scan 13ms 回 96 筆、TTL 299s
- 快取新鮮時重複打 /scan 不會重複觸發(refresh counter=1)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 09:30:53 +08:00
parent c95d6074d5
commit 2f7285437b
2 changed files with 62 additions and 17 deletions

View File

@@ -14,16 +14,7 @@
# 起本機 Redis一次性
docker run -d --name pokemon-radar-redis --restart unless-stopped -p 6379:6379 redis:7-alpine
# 跑一次 fetcher把資料抓進 RedisTTL 300s
uv run python radar_fetcher.py
# 看 Redis 目前有什麼rich 表格)
uv run python reader.py
# 看 Redis raw JSON
uv run python check_redis.py
# 起 API
# 起 API日常只要這一個
./run_pokemon_radar_api.sh
# 內部執行uv run uvicorn pokemon_radar_api:app --host 0.0.0.0 --port 8008 --log-config logging.ini
@@ -33,7 +24,17 @@ curl -s -X POST http://127.0.0.1:8008/scan -H "Content-Type: application/json" \
-d '{"lat":25.0478,"lng":121.5170,"min_iv":90}'
```
**生產環境 cron**`./fetch_and_upload.sh` — 走的是**舊 pipeline**`pokemon_location_fetcher.py` 直接輸出本地 JSON`scp``192.168.42.104` 的 caddy不經 Redis不經 API。跟 fetcher/API 這條線沒關係、請不要混淆
**通常不用手動跑 fetcher**。API 採 stale-while-revalidate`/scan` 永遠秒回當前快取,若快取剩餘 TTL 低於 `STALE_THRESHOLD`(預設 120s或 key 不存在,會在背景 thread 觸發 `radar_fetcher.main()` 更新;同時用 Redis lock`pokemon:radar:fetching`TTL 180s避免重複觸發。沒人打 `/scan` 時不會動,閒置零呼叫
需要偵錯或手動拉資料時:
```bash
uv run python radar_fetcher.py # 手動拉一次寫入 Redis
uv run python reader.py # rich 表格看目前快取
uv run python check_redis.py # raw JSON dump
```
**生產環境 cron**`./fetch_and_upload.sh` — 走的是**舊 pipeline**`pokemon_location_fetcher.py` 直接輸出本地 JSON`scp``192.168.42.104` 的 caddy不經 Redis不經 API。跟這條線沒關係、請不要混淆。
沒有 test / lint / build。
@@ -56,6 +57,9 @@ Secrets 放 `.env`gitignore。本機與生產共用同一份格式、只
| `REDIS_HOST` / `REDIS_PORT` / `REDIS_DB` / `REDIS_PASSWORD` | 全部 | 本機 `127.0.0.1` / 生產 `192.168.42.211` | 預設本機無密碼 |
| `REDIS_TTL` | fetcher | `300` | 寫入 TTL |
| `REDIS_KEY` | 全部 | `pokemon:radar:latest` | |
| `STALE_THRESHOLD` | API | `120` | 快取剩餘 TTL 低於此值(秒)就在背景觸發 fetcher |
| `FETCH_LOCK_KEY` | API | `pokemon:radar:fetching` | 背景刷新互斥 lock 的 Redis key |
| `FETCH_LOCK_TTL` | API | `180` | lock 自動過期時間,應 >= fetcher 最長執行時間 |
---
@@ -128,7 +132,8 @@ pokemon_radar_api.py :8008
### 非看不可的設計決策
- **為什麼要 Redis**v1 是每個 `/scan` 都打 Function Server一次 browser automation 要 30-60s多人同時打直接壞。拆成 writerfetcher 定期 cron+ readerAPI 讀快取)後壓力從「每 request」降到「每 TTL」。
- **為什麼要 Redis**v1 是每個 `/scan` 都打 Function Server一次 browser automation 要 30-60s多人同時打直接壞。拆成 writerfetcher+ readerAPI 讀快取)後壓力從「每 request」降到「每 TTL」。
- **為什麼 stale-while-revalidatev2.1**:原本的 cron 方案「沒人用也一直打」,會被 twpkinfo 標記、也浪費 Function Server 資源。現在改為 API 端觸發:`/scan` 秒回當前 Redis 快取(即使快過期),若 TTL < `STALE_THRESHOLD` 或 key 不存在,用 `threading.Thread` 呼叫 `radar_fetcher.main()` 非同步更新;以 Redis `SET NX EX` 做互斥 lock 避免並發。閒置時完全靜止。
- **為什麼兩條 pipeline 並存**caddy 前端走靜態 JSON舊 CLI 的產物),沒理由為它引 Redis。所以 A 走 Redis 給 APIB 走本地檔給靜態前端,**兩條都還在跑**。
- **`twpk_radar.mjs` 靠 regex 注入參數**Function Server 的契約是「你丟 JS 過來我跑」,所以參數化只能在字串層做。三個錨點別改形狀:
- `const TARGET = { lat: ..., lng: ..., zoom: ... };`