Skip to content

feat: Implement smart group#2711

Open
zhboner wants to merge 2 commits intoMetaCubeX:Alphafrom
zhboner:Alpha
Open

feat: Implement smart group#2711
zhboner wants to merge 2 commits intoMetaCubeX:Alphafrom
zhboner:Alpha

Conversation

@zhboner
Copy link
Copy Markdown

@zhboner zhboner commented Apr 15, 2026

#1226

0. 实现原理

Smart Proxy Group 的核心思想是用多维评分替代单一延迟指标,通过被动观察 + 主动测试 + 历史记忆,选出综合表现最优的代理节点。

评分模型

每个候选节点的综合评分由三个维度加权计算:

score = w_delay × norm_delay + w_loss × norm_loss + w_speed × (1 - norm_speed) + degrade_penalty
  • 延迟(默认权重 0.3):归一化到 [0, 1],2000ms 以上视为最差
  • 丢包率(默认权重 0.3):直接使用失败率,上限 1.0
  • 速度(默认权重 0.4):归一化到 [0, 1],无数据时跳过(中性值不奖不罚)
  • 退化惩罚(固定 0.5):节点被标记退化时额外加分

分数越低越好。不存活节点直接给最高分 10.0。

速度指标设计

速度不是主动测量的,而是被动观察已有连接的吞吐量推导出来的。

SpeedObserver 每秒采样所有活跃 TCP Tracker,计算 delta 速度,按 leaf proxy 名称聚合后推送。采用双时间戳设计分离 currentSpeed 和 peakSpeed:

  • currentSpeed / currentAt — 最新采样值,30s 无更新自动归零
  • peakSpeed / peakAt — 历史峰值,只有新速度 ≥ 当前衰减峰值时才更新

评分时使用 effectiveSpeed = max(衰减峰值, 新鲜当前速度)。衰减公式为 peakSpeed × e^(-λ × elapsed),λ=0.001,半衰期约 11.5 分钟。

优势:下载完成不惩罚(峰值还在),长期空闲自动遗忘,小流量不污染峰值,曾经表现好的节点有"记忆优势"。

基线与退化检测

Smart 为每个 proxy 维护独立的 EMA 延迟基线:

  • 建立:至少 3 次成功样本前用算术平均 bootstrap
  • 更新new = old × 0.8 + current × 0.2,单次最多上涨 25%
  • 异常值忽略:延迟 > 基线×3 视为 spike 不更新,连续 5 次高延迟用中位数重校准

退化标记:延迟 > 基线×1.5 或连接失败时标记。
滞后清除:延迟 ≤ 基线×1.2 且连续 2 次成功才清除(防止抖动)。

切换防抖(Tolerance)

评分选出最优节点后,检查评分容差:

如果 当前节点评分 ≤ 最优节点评分 + tolerance → 不切换
否则 → 切换到最优节点

默认 tolerance = 0.1(评分差距 10% 才切换)。维度一致(都是评分),避免"评分选路但延迟防抖"的不一致。

嵌套组支持

Smart 支持嵌套其他代理组(URLTest/Fallback/Selector/Smart)。通过 resolve() 方法递归 unwrap 到 leaf proxy,从同一 leaf 读取所有物理指标。

关键设计:延迟/丢包保留在 wrapper 层(URL 作用域指标),速度委托给 leaf(物理层指标)。

Adaptive Retry

连接失败时区分自修复组和非自修复组:

  • 自修复组(URLTest/Fallback/LoadBalance/Smart):只清除缓存,组内部会切换 leaf
  • 非自修复组(Selector/Leaf):标记退化 + 清除缓存,Smart 主动避开

1. 变更总览

文件 类型 说明
constant/adapters.go 修改 C.Proxy 接口扩展 6 个方法 + Smart AdapterType + SpeedHistory 类型
adapter/adapter.go 修改 Proxy 新增速度/测试字段,实现 6 个接口方法
tunnel/statistic/speedobserver.go 新建 SpeedObserver:每秒采样活跃 Tracker 推导 per-proxy 速度
tunnel/statistic/manager.go 修改 Manager 集成 SpeedObserver(Join/Leave/Tick)
adapter/outboundgroup/smart.go 新建 Smart group 核心实现(675 行)
adapter/outboundgroup/parser.go 修改 注册 "smart" 类型 + parseSmartOption(4 参数 + 校验)
adapter/outboundgroup/urltest.go 修改 添加 EffectiveSpeed() 委托
adapter/outboundgroup/fallback.go 修改 添加 EffectiveSpeed() 委托
adapter/outboundgroup/selector.go 修改 添加 EffectiveSpeed() 委托
adapter/outboundgroup/groupbase.go 修改 URLTest 添加 nil guard + defer wg.Done()

2. constant/adapters.go

C.Proxy 接口扩展

新增 6 个方法:

  • SpeedHistory() — 返回速度历史记录(bounded queue,120 条)
  • PushSpeed(speed uint64) — SpeedObserver 推送速度时调用
  • LastSpeed() uint64 — 返回新鲜的原始采样速度,超过 30s 无更新返回 0
  • EffectiveSpeed() uint64 — 评分用:max(衰减峰值, 新鲜当前速度)
  • PacketLossRate(url string) float64 — 返回丢包率
  • PushTestResult(url string, success bool) — 记录 health check 结果

同时新增 Smart AdapterType 和 SpeedHistory 结构体(Time + Speed)。


3. adapter/adapter.go

新增常量

常量 说明
defaultSpeedHistoriesNum 120 速度历史记录条数(≈ 2 分钟 @ 1Hz)
defaultTestResultsNum 20 测试结果历史记录条数
speedStaleThreshold 30s 速度数据过期阈值
speedDecayLambda 0.001 速度衰减系数(半衰期 ≈ 11.5 分钟)

Proxy struct 新增字段

速度指标(双时间戳设计)

  • peakSpeed / peakAt — 历史最高速度及其更新时间
  • currentSpeed / currentAt — 最新采样速度及其更新时间
  • speedHistoryMu + speedHistory — 速度历史记录(mutex 保护,bounded queue)

测试结果

  • testResultsMu + testResults — health check 结果记录(mutex 保护,bounded queue)

实现的方法

PushSpeed — SpeedObserver 每秒推送速度时调用:

  1. 速度为 0 时直接返回(不记录零速度)
  2. 更新 currentSpeed/currentAt
  3. 计算当前衰减峰值,只有新速度 ≥ 衰减峰值时才更新 peakSpeed/peakAt(小流量不污染峰值)
  4. 写入 speedHistory(bounded queue,最多 120 条)

LastSpeed — 返回新鲜的原始采样速度:

  • 超过 30s 无更新返回 0
  • 用于 UI 展示

EffectiveSpeed — 评分用速度指标:

  • effectiveSpeed = max(衰减峰值, 新鲜当前速度)
  • 衰减峰值 = peakSpeed × e^(-λ × elapsed)
  • 用于评分算法

SpeedHistory — 返回速度历史记录拷贝(线程安全)

PushTestResult — 记录 health check 结果(bounded queue,最多 20 条)

PacketLossRate — 简单计算全部 testResults 的失败率

  • 注意:Smart group 内部有自己更复杂的 PacketLossRate 实现(含时间窗口和置信度门控)

4. tunnel/statistic/speedobserver.go(新建)

核心结构

trackerSample — per-tracker 采样状态:

  • mu — per-tracker 锁,避免 Leave/Tick 竞态
  • leaf — Chain[0],即 leaf egress proxy 名称
  • lastTotal — 上次采样的下载总量
  • windowBytes — 当前窗口累积字节
  • windowStart — 当前窗口起始时间
  • closed — 是否已关闭

SpeedObserver — 速度观察器:

  • trackers — 活跃 tracker 映射(xsync.Map)
  • leaveTemp — Leave 产生的速度暂存(atomic.Uint64),由下一 tick 统一合并

Join(tracker)

  • 过滤 info 为 nil 或 Chain 为空的 tracker
  • 记录初始 DownloadTotal 和起始时间
  • 使用 Chain[0] 作为 leaf proxy 名称

Leave(tracker)

  • 从 trackers 中移除
  • 计算最终 delta 并 flush
  • 结果写入 leaveTemp(而非直接 PushSpeed),由下一 tick 统一合并,避免 Leave/Tick 竞态

Tick(now, resolve)

  1. 遍历所有活跃 tracker,计算 delta 速度
  2. 合并 leaveTemp 中的速度(避免 Leave/Tick 竞态导致重复计算或丢失)
  3. 通过 resolve 函数查找 proxy 并 PushSpeed

注意:当前 resolve 函数返回 nil(待后续集成 proxy 查找逻辑),SpeedObserver 已就绪但暂未实际推送速度。

trackerSample.flush()

  • per-tracker 锁保护
  • 已关闭的 tracker 不重复计算
  • 非 final 时,窗口 < 1s 不 emit(避免亚秒噪声)
  • final 时(Leave),窗口 < 1s 用 1s 作为分母(防爆发)
  • 零字节不 emit

5. tunnel/statistic/manager.go

变更

  • Manager 新增 speedObserver 字段
  • init() 中初始化 SpeedObserver
  • Join() / Leave() 调用 speedObserver 对应方法
  • handle() 中每秒调用 Tick()

6. adapter/outboundgroup/smart.go(新建,675 行)

常量定义

速度归一化

常量 说明
speedRefFloor 4 MiB/s 样本不足时的地板值,新节点达到此速度即可拿满分
speedRefCeiling 32 MiB/s P90 上限,防止快网络中速度差异被过度放大
minSpeedSamples 3 计算 P90 所需的最小速度样本数

EMA 基线更新

常量 说明
baselineAlpha 0.20 指数移动平均系数,新样本权重 20%,历史权重 80%
baselineMinSamples 3 建立基线所需的最小成功样本数,此前用算术平均 bootstrap
spikeIgnoreFactor 3.0 异常值忽略因子,延迟 > 基线×3 视为 spike 不更新基线
baselineMaxStepUp 1.25 基线单次最大上涨比例 25%,防止 spike 缓慢污染基线
reseedHighStreak 5 连续高延迟次数触发中位数重校准

退化检测与恢复

常量 说明
degradeFactor 1.50 退化因子,延迟 > 基线×1.5 标记退化
recoverFactor 1.20 恢复因子,延迟 ≤ 基线×1.2 才可清除退化(滞后防抖)
recoverSuccesses 2 清除退化所需连续成功次数
degradePenalty 0.5 退化节点评分惩罚值

评分权重(默认值,可在 YAML 中覆盖)

常量 说明
defaultWeightDelay 0.3 延迟权重
defaultWeightLoss 0.3 丢包率权重
defaultWeightSpeed 0.4 速度权重

其他默认参数

常量 说明
defaultTolerance 0.1 评分切换容差,当前节点评分 ≤ 最优节点评分 + tolerance 时不切换
defaultDegradeFactor 1.5 退化因子默认值(写死)
defaultDecayLambda 0.001 速度衰减系数(写死)

数据结构

nodeMetrics — 评分时单个节点的指标快照(proxy, delay, loss, speed, alive)

NodeState — Smart 内部维护的 per-proxy 状态(mutex 保护):

  • Baseline / BaselineSamples — EMA 基线及样本数
  • HighLatencyStreak — 连续高延迟计数
  • RecentSuccessDelays — 最近成功延迟(bounded,最多 10 个,用于重校准)
  • Degraded / RecoverStreak — 退化状态及恢复计数
  • TestResults — 测试结果(bounded,最多 20 个)

Smart — Smart group 主体:

  • 嵌入 GroupBase
  • tolerance(float64,评分容差)
  • weightDelay / weightLoss / weightSpeed
  • fastNode / fastSingle(singledo 缓存 10s)
  • nodeStates(xsync.Map,per-proxy 状态)

Smart.resolve(proxy) — 嵌套组解析

通过 proxy.Adapter() 获取底层 adapter(因为候选节点可能是 *adapter.Proxy 包装),type switch 到具体 group 类型,调用其 fast/findAliveProxy/selectedProxy 拿到 leaf。leaf proxy 直接返回,因为速度/延迟/丢包等物理指标只在 leaf 层有意义。

支持嵌套:URLTest → Fallback → Selector → Smart → leaf proxy。

Smart.fast(touch) — 核心选路

  1. fastSingle 缓存 10s,避免频繁评分
  2. 冷启动检查:
    • 所有节点都不存活 → 随机选择(避免流量全部打向第一个)
    • 有存活节点但无延迟数据 → 从存活节点中随机选择
  3. 有数据时:收集所有候选节点指标(通过 resolve 拿到 leaf proxy 读取物理指标)→ selectBest

Smart.selectBest(metrics) — 评分选择

  1. 计算速度参考值(P90 锚定,限制在 [floor, ceiling])
  2. 遍历所有存活节点,计算加权评分,选最低分
  3. 兜底:如果所有节点都不存活,选第一个
  4. 评分容差防抖:当前节点评分 ≤ 最优节点评分 + tolerance 时不切换(维度一致,都是评分)

Smart.calculateScore(m, speedRef) — 评分计算

  • 延迟归一化:delay/2000ms,上限 1.0(2000ms 以上视为最差)
  • 丢包归一化:直接使用失败率,上限 1.0
  • 速度归一化:speed/speedRef,上限 1.0
  • 无速度数据时跳过速度项(等价于 norm_speed=1,中性值不奖不罚)
  • 速度是正向指标,所以用 (1 - normSpeed)
  • 退化节点加 0.5 惩罚分

Smart.computeSpeedRef(metrics) — 速度参考值

  • 收集所有存活且有速度数据的节点
  • 样本不足 3 个时用地板值(4 MiB/s)保底,避免 0/0 和慢网络 inflation
  • P90 锚定:限制在 [floor, ceiling] 防止极端值

NodeState 并发安全模型

每个 proxy 的 Smart 内部状态使用独立 mutex 保护。所有状态更新方法(updateBaseline, updateDegraded, markDegraded, isDegraded, PushTestResult, PacketLossRate)通过 state.mu.Lock() 保护。

updateBaseline(proxyName, success, delay) — Capped EMA 基线更新

3 阶段更新:

  1. 样本不足时(< 3 个):用算术平均 bootstrap 基线
  2. 异常值忽略:延迟 > 基线×3 视为 spike,不更新基线。连续 5 次高延迟说明网络环境真的变了,用中位数重校准
  3. Capped EMA:单次最多上涨 25%,防止 spike 缓慢污染基线

updateDegraded(proxyName, success, delay) — 退化检测与滞后清除

  • 失败 → 立即标记退化
  • 基线未建立 → 连续 2 次成功清除退化
  • 延迟 > 基线×1.5 → 标记退化
  • 滞后清除:延迟 ≤ 基线×1.2 且连续 2 次成功 → 清除退化(recoverFactor < degradeFactor 防止抖动)

PacketLossRate(proxyName) — 丢包率计算

  • 时间上限:interval=300s → 30min,interval=30s → 10min(防止 interval 过大时丢包率反映太久远的状态)
  • 倒序遍历,最多取 20 个样本,超过 maxAge 的丢弃
  • 样本不足 3 个不惩罚
  • 置信度门控:3-5 个样本部分权重,5+ 个全权重

onDialFailed(proxy, err) — Adaptive Retry

  • 自修复组(URLTest/Fallback/LoadBalance/Smart):内部会切换 leaf,Smart 只需清除缓存重新评估
  • 非自修复组(Selector/Leaf):无自修复能力,Smart 需要主动标记退化并避开

NewSmart() — 构造函数

  • 递归检查所有子节点(包括嵌套组内部),不允许 LoadBalance 作为子节点
  • 初始化 Smart struct,设置默认权重和容差
  • 应用 parseSmartOption 传入的自定义参数
  • 注意:冷启动后台 goroutine 已移除(之前会导致 GroupBase.URLTest panic)

checkNoLoadBalance — 递归检查

递归遍历所有子节点(包括嵌套组内部),发现 LoadBalance 即拒绝创建。通过 proxy.Adapter() type switch 到具体 group 类型,递归检查其子节点。


7. adapter/outboundgroup/parser.go

注册 "smart" 类型

在 ParseProxyGroup 的 switch 中添加 "smart" case,调用 parseSmartOption 解析配置后创建 Smart group。

parseSmartOption(config)

解析 4 个用户可配置参数:

参数 默认值 校验
weight-delay 0.3 ≥ 0,非 NaN/Inf
weight-loss 0.3 ≥ 0,非 NaN/Inf
weight-speed 0.4 ≥ 0,非 NaN/Inf
tolerance 0.1 ≥ 0

权重归一化:三个权重之和归一化为 1.0。全零时拒绝创建。

已移除的配置项(写死为常量):

  • degrade-factor(固定 1.5)
  • decay-lambda(固定 0.001)

8. adapter/outboundgroup/{urltest,fallback,selector}.go

EffectiveSpeed() 委托

三个单活跃子节点的 group 都添加了 EffectiveSpeed() 方法,委托给当前选中的 leaf proxy:

  • URLTestu.fast(false).EffectiveSpeed()
  • Fallbackf.findAliveProxy(false).EffectiveSpeed()
  • Selectors.selectedProxy(false).EffectiveSpeed()

速度是物理层指标,只有 leaf proxy 才有真实速度数据,所以委托给 leaf。延迟/丢包保留在 wrapper 层(URL 作用域指标)。


9. 已知限制

  • LoadBalance 不支持作为 Smart 子节点:构造函数中显式拒绝
  • 不同 testUrl 的延迟可比性:Smart 使用子节点自身的 LastDelayForTestUrl() 值评分。如果子节点是 URLTest 且使用不同的 testUrl,Smart 获取的是子节点对其 testUrl 的延迟。建议 Smart 的子节点使用相同或相近的 testUrl。

@zhboner zhboner changed the title Alpha feat: Implement smart group Apr 15, 2026
@wwqgtxx wwqgtxx force-pushed the Alpha branch 2 times, most recently from 5a5e312 to 8d53952 Compare April 22, 2026 10:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant