0.5 配套代码:排队模拟——为什么 90% 利用率已经很危险
对应小节:0.5 为什么 90% 利用率已经很危险 要验证的三个结论:① 排队时间的非线性爆炸 ② 平均利用率掩盖高峰 ③ 30%–50% headroom 的数学依据
一、模拟设计
我们模拟一个最简单的排队系统(M/M/1):
- 一个服务台(一个 CPU 核 / 一个数据库连接);
- 请求随机到达(到达间隔服从指数分布);
- 处理时间随机(服务时间服从指数分布);
- 先到先服务,队列无上限。
参数只有两个:利用率 ρ(服务台有多忙)和样本量。所有时间都以「一次服务时间」为单位,这样结果可以直接和正文的公式对照:
理论平均排队时间 = ρ / (1 - ρ) × 服务时间
二、代码
// src/main/kotlin/lesson00/Queueing.kt
package lesson00
import kotlin.math.ln
import kotlin.random.Random
/**
* 模拟 M/M/1 队列,返回「平均排队等待时间 / 服务时间」的比值。
*
* @param rho 利用率,0 < rho < 1
* @param arrivals 生成的请求总数(前 10% 作为预热丢弃)
*/
fun simulateMm1(rho: Double, arrivals: Int = 1_000_000, seed: Int = 42): Double {
val rnd = Random(seed)
val meanService = 1.0
val meanInterArrival = meanService / rho // 到达率越高,间隔越短
var now = 0.0 // 当前请求的到达时刻
var serverFreeAt = 0.0 // 服务台何时空闲
var totalWait = 0.0
var counted = 0
val warmup = arrivals / 10 // 丢弃初始瞬态
repeat(arrivals) { i ->
// 指数分布的到达间隔:-mean * ln(1 - U)
now += -meanInterArrival * ln(1.0 - rnd.nextDouble())
// 如果服务台还在忙,就要排队
val serviceStart = maxOf(now, serverFreeAt)
val wait = serviceStart - now
serverFreeAt = serviceStart + (-meanService * ln(1.0 - rnd.nextDouble()))
if (i >= warmup) { totalWait += wait; counted++ }
}
return totalWait / counted
}
/** 理论值:ρ / (1 - ρ) */
fun theoreticalWait(rho: Double): Double = rho / (1.0 - rho)
fun main() {
println("=== 实验一:排队时间随利用率的变化 ===")
println()
println("利用率 ρ | 理论倍数 | 模拟倍数 | 服务 10ms 时平均等多久")
println("---------|-----------|-----------|------------------------")
for (rho in listOf(0.3, 0.5, 0.7, 0.8, 0.9, 0.95, 0.98)) {
val measured = simulateMm1(rho, arrivals = 500_000)
val theory = theoreticalWait(rho)
println("%8.2f | %9.2f | %9.2f | %6.1f ms"
.format(rho, theory, measured, theory * 10))
}
println()
println("关键观察:ρ 从 0.9 涨到 0.95(只多 5 个百分点),")
println(" 等待时间从 9 倍涨到 19 倍(翻了一倍多)。")
println()
// ── 实验二:平均利用率是骗人的 ──────────────────────────────
demonstrateAverageUtilizationLie()
// ── 实验三:headroom 换算成实际意义 ─────────────────────────
demonstrateHeadroom()
}
/**
* 两个系统的【平均】利用率都是 70%,但一个平稳、一个忽高忽低。
* 结果:平均一样,体验差好几倍。
*/
fun simulateBursty(rhoLow: Double, rhoHigh: Double, segment: Int = 5_000,
arrivals: Int = 1_000_000, seed: Int = 7): Double {
val rnd = Random(seed)
var currentRho = rhoHigh
var now = 0.0
var serverFreeAt = 0.0
var totalWait = 0.0
var counted = 0
val warmup = arrivals / 10
repeat(arrivals) { i ->
// 每 segment 个请求切换一次"忙/闲"状态
if (i % segment == 0) {
currentRho = if ((i / segment) % 2 == 0) rhoHigh else rhoLow
}
val meanInterArrival = 1.0 / currentRho
now += -meanInterArrival * ln(1.0 - rnd.nextDouble())
val serviceStart = maxOf(now, serverFreeAt)
totalWait += (serviceStart - now)
serverFreeAt = serviceStart + (-1.0 * ln(1.0 - rnd.nextDouble()))
if (i >= warmup) counted++
}
return totalWait / counted
}
fun demonstrateAverageUtilizationLie() {
println("=== 实验二:平均利用率都是 70%,但流量形态不同 ===")
val steady = simulateMm1(0.70, arrivals = 500_000)
val bursty = simulateBursty(rhoLow = 0.50, rhoHigh = 0.90)
println("平稳流量(恒定 ρ=0.70) : 平均等待 %.2f 个服务时间".format(steady))
println("突发流量(ρ 在 0.5/0.9 间切换): 平均等待 %.2f 个服务时间".format(bursty))
println("→ 平均利用率完全相同,但突发流量的等待时间高了 %.1f 倍。"
.format(bursty / steady))
println()
println("结论:只看平均利用率会严重低估风险。要看利用率的高峰(P95/P99),")
println(" 或者直接盯饱和度指标(队列深度、连接池 pending)。")
println()
}
fun demonstrateHeadroom() {
println("=== 实验三:headroom 到底买到了什么 ===")
println()
println("假设服务时间是 10 ms,我们比较不同水位下的平均延迟:")
println("目标利用率 | 平均排队 | 总延迟(P50) | 一句话")
for (rho in listOf(0.5, 0.6, 0.7, 0.8, 0.9, 0.95)) {
val wait = theoreticalWait(rho) * 10
val total = wait + 10
val comment = when {
rho <= 0.6 -> "很宽裕,能吸收突发"
rho <= 0.7 -> "推荐区间"
rho <= 0.8 -> "开始吃紧"
rho <= 0.9 -> "危险,一次 GC 停顿就可能雪崩"
else -> "已经在排队地狱里"
}
println("%9.2f | %7.1fms | %10.1fms | %s".format(rho, wait, total, comment))
}
println()
println("注意最后两行:从 90% 提到 95%,只是「多榨出 5% 的 CPU」,")
println(" 但用户感知的延迟几乎翻倍。")
println()
println("这就是「容量规划要留 30%~50% 余量」的数学依据——")
println("它不是为了保守,而是因为排队是非线性的:低水位买到的是「吸收波动的能力」。")
}
三、预期输出
=== 实验一:排队时间随利用率的变化 ===
利用率 ρ | 理论倍数 | 模拟倍数 | 服务 10ms 时平均等多久
---------|-----------|-----------|------------------------
0.30 | 0.43 | 0.43 | 4.3 ms
0.50 | 1.00 | 1.00 | 10.0 ms
0.70 | 2.33 | 2.33 | 23.3 ms
0.80 | 4.00 | 4.00 | 40.0 ms
0.90 | 9.00 | 8.99 | 90.0 ms
0.95 | 19.00 | 19.02 | 190.0 ms
0.98 | 49.00 | 48.97 | 490.0 ms
模拟值与理论值几乎完全吻合——这说明排队公式不是玄学,是数学。
=== 实验二:平均利用率都是 70%,但流量形态不同 ===
平稳流量(恒定 ρ=0.70) : 平均等待 2.33 个服务时间
突发流量(ρ 在 0.5/0.9 间切换): 平均等待 4.80 个服务时间
→ 平均利用率完全相同,但突发流量的等待时间高了 2.1 倍。
四、四个必须记住的点
- 排队时间对利用率是非线性的。 ρ=0.5 → 1 倍,ρ=0.9 → 9 倍,ρ=0.95 → 19 倍,ρ=0.98 → 49 倍。
- 「90% 利用率」不是「还剩 10% 余量」,而是「延迟已经涨了 9 倍,缓冲区即将耗尽」。
- 平均利用率会掩盖高峰。 两个平均都是 70% 的系统,如果一个是平稳的、一个是忽高忽低的,后者的实际体验差好几倍——要看的是利用率的分布,不是平均值。
- headroom 买的是「吸收波动的能力」,不是「更多的算力」。突发流量(重试风暴、缓存失效、定时任务、上游抖动)不由你控制,所以必须留缓冲区。
五、动手改造
| 改动 | 观察什么 |
|---|---|
把 rho 加到 0.99 |
等待时间会变成多少?为什么真实系统不能跑到这里? |
把实验二的 rhoHigh 改成 0.95 |
突发流量的平均等待涨到多少?和「平稳 ρ=0.725」对比 |
把 segment 从 5000 改成 500(切换更频繁) |
等待时间是变大还是变小?为什么? |
| 给服务时间加上「偶尔慢 100 倍」的长尾(模拟 GC 停顿) | 排队时间如何变化?这解释了为什么 GC 会放大排队问题 |
| 把队列改成「有上限、满了就拒绝」 | 延迟会发生什么变化?(提示:快速失败会截断长尾——这就是限流保护延迟的原理) |
六、这段代码的局限
- M/M/1 是极度简化的模型:单队列、单服务、泊松到达、指数服务时间。真实系统是多核、多阶段、有优先级的,而且流量有明显的周期性。
- 它假设队列无限长。真实队列有上限,满了会拒绝请求——这时表现出来的是「错误率上升」而不是「延迟无限增长」。
- 它不含缓存效应。有缓存时服务时间会出现「快/慢两极」的分布,行为更复杂。
- 用途:建立直觉、理解趋势、为 headroom 提供依据。不要用它精确预测你的系统延迟——那要靠实测。