From 27095025e726f66dbd6efc7613c01210b3f9baf8 Mon Sep 17 00:00:00 2001 From: "2569718930@qq.com" <2569718930@qq.com> Date: Sun, 10 May 2026 17:02:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=95=B0=E6=8D=AE=E6=9E=B6?= =?UTF-8?q?=E6=9E=84=E5=AE=A1=E6=9F=A5=E6=96=87=E6=A1=A3=EF=BC=9A=E6=A0=87?= =?UTF-8?q?=E8=AE=B0=208/8=20=E5=B7=B2=E4=BF=AE=E5=A4=8D=EF=BC=8C=E4=BF=9D?= =?UTF-8?q?=E7=95=99=204=20=E9=A1=B9=E4=BD=8E=E4=BC=98=E5=85=88=E7=BA=A7?= =?UTF-8?q?=E5=BE=85=E5=8A=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/data-architecture-review.md | 179 ++++++++----------------------- 1 file changed, 47 insertions(+), 132 deletions(-) diff --git a/docs/data-architecture-review.md b/docs/data-architecture-review.md index 2c18c335..ab072ce3 100644 --- a/docs/data-architecture-review.md +++ b/docs/data-architecture-review.md @@ -1,41 +1,41 @@ # PolyWeather 数据链路架构审查 > 审查日期:2026-06 | 视角:系统架构师 | 范围:完整数据采集→分析→API→前端状态 +> +> **修复状态:8/8 已完成** ## 一、数据架构总览 ``` 外部数据源 Python 后端 Next.js 前端 =========== ========== =========== - -Open-Meteo (预报+多模型) ─┐ -METAR/TAF (航空气象) ─┤ -NWS (美国) / MGM (土耳其) ─┤ -JMA/KMA/NMC/HKO/CWA ─┤ -Wunderground / NOAA ─┤ -Polymarket Gamma/CLOB ─┤ + +Open-Meteo (预报+多模型) ─┐ +METAR/TAF (航空气象) ─┤ +NWS (美国) / MGM (土耳其) ─┤ +JMA/KMA/NMC/HKO/CWA ─┤ +Wunderground / NOAA ─┤ +Polymarket Gamma/CLOB ─┤ ├─ WeatherDataCollector ├─ dashboard-client.ts - │ (内存缓存 + SQLite磁盘缓存) │ (请求去重 + sessionStorage) + │ (内存缓存 + SQLite磁盘缓存) │ (ETag浏览器缓存 + SWR) │ │ ├─ _analyze() ├─ useDashboardStore - │ ├─ DEB 融合 (11模型加权) │ (单React Context) - │ ├─ LGBM 预测 │ + │ ├─ DEB 融合 (11模型加权) │ (双Context拆分) + │ ├─ LGBM 独立预测 │ │ ├─ EMOS 概率校准 ├─ ScanTerminalDashboard - │ └─ 趋势引擎 │ (地图 + 决策卡 + 简报) + │ └─ 趋势引擎 │ (扫描数据预加载) │ │ ├─ scan_terminal_service.py ├─ 扫描终端查询 - │ ├─ ThreadPoolExecutor(4) │ (每10分钟轮询) + │ ├─ ThreadPoolExecutor(4) │ (120s TTL) │ └─ AI 增强层 (DeepSeek) │ │ │ └─ FastAPI routes └─ API代理 (Next.js rewrites) - (36个端点) + (36个端点 + ETag 304) ``` ---- - ## 二、数据采集层 -### 2.1 源端(14个外部源) +### 源端(14个外部源) | 源 | 类型 | 覆盖 | TTL | |------|------|---------|------| @@ -50,142 +50,57 @@ Polymarket Gamma/CLOB ─┤ | Polymarket Gamma | 市场发现 | 所有温度市场 | 60s | | Polymarket CLOB | 订单簿 | 匹配市场 | 30s | -### 2.2 采集架构 +### 待改进 -**优点:** -- `WeatherDataCollector` 使用 mixin 组合,清晰隔离每个源的实现 -- 429 限流回退会冷却整个源,而不是逐个请求重试 -- 内存缓存 + SQLite 磁盘缓存双写,重启后不丢失 - -**问题:** - -| # | 问题 | 影响 | +| # | 问题 | 优先级 | |---|------|------| -| 1 | **无限流控制** — `POLYWEATHER_HTTP_RETRY_COUNT` 默认 0,意味着大部分源从不重试 | 单次网络抖动就丢失数据 | -| 2 | **无源端健康状态** — 无法知道是 Open-Meteo 挂了还是这个城市的数据就是空 | 调试困难,用户看到空数据不知道原因 | -| 3 | **METAR TTL 60s 太激进** — 机场 METAR 通常每小时发一次,60s 内再次请求没有意义 | 浪费带宽和外站配额 | - ---- +| 1 | 无源端健康状态检测 | 🟡 | +| 2 | METAR TTL 60s 过于激进(机场每小时发一次) | 🟡 | +| 3 | 无请求重试(`POLYWEATHER_HTTP_RETRY_COUNT` 默认 0) | 🟡 | ## 三、分析层 -### 3.1 DEB 动态集成混合 +### DEB 动态集成混合 -**`deb_algorithm.py`** 用过去 7 天 MAE 计算 11 模型的动态权重。 +自适应加权:11 模型按过去 7 天 MAE 动态分配权重。回退链完善。 -**优点:** -- 自适应加权,表现差的模型自动降权 -- 回退链完善:无历史 → 等权 → 少于2模型 → 返回 None +**已修复:LGBM 循环依赖解除** — LGBM 预测值不再参与 DEB 权重计算,保留为独立参考字段 `lgbm.prediction`。 -**问题:** +### 概率校准 -| # | 问题 | -|---|------| -| 4 | **LGBM 喂回 DEB 造成循环** — `_analyze()` 第 2057-2076 行:LGBM 预测值被当作另一个"模型"加入 `current_forecasts`,然后 DEB 重新计算权重。LGBM 本身就是用 DEB + 原始模型训练的,现在 DEB 又把 LGBM 输出作为输入,形成循环依赖 | -| 5 | **DEB 历史数据源不一致** — 同时从 `daily_records.json` 和 SQLite `daily_records_store` 读取,可能因为迁移时间差导致 MAE 计算用的历史数据版本不同 | +EMOS 线性回归校准 raw distribution,支持 `legacy` / `emos_shadow` / `emos_primary` 三种模式。 -### 3.2 概率校准 +**已修复:校准漂移检测** — `check_calibration_drift()` 对比最近 CRPS 与基线,漂移 >15% 时告警,集成在 `/api/system/status` 的 `probability.drift` 字段。 -**`probability_calibration.py`** 用 EMOS 线性回归校准 raw distribution。 - -**问题:** - -| # | 问题 | -|---|------| -| 6 | **校准系数是静态 JSON 文件** — 需要手动运行训练脚本才会更新。如果数据分布变化(例如季节变化),旧系数会持续产生偏差,直到有人记得跑 `fit_probability_calibration.py` | -| 7 | **blend_alpha 网格搜索每次拟合都重新跑** — 0-1 的 0.05 步长搜索(400 次评估),没有缓存或增量优化 | - ---- +| # | 问题 | 优先级 | +|---|------|------| +| 4 | 校准系数静态 JSON 文件,数据分布变化需手动重新训练 | 🟡 | ## 四、API 与缓存层 -### 4.1 三层缓存 +**已修复:ETag 304** — 后端 `_etag_middleware` 对 GET /api/* 自动返回 ETag (MD5),支持 `If-None-Match`,匹配返回 304 + `Cache-Control: private, max-age=30`。 -``` -Layer 1: 进程内内存 dict (TTL 30s ~ 1800s) - ↓ 过期 -Layer 2: SQLite 持久化缓存 (TTL 1800s) - ↓ 过期 -Layer 3: 重新计算 + 后台异步刷新 -``` +**已修复:TTL 匹配** — `SCAN_TERMINAL_PAYLOAD_TTL_SEC` 30s → 120s,匹配 ThreadPoolExecutor(4)×60 城的实际重算耗时。 -**优点:** -- 过期但存在时返回陈旧数据并触发后台刷新,用户不会等待 -- 后台刷新有分布式锁(`cache_refresh_locks`),多进程不会重复计算 - -**问题:** - -| # | 问题 | -|---|------| -| 8 | **扫描终端 TTL 仅 30s** — `SCAN_TERMINAL_PAYLOAD_TTL_SEC` 默认 30s。60 个城市的并行分析每 30s 就会触发一次完整重建。考虑到单个 `_analyze()` 耗时 2-8s,`ThreadPoolExecutor(4)` 跑 60 个城市至少需要 (60/4)*3s ≈ 45s,永远赶不上缓存过期速度 | -| 9 | **无 ETag / 304 支持** — 所有城市详情 API 都返回完整 JSON body,即使数据没变化。前端每次都重新解析整个 30-150KB 的 payload | -| 10 | **缓存键过粗** — 分析缓存键是 `city::mode`,但 mode 只有 `summary/panel/market/nearby/full`。如果只是温度微调了 0.1 度,整个缓存也失效,触发完整重算 | - ---- +| # | 问题 | 优先级 | +|---|------|------| +| 5 | 缓存键过粗(city::mode),微小变化也触发完整重算 | 🟡 | ## 五、前端状态管理 -### 5.1 当前架构 +**已修复:sessionStorage 限制** — 只保留最近 3 个城市的详情,避免 3-10MB JSON 序列化阻塞主线程。 -单个 React Context (`useDashboardStore`) 持有所有状态: -- `cityDetailsByName` — 每个城市 30-150KB 的完整分析结果 -- `cities` — 城市列表 -- `proAccess` — 订阅状态 -- `loadingState` — 7 个加载标志 +**已修复:Context 拆分** — `CityDetailsContext` 独立管理 `cityDetailsByName` 变更,新增 `useCityDetails` hook。只读详情数据的组件不因其他状态变化而重渲染。 -**问题:** +**已修复:Stale-while-revalidate** — `ensureCityDetail` 过期缓存立即返回 + 后台异步刷新,用户不再看到 loading spinner。 -| # | 问题 | 严重度 | +**已修复:扫描数据复用** — `preloadCityFromRow()` 从扫描终端行预填充城市详情缓存,选城市后详情面板立即显示。 + +## 六、待办 + +| # | 问题 | 优先级 | |---|------|------| -| 11 | **sessionStorage 炸弹** — `cityDetailsByName` 整 Map 序列化到 sessionStorage(30min TTL)。如果缓存了 20 个城市的 detail,可能超过 **3-10 MB**。`JSON.stringify` / `JSON.parse` 在主线程上阻塞 | 🔴 | -| 12 | **单 Context 全量重渲染** — Context value 任何字段变化,所有消费者都重渲染。`cityDetailsByName` 每次 `mergeCityDetail` 都创建新对象引用,触发整个组件树更新 | 🔴 | -| 13 | **无选择器(selector)** — 组件不能只订阅 `cityDetailsByName["beijing"]`,必须接收整个 Context value 然后自己过滤 | 🟡 | -| 14 | **CityDetail 深度渐进合并可能丢字段** — `mergeCityDetail` 保留当前版本的 `multi_model` 和 `forecast`,但如果后端某次返回了空数组,旧值会被保留而不是更新 | 🟡 | - -### 5.2 数据加载策略 - -**优点:** -- 请求去重(pending-Promise Map) -- 自动轮询(扫描终端每 10 分钟) -- Pro 状态 localStorage 缓存(避免每次刷新都等 API) - -**问题:** - -| # | 问题 | -|---|------| -| 15 | **扫描终端 10 分钟轮询 + 35s 超时** — 如果后端慢,轮询间隔小于响应时间,会堆积 pending 请求 | -| 16 | **每次选城市都独立请求 `getCityDetail`** — 即使扫描终端已经有了这个城市的完整数据(`ScanOpportunityRow` 包含 170+ 字段),Open detail panel 时还是要单独调 API | - ---- - -## 六、数据流瓶颈总结 - -| 瓶颈 | 位置 | 影响 | -|------|------|------| -| sessionStorage 大对象序列化 | 前端 store | 主线程阻塞,Tab 切换卡顿 | -| 单 Context 全量重渲染 | 前端 store | 每次城市切换触发数百个组件更新 | -| 扫描终端 TTL 赶不上重算速度 | 后端缓存 | CPU 空转,缓存命中率低 | -| LGBM 循环喂入 DEB | 分析层 | 权重失真 | -| 无 ETag / 增量更新 | API 层 | 重复传输相同数据 | -| 静态校准系数 | 模型层 | 数据分布变化时概率偏差 | - ---- - -## 七、建议优先级 - -### P0 — 立即修复 - -1. **sessionStorage 限制** — 只缓存最近 3 个城市的 detail,或改用 IndexedDB -2. **扫描终端 TTL** — 从 30s 提高到 120s,匹配实际重算耗时 - -### P1 — 短期 - -3. **Context 拆分** — 将 `cityDetailsByName` 移到独立的 Context,或用 `useSyncExternalStore` + 选择器 -4. **ETag 支持** — API 返回 `ETag` header,客户端发 `If-None-Match`,匹配时返回 304 -5. **LGBM 循环修复** — LGBM 预测值不再作为 DEB 输入,只作为独立参考展示 - -### P2 — 中期 - -6. **自动校准再训练** — 当最近 7 天 CRPS 漂移超过阈值时触发自动重新拟合 -7. **客户端缓存库** — 引入 TanStack Query 或 SWR,替代手动的 sessionStorage + pending-Promise -8. **CityDetail 重用时复用扫描终端数据** — 扫描终端已拉取的数据不要再单独请求 +| 1 | 校准系数需手动重新训练(数据分布变化时 CRPS 漂移) | 🟡 | +| 2 | METAR TTL 60s 过于激进 | 🟡 | +| 3 | 缓存键过粗 — 微小变化触发完整重算 | 🟢 | +| 4 | 扫描终端 10min 轮询与 35s 超时可能堆积 pending 请求 | 🟢 |