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一次性 # 起本機 Redis一次性
docker run -d --name pokemon-radar-redis --restart unless-stopped -p 6379:6379 redis:7-alpine docker run -d --name pokemon-radar-redis --restart unless-stopped -p 6379:6379 redis:7-alpine
# 跑一次 fetcher把資料抓進 RedisTTL 300s # 起 API日常只要這一個
uv run python radar_fetcher.py
# 看 Redis 目前有什麼rich 表格)
uv run python reader.py
# 看 Redis raw JSON
uv run python check_redis.py
# 起 API
./run_pokemon_radar_api.sh ./run_pokemon_radar_api.sh
# 內部執行uv run uvicorn pokemon_radar_api:app --host 0.0.0.0 --port 8008 --log-config logging.ini # 內部執行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}' -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。 沒有 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_HOST` / `REDIS_PORT` / `REDIS_DB` / `REDIS_PASSWORD` | 全部 | 本機 `127.0.0.1` / 生產 `192.168.42.211` | 預設本機無密碼 |
| `REDIS_TTL` | fetcher | `300` | 寫入 TTL | | `REDIS_TTL` | fetcher | `300` | 寫入 TTL |
| `REDIS_KEY` | 全部 | `pokemon:radar:latest` | | | `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 走本地檔給靜態前端,**兩條都還在跑**。 - **為什麼兩條 pipeline 並存**caddy 前端走靜態 JSON舊 CLI 的產物),沒理由為它引 Redis。所以 A 走 Redis 給 APIB 走本地檔給靜態前端,**兩條都還在跑**。
- **`twpk_radar.mjs` 靠 regex 注入參數**Function Server 的契約是「你丟 JS 過來我跑」,所以參數化只能在字串層做。三個錨點別改形狀: - **`twpk_radar.mjs` 靠 regex 注入參數**Function Server 的契約是「你丟 JS 過來我跑」,所以參數化只能在字串層做。三個錨點別改形狀:
- `const TARGET = { lat: ..., lng: ..., zoom: ... };` - `const TARGET = { lat: ..., lng: ..., zoom: ... };`

View File

@@ -1,28 +1,36 @@
import json import json
import os import os
import sys import sys
import threading
import time
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import redis import redis
from dotenv import load_dotenv from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from loguru import logger
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
# 引用舊有的 Client 主要是為了使用它的 postprocess 篩選邏輯,不進行連線 # 載入環境變數(要在 import radar_fetcher 之前,讓它 module-level 讀到)
from pokemon_location_fetcher import PokemonRadarClient
# 載入環境變數
load_dotenv() load_dotenv()
import radar_fetcher
from pokemon_location_fetcher import PokemonRadarClient
# ========= 設定 ========= # ========= 設定 =========
# Redis 連線資訊 (指向 192.168.42.211)
REDIS_HOST = os.getenv("REDIS_HOST", "192.168.42.211") REDIS_HOST = os.getenv("REDIS_HOST", "192.168.42.211")
REDIS_PORT = int(os.getenv("REDIS_PORT", 6379)) REDIS_PORT = int(os.getenv("REDIS_PORT", 6379))
REDIS_DB = int(os.getenv("REDIS_DB", 0)) REDIS_DB = int(os.getenv("REDIS_DB", 0))
REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", None) REDIS_PASSWORD = os.getenv("REDIS_PASSWORD", None)
REDIS_KEY = "pokemon:radar:latest" REDIS_KEY = "pokemon:radar:latest"
# Stale-while-revalidate快取剩餘 TTL 低於此值時,在背景觸發 fetcher 更新
STALE_THRESHOLD = int(os.getenv("STALE_THRESHOLD", 120))
# 互斥 lock避免同時多個 fetcherFETCH_LOCK_TTL 要 >= fetcher 最長執行時間
FETCH_LOCK_KEY = os.getenv("FETCH_LOCK_KEY", "pokemon:radar:fetching")
FETCH_LOCK_TTL = int(os.getenv("FETCH_LOCK_TTL", 180))
# ========= FastAPI 初始化 ========= # ========= FastAPI 初始化 =========
app = FastAPI(title="Pokemon Radar API (Redis Backed)", version="2.0.0") app = FastAPI(title="Pokemon Radar API (Redis Backed)", version="2.0.0")
@@ -120,6 +128,34 @@ def get_data_from_redis():
_filter_helper = PokemonRadarClient(url="", token="") _filter_helper = PokemonRadarClient(url="", token="")
# ========= Stale-while-revalidate 背景刷新 =========
def _trigger_background_refresh() -> None:
"""若沒有其他 fetcher 在跑,開一個 daemon thread 更新 Redis 快取。非阻塞。"""
try:
r = redis.Redis(connection_pool=redis_pool)
acquired = r.set(FETCH_LOCK_KEY, str(time.time()), nx=True, ex=FETCH_LOCK_TTL)
except redis.exceptions.RedisError as e:
logger.warning(f"refresh lock check failed: {e}")
return
if not acquired:
return # 有其他 fetcher 正在跑
def _run() -> None:
try:
logger.info("background refresh: start")
radar_fetcher.main()
logger.info("background refresh: done")
except Exception as e:
logger.exception(f"background refresh failed: {e}")
finally:
try:
redis.Redis(connection_pool=redis_pool).delete(FETCH_LOCK_KEY)
except redis.exceptions.RedisError:
pass
threading.Thread(target=_run, daemon=True, name="radar-refresh").start()
# ========= 路由 ========= # ========= 路由 =========
@app.get("/ping") @app.get("/ping")
def ping(): def ping():
@@ -131,6 +167,10 @@ def scan(req: ScanRequest) -> Any:
# 1. 從 Redis 讀取資料 # 1. 從 Redis 讀取資料
data, ttl, err = get_data_from_redis() data, ttl, err = get_data_from_redis()
# Stale-while-revalidateRedis 可達且key 不存在 or TTL 快過期)時在背景觸發更新
if err is None and (data is None or (0 < ttl < STALE_THRESHOLD)):
_trigger_background_refresh()
if not data: if not data:
# Redis 沒資料或連不上,都回傳空結果而不是 500避免前端炸掉 # Redis 沒資料或連不上,都回傳空結果而不是 500避免前端炸掉
return { return {