# Redis Stream Realtime Event Architecture Design > 日期: 2026-05-27 > 范围: PolyWeather 网站图表实时观测事件层 > 目标服务器: 2 vCPU / 8 GB RAM / 50 GB 系统盘 > 实施状态: 2026-05-28 已落地为 `v1.8.1` ## 背景 当前实时层已经具备生产化雏形: - 后端已有 `city_observation_patch.v1` schema。 - `web/realtime_event_store.py` 使用 SQLite 保存 `observation_patch_events`,支持 `since_revision` replay。 - `/api/events?cities=...&since_revision=...&replay_limit=...` 已经是前端 SSE 入口。 - 前端 `frontend/hooks/use-sse-patches.ts` 使用 `EventSource`,并按当前可见城市订阅。 这解决了单进程、单实例下的实时推送和短窗口 replay,但还不够“一步到位”: - 多 worker / 多实例时,进程内广播不能共享。 - SQLite replay log 可以补数据,但不适合作为跨实例 live fanout。 - 服务重启、浏览器后台恢复、SSE 断线后,需要更稳定的事件源。 - 后续如果把采集器拆成独立进程,需要一个明确的后端事件总线。 ## 目标 升级为“Redis Stream 主事件日志 + SSE 浏览器传输 + SQLite fallback”的架构: - Redis Stream 是生产环境实时事件主通道。 - SSE 仍然是浏览器唯一实时入口。 - 前端协议保持 `city_observation_patch.v1`,不让浏览器感知 Redis。 - 继续使用 numeric `revision`,兼容现有前端 `since_revision` 逻辑。 - 支持按城市订阅、断线 replay、多 worker fanout。 - 事件只保留短窗口;当前默认按 Redis Stream `MAXLEN` 控制,建议以约 24 小时 replay 为目标,不做 72 小时保留。 ## 非目标 这次不做以下事情: - 不引入 Kafka、RabbitMQ 或复杂 consumer group。 - 不把天气历史数据全部 event sourcing。 - 不让浏览器直接连接 Redis。 - 不把 DEB 预测、模型曲线、概率分布都改成事件流。 - 不做永久行情归档;超过 replay 窗口时继续走 HTTP full detail resync。 ## 推荐架构 ```mermaid flowchart LR Collector["WeatherDataCollector / city refresh"] --> Ingest["POST /api/internal/collector-patch"] Ingest --> Normalize["city_observation_patch.v1 normalizer"] Normalize --> Store["RealtimeEventStore interface"] Store --> Redis["Redis Stream: stream:city_observation"] Store --> SQLite["SQLite fallback: observation_patch_events"] Redis --> WorkerSub["Redis XREAD subscriber per API worker"] WorkerSub --> LocalFanout["Local SSE fanout"] SQLite --> ReplayFallback["SQLite replay fallback"] SSE["GET /api/events?cities=...&since_revision=..."] --> Browser["Frontend EventSource"] LocalFanout --> SSE Redis --> SSE ReplayFallback --> SSE Browser --> Chart["Chart applies patch"] Browser -->|resync_required| Detail["HTTP /api/city/{city}/detail"] ``` 关键原则: - Redis Stream 负责后端事件保存、短窗口 replay、多实例共享。 - SSE 负责服务端到浏览器的单向推送。 - SQLite 只做 fallback 和本地开发,不再作为生产主事件总线。 - 前端只认 patch event,不关心事件来自 Redis 还是 SQLite。 ## 事件模型 继续使用现有 schema: ```json { "type": "city_observation_patch.v1", "revision": 12345, "city": "taipei", "source": "cwa", "obs_time": "2026-05-27T10:00:00+08:00", "observed_at_utc": "2026-05-27T02:00:00Z", "observed_at_local": "2026-05-27T10:00:00+08:00", "city_local_date": "2026-05-27", "city_timezone": "Asia/Taipei", "source_cadence_sec": 600, "ts": 1780000000000, "payload": { "temp": 34.2, "max_so_far": 34.2, "station_code": "466920", "station_label": "中央气象署台北站", "series_key": "settlement", "unit": "celsius" } } ``` `revision` 仍然是前端公开排序键,必须是全局单调递增整数。 Redis Stream 自身的 stream ID,例如 `1780000000000-0`,只作为后端内部定位信息,不暴露给前端作为主 revision。 ## Redis Stream 设计 Stream key: ```text stream:city_observation ``` Revision key: ```text counter:city_observation_revision ``` 每条 stream entry 字段: - `revision`: 全局递增整数,公开给 SSE/前端。 - `type`: `city_observation_patch.v1` - `city`: normalized city key。 - `source`: source code,例如 `cwa`, `metar`, `amos`, `amsc_awos`。 - `obs_time`: 原始观测时间。 - `payload_json`: compact JSON。 - `created_at_ms`: 写入 Redis 的 UTC epoch ms。 - `producer_id`: API worker / collector instance id,便于排查。 写入必须通过 Lua 脚本原子完成: 1. `INCR counter:city_observation_revision` 2. `XADD stream:city_observation MAXLEN ~ {maxlen} * revision ... payload_json ...` 3. 返回 `{revision, stream_id}` 原因: - 避免 `INCR` 成功但 `XADD` 失败后造成非必要 gap。 - 让 revision 分配和事件入流成为一个原子操作。 - 保持现有前端 numeric `since_revision` 不变。 ## Replay 策略 `GET /api/events` 收到 `since_revision` 后: 1. 读取 Redis Stream 最旧 entry,拿到 oldest revision。 2. 如果 `since_revision > 0` 且 `since_revision < oldest_revision - 1`,发送 `resync_required`。 3. 从 Redis Stream 顺序扫描事件。 4. 过滤 `revision > since_revision`。 5. 过滤 `city IN subscribed_cities`。 6. 返回最多 `replay_limit + 1` 条。 7. 如果超过 `replay_limit`,发送 `resync_required`,让前端 HTTP resync。 当前事件量很小,单个 stream 顺序扫描 24 小时事件是可以接受的: - 30 城市 * 1 条/分钟 * 24 小时 = 43,200 条。 - replay 通常只发生在断线重连,不是高频查询。 如果后续城市量扩大,或者确实需要超过 24 小时的 replay,再加 per-city stream 或 Redis sorted index。 ## Live Fanout 每个 API worker 启动一个 Redis subscriber loop: ```text XREAD BLOCK 5000 STREAMS stream:city_observation {last_seen_stream_id} ``` 读取到事件后: 1. 解析 `city_observation_patch.v1`。 2. 按本进程 SSE 连接的 `cities` subscription 过滤。 3. 推送给本进程匹配的浏览器连接。 4. 更新 worker 内存中的 `last_seen_stream_id`。 ingest endpoint 写入 Redis 后不直接广播本地连接,由 subscriber loop 统一 fanout。这样避免同一个 worker 上“写入后本地广播一次、subscriber 又广播一次”的重复事件。 前端仍然用 revision 去重。即使极端情况下收到重复 revision,也应忽略旧 revision。 ## SQLite Fallback 保留当前 SQLite store,但角色调整为: - 本地开发无需 Redis 时使用。 - Redis 不可用且允许降级时使用。 - 单实例临时运行时可用。 新增环境变量: ```text POLYWEATHER_EVENT_STORE=redis POLYWEATHER_EVENT_STORE_FALLBACK=sqlite POLYWEATHER_REDIS_URL=redis://127.0.0.1:6379/0 POLYWEATHER_REDIS_STREAM_KEY=stream:city_observation POLYWEATHER_REDIS_STREAM_MAXLEN=50000 POLYWEATHER_PATCH_EVENT_RETENTION_HOURS=24 POLYWEATHER_REDIS_REQUIRED=true ``` 推荐生产策略: - `POLYWEATHER_EVENT_STORE=redis` - `POLYWEATHER_REDIS_REQUIRED=true` - Redis 写入失败时 ingest 返回 503,不广播不可 replay 的事件。 推荐本地策略: - 不配置 Redis,自动使用 SQLite。 - 或 `POLYWEATHER_EVENT_STORE=sqlite`。 ## Redis 容量与配置 当前 VPS 可以跑本机 Redis。 建议: ```text bind 127.0.0.1 protected-mode yes appendonly yes appendfsync everysec maxmemory 512mb maxmemory-policy noeviction ``` 默认 `MAXLEN ~ 50000` 足够约 24 小时事件: - 30 城市 * 1/min * 24h = 43,200 条。 - payload 约 1-3 KB,实际内存取决于 Redis Stream overhead。 - 512 MB Redis maxmemory 对当前事件量足够。 注意:这个事件流不是永久数据仓库。超过 24 小时窗口后,前端应 HTTP resync 当前城市 detail。 ## 模块边界 新增/调整后端接口: ### `web/realtime_event_store.py` 定义统一接口: ```python class RealtimeEventStore: def append_event(self, event: dict) -> dict: ... def replay_events(self, *, cities: set[str] | None, since_revision: int, limit: int) -> list[dict]: ... def replay_requires_resync(self, *, cities: set[str] | None, since_revision: int, replay_count: int, limit: int) -> bool: ... def latest_revision(self) -> int: ... ``` 保留现有 SQLite 实现,新增 Redis 实现。 ### `web/redis_realtime_event_store.py` 职责: - Redis 连接与健康检查。 - Lua append script。 - Redis Stream replay。 - maxlen trim。 - entry 到 SSE event 的反序列化。 ### `web/sse_manager.py` 职责: - 管理本进程 SSE 连接。 - 记录每个连接订阅城市集合。 - 将 Redis subscriber 读到的 event fanout 给匹配连接。 ### `web/routers/sse_router.py` 职责: - `/api/internal/collector-patch` 规范化并写入 event store。 - `/api/events` 处理 connected、replay、heartbeat、live stream。 - 当 replay 不完整时发送 `resync_required`。 ### 前端 前端原则上不需要大改: - `use-sse-patches.ts` 继续使用 `EventSource`。 - `lastRevision` 仍然是 number。 - `resync_required` 继续触发当前可见城市 HTTP detail refresh。 - 继续按可见城市列表构建 `cities` 参数。 ## 运行流程 ### 首屏 1. 前端 HTTP 加载 terminal rows。 2. 可见图表 HTTP 加载 full detail。 3. 前端连接 `/api/events?cities=...&since_revision=...&replay_limit=500`。 4. 后端先 replay Redis Stream 中缺失事件,再进入 live stream。 ### 新观测 1. 采集器产生 `city_patch` 或 v1 patch。 2. ingest endpoint 规范化成 `city_observation_patch.v1`。 3. Redis Lua script 写入 stream 并生成 revision。 4. 每个 API worker 的 subscriber loop 读到事件。 5. worker fanout 给订阅对应城市的 SSE 连接。 6. 前端 apply patch,图表无痛追加点。 ### 浏览器后台恢复 1. EventSource 如果断线,前端按指数退避重连。 2. URL 带 `since_revision=lastRevision`。 3. Redis replay 补齐后台期间错过的事件。 4. 如果 revision 太旧或 replay 超限,前端收到 `resync_required`,HTTP 重拉 full detail。 ## 错误处理 - Redis append 失败且 `POLYWEATHER_REDIS_REQUIRED=true`:ingest 返回 503,不广播。 - Redis append 失败且允许 fallback:写入 SQLite,并在 health 状态标记 degraded。 - Redis replay 失败:SSE 发送 `resync_required`,然后继续尝试 live stream。 - Redis subscriber loop 断开:指数退避重连;期间 SSE heartbeat 仍可维持连接,但不会有 live patch。 - 前端收到 revision 倒退或重复:忽略。 - 前端收到未知 event type:忽略并记录 debug。 ## 监控与诊断 新增健康指标: - 当前 event store mode: `redis` / `sqlite` / `degraded_sqlite` - Redis ping latency - latest revision - stream length - oldest revision - subscriber connected - SSE active connection count - dropped/resync_required count 建议暴露到现有 health endpoint 或日志: ```json { "realtime": { "store": "redis", "redis_connected": true, "stream_len": 43210, "latest_revision": 123456, "oldest_revision": 80200, "subscriber_connected": true, "sse_connections": 9 } } ``` ## 测试策略 后端: - Redis store append 会生成全局递增 numeric revision。 - Redis replay 支持 city 过滤。 - Redis replay 支持 `since_revision`。 - Redis replay 超出 retention/limit 返回 resync required。 - Redis 不可用时,根据 env 严格失败或 fallback SQLite。 - SQLite store 现有测试保持通过。 - SSE router 在 Redis store 下仍发送 connected、replay、heartbeat。 前端: - `use-sse-patches.ts` 保持 `revision` numeric。 - 重连 URL 保留 `since_revision`。 - 收到 `resync_required` 会触发 visible city refresh。 - 重复 revision 不重复追加图表点。 验收命令: ```powershell python -m pytest tests/test_realtime_patch_schema.py tests/test_realtime_event_store.py tests/test_sse_replay.py python -m pytest tests/test_redis_realtime_event_store.py cd frontend npm run test:business npm run typecheck npm run build ``` ## 迁移步骤 1. 保留现有 SQLite implementation,抽出 event store factory。 2. 新增 Redis implementation 和单元测试。 3. 新增 Redis subscriber loop,但先在本地开发环境跑。 4. `/api/events` 接入 event store factory。 5. 线上安装 Redis,只监听 `127.0.0.1`。 6. 先用 `POLYWEATHER_EVENT_STORE=redis`、`POLYWEATHER_REDIS_REQUIRED=false` 灰度。 7. 观察 stream length、latest revision、SSE error、resync count。 8. 稳定后切换 `POLYWEATHER_REDIS_REQUIRED=true`。 9. 保留 SQLite fallback 代码,但生产不主动降级,避免多实例状态分裂。 ## 验收标准 - 断开浏览器网络 1-5 分钟后恢复,图表自动补齐期间 patch。 - 后台挂页面再回来,图表不需要等待 full loading 才更新当前点。 - 多 worker 下,任意 worker 写入的 patch 都能推到其他 worker 的 SSE 连接。 - `/api/events?cities=taipei,shanghai&since_revision=...` 只 replay 指定城市。 - Redis 重启后,前端收到 `resync_required` 并 HTTP 重建画面。 - 前端无需知道 Redis 存在。 - 现有 `city_observation_patch.v1` schema 不破坏。 ## 结论 这是一种“一步到位但不过度设计”的方案: - 一步到位的是事件契约、replay、跨实例 fanout 和生产部署边界。 - 不过度设计的是不用 Kafka、不做永久行情库、不重写前端协议。 对当前产品最关键的是:图表像股票行情一样无痛刷新,断线可补,后台恢复可追上,多实例以后不用再推倒实时层。