# 分析层(Backend.Analysis) ## 1. 结构:算子 → 指标 → 模型 ```text 原始块(自描述列) │ 解码为列视图(按列名读取) ▼ ┌────────────┐ ┌──────────────────────┐ ┌───────────────────────┐ │ 算子 Op │──>│ 指标 MetricDef │──>│ 模型 Model(评分) │ │ 可复用计算 │ │ 一行声明 = 一个指标 │ │ 指标的评分函数 │ │ rtt/loss/… │ │ 输入 + 算子 + 累加器 │ │ base/mc/hy2/reality │ └────────────┘ └──────────┬───────────┘ └──────────┬────────────┘ │ 累加器状态(可合并) │ 查询时计算 ▼ ▼ 指标窗口(1m) → 5m → 1h API:metrics{} + scores{} ``` 三层都采用注册制,由代码中的注册表驱动;API 的 `/catalog/metrics`、`/catalog/models` 也直接由注册表生成。 ## 2. 累加器(固定的少数几种) 所有指标的中间状态只能是下面几种累加器之一。合并规则只写一次,存储、降采样、按时间范围聚合都通用处理: | 累加器 | 状态 | 合并 | 可导出的值 | | --- | --- | --- | --- | | `Counter` | u64 | 相加 | 值;除以时长得到速率(例如每分钟) | | `Ratio` | 分子、分母 | 分别相加 | 百分比 | | `Sum` / `Min` / `Max` | f64 | 相加 / 取极值 | 值;平均值 = Sum / Counter | | `Histogram` | 固定对数桶(100 µs~30 s,每 2 倍 8 个桶,相对误差 ≤ 4.5%)| 逐桶相加 | p50 / p90 / p95 / p99、均值、最值 | | `RunLength` | 连续段长度的直方图 | 逐桶相加 | 平均或最大连续丢包长度 | | `Series` | 固定分辨率的时间序列(例如每秒一个点) | 拼接 | 曲线(仅用于测试结果) | 状态的二进制编码由 `AccumulatorCodec` 统一实现,存储层只把它当作不透明的字节处理。 ## 3. 算子(可复用的计算,新增时才需要写代码) | 算子 | 输入列 | 作用 | | --- | --- | --- | | `rtt` | 回显流的 `t1..t4` | `(t4−t1)−(t3−t2)` | | `loss(dir)` | 回显流或包列 | 按方向判断丢失 | | `owd_var(dir)` | `t1,t2` 或 `t3,t4` | 去除偏移和线性漂移后的单向时延变化(抖动) | | `loss_runs` | 丢失标记 | 连续丢失段的长度 | | `reorder` / `dup` | `seq` | 乱序与重复 | | `gap(expected, threshold)` | 到达时间 | 卡顿(到达间隔超过期望值 + 阈值) | | `kernel_delta(field)` | `tcp_info` 流 | 两次采样之间的差值(重传、确认字节数) | | `rate(dir)` | 字节数或包数 + 时间 | 每秒吞吐 | | `phase_ms(a, b)` | `connect` 事件 | 阶段耗时(TCP 建连、TLS 握手) | | `outcome` | 事件 | 按错误类别计数(超时、RST、TLS alert) | | `change_point` | 逐秒序列 | 变点检测(QoS 开始限速的时刻) | 算子都是纯函数,每个都有单元测试。 ## 4. 指标定义(一行声明) ```csharp // Backend.Analysis/Metrics/LinkMetrics.cs public static class LinkMetrics { public static readonly MetricDef Rtt = Metric.Histogram("rtt", from: "*.echo", op: Ops.Rtt, unit: Unit.Ms, exports: [Stat.P50, Stat.P95, Stat.P99, Stat.Mean]); public static readonly MetricDef LossUp = Metric.Ratio("loss.up", from: "udp_echo.msg", op: Ops.Loss(Dir.Up), unit: Unit.Pct); public static readonly MetricDef JitterDown = Metric.Histogram("jitter.down", from: "udp_echo.msg", op: Ops.OwdVar(Dir.Down), unit: Unit.Ms, exports: [Stat.P95]); public static readonly MetricDef TcpStall = Metric.Counter("tcp.stall", from: "tcp_echo.msg", op: Ops.Gap(expectedFrom: "rate_hz", thresholdMs: 100), unit: Unit.PerMin); public static readonly MetricDef TcpRetrans = Metric.Ratio("tcp.retrans", from: "tcp_echo.tcp_info", op: Ops.KernelDelta("retrans_total", over: "segs_out"), unit: Unit.Pct); } ``` - `from` 写成 `kind.stream`,`*` 表示所有具有该 stream 的 Kind;某个窗口里没有对应的输入时,该指标在这个窗口就不存在。 - TCP 长尾:均值和 p95 看不到偶发的重传尖峰(一次丢包后的 RTT 聚在 RTO 附近),所以 `tcp.rtt` 还导出 `p999`、`max` 和长尾比 `tail`(p99 / p50,从同一个直方图导出,窗口合并后仍然精确);`tcp.slow` 是慢回显的占比——阈值 `max(1.8 × 基线, 基线 + max(15 ms, 0.35 × 基线))`,基线是同一个 10 秒块里 RTT 的中位数(所以每个观测只依赖自己所在的块,窗口合并仍然精确;块里少于 20 个样本不产生观测)。把它和 `tcp.retrans`(内核计数,Agent 一侧反映下行)、`tcp.stall` 对照,可以区分"重传造成的尖峰"和"队头阻塞"。 - 导出值的名字为 `指标id.统计量`,例如 `rtt.p95`、`loss.up`、`tcp.stall.per_min`。 新增一个统计只需要新增一行 `MetricDef`,需要新算子时再加一个算子类。存储、降采样、API 输出、目录说明都不需要改。新指标上线后,会自动对保留期内(30 天)的原始数据回填(见第 7 节)。 ## 5. 模型(服务体验评分) 模型是一组指标的评分函数,在查询时计算,不存储。改动评分公式后,立即对全部历史生效。 ```csharp public sealed class McModel : IServiceModel { public string Id => "mc"; public int Version => 1; public IReadOnlyList Needs => ["rtt.p95", "tcp.stall.per_min", "tcp.stall_max", "tcp.retrans"]; public ModelScore Score(MetricView m) => /* 分段打分 + 加权,返回 0..100 与各维度得分和说明 */; } ``` | 模型 | 主要指标 | 说明 | | --- | --- | --- | | `base` | `rtt.*`、`loss.up/down`、`jitter.up/down`、`loss.run_max` | 线路基础质量 | | `mc` | `tcp_echo` 的 `rtt.p95/p99`、`tcp.stall.per_min`、`tcp.stall_max`、`tcp.retrans` | TCP 上不会丢包,丢包表现为重传和卡顿(队头阻塞)。没有 `tcp_echo` 数据时,用 `udp_echo` 的丢包序列代入 TCP 恢复模型估算,并标记为 `estimated` | | `hy2` | 测试中 `udp_train` 各档的 `loss`、`jitter`,以及变点检测得到的 `qos_onset`;持续的 `udp_echo` 作为趋势参考 | Brutal 固定码率:计算可持续码率(丢包 ≤ 5% 或 ≤ 10%)、有效吞吐、限速开始的时刻 | | `reality` | `connect` 的成功率和阶段耗时、`hold` 的重置次数和存活时长、`tcp_bulk` 的吞吐(`tcp.bulk.peak.max`:最好的持续 1 秒窗口,评分用它;`tcp.bulk.mbps.*`:每个采样间隔)和重传。`hy2_ladder` 的阶梯里一步是一个阶段:拨号方 会把一个阶段切成几块(一块最多 50000 条记录,100 Mbit/s 的 10 秒要切 3~4 块),评估时同一阶段的块先合并,再算丢包、抖动和实际吞吐;实际吞吐的时长取发送时间戳(Agent 时钟,按每块的时钟锚点换算成 UTC)的跨度,不用块头里的时长(拨号方 切块时块头带的时长可能是整个阶段的)、`tcp_echo` 的 RTT | 握手、稳定性、首字节时间估算、吞吐 | ## 6. 两种处理路径 ```text chunk 入库 ─┬─> 流式路径:按 1s 桶计算累加器 → 热层环形缓冲(最近 1 小时) │ → 每满 10 s 写入 metric_10s → 推送 SSE(series / overview) └─> 持久路径:分钟窗口就绪(该分钟的块都已到达,或已过窗口结束时间 + 1 分钟) → 计算完整窗口 → 写入 metric_1m → 标记降采样脏桶(5m、1h) 按需测试:所有阶段的块到达后 → 计算测试结果(包含 Series 曲线)→ 写入 test_results ``` 两条路径调用同一份 `MetricDef` 和算子代码,所以实时值与历史值使用的是同一套口径。迟到的块(例如 Agent 重连后补传)会同时更新热层(如果仍在 1 小时内)、`metric_10s`,并重新进入持久路径。 指标分组(preset)同样是注册制:`MetricRegistry.Presets` 中的 `PresetDef(id, label, metricIds)`,由 `/api/public/catalog/presets` 下发。前端按分组展示,新增指标只需要把它加进分组。查询规则见 [query.md](query.md)。 ## 7. 版本与重算 - 每个 `MetricDef` 都有一个指纹(由声明内容和算子版本计算得出)。启动时与 `metric_versions` 表比对,发现新增或变化的指标时,就把保留期内的相关窗口加入重算队列,由后台以低优先级、限速的方式执行,进度可以通过 API 查询。 - 超出原始数据保留期的历史保留旧口径,查询结果中会标注指标的版本。 - 模型只在查询时计算,不参与重算。 ## 8. 置信说明 每个模型的输出都附带:各指标的样本量、`estimated` 标记,以及局限说明,例如: - 只模拟流量特征,不实现真实协议; - 测量端口与真实服务端口不同(`port_differs_from_service`,默认端口 7443/7444 时总会出现)。 ## 9. 项目结构 ```text backend/src/MoShiro.Backend.Analysis/ 依赖:MoShiro.Protocol、Fleet.Core(纯计算,没有 I/O) ├─ Accumulators/ Accumulator(Counter、Ratio、Sum、Min、Max、RunLength、Series)、HistogramAcc、AccumulatorCodec ├─ Ops/ Ops(通用算子)、EchoOps、SessionOps ├─ Metrics/ LinkMetrics、TcpMetrics、SessionMetrics(只包含声明)、MetricRegistry(指标与分组的注册表) ├─ Models/ ServiceModel(基类)、Models(base、mc、hy2、reality) ├─ Engine/ ChunkView(列视图)、MetricDef、WindowEvaluator、TestEvaluator、ChangePoint └─ Clock/ ClockDriftFit(估计两端的时钟偏移和漂移) ``` 分析库没有 I/O,可以脱离 Backend 单独引用。