/** * 覆盖网络 · **链路抖动(jitter)采样 · 直方图 · 选路主序**(序 ㉖ · 骨干稳定选路与加密)。 * * ## 为什么需要它(用户口径 → 机器判据) * * 用户 2026-09-17 20:2x 的口径是「**按照连接稳定高效的方式 数据安全可加密传输**」。翻译成 * 可验证的工程质量判据就是 **"选路看 jitter、⛔ 不看 RTT"**: * * - 改造前:`directory.ts` 的候选序 = **签名目录发布序**(同源优先插首位),`switcher.ts` 的换址 * = **排除当前 + 排除冷却中的 ⇒ 取链里下一个** ⇒ 两处**都没有**"哪条更稳"这个维度。 * - 后果:一条 RTT 低但**抖得厉害**的路径会长期霸占首位 —— 而"稳定"恰恰是交互式会话的第一诉求 * (RTT 高只是慢,jitter 高是**卡顿/超时/断连**)。 * * ## 三条口径(⛔ 改这三条等于改判据,必须同步改参数表) * * 1. **量 = `|ΔRTT|` 的 p95**(相邻两次心跳往返之差的绝对值),与 `scripts/overlay-jitter.cjs` * 序⑥ 实测所用的**同一个量**(`p95AbsDelta`)⇒ 历史读数(`p95 = 3 ms`)与本模块**同口径可比**。 * 2. **序 = jitter 升序**,且 **只对"已测出样本"的候选生效**;**无样本者保原序排在其后** * (⛔ 不惩罚"还没测过的备用中继",也不凭空给它排位 —— 见 {@link orderByJitter})。 * 3. **零样本 ⇒ 逐字返回原数组**(⛔ 这是零回归的机器判据 `D9`:观测器没喂过数, * 行为必须与改造前**逐字一致**)。 * * ## 为什么只有这一份算法 * * relay 侧(`server.ts` 的每会话 RTT)与平台侧(`switcher.ts` 的选路)**共用**本模块的 * `absDeltas` / `percentile` / `histogram` —— 本线的教训是「**另一份实现 = 另一处静默失效**」 * (取址链踩过两次)。⛔ 不许在 `scripts/**` 或别的模块里再写一份 p95/直方图。 * * ## 阈值来源(⛔ 全部来自参数表,模块内零魔数) * * `JITTER_ENABLE` / `JITTER_SAMPLE_MAX` / `JITTER_MIN_SAMPLES` / **`JITTER_LIMIT_MS`** / * `JITTER_HIST_MAX_MS` / `JITTER_HIST_BUCKETS` / `JITTER_SAMPLE_GAP_MS` * (口径见 `参数表_覆盖网络_20260917.md §3.7`)。 * * 🔴 **劣化阈值复用 `JITTER_LIMIT_MS`(⛔ 不新造 `JITTER_SWITCH_MS`)**:该键在序⑥ 就已登记 * (值 `20 ms`,语义 = `p95(|ΔRTT|)` 的**达标限值**)—— "超标"与"劣化到该换路"是**同一件事** * ⇒ 新造一个同值键只会变成"同一事实两处写"(本线的知识碎片化教训)。 * * @module src/net/relay/jitter */ /** 本模块的阈值(**全部**来自参数表;见模块头)。 */ export interface JitterThresholds { /** 总开关(`JITTER_ENABLE`,默认 `1`)。置 `0` ⇒ 采样与排序**全部失效**回到改造前行为。 */ enabled: boolean /** 每个 url 保留的 RTT 样本上限(环状,老的丢弃)。 */ sampleMax: number /** 参与排序 / 劣化判定所需的**最小 |ΔRTT| 样本数**(不足 ⇒ 该 url 视为"未知")。 */ minSamples: number /** * **劣化阈值**:`p95(|ΔRTT|) ≥ 它` ⇒ 这条路径被判"不稳",允许换到更稳的候选。 * * ⚠️ 来源 = 参数表的 **`JITTER_LIMIT_MS`**(序⑥ 就有的"达标限值";⛔ 不是新键)。 */ switchMs: number /** 直方图上界(`≥ 它` 的样本落进末桶)。 */ histMaxMs: number /** 直方图桶数(**固定值**;探针拿它校验"口径一致",⛔ 不是实现细节)。 */ histBuckets: number /** * **同一份缓存读数的最小采样间隔(ms)**。 * * 🔴 为什么必须有这个键:`RelayClient.status().rttMs` 是**上一次心跳的结果**,而心跳周期 * (`HB_SEC = 15 s`)远大于巡检周期(`checkMs = 2 s`)⇒ 不做门限的话**同一个 RTT 值会被 * 反复记录** ⇒ 差分恒为 0 ⇒ **jitter 被系统性低估到 0**(判据假绿)。 * ⇒ 门限必须 **> 心跳周期**:同一份缓存值最多只贡献一个样本。 * * ⚠️ relay 侧(`server.ts`)**不用**这个键 —— 它在 `PONG` 到达那一刻采样,本来就是新测量。 */ sampleGapMs: number } /** * 从 env 读阈值(**值格必须纯数字**;非纯数字一律回退默认值)。 * * ⚠️ 与 `relayFailoverThresholds` 同款纪律:参数表里写 `1` / `0`,⛔ **不许**写 `true` 或 * 带夹注的 `1(默认)` —— 后者解析失败会**静默回退默认值**(本线已踩过一次)。 */ export function jitterThresholds(env: Record = process.env): JitterThresholds { const num = (key: string, dflt: number): number => { const raw = (env[key] ?? '').trim() if (raw === '') return dflt return /^\d+$/.test(raw) ? Number(raw) : dflt } return { enabled: num('JITTER_ENABLE', 1) !== 0, sampleMax: num('JITTER_SAMPLE_MAX', 32), minSamples: num('JITTER_MIN_SAMPLES', 3), switchMs: num('JITTER_LIMIT_MS', 20), histMaxMs: num('JITTER_HIST_MAX_MS', 200), histBuckets: num('JITTER_HIST_BUCKETS', 8), sampleGapMs: num('JITTER_SAMPLE_GAP_MS', 20_000), } } /** * 相邻样本的一阶差分绝对值 = **抖动量**(⛔ 不是标准差)。 * * 🔴 为什么不用标准差:标准差会把"单调漂移"(排队时延缓慢变化)算成抖动,而交互式会话真正 * 怕的是**相邻两拍之间的突变**(卡一下)。序⑥ 的实测口径同样是相邻差分 ⇒ 保持一致。 */ export function absDeltas(samples: readonly number[]): number[] { const out: number[] = [] for (let i = 1; i < samples.length; i++) { const d = Math.abs(samples[i]! - samples[i - 1]!) if (Number.isFinite(d)) out.push(d) } return out } /** * 百分位(**与 `scripts/overlay-jitter.cjs` 逐字同口径**:`sorted[min(len-1, floor(len·p))]`)。 * * ⚠️ 口径必须与脚本一致,否则"实时选路看到的 p95"与"运维点测的 p95"会给出**两个数**。 */ export function percentile(values: readonly number[], p: number): number { if (values.length === 0) return 0 const s = [...values].sort((a, b) => a - b) return s[Math.min(s.length - 1, Math.floor(s.length * p))]! } /** 直方图(等宽桶;`≥ histMaxMs` 落末桶)。返回值长度**恒等于** `buckets`。 */ export function histogram(deltas: readonly number[], histMaxMs: number, buckets: number): number[] { const out = new Array(Math.max(1, buckets)).fill(0) if (histMaxMs <= 0) return out const n = out.length for (const d of deltas) { const idx = Math.min(n - 1, Math.max(0, Math.floor((d / histMaxMs) * n))) out[idx] = (out[idx] ?? 0) + 1 } return out } /** 一组样本的抖动画像。 */ export interface JitterStats { /** 已采到的 RTT 样本数。 */ samples: number /** `|ΔRTT|` 样本数(= `samples - 1`,样本不足 2 时为 0)。 */ deltas: number p95AbsDeltaMs: number meanAbsDeltaMs: number maxAbsDeltaMs: number /** 直方图(长度 = `histBuckets`)。 */ hist: number[] } /** 由**已在别处算好的差分序列**构造画像(relay 侧多会话合并时用;⛔ 不重复实现统计)。 */ export function statsFromDeltas(deltas: readonly number[], th: JitterThresholds): JitterStats { const sum = deltas.reduce((a, b) => a + b, 0) return { samples: deltas.length + 1, deltas: deltas.length, p95AbsDeltaMs: round2(percentile(deltas, 0.95)), meanAbsDeltaMs: deltas.length === 0 ? 0 : round2(sum / deltas.length), maxAbsDeltaMs: deltas.length === 0 ? 0 : round2(Math.max(...deltas)), hist: histogram(deltas, th.histMaxMs, th.histBuckets), } } function round2(v: number): number { return Math.round(v * 100) / 100 } /** * **每个 url 一份的 RTT 样本环 + 抖动画像**(进程内单例,见 {@link sharedJitterTracker})。 * * ⛔ 它**不做 I/O**、不定时、不联网 —— 采样由调用方喂(relay 侧 = PONG 回来的那一刻; * 平台侧 = 当前通道的 `status().rttMs`)。这样本模块可以在单测里被**逐项断言**。 */ export class JitterTracker { private readonly th: JitterThresholds private readonly rings = new Map() constructor(th: Partial = {}) { this.th = { ...jitterThresholds(), ...th } } /** 阈值快照(调用方据此做判定,⛔ 不许各自再读一遍 env)。 */ thresholds(): JitterThresholds { return this.th } /** 是否开启(`JITTER_ENABLE=0` ⇒ 采样与排序全部失效)。 */ get enabled(): boolean { return this.th.enabled } /** * 记一次 RTT 样本。 * * 非法输入(非有限数 / 负数)**静默丢弃并返回 `false`** —— 这里故意不抛: * 采样点在生产路径上(每 15 s 一次心跳),抛异常会把**选路**带崩, * 而"少一个样本"只影响排序精度。⛔ 但**不静默吞掉"整条通道读不到 RTT"**:那是 * `switcher.ts` 的 `jitterAlerts` / 探针 `OBS-19` 负责点名的分工。 */ record(url: string, rttMs: number): boolean { if (!this.th.enabled) return false if (url === '' || !Number.isFinite(rttMs) || rttMs < 0) return false const ring = this.rings.get(url) ?? [] ring.push(rttMs) const max = Math.max(2, this.th.sampleMax) if (ring.length > max) ring.splice(0, ring.length - max) this.rings.set(url, ring) return true } /** 该 url 的画像;**样本不足 `minSamples` 个差分 ⇒ `undefined`(= 未知,⛔ 不当 0 用)**。 */ stats(url: string): JitterStats | undefined { const ring = this.rings.get(url) if (ring === undefined || ring.length < 2) return undefined const st = statsFromDeltas(absDeltas(ring), this.th) if (st.deltas < this.th.minSamples) return undefined return st } /** 该 url 的 jitter(`undefined` = 未知)。 */ jitterMs(url: string): number | undefined { return this.stats(url)?.p95AbsDeltaMs } /** 已采过样的 url(信息输出 / 观测用)。 */ urls(): string[] { return [...this.rings.keys()].sort() } /** 全量快照(**只读**副本;给 `/status` 与探针用)。 */ snapshot(): { url: string; samples: number; jitterMs?: number }[] { return this.urls().map((url) => { const st = this.stats(url) return { url, samples: this.rings.get(url)?.length ?? 0, ...(st === undefined ? {} : { jitterMs: st.p95AbsDeltaMs }), } }) } /** 清空(测试与"配置热更"用;⛔ 生产路径不调它)。 */ reset(): void { this.rings.clear() } } /** * **候选排序:jitter 为主序**(`E1` 的实现本体)。 * * 语义(⛔ 三条都要照做,改一条就等于改判据): * 1. **已测出样本**(差分 ≥ `minSamples`)的候选按 `jitter` **升序**在前 —— 并列时**保原相对序**; * 2. **未测出样本**的候选按**原相对序**排在其后 —— ⛔ 不把"没测过"当成"很差"(那会让**备用中继 * 永远排最后 ⇒ 永远不被使用 ⇒ 永远测不出来**,形成死角),也⛔ 不把它当成"很好"(那会让 * 推荐序失去意义); * 3. **一个都没测出来 ⇒ 返回原数组本身**(`D9`:零回归的机器判据)。 * * ⚠️ 排序必须 **stable**(同 key 保原序)—— 否则同源优先、目录发布序这些**已有语义**会被 * 一次抖动采样随机洗牌 ⇒ 那是净退化(R11)。 */ export function orderByJitter( urls: readonly string[], tracker: JitterTracker | undefined, minSamples?: number, ): readonly string[] { if (tracker === undefined || !tracker.enabled || urls.length < 2) return urls const th = tracker.thresholds() const need = minSamples ?? th.minSamples const known: { url: string; jitterMs: number; idx: number }[] = [] for (let i = 0; i < urls.length; i++) { const url = urls[i]! const st = tracker.stats(url) if (st === undefined || st.deltas < need) continue known.push({ url, jitterMs: st.p95AbsDeltaMs, idx: i }) } /** ③ 零已知 ⇒ 原数组(⛔ 连新数组都不建:`D9` 要的是"逐字一致")。 */ if (known.length === 0) return urls known.sort((a, b) => (a.jitterMs === b.jitterMs ? a.idx - b.idx : a.jitterMs - b.jitterMs)) const pinned = new Set(known.map((k) => k.url)) return [...known.map((k) => k.url), ...urls.filter((u) => !pinned.has(u))] } /** * **"jitter 劣化即切"的挑人单点判据**(`E2` 的实现本体)。 * * 规则(⛔ 只有这一处实现;`switcher.ts` 只负责调用 + 记数 + 告警): * - 当前通道 jitter `< switchMs` ⇒ `undefined`(**没劣化,一步都不许动**); * - 否则在候选里找 **① 不是当前 ② 不在 `blocked` 里**(`blocked` = 当前 + 冷却中的, * ⛔ **jitter 换址没有打破冷却的权力** —— 豁免权只属于"当前这条已经挂了"这条语义,见 `switcher.ts`) * 且 **jitter 已知且严格更小** 的最小者; * - 找不到 ⇒ `undefined`(**原地不动**,⛔ 不切空、⛔ 不静默回退默认机)。 */ export function pickJitterTarget(opts: { urls: readonly string[] tracker: JitterTracker | undefined curUrl: string curJitterMs: number switchMs: number minSamples?: number blocked?: ReadonlySet }): { url: string; jitterMs: number } | undefined { const { urls, tracker, curUrl, curJitterMs, switchMs, blocked } = opts if (tracker === undefined || !tracker.enabled) return undefined if (!Number.isFinite(curJitterMs) || curJitterMs < switchMs) return undefined const need = opts.minSamples ?? tracker.thresholds().minSamples let best: { url: string; jitterMs: number } | undefined for (const url of urls) { if (url === curUrl) continue if (blocked !== undefined && blocked.has(url)) continue const st = tracker.stats(url) if (st === undefined || st.deltas < need) continue const j = st.p95AbsDeltaMs if (j >= curJitterMs) continue if (best === undefined || j < best.jitterMs) best = { url, jitterMs: j } } return best } /** * **进程级共享 tracker**(`switcher.ts` 采样 / `directory.ts` 排序 / relay `/status` 观测 * 都用它 ⇒ 装配点**零改动**)。 * * 🔴 为什么必须是单例:装配点(`src/web/server.ts` / `src/worker/relay-tunnel.ts` / * `src/net/relay/main.ts`)**不在序㉖ 的在册文件集**内 ⇒ 若把 tracker 做成"构造时注入", * 生产上**永远不会被注入** ⇒ 本序所有判据都变成**静默失效**(装了但一次都没生效)。 * 单例把"接线"这件事**从装配点挪进模块内部**,代价是"测试要能换掉它" ⇒ 见 * {@link setSharedJitterTracker}。 */ let shared: JitterTracker | undefined export function sharedJitterTracker(): JitterTracker { if (shared === undefined) shared = new JitterTracker() return shared } /** 注入/清空共享 tracker(**只有单测用**)。 */ export function setSharedJitterTracker(t: JitterTracker | undefined): void { shared = t }