别只会用均匀分布:三种延迟模型和两种丢包模型的原理与实现
WeakNet 技术博客系列 | 第 6 篇
上一篇实现了 5 种操控器,但有一个问题没回答:延迟的抖动值怎么取?丢包的概率怎么定?直接用随机数行不行?答案是行,但效果不好。如果你用 Random.nextInt() 生成抖动,用 Random.nextInt(100) < 30 决定丢包,模拟出来的弱网和真实弱网差得很远。
真实网络的延迟和丢包不是均匀随机的。延迟通常聚集在某个均值附近,偶尔出现大幅飙升;丢包也不是独立发生的,而是成串成片地爆发。要让模拟效果逼真,需要用对数学模型。WeakNet 提供了 3 种延迟模型和 2 种丢包模型,这篇文章把它们挨个讲清楚。
三种延迟模型
延迟模型的职责是生成抖动值(jitter)------在基础延迟 delayMs 上叠加的随机偏移量。三种模型对应三种概率分布,模拟不同类型的网络环境。
UNIFORM -- 均匀分布
kotlin
DelayModel.UNIFORM -> Random.nextInt(-jitterMs, jitterMs + 1)
最简单的模型。在 [-jitterMs, +jitterMs] 区间内均匀取值,每个整数出现的概率完全相等。设置 jitterMs=100,抖动值在 -100 到 +100 之间均匀分布。-100、0、+100 出现的概率一模一样。
问题在于:真实网络的抖动不是均匀的。大多数时候延迟稳定在均值附近,偶尔出现大幅偏移。均匀分布把"大幅偏移"和"小幅偏移"赋予了同样的概率,模拟不出这种"平时稳、偶尔崩"的特征。
适用场景:基线测试。简单、可预测,适合验证功能正确性。
GAUSSIAN -- 高斯分布(Box-Muller 变换)
kotlin
DelayModel.GAUSSIAN -> {
// Box-Muller 变换生成标准正态分布
val u1 = Random.nextDouble(1e-10, 1.0)
val u2 = Random.nextDouble()
val g = sqrt(-2.0 * ln(u1)) * cos(2.0 * PI * u2) // 标准正态 N(0,1)
(g * jitterMs).toInt().coerceIn(-3 * jitterMs, 3 * jitterMs)
}
高斯分布(正态分布)是自然界最常见的分布。Box-Muller 变换是生成高斯随机数的经典方法,原理不复杂:
取两个 (0, 1) 区间的均匀随机数 u1、u2,通过公式 g = sqrt(-2 * ln(u1)) * cos(2 * PI * u2) 变换,得到一个标准正态分布 N(0,1) 的随机数。这里 ln(u1) 产生指数分布的效果,cos(2*PI*u2) 提供角度均匀性,两者组合恰好抵消了笛卡尔坐标到极坐标变换中的雅可比行列式,得到标准正态。
乘以 jitterMs(标准差 sigma),得到实际的抖动值。最后用 coerceIn(-3*sigma, 3*sigma) 做 3sigma 裁剪------正态分布中 99.7% 的值落在 3sigma 以内,裁剪掉极端尾部防止出现不合理的巨大延迟。
设置 jitterMs=100,大约 68% 的抖动值在 -100 到 +100 之间,95% 在 -200 到 +200 之间,偶尔出现接近 300 的偏移。大部分包的延迟接近基础值,少数包出现较大抖动------比均匀分布真实得多。
适用场景:稳定的弱信号网络。4G 弱信号、WiFi 远距离,延迟围绕均值波动。
LONG_TAIL -- 对数正态分布
kotlin
DelayModel.LONG_TAIL -> {
val u1 = Random.nextDouble(1e-10, 1.0)
val u2 = Random.nextDouble()
val normal = sqrt(-2.0 * ln(u1)) * cos(2.0 * PI * u2)
val logNormal = exp(normal) // e^N(0,1) = LogNormal(0,1)
(logNormal * jitterMs).toInt().coerceIn(0, 5 * jitterMs)
}
对数正态分布的生成方法很简单:先产生一个标准正态数,再取 exp()。exp(N(0,1)) 就是对数正态分布 LogNormal(0,1)。
这个分布的关键特征是:中位数是 1,均值约 1.65,但右尾极长。P(X>5) 约 5.4%,P(X>10) 约 1.1%。换句话说,绝大多数时候抖动值很小(靠近 jitterMs),但偶尔会出现 5 倍甚至 10 倍于正常值的巨大延迟尖峰。
还有一个重要特性:值永远为正。exp() 的结果不可能小于 0,所以对数正态分布只会产生正方向的抖动。coerceIn(0, 5*jitterMs) 把上限截断在 5 倍,防止极端值把延迟推到离谱的程度。
这正好模拟了真实弱网的典型行为:平时延迟还行,突然来一个巨大的尖峰。地铁进隧道、电梯关门、基站切换------这些场景的共同特点是"大部分时间可以,偶尔崩一下",对数正态的右偏特征完美匹配。
适用场景:真实弱网环境。地铁、电梯、高铁等移动场景,是三种模型中最贴近现实的。
三种模型对比
| 模型 | 分布形态 | 延迟范围 | 适用场景 |
|---|---|---|---|
| UNIFORM | 对称、平坦 | -jitter, +jitter | 基线测试,简单可预测 |
| GAUSSIAN | 对称、钟形 | -3sigma, +3sigma | 稳定弱信号,延迟围绕均值波动 |
| LONG_TAIL | 右偏、长尾 | 0, 5\*sigma | 真实弱网,偶尔出现巨大尖峰 |
两种丢包模型
丢包模型决定每个数据包是否被丢弃。WeakNet 实现了两种模型,对应两种截然不同的丢包行为。
RANDOM -- 伯努利模型
kotlin
LossModel.RANDOM -> Random.nextInt(100) < lossPercent
最直观的丢包方式:每个包独立地以概率 p 被丢弃。设置 lossPercent=30,每个包有 30% 的概率被丢掉,丢不丢和其他任何包无关。
数学上这是 n 重伯努利试验。期望丢包率等于 p,方差等于 p(1-p)。连续丢 3 个包的概率是 p^3------当 p=0.3 时只有 2.7%。
问题在哪?真实网络的丢包不是独立的。丢包倾向于成串发生------进入隧道时连续丢一堆,切基站时又连续丢一堆。伯努利模型下 30% 丢包率的表现是"平均每 3 个包丢 1 个",应用层的纠错机制(TCP 重传、ARQ)很容易应对。但真实场景是"连丢 3 个包然后恢复",这对应用层的冲击大得多。
适用场景:简单的丢包测试。验证应用在丢包环境下的基本表现。
BURST -- Gilbert 模型(两状态马尔可夫链)
kotlin
// 状态转移概率
r = 1.0 / 3.0 // BAD -> GOOD
p = r * lossPercent / (100.0 - lossPercent) // GOOD -> BAD
// 状态机
synchronized(lock) {
val drop = inBadState // 坏状态:所有包丢弃
if (inBadState) {
if (Random.nextDouble() < r) inBadState = false
} else {
if (Random.nextDouble() < p) inBadState = true
}
drop
}
Gilbert 模型是最经典的突发丢包模型,核心思想是用两状态马尔可夫链描述网络的好与坏:
- GOOD 状态:正常传输,不丢包
- BAD 状态:所有包被丢弃
- 转移概率:GOOD 到 BAD 的概率是 p,BAD 到 GOOD 的概率是 r
参数推导的过程挺有意思。r 固定为 1/3,意思是处于 BAD 状态时,平均经过 3 个包后恢复(几何分布的期望是 1/r = 3)。p 不是随意设置的------它由目标丢包率反推:
稳态下 P(BAD) = p / (p + r)。令 P(BAD) = lossPercent/100,解出 p = r * lossPercent / (100 - lossPercent)。
设置 lossPercent=30:p = (1/3) * 30/70 = 0.143。稳态验证:P(BAD) = 0.143 / (0.143 + 0.333) = 0.30,精确等于 30%。每次进入 BAD 状态后平均丢 3 个包再恢复------3 个连续丢包,比伯努利模型的"隔三差五丢一个"杀伤力大得多。
为什么 Gilbert 比伯努利好?真实弱网的丢包是突发的。地铁进隧道,信号断 2 秒,这 2 秒内的所有包全部丢失;出了隧道信号恢复,传输正常。伯努利模型可能丢 1 个、传 2 个、丢 1 个------应用层重传就补上了。Gilbert 模型则是连丢 3-4 个,应用层的滑动窗口直接被清空,TCP 退避重传,真实得多。
适用场景:真实弱网模拟。地铁、电梯、高铁等移动场景,必须用突发丢包才能测出应用的真实表现。
延迟施加的位置
延迟不是在操控管线里施加的。管线只负责"限速、乱序、重复、丢包、篡改"五种操作,延迟的计算(calculateJitter)虽然定义在 ManipulationPipeline 中,但实际的 sleep 调用分散在不同位置。原因前面提到过:TCP 是流式协议,逐包延迟会导致累积堆叠。
| 场景 | 施加位置 | 说明 |
|---|---|---|
| TCP SYN-ACK | connectExecutor 中 Thread.sleep(delay+jitter) |
模拟握手 RTT,只在建连时延迟一次 |
| TCP 数据包 | 不施加延迟 | 逐包延迟会累积堆叠,连接卡死 |
| UDP 出方向 | udpExecutor 中 applyDelay() |
每包独立延迟,模拟上行弱网 |
| UDP 入方向 | udpExecutor 中 applyDelay() |
每包独立延迟,模拟下行弱网 |
| ICMP | icmpExecutor 中 Thread.sleep(delay+jitter) |
模拟 ping RTT |
TCP 是流式传输,如果每个包都延迟 100ms,10 个包就是 1 秒累积延迟,连接直接卡死。所以延迟只在 SYN-ACK 阶段施加一次,模拟握手的往返延迟。建连后的数据流不再加延迟,靠上游网络本身的 RTT 和管线中的限速来控制速率。
UDP 和 ICMP 是报文式传输,每个包独立,不存在累积问题。出方向和入方向各加一次延迟,合起来就是 RTT = 2 * delayMs。
写在最后
三种延迟模型和两种丢包模型的选择,直接决定了弱网模拟的逼真程度。均匀分布和伯努利丢包能跑通基本功能测试,但测不出应用在真实弱网下的表现。高斯分布和对数正态分布模拟了延迟的聚集特征,Gilbert 模型模拟了丢包的突发特征------这些才是地铁、电梯、高铁场景的真实写照。WeakNet 的 12 个预设场景里,"地铁"、"电梯"、"丢包地狱"等高强度预设默认使用 LONG_TAIL + BURST 组合,原因就在这里。
数学模型讲完了,下一篇看两个高级场景:网络闪断和 DNS 故障模拟。