Files
MoShiro/docs/design/query.md
T
justme 4609048865
ci/woodpecker/push/web-build Pipeline was successful
ci/woodpecker/push/verify Pipeline was successful
ci/woodpecker/push/dev-image Pipeline was successful
docs: merge the API guidelines into api.md; drop the one-off trial reports; fix the node-update description (Quadlet restart vs host updater); tidy TODO [CI]
2026-10-09 22:31:11 +08:00

210 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 展示数据查询(前端取数)
本文定义前端从 Backend 拉取展示数据的方式:时间段、步长、指标选择、响应格式、实时跟随、缓存与限额。端点清单见 [api.md](../api/api.md) 第 2 节;指标与累加器见 [analysis.md](analysis.md)。
## 1. 设计目标
1. **任意时间段**:支持 5 分钟、15 分钟这类相对时间段,也支持任意绝对区间。
2. **短时间段足够精细**:近 5 分钟精确到 1 秒,近 1 小时精确到 5 秒。
3. **汇总精确**:“这段时间内的丢包率、p95”由累加器精确合并得出,**不是各点的平均**。
4. **快**:常用的短时间段直接从内存读取;较长的时间段读取预聚合层,现场合并。
5. **新增指标时前端不用改**:指标按 id 选取,按分组(preset)展示,含义和单位来自目录。
## 2. 时间段与步长
### 2.1 参数
| 参数 | 取值 | 说明 |
| --- | --- | --- |
| `range` | `5m`、`15m`、`30m`、`1h`、`3h`、`6h`、`12h`、`24h`、`7d`、`30d`、`90d`、`365d`,或任意 `<数字><s\|m\|h\|d>` | 相对时间段,`to = now`,可以跟随实时数据 |
| `from` + `to` | RFC 3339 UTC | 绝对区间,左闭右开 `[from, to)`;与 `range` 互斥 |
| `step` | `auto`(默认),或下列常用步长之一 | 指定的步长比数据源所能提供的更细时,自动降级,并在响应中标记 `degraded` |
| `anchor` | `now`(默认)或 `latest` | 只用于序列接口的相对时间段(`range=`):`latest` 以这条线路最后一个样本为终点(最后样本在最近 5 秒内就仍以当前时间为终点)。离线的线路因此显示它最后一段数据,而不是空图;响应里的 `from/to` 是实际使用的窗口,`live` 为 false |
| `tz` | IANA 时区,默认 `UTC` | 只影响 ≥ 1d 的分桶对齐,比如“按天”统计时从本地零点切分 |
常用步长:`1s 2s 5s 10s 15s 30s 1m 2m 5m 10m 15m 30m 1h 2h 3h 6h 12h 1d`
### 2.2 自动步长
`step = 常用步长中 ≥ 区间长度 / 720 的最小值`,即每条曲线最多约 720 个点。
| 时间段 | 步长 | 点数 | 数据源 |
| --- | --- | --- | --- |
| 5m | 1s | 300 | 内存热层 |
| 15m | 2s | 450 | 内存热层 |
| 30m | 5s | 360 | 内存热层 |
| 1h | 5s | 720 | 内存热层 |
| 6h | 30s | 720 | 10s 层 |
| 24h | 2m | 720 | 1m 层 |
| 7d | 15m | 672 | 5m 层 |
| 30d | 1h | 720 | 1h 层 |
| 90d | 3h | 720 | 1h 层 |
- 桶对齐到 UTC 的整数倍步长。
- 起点不对齐的绝对区间:第一个桶和最后一个桶可能不完整,会在响应的 `edge_partial` 中标出。
## 3. 数据分层
```text
精细 ◀────────────────────────────────────────────────────▶ 粗略
┌─────────────────┬──────────────┬──────────────┬─────────────┬─────────────┐
│ 热层(内存) │ metric_10s │ metric_1m │ metric_5m │ metric_1h │
│ 1s 累加器 │ 保留 48 小时 │ 保留 30 天 │ 保留 365 天 │ 永久 │
│ 最近 1 小时(环形缓冲)│ SQLite │ SQLite │ SQLite │ SQLite │
└────────┬────────┴──────────────┴──────────────┴─────────────┴─────────────┘
│ 重启后热层为空 → 按需从原始块回填(最近 1 小时,后台以低优先级执行)
原始段文件(30 天)── 超出热层时间范围的 1s~5s 精度查询 → 从原始块现算(区间 ≤ 15 分钟)
```
**选择数据源的规则**:在“覆盖该时间段”并且“粒度能整除步长”的各层中,选择**最粗**的一层,然后把累加器按步长现场合并。
示例:
- `step = 30s` 时,取 10s 层,每 3 个桶合并一次;
- `step = 2m` 时,取 1m 层,每 2 个桶合并一次。
合并方式是计数相加、直方图逐桶相加,所以结果精确。
| 情况 | 处理方式 |
| --- | --- |
| 时间段在最近 1 小时内,步长 ≥ 1s | 热层 |
| 时间段在 1 小时之前、30 天之内,步长 < 10s,区间 ≤ 15 分钟 | 从原始块现算(全局最多同时 2 个查询) |
| 同上,但区间 > 15 分钟 | 降级为 10s 层或 1m 层,并标记 `degraded: true` |
| 所需的层已经过期 | 降级为下一个更粗的可用层,并标记 `degraded: true` |
**热层内存估算**:15 条线路 × 3600 秒 × 约 300 B(稀疏直方图 + 计数)≈ 16 MB。热层也是 LiveHub 实时推送的数据来源。
## 4. 指标选择
### 4.1 按 id 选取
`metrics=loss.down,tcp.retrans,rtt.p95`
id 的格式为 `指标.统计量`,详见 [analysis.md](analysis.md) 第 4 节;所有可用的 id 可以从 `/api/public/catalog/metrics` 获取。
### 4.2 按分组(preset)选取
`preset=udp,tcp`
分组在 `Backend.Analysis` 的注册表中声明,并通过 `/api/public/catalog/presets` 下发给前端。新增指标后,只要把它加进某个分组,前端就会自动展示。
| 分组 | 指标 |
| --- | --- |
| `udp` | `loss.up`、`loss.down`、`loss.total`、`rtt.p50`、`rtt.p95`、`rtt.p99`、`jitter.up.p95`、`jitter.down.p95`、`loss.run_max`、`reorder`、`dup` |
| `tcp` | `tcp.rtt.p50`、`tcp.rtt.p95`、`tcp.rtt.p99`、`tcp.rtt.p999`、`tcp.rtt.max`、`tcp.rtt.tail`、`tcp.slow`、`tcp.stall.per_min`、`tcp.stall_max`、`tcp.retrans`、`tcp.srtt.p95` |
| `connect` | `connect.success`、`connect.tcp_ms.p95`、`connect.tls_ms.p95`、`connect.fail.timeout`、`connect.fail.rst`、`connect.fail.tls` |
| `session` | `hold.resets.per_h`、`hold.lifetime_max`、`probe.endpoint_changes` |
| `scores` | `score.base`、`score.mc`、`score.hy2`、`score.reality` |
目录中每个 id 都附带 `unit`、`label`、`description`、`better`(`lower` 或 `higher`,用于前端配色),以及 `thresholds`(优、良、差三档阈值,由模型给出)。
## 5. 响应格式(列式)
```json
{
"agent_id": "agt_…",
"from": "2026-09-28T10:00:00.000Z", "to": "2026-09-28T10:15:00.000Z",
"step": "2s", "source": "hot", "live": true, "degraded": false,
"t": [1759053600000, 1759053602000, 1759053604000],
"values": {
"loss.down": [0.0, 0.0, 10.0],
"rtt.p95": [48.1, 49.0, 61.2],
"tcp.retrans": [0.0, 0.1, null]
},
"summary": {
"metrics": { "loss.down": 0.42, "rtt.p95": 61.3, "tcp.retrans": 0.08 },
"samples": { "udp_echo": 8990, "tcp_echo": 17980 },
"coverage": 0.998
},
"edge_partial": { "first": false, "last": true },
"gaps": [{ "from": "…", "to": "…", "reason": "probe_disconnected" }],
"events": [{ "at": "…", "kind": "endpoint_changed", "detail": "…" }],
"versions": { "loss.down": "a1f3…" }
}
```
- 时间轴 `t` 使用 Unix 毫秒,只出现一次;`values` 中每个数组的长度都与 `t` 相同,可以直接交给 uPlot 或 ECharts。**这是 [api.md](../api/api.md) 第 7.4 节“时间用 RFC 3339”规则的明确例外**,理由是减小体积、方便绘图;其余时间字段仍然使用 RFC 3339。
- 某个桶没有数据时填 `null`(不填 0),前端据此绘制断线。
- `summary` 是**整个时间段**的精确合并结果。`coverage` = 实际数据量 / 期望数据量,用来提示前端“数据是否完整”。
- `gaps` 和 `events` 用于在曲线上做标注(断线、端点变化、时钟跳变等)。
## 6. 端点
| 端点 | 用途 | 主要参数 |
| --- | --- | --- |
| `GET /api/public/overview` | 首页排行:每条线路在该时间段内的汇总,并支持排序 | `range`(默认 `5m`)、`preset` 或 `metrics`、`sort`、`order` |
| `GET /api/public/lines/{line_id}/series` | 单条线路的时序 | `range` 或 `from/to`、`step`、`preset` 或 `metrics` |
| `GET /api/public/compare` | 多条线路对比(时间轴对齐) | `agents`、`metric`(单个)、`range` 或 `from/to`、`step` |
| `GET /api/public/lines/{line_id}/distribution` | 该时间段内的分布(直方图、CDF) | `metric`(直方图类,如 `rtt`、`jitter.down`、`tcp.rtt`)、`range` 或 `from/to` |
| `GET /api/public/lines/{line_id}/heatmap` | 时间 × 延迟桶的热力图(类似 smokeping) | `metric`、`range` 或 `from/to`、`step` |
| `GET /api/public/catalog/presets` | 分组定义 | — |
`overview` 的响应示例:
```json
{
"range": "15m", "from": "…", "to": "…",
"lines": [
{ "agent_id": "agt_…", "name": "jp-1", "online": true,
"summary": { "loss.down": 0.1, "loss.up": 0.0, "rtt.p95": 48.0, "tcp.retrans": 0.1, "score.mc": 94 },
"coverage": 1.0 }
]
}
```
`compare` 的响应示例:`{ "t":[…], "values":{ "agt_a":[…], "agt_b":[…] }, "summary":{ "agt_a":0.1, "agt_b":0.4 } }`
`distribution` 的响应示例:`{ "metric":"rtt", "unit":"ms", "buckets":[{"le":40.0,"count":812}, …], "p50":44.2, "p95":61.0, "p99":140.2, "count":8990 }`
`heatmap` 的响应示例:`{ "t":[…], "edges":[…], "counts":[[…],[…]] }`,其中 `counts[i]` 是时间桶 `t[i]` 在各个延迟桶中的计数。
## 7. 实时跟随
```text
前端选择“近 15 分钟”
① GET /lines/{id}/series?range=15m&preset=udp,tcp → 一次性获取 450 个点和汇总
② SSE /api/public/live?topics=series:{id}:2s:udp,tcp → 每当原始块到达(约每 10 s),
推送一批新的 2s 桶和最新的汇总
event: series
data: {"agent_id":"…","step":"2s","t":[…5 个],"values":{…},"summary":{…}}
③ 前端把新点追加到末尾,并丢弃超出窗口的旧点;用推送来的汇总替换原来的汇总
前端切换为“自定义 09:00–10:00”→ 只执行 ①(live=false)
```
- SSE 的订阅主题格式为 `series:{line_id}:{step}:{preset}`,只允许 ≤ 1m 的步长;更粗的步长由前端定时重新拉取即可。
- `overview` 也可以跟随实时:订阅 `overview:{range}:{preset}`,每 10 s 推送一次最新汇总。
- 实时延迟:取决于原始块的切块节奏(10 s)加上处理时间,**典型值为 10~12 s**。1s 步长的点会按批次到达,而不是每秒一个。
## 8. 缓存与限额
| 请求类型 | 缓存策略 |
| --- | --- |
| 已经结束的绝对区间(`to` 早于当前时间 − 2 分钟) | `Cache-Control: public, max-age=31536000, immutable`,并带 ETag(由参数和指标版本计算) |
| 相对时间段(`to = now`) | `Cache-Control: no-store` |
| 目录(catalog) | `max-age=300`,并带 ETag |
| 限额 | 默认值 | 超出时 |
| --- | --- | --- |
| 单次响应的点数 | 每条曲线 ≤ 2000 | 422 `range_too_large`,并给出建议的步长 |
| 单次请求的指标数 | ≤ 32 | 422 `too_many_metrics` |
| `compare` 的线路数 | ≤ 20 | 422 |
| 从原始块现算的查询 | 全局最多同时 2 个,区间 ≤ 15 分钟 | 429 `too_many_requests`(带 `Retry-After`)或者降级 |
| 查询超时 | 10 s | 503 |
## 9. 实现位置
```text
Backend.Api/Public/SeriesEndpoints.cs 参数解析、校验、缓存头(不含业务)
Backend.Application/Query/
├─ RangeResolver range 或 from/to 与 step → 对齐后的桶序列
├─ SourcePlanner 选择数据源(热层 / 10s / 1m / 5m / 1h / 原始块现算),判断是否降级
├─ SeriesQuery 读取累加器 → 按步长合并 → 导出统计量 → 列式结果
├─ SummaryQuery 整个时间段的精确合并(overview 和 series 共用)
└─ DistributionQuery 合并直方图 → 分桶、CDF、热力图
Backend.Application/Live/LiveHub 热层环形缓冲(1s,1 小时)+ SSE 主题
Backend.Analysis 累加器的合并与导出(与持久化路径共用同一份代码)
```
新增指标时,这套查询代码不需要任何改动:`SeriesQuery` 只通过注册表按 id 读取累加器并导出值。