文档目录

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 倍。

四、四个必须记住的点

  1. 排队时间对利用率是非线性的。 ρ=0.5 → 1 倍,ρ=0.9 → 9 倍,ρ=0.95 → 19 倍,ρ=0.98 → 49 倍。
  2. 「90% 利用率」不是「还剩 10% 余量」,而是「延迟已经涨了 9 倍,缓冲区即将耗尽」。
  3. 平均利用率会掩盖高峰。 两个平均都是 70% 的系统,如果一个是平稳的、一个是忽高忽低的,后者的实际体验差好几倍——要看的是利用率的分布,不是平均值。
  4. headroom 买的是「吸收波动的能力」,不是「更多的算力」。突发流量(重试风暴、缓存失效、定时任务、上游抖动)不由你控制,所以必须留缓冲区。

五、动手改造

改动 观察什么
把 rho 加到 0.99 等待时间会变成多少?为什么真实系统不能跑到这里?
把实验二的 rhoHigh 改成 0.95 突发流量的平均等待涨到多少?和「平稳 ρ=0.725」对比
把 segment 从 5000 改成 500(切换更频繁) 等待时间是变大还是变小?为什么?
给服务时间加上「偶尔慢 100 倍」的长尾(模拟 GC 停顿) 排队时间如何变化?这解释了为什么 GC 会放大排队问题
把队列改成「有上限、满了就拒绝」 延迟会发生什么变化?(提示:快速失败会截断长尾——这就是限流保护延迟的原理)

六、这段代码的局限

  • M/M/1 是极度简化的模型:单队列、单服务、泊松到达、指数服务时间。真实系统是多核、多阶段、有优先级的,而且流量有明显的周期性。
  • 它假设队列无限长。真实队列有上限,满了会拒绝请求——这时表现出来的是「错误率上升」而不是「延迟无限增长」。
  • 它不含缓存效应。有缓存时服务时间会出现「快/慢两极」的分布,行为更复杂。
  • 用途:建立直觉、理解趋势、为 headroom 提供依据。不要用它精确预测你的系统延迟——那要靠实测。