> ## Documentation Index
> Fetch the complete documentation index at: https://www.worldmonitor.app/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# 缓存与带宽

> World Monitor 的 Vercel CDN 头、客户端熔断器、Brotli 预压缩、中继压缩、请求去重、轮询间隔以及多层缓存架构，说明各层缓存如何协同降低带宽与上游负载，并在上游故障时继续提供数据，面向评估平台的工程师与希望参与贡献的开发者阅读。

本页属于[设计哲学](https://www.worldmonitor.app/docs/zh/architecture.md)指南，该页索引全部主题。部署拓扑、目录结构与 CI/CD 请参阅 [`ARCHITECTURE.md`](https://github.com/koala73/worldmonitor/blob/main/ARCHITECTURE.md)。

***

## 带宽与缓存

### Vercel CDN 头

每个 API 边缘函数包含 `Cache-Control` 头，使 Vercel 的 CDN 能在不命中源站的情况下提供缓存响应：

| 数据类型 | `s-maxage` | `stale-while-revalidate` | 理由 |
| - | - | - | - |
| 分类结果 | 3600s（1 小时） | 600s（10 分钟） | 标题不会频繁重新分类 |
| 国家情报 | 3600s（1 小时） | 600s（10 分钟） | 简报变化缓慢 |
| 风险评分 | 300s（5 分钟） | 60s（1 分钟） | 近实时，低延迟 |
| 市场数据 | 3600s（1 小时） | 600s（10 分钟） | 日内粒度已足够 |
| 火灾检测 | 600s（10 分钟） | 120s（2 分钟） | VIIRS 约每 12 小时更新 |
| 经济指标 | 3600s（1 小时） | 600s（10 分钟） | 月度/季度发布 |

静态资源使用内容哈希文件名配 1 年不可变缓存头。Service worker 文件（`sw.js`）从不缓存（`max-age=0, must-revalidate`）以确保更新检测。

### 客户端熔断器

每个数据抓取面板使用熔断器，防止级联故障拖垮整个仪表盘。该模式在两个层面工作：

**每订阅源熔断器**（RSS） — 每个 RSS 订阅源 URL 有独立失败计数器。连续 2 次失败后，订阅源进入 5 分钟冷却期，期间不进行任何抓取尝试。冷却到期后订阅源自动重新进入池。这防止单个配置错误或宕机的订阅源消耗抓取预算并拖慢整个新闻刷新周期。

**每面板熔断器**（数据面板） — 从 API 端点抓取的面板使用 IndexedDB 支持的持久缓存（`worldmonitor_persistent_cache` 存储）配 TTL 包络。抓取成功时，结果带过期时间戳存储。后续加载时，熔断器立即提供缓存结果并尝试后台刷新。如果后台刷新失败，过期的缓存数据继续显示 — 面板不会因瞬时 API 故障而空白。缓存条目在页面重载和浏览器重启后保留。

熔断器跨存储层优雅降级：IndexedDB（主要，至设备配额）→ localStorage 回退（5MB 限制）→ 内存 Map（仅会话）。当设备存储配额耗尽时（移动版 Safari 常见），全局 `_storageQuotaExceeded` 标志禁用所有后续写入，同时读取正常继续。

### Brotli 预压缩（构建时）

`vite build` 现在为大于 1KB 的静态资源（JS、CSS、HTML、SVG、JSON、XML、TXT、WASM）输出预压缩 Brotli 产物（`*.br`）。当边缘可直接提供 Brotli 时，这比仅 gzip 投递减少约 20–30% 的传输体积。

对于 Hetzner Nginx 源站，启用静态压缩文件服务，使 `dist/*.br` 文件无需运行时重新压缩即可返回：

```nginx theme={null}
gzip on;
gzip_static on;

brotli on;
brotli_static on;
```

当源站/边缘有 Brotli 资源可用时，Cloudflare 会自动为兼容客户端协商 Brotli。

### Railway 中继压缩

当客户端接受 gzip 且负载超过 1KB 时，所有中继服务器响应都通过 `gzipSync` 处理。Sidecar API 响应优先 Brotli，并使用 gzip 回退，配合正确的 `Content-Encoding`/`Vary` 头，阈值相同。这适用于 OpenSky 飞机 JSON、RSS XML 订阅源、UCDP 事件数据、AIS 快照和健康检查 — 将传输体积减少约 50–80%。

### 进行中请求去重

当多个连接的客户端同时轮询时（中继的多租户 WebSocket 架构中常见），相同的上游请求在中继层去重。对给定资源键（例如 RSS 订阅源 URL 或 OpenSky 边界框）的第一个请求创建一个存储在进行中 Map 中的 Promise。所有对相同键的并发请求 await 那个单一 Promise，而非冲击上游 API。后续请求从缓存提供，带 `X-Cache: DEDUP` 头。这防止了如 53 个并发 RSS 缓存未命中或 5 个对同一地理区域的同步 OpenSky 请求的场景 — 全部由单次上游抓取解决。

### 自适应刷新调度

仪表盘不采用固定间隔轮询，而是使用响应网络条件、标签页可见性和数据新鲜度的自适应刷新调度器：

* **失败时指数退避** — 当刷新失败或未返回新数据时，下次轮询间隔加倍，最高至基础间隔的 4×。成功抓取到新数据将乘数重置为 1×
* **隐藏标签页节流** — 当 `document.visibilityState` 为 `hidden` 时，所有轮询间隔乘以 10×。前台每 60 秒轮询的标签页在后台放慢到每 10 分钟，大幅减少非活跃标签页的浪费请求
* **抖动** — 每个计算间隔随机化 ±10%，防止多个标签页或用户共享同一服务器时的同步 API 风暴。无抖动时，同时打开的两个标签页将永远同步轮询
* **可见性恢复时过期刷新** — 当隐藏标签页变为可见时，调度器识别所有数据已超过其基础间隔的刷新任务并立即重新运行，间隔 150ms 错开以避免请求突发。这确保用户返回后台标签页时在几秒内看到新鲜数据
* **进行中去重** — 对同一命名刷新的并发调用被合并；同时只允许一个进行中
* **条件注册** — 刷新任务可包含一个 `condition` 函数，在每次轮询前求值；条件不再满足的任务（例如已折叠的面板）完全跳过其抓取周期

### 前端轮询间隔

面板以错峰间隔刷新以避免同步 API 风暴：

| 面板 | 间隔 | 理由 |
| - | - | - |
| AIS 海事快照 | 10s | 实时船舶位置 |
| 服务状态 | 60s | 健康检查节奏 |
| 市场信号 / ETF / 稳定币 | 180s（3 分钟） | 市场时段粒度 |
| 风险评分 / 战区态势 | 300s（5 分钟） | 复合评分变化缓慢 |

标签页隐藏或 2 分钟不活动后，所有动画和轮询暂停，防止后台标签页的浪费请求。

### 缓存架构

每个外部 API 调用都经过带出错返回过期数据回退的三层缓存：

```
Request → [1] In-Memory Cache → [2] Redis (Upstash) → [3] Upstream API
                                                              │
             ◄──── stale data served on error ────────────────┘
```

| 层 | 范围 | TTL | 用途 |
| - | - | - | - |
| **内存** | 每边缘函数实例 | 不同（60s–900s） | 消除热路径的 Redis 往返 |
| **Redis (Upstash)** | 跨用户、跨实例 | 不同（120s–900s） | 跨所有访客去重 API 调用 |
| **上游** | 事实来源 | N/A | 外部 API（Yahoo Finance、CoinGecko 等） |

缓存键是版本化的（`opensky:v2:lamin=...`、`macro-signals:v2:default`），因此 schema 变更不会提供过期格式。每个响应包含 `X-Cache` 头（`HIT`、`REDIS-HIT`、`MISS`、`REDIS-STALE`、`REDIS-ERROR-FALLBACK`）用于调试。

**共享缓存层** — 所有 sebuf 处理器实现共享统一的 Upstash Redis 缓存模块（`_upstash-cache.js`），提供一致 API：`getCachedOrFetch(cacheKey, ttlSeconds, fetchFn)`。这消除了每处理器的缓存样板，确保每个 RPC 端点都受益于三层策略。缓存键包含随请求变化的参数（例如，请求的标的代码、国家代码、边界框）以防止不同输入调用者间的缓存污染。在桌面端，当 Redis 不可用时，同一模块在 sidecar 中以内存 + 持久文件后端运行。

**进行中 Promise 去重** — `server/_shared/redis.ts` 中的 `cachedFetchJson` 函数维护一个活动上游请求的内存 `Map<string, Promise>`。缓存未命中时，第一个调用者的抓取在 map 中创建并注册一个 Promise。所有对相同缓存键的并发调用者 await 那个单一 Promise，而非独立命中上游 API。这消除了多个边缘函数实例同时竞争重新填充过期缓存条目的"惊群"问题 — 此场景此前在热门端点约 15 秒的重新填充窗口期间导致 50+ 个并发上游请求。

**负缓存** — 当上游 API 返回错误时，系统缓存一个哨兵值（`__WM_NEG__`）120 秒，而非让缓存为空。这防止了数百个并发请求各自独立发现缓存为空并同时轰炸宕机 API 的失败级联。负哨兵对消费者透明 — `cachedFetchJson` 对负缓存键返回 `null`，面板回退到过期数据或显示适当的空状态。特定 API 使用更长的负 TTL：UCDP 使用 5 分钟退避，Polymarket 队列拒绝使用 30 秒退避。

AI 摘要管道添加了基于内容的去重：标题在调用 LLM 提供商前进行哈希并对照 Redis 检查，因此 1,000 个并发用户查看的同一条突发新闻恰好触发一次 LLM 调用。


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.