453 lines
18 KiB
C#
453 lines
18 KiB
C#
namespace MoShiro.Measurement;
|
||
|
||
/// <summary>探测节奏的状态。<see cref="Dead"/> 是线路几乎全丢:不再按正常频率白白发包,指数退避着探测,等到有回包再恢复。</summary>
|
||
public enum ProbeState : byte
|
||
{
|
||
Quiet = 0,
|
||
Watch = 1,
|
||
Active = 2,
|
||
Dead = 3,
|
||
}
|
||
|
||
/// <summary>自适应探测频率的参数。频率范围、精度目标和各状态的保持时间都在这里。</summary>
|
||
public sealed record AdaptiveRateOptions
|
||
{
|
||
/// <summary>平稳时的最低频率(Hz)。</summary>
|
||
public double MinHz { get; init; } = 2;
|
||
|
||
/// <summary>
|
||
/// 最高频率(Hz),也是权重的参考网格:每个探测包的权重是它代表的时间除以 <c>1/MaxHz</c>,
|
||
/// 分析时丢包率和 RTT 分位数按权重累计,所以频率怎么变,得到的仍是按时间平均的值,不会偏向探测更密的时段。
|
||
/// </summary>
|
||
public double MaxHz { get; init; } = 10;
|
||
|
||
/// <summary>平稳时 RTT 分位数(p99)的 95% 置信区间半宽容差:取 max(下限毫秒, 百分比 × p99)。样本多到超出这个精度才会降频。</summary>
|
||
public double RttTolPct { get; init; } = 2;
|
||
|
||
public double RttTolFloorMs { get; init; } = 0.3;
|
||
|
||
/// <summary>平稳线路要能证明的丢包上界(百分比,95% 置信)。三法则:零丢包需要 3 / τ 个样本。</summary>
|
||
public double TauPct { get; init; } = 0.2;
|
||
|
||
/// <summary>证明窗口(秒):平稳频率 = 3 / (τ × 窗口)。</summary>
|
||
public double CertifySeconds { get; init; } = 300;
|
||
|
||
/// <summary>历史上一直平稳的时段可以拉长证明窗口,同样的精度用更少的包。</summary>
|
||
public double QuietCertifySeconds { get; init; } = 900;
|
||
|
||
/// <summary>回包等多久算丢(秒)。</summary>
|
||
public double GraceSeconds { get; init; } = 3;
|
||
|
||
/// <summary>刚启动没有历史:先按 Watch 频率跑这么久(秒)。</summary>
|
||
public double WarmupSeconds { get; init; } = 300;
|
||
|
||
/// <summary>一次丢包事件之后,Watch 至少保持多久(秒)。</summary>
|
||
public double WatchHoldSeconds { get; init; } = 1800;
|
||
|
||
/// <summary>进入 Active 之后,没有新的丢包事件要保持多久才降到 Watch(秒)。</summary>
|
||
public double ActiveHoldSeconds { get; init; } = 900;
|
||
|
||
/// <summary>证明窗口内有这么多个不同的秒出现丢包,就算持续丢包,升到 Active。</summary>
|
||
public int ActiveLossCount { get; init; } = 3;
|
||
|
||
/// <summary>判断线路全丢的窗口(秒)和最少样本数、丢包比例。</summary>
|
||
public double DeadWindowSeconds { get; init; } = 10;
|
||
|
||
public int DeadMinSent { get; init; } = 10;
|
||
|
||
public double DeadLossRatio { get; init; } = 0.95;
|
||
|
||
/// <summary>Dead 状态的探测间隔:从 1 秒起翻倍,最长这么久(秒),带 ±20% 抖动。</summary>
|
||
public double DeadMaxIntervalSeconds { get; init; } = 30;
|
||
|
||
/// <summary>RTT 比长期水平高出这个倍数(再加下面的绝对值)持续几秒,就当作链路在变差,至少升到 Watch。</summary>
|
||
public double RttFactor { get; init; } = 1.5;
|
||
|
||
public double RttFloorMs { get; init; } = 5;
|
||
|
||
/// <summary>用 RTT p99 的置信区间限制降频。样本稀少的任务(每分钟几次)估不出区间,关掉后平稳频率只由丢包上界决定。</summary>
|
||
public bool RttGuard { get; init; } = true;
|
||
|
||
/// <summary>先验:时间 → 这个时刻丢包的历史概率(0 到 1);null 表示没有历史。</summary>
|
||
public Func<long, double?>? Prior { get; init; }
|
||
|
||
/// <summary>先验里的"丢包高发"阈值:达到它在到来之前 <see cref="PriorLeadSeconds"/> 提前预热。</summary>
|
||
public double PriorWatchAt { get; init; } = 0.15;
|
||
|
||
public double PriorActiveAt { get; init; } = 0.5;
|
||
|
||
public double PriorLeadSeconds { get; init; } = 1800;
|
||
}
|
||
|
||
/// <summary>状态或频率变了:<see cref="Reason"/> 是给日志和审计看的固定短语。</summary>
|
||
public sealed record ProbeTransition(ProbeState From, ProbeState To, double Hz, string Reason, double LossPct, int Sent, int Lost);
|
||
|
||
/// <summary>
|
||
/// 自适应探测频率:按"要证明的精度"决定频率,丢包多了自动升频,线路全丢就指数退避。
|
||
/// 平稳线路只需要能证明丢包低于 τ 的样本数(三法则 N = 3/τ),有丢包的线路用满频率保精度和丢包的细节(连续丢包、乱序)。
|
||
/// 状态靠丢包事件的频率升降,带滞回,不会在阈值附近来回跳;时间画像可以让高发时段提前预热。
|
||
/// 纯逻辑:时间由调用方传入,不依赖时钟,方便测试。
|
||
/// </summary>
|
||
public sealed class AdaptiveRate
|
||
{
|
||
private const long UsPerSecond = 1_000_000;
|
||
private const int Buckets = 1200; // 20 分钟,够覆盖最长的统计窗口
|
||
|
||
private readonly AdaptiveRateOptions _options;
|
||
private readonly Random _random;
|
||
private readonly object _gate = new();
|
||
private readonly long[] _second = new long[Buckets];
|
||
private readonly int[] _sent = new int[Buckets];
|
||
private readonly int[] _replied = new int[Buckets];
|
||
|
||
private long _startUs = -1;
|
||
private long _lastLossUs = long.MinValue;
|
||
private long _activeUntilUs = long.MinValue;
|
||
private long _lastReplyUs = long.MinValue;
|
||
private long _deadSinceUs = long.MinValue;
|
||
private int _deadProbes;
|
||
private readonly Queue<(long Second, double Ms)> _rtts = new();
|
||
private long _lastGuardSecond = long.MinValue;
|
||
private double _guardHz = double.NaN;
|
||
private TimeSpan _lastDelay = TimeSpan.Zero;
|
||
private double _rttFast = double.NaN;
|
||
private double _rttSlow = double.NaN;
|
||
private int _rttHighSeconds;
|
||
private long _lastRttSecond = long.MinValue;
|
||
|
||
public AdaptiveRate(AdaptiveRateOptions options, Random? random = null)
|
||
{
|
||
_options = options;
|
||
_random = random ?? Random.Shared;
|
||
State = ProbeState.Watch;
|
||
Hz = _options.MaxHz;
|
||
}
|
||
|
||
public ProbeState State { get; private set; }
|
||
|
||
/// <summary>当前的探测频率(Hz)。Dead 时是退避后的近似频率。</summary>
|
||
public double Hz { get; private set; }
|
||
|
||
/// <summary>发出一个探测包。</summary>
|
||
public void OnSend(long nowUs)
|
||
{
|
||
lock (_gate)
|
||
{
|
||
if (_startUs < 0)
|
||
{
|
||
_startUs = nowUs;
|
||
}
|
||
|
||
long second = nowUs / UsPerSecond;
|
||
int slot = Slot(second);
|
||
if (_second[slot] != second)
|
||
{
|
||
_second[slot] = second;
|
||
_sent[slot] = 0;
|
||
_replied[slot] = 0;
|
||
}
|
||
|
||
_sent[slot]++;
|
||
if (State == ProbeState.Dead)
|
||
{
|
||
_deadProbes++;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>收到回包:<paramref name="sentUs"/> 是它发出的时间,<paramref name="rttMs"/> 是往返时延。</summary>
|
||
public void OnReply(long sentUs, long nowUs, double rttMs)
|
||
{
|
||
lock (_gate)
|
||
{
|
||
long second = sentUs / UsPerSecond;
|
||
int slot = Slot(second);
|
||
if (_second[slot] == second && _replied[slot] < _sent[slot])
|
||
{
|
||
_replied[slot]++;
|
||
}
|
||
|
||
if (double.IsNaN(_rttSlow))
|
||
{
|
||
_rttSlow = _rttFast = rttMs;
|
||
}
|
||
else
|
||
{
|
||
// 时间常数:快的 5 秒,慢的 15 分钟(按时间而不是按包数,频率变了也一样)
|
||
double dt = Math.Max(0, (nowUs - Math.Max(_lastReplyUs, 0)) / (double)UsPerSecond);
|
||
_rttFast += (1 - Math.Exp(-dt / 5)) * (rttMs - _rttFast);
|
||
_rttSlow += (1 - Math.Exp(-dt / 900)) * (rttMs - _rttSlow);
|
||
}
|
||
|
||
_lastReplyUs = nowUs;
|
||
_rtts.Enqueue((nowUs / UsPerSecond, rttMs));
|
||
while (_rtts.Count > 6000)
|
||
{
|
||
_rtts.Dequeue();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>连接(重新)建立了:之前线路不通退避着的话,马上恢复探测,不用等退避间隔。</summary>
|
||
public void Reconnected(long nowUs)
|
||
{
|
||
lock (_gate)
|
||
{
|
||
if (State == ProbeState.Dead)
|
||
{
|
||
_lastReplyUs = nowUs;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>距离下一个探测包还有多久(带抖动,平均值等于频率的倒数)。</summary>
|
||
public TimeSpan NextDelay()
|
||
{
|
||
lock (_gate)
|
||
{
|
||
if (State == ProbeState.Dead)
|
||
{
|
||
double seconds = Math.Min(_options.DeadMaxIntervalSeconds, Math.Pow(2, Math.Min(_deadProbes, 10)));
|
||
return _lastDelay = TimeSpan.FromSeconds(seconds * (0.8 + 0.4 * _random.NextDouble()));
|
||
}
|
||
|
||
// 平稳时间隔精确(不加抖动):发包时刻规整,列块里的时间戳差分更小;抖动只用在退避里避免多条线路同步重试
|
||
return _lastDelay = TimeSpan.FromSeconds(1.0 / Hz);
|
||
}
|
||
}
|
||
|
||
/// <summary>刚刚等过的那个间隔所代表的权重(以 1/MaxHz 为单位):下一个发出的包用它。平稳时是 MaxHz/Hz,退避时是几十到几百。</summary>
|
||
public int Weight
|
||
{
|
||
get
|
||
{
|
||
lock (_gate)
|
||
{
|
||
return (int)Math.Clamp(Math.Round(_lastDelay.TotalSeconds * _options.MaxHz), 1, 65535);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>重新评估状态,大约每秒调用一次;状态或频率变了就返回变化。</summary>
|
||
public ProbeTransition? Evaluate(long nowUs)
|
||
{
|
||
lock (_gate)
|
||
{
|
||
if (_startUs < 0)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
long finalSecond = nowUs / UsPerSecond - (long)Math.Ceiling(_options.GraceSeconds);
|
||
(int sent, int replied, int _) shortWindow = Window(finalSecond, (int)_options.DeadWindowSeconds);
|
||
(int sent, int replied, int lossSeconds) certify = Window(finalSecond, (int)_options.CertifySeconds);
|
||
int lostCertify = certify.sent - certify.replied;
|
||
double lossPct = certify.sent == 0 ? 0 : 100.0 * lostCertify / certify.sent;
|
||
|
||
// 最近一次有丢包的时间:用最近 CertifySeconds 里最晚一个有丢包的整秒
|
||
for (long second = finalSecond; second > finalSecond - (long)_options.CertifySeconds; second--)
|
||
{
|
||
int slot = Slot(second);
|
||
if (_second[slot] == second && _sent[slot] > _replied[slot])
|
||
{
|
||
_lastLossUs = Math.Max(_lastLossUs, second * UsPerSecond);
|
||
break;
|
||
}
|
||
}
|
||
|
||
UpdateRttTrend(nowUs);
|
||
|
||
ProbeState next;
|
||
string reason;
|
||
if (State == ProbeState.Dead)
|
||
{
|
||
if (_lastReplyUs > _deadSinceUs)
|
||
{
|
||
next = ProbeState.Watch;
|
||
reason = "reply received";
|
||
_deadProbes = 0;
|
||
_lastLossUs = nowUs;
|
||
}
|
||
else
|
||
{
|
||
return null;
|
||
}
|
||
}
|
||
else if (shortWindow.sent >= _options.DeadMinSent && shortWindow.replied <= shortWindow.sent * (1 - _options.DeadLossRatio))
|
||
{
|
||
next = ProbeState.Dead;
|
||
reason = "line unreachable";
|
||
_deadSinceUs = nowUs;
|
||
_deadProbes = 0;
|
||
}
|
||
else
|
||
{
|
||
(next, reason) = Decide(nowUs, certify.lossSeconds, lostCertify, lossPct);
|
||
}
|
||
|
||
UpdateGuard(nowUs);
|
||
double hz = next switch
|
||
{
|
||
ProbeState.Quiet => Quantize(Math.Max(QuietHz(QuietWindowSeconds(nowUs)), !_options.RttGuard ? _options.MinHz : double.IsNaN(_guardHz) ? _options.MaxHz : _guardHz)),
|
||
ProbeState.Watch or ProbeState.Active => _options.MaxHz,
|
||
_ => Math.Max(_options.MinHz / 4, 1.0 / _options.DeadMaxIntervalSeconds),
|
||
};
|
||
hz = Math.Min(_options.MaxHz, hz);
|
||
|
||
if (next == State && Math.Abs(hz - Hz) / Hz < 0.05)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
ProbeTransition result = new(State, next, hz, reason, lossPct, certify.sent, lostCertify);
|
||
State = next;
|
||
Hz = hz;
|
||
return result;
|
||
}
|
||
}
|
||
|
||
private (ProbeState State, string Reason) Decide(long nowUs, int lossSeconds, int lostCertify, double lossPct)
|
||
{
|
||
ProbeState state = ProbeState.Quiet;
|
||
string reason = "quiet";
|
||
|
||
double sinceLoss = _lastLossUs == long.MinValue ? double.PositiveInfinity : (nowUs - _lastLossUs) / (double)UsPerSecond;
|
||
if (lossSeconds >= _options.ActiveLossCount || (lossPct >= _options.TauPct * 2 && lossSeconds >= 2))
|
||
{
|
||
_activeUntilUs = nowUs + (long)(_options.ActiveHoldSeconds * UsPerSecond);
|
||
}
|
||
|
||
if (nowUs < _activeUntilUs)
|
||
{
|
||
return (ProbeState.Active, "sustained loss");
|
||
}
|
||
|
||
if (sinceLoss <= _options.WatchHoldSeconds)
|
||
{
|
||
state = ProbeState.Watch;
|
||
reason = "recent loss";
|
||
}
|
||
|
||
if (_rttHighSeconds >= 3)
|
||
{
|
||
state = ProbeState.Watch;
|
||
reason = "rtt above baseline";
|
||
}
|
||
|
||
double? prior = _options.Prior?.Invoke(nowUs);
|
||
double? ahead = _options.Prior?.Invoke(nowUs + (long)(_options.PriorLeadSeconds * UsPerSecond));
|
||
double expected = Math.Max(prior ?? 0, ahead ?? 0);
|
||
if (expected >= _options.PriorActiveAt)
|
||
{
|
||
return (ProbeState.Active, "expected peak");
|
||
}
|
||
|
||
if (expected >= _options.PriorWatchAt && state == ProbeState.Quiet)
|
||
{
|
||
state = ProbeState.Watch;
|
||
reason = "expected loss window";
|
||
}
|
||
|
||
if (state == ProbeState.Quiet && _options.Prior is null && nowUs - _startUs < (long)(_options.WarmupSeconds * UsPerSecond))
|
||
{
|
||
state = ProbeState.Watch;
|
||
reason = "warm-up";
|
||
}
|
||
|
||
return (state, reason);
|
||
}
|
||
|
||
/// <summary>历史上这个时段(现在和将要到来的一段时间)都平稳时,证明窗口可以更长,频率更低。</summary>
|
||
private double QuietWindowSeconds(long nowUs)
|
||
{
|
||
if (_options.Prior is null)
|
||
{
|
||
return _options.CertifySeconds;
|
||
}
|
||
|
||
double now = _options.Prior(nowUs) ?? 1;
|
||
double ahead = _options.Prior(nowUs + (long)(_options.PriorLeadSeconds * UsPerSecond)) ?? 1;
|
||
return Math.Max(now, ahead) <= 0.02 ? _options.QuietCertifySeconds : _options.CertifySeconds;
|
||
}
|
||
|
||
/// <summary>频率取 MaxHz/k(k 为整数):每个包的权重正好是整数 k,分析时没有舍入误差。</summary>
|
||
private double Quantize(double hz)
|
||
{
|
||
double k = Math.Max(1, Math.Floor(_options.MaxHz / Math.Max(hz, 1e-9)));
|
||
return _options.MaxHz / k;
|
||
}
|
||
|
||
/// <summary>
|
||
/// 分位数精度保护:用最近一个证明窗口里的 RTT 样本估计 p99 的 95% 置信区间(次序统计量),
|
||
/// 半宽超过容差就升频,远小于容差才降频(单步最多减半),使平稳线路的 RTT 分位数始终保持在设定的精度内。
|
||
/// </summary>
|
||
private void UpdateGuard(long nowUs)
|
||
{
|
||
long second = nowUs / UsPerSecond;
|
||
if (_lastGuardSecond != long.MinValue && second - _lastGuardSecond < 10)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_lastGuardSecond = second;
|
||
long from = second - (long)_options.CertifySeconds;
|
||
double[] window = [.. _rtts.Where(r => r.Second >= from).Select(r => r.Ms).Order()];
|
||
if (window.Length < 200)
|
||
{
|
||
_guardHz = double.NaN; // 样本不够估计精度:按最高频率
|
||
return;
|
||
}
|
||
|
||
int n = window.Length;
|
||
double rank = n * 0.99;
|
||
double half = 1.96 * Math.Sqrt(n * 0.99 * 0.01);
|
||
int lo = Math.Clamp((int)Math.Floor(rank - half), 0, n - 1);
|
||
int hi = Math.Clamp((int)Math.Ceiling(rank + half), 0, n - 1);
|
||
double halfWidth = (window[hi] - window[lo]) / 2;
|
||
double tolerance = Math.Max(_options.RttTolFloorMs, _options.RttTolPct / 100.0 * window[Math.Min(n - 1, (int)rank)]);
|
||
double ratio = Math.Clamp(halfWidth / tolerance, 0.7, 2.0); // 单步变化有限,避免来回跳
|
||
double current = Hz;
|
||
_guardHz = Math.Clamp(current * ratio * ratio, _options.MinHz, _options.MaxHz);
|
||
}
|
||
|
||
private double QuietHz(double windowSeconds) => Math.Min(_options.MaxHz, Math.Max(_options.MinHz, 3.0 / (_options.TauPct / 100.0 * windowSeconds)));
|
||
|
||
/// <summary>窗口内发出、回来的包数,以及有丢包的整秒数(一次连续丢包只算一个"事件",不会因为一次短暂中断就判成持续丢包)。</summary>
|
||
private (int Sent, int Replied, int LossSeconds) Window(long finalSecond, int seconds)
|
||
{
|
||
int sent = 0;
|
||
int replied = 0;
|
||
int lossSeconds = 0;
|
||
for (long second = finalSecond; second > finalSecond - seconds; second--)
|
||
{
|
||
int slot = Slot(second);
|
||
if (_second[slot] == second)
|
||
{
|
||
sent += _sent[slot];
|
||
replied += _replied[slot];
|
||
if (_sent[slot] > _replied[slot])
|
||
{
|
||
lossSeconds++;
|
||
}
|
||
}
|
||
}
|
||
|
||
return (sent, replied, lossSeconds);
|
||
}
|
||
|
||
private void UpdateRttTrend(long nowUs)
|
||
{
|
||
long second = nowUs / UsPerSecond;
|
||
if (second == _lastRttSecond || double.IsNaN(_rttFast) || double.IsNaN(_rttSlow))
|
||
{
|
||
return;
|
||
}
|
||
|
||
_lastRttSecond = second;
|
||
bool high = _rttFast > _rttSlow * _options.RttFactor + _options.RttFloorMs;
|
||
_rttHighSeconds = high ? _rttHighSeconds + 1 : 0;
|
||
}
|
||
|
||
private static int Slot(long second) => (int)(((second % Buckets) + Buckets) % Buckets);
|
||
}
|