文档目录

1.5 配套代码:延迟预算的自洽性检查与超时链推导

对应小节:1.5 延迟预算 两份代码解决两个问题:① 预算表加起来超了没有?② 超时设置是不是逐层递减的?

一、预算表的数据结构

// src/main/kotlin/budget/LatencyBudget.kt
package budget

/**
 * 一个预算条目。
 * @param parallelGroup 非 null 表示"同组内的条目是并行执行的"(取最大值而非相加)
 */
data class BudgetEntry(
    val name: String,
    val p99Ms: Double,
    val parallelGroup: String? = null,
    val note: String = "",
)

class LatencyBudget(
    val sloP99Ms: Double,
    val reserveMs: Double,          // 余量(必须 > 0)
    val entries: List<BudgetEntry>,
) {
    /** 串行段之和 + 每个并行组的最大值 + 余量 */
    fun totalP99Ms(): Double {
        val serial = entries.filter { it.parallelGroup == null }.sumOf { it.p99Ms }
        val parallel = entries.filter { it.parallelGroup != null }
            .groupBy { it.parallelGroup!! }
            .map { (_, group) -> group.maxOf { it.p99Ms } }
            .sum()
        return serial + parallel + reserveMs
    }

    fun validate(): List<String> {
        val problems = mutableListOf<String>()
        val total = totalP99Ms()

        if (reserveMs <= 0) {
            problems += "余量必须为正数:没有余量的预算在流量波动时必然崩(见第 0 章 0.5 节)"
        }
        if (total > sloP99Ms) {
            problems += "预算合计 %.0fms 超过 SLO %.0fms,超出 %.0fms —— 必须砍掉某一跳或放宽 SLO"
                .format(total, sloP99Ms, total - sloP99Ms)
        }
        if (entries.none { it.name.contains("排队") }) {
            problems += "警告:预算表里没有「排队」这一项。请求在队列里等待的时间也必须计入预算"
        }
        val reserveRatio = reserveMs / sloP99Ms
        if (reserveRatio in 0.0001..0.05) {
            problems += "余量只占 %.1f%%,偏少(建议 10%%~20%%)".format(reserveRatio * 100)
        }
        return problems
    }

    fun report(): String = buildString {
        appendLine("延迟预算(SLO = %.0fms,余量 = %.0fms)".format(sloP99Ms, reserveMs))
        appendLine("-".repeat(64))
        val serial = entries.filter { it.parallelGroup == null }
        val parallelGroups = entries.filter { it.parallelGroup != null }.groupBy { it.parallelGroup!! }

        serial.forEach { appendLine("  %-32s %8.1fms".format(it.name, it.p99Ms)) }
        parallelGroups.forEach { (g, list) ->
            appendLine("  [并行组 $g]")
            list.forEach { appendLine("    %-30s %8.1fms".format(it.name, it.p99Ms)) }
            appendLine("    %-30s %8.1fms  ← 并行取最大值".format("小计", list.maxOf { it.p99Ms }))
        }
        appendLine("  %-32s %8.1fms".format("余量", reserveMs))
        appendLine("-".repeat(64))
        appendLine("  %-32s %8.1fms".format("合计", totalP99Ms()))
        appendLine("  %-32s %8.1fms".format("SLO", sloP99Ms))
        appendLine()
        val problems = validate()
        if (problems.isEmpty()) {
            appendLine("✅ 校验通过")
        } else {
            problems.forEach { appendLine("❌ $it") }
        }
    }
}

fun main() {
    // ── 反例:故意算错的预算(正文里的例子) ──────────────────
    val bad = LatencyBudget(
        sloP99Ms = 200.0, reserveMs = 0.0,
        entries = listOf(
            BudgetEntry("客户端 → 网关", 25.0),
            BudgetEntry("网关处理", 15.0),
            BudgetEntry("应用排队", 20.0),
            BudgetEntry("应用逻辑", 50.0),
            BudgetEntry("PostgreSQL", 60.0),
            BudgetEntry("Redis", 5.0),
            BudgetEntry("下游 HTTP", 40.0),
            BudgetEntry("响应传输", 10.0),
        ),
    )
    println("=== 反例:忘了做加法 ===")
    println(bad.report())
    println()

    // ── 正例:压缩后并留出余量 ────────────────────────────────
    val good = LatencyBudget(
        sloP99Ms = 200.0, reserveMs = 20.0,
        entries = listOf(
            BudgetEntry("客户端 → 网关", 25.0),
            BudgetEntry("网关处理", 15.0),
            BudgetEntry("应用排队", 20.0),
            BudgetEntry("应用逻辑", 45.0),
            BudgetEntry("PostgreSQL", 40.0),
            BudgetEntry("Redis", 5.0),
            BudgetEntry("下游 HTTP", 25.0),
            BudgetEntry("响应传输", 5.0),
        ),
    )
    println("=== 正例:压缩 + 留余量 ===")
    println(good.report())
    println()

    // ── 并行调用的写法 ────────────────────────────────────────
    val withParallel = LatencyBudget(
        sloP99Ms = 200.0, reserveMs = 20.0,
        entries = listOf(
            BudgetEntry("客户端 → 网关", 25.0),
            BudgetEntry("网关处理", 15.0),
            BudgetEntry("应用排队", 20.0),
            BudgetEntry("用户服务", 60.0, parallelGroup = "fanout"),
            BudgetEntry("商品服务", 80.0, parallelGroup = "fanout"),
            BudgetEntry("库存服务", 45.0, parallelGroup = "fanout"),
        ),
    )
    println("=== 并行扇出:取最大值,不是相加 ===")
    println(withParallel.report())
    println("说明:如果按相加算 = 185ms(早就超了),实际只需 80ms(最慢的那个)。")
}

二、预期输出

=== 反例:忘了做加法 ===
延迟预算(SLO = 200ms,余量 = 0ms)
----------------------------------------------------------------
  客户端 → 网关                        25.0ms
  网关处理                             15.0ms
  应用排队                             20.0ms
  应用逻辑                             50.0ms
  PostgreSQL                          60.0ms
  Redis                                5.0ms
  下游 HTTP                           40.0ms
  响应传输                             10.0ms
  余量                                  0.0ms
----------------------------------------------------------------
  合计                                225.0ms
  SLO                                 200.0ms

❌ 预算合计 225ms 超过 SLO 200ms,超出 25ms —— 必须砍掉某一跳或放宽 SLO
❌ 余量必须为正数:没有余量的预算在流量波动时必然崩

三、超时链推导(更救命的第二份代码)

规则只有一条:本层超时必须小于上游给的时间,且逐层递减。

// src/main/kotlin/budget/TimeoutChain.kt
package budget

data class TimeoutNode(
    val layer: String,
    val upstreamBudgetMs: Double,     // 上游给这一层的时间
    val selfOverheadMs: Double,       // 这一层自身的处理开销(不含下游)
    val target: String = "",          // 这一层要调用的下游
)

data class TimeoutPlan(
    val layer: String,
    val selfTimeoutMs: Double,
    val remainingForDownstreamMs: Double,
    val problems: List<String>,
)

/**
 * 推导超时链。
 *
 * @param retryBudgetRatio 如果要重试,需要为"多次尝试 + 退避"预留时间(例如 0.5 表示留一半)
 */
fun deriveTimeouts(nodes: List<TimeoutNode>, retryBudgetRatio: Double = 0.0): List<TimeoutPlan> {
    var upstream = Double.MAX_VALUE
    return nodes.map { node ->
        val problems = mutableListOf<String>()

        // ① 本层超时必须小于上游给的预算
        val maxSelf = if (upstream == Double.MAX_VALUE) node.upstreamBudgetMs
                      else minOf(node.upstreamBudgetMs, upstream)
        var selfTimeout = maxSelf - node.selfOverheadMs

        if (selfTimeout <= 0) {
            problems += "上游只给了 ${maxSelf}ms,但本层自身开销就要 ${node.selfOverheadMs}ms —— 必然超时"
            selfTimeout = 1.0
        }

        // ② 如果要重试,单次超时必须更短
        if (retryBudgetRatio > 0) {
            selfTimeout *= (1 - retryBudgetRatio)
        }

        // ③ 留给下游的时间必须更少
        val remaining = selfTimeout - node.selfOverheadMs
        if (remaining <= 0) {
            problems += "扣掉自身开销后没有时间留给下游「${node.target}」,下游必须降级或异步化"
        }

        upstream = remaining

        TimeoutPlan(
            layer = node.layer,
            selfTimeoutMs = selfTimeout,
            remainingForDownstreamMs = maxOf(remaining, 0.0),
            problems = problems,
        )
    }
}

fun main() {
    println("=== 反例:下游超时比上游还长(级联雪崩的配方)===")
    val badPlan = deriveTimeouts(
        listOf(
            TimeoutNode("客户端 → 网关", 300.0, 5.0),
            TimeoutNode("网关", 250.0, 15.0, "应用"),
            TimeoutNode("应用(给 PostgreSQL)", 5000.0, 10.0),   // ← 默认 5 秒,没人改过
        )
    )
    badPlan.forEach { println("  ${it.layer}: 自身超时 %.0fms".format(it.selfTimeoutMs)) }
    println("  ⚠️ 应用给 PostgreSQL 的超时是 5 秒,而网关只给应用 250ms ——")
    println("     数据库一慢,应用线程会被占住 5 秒,而网关早已放弃。线程耗尽 → 雪崩。")
    println()

    println("=== 正例:逐层递减 + 重试预算 ===")
    val goodPlan = deriveTimeouts(
        listOf(
            TimeoutNode("客户端 → 网关", 300.0, 5.0),
            TimeoutNode("网关", 250.0, 15.0, "应用"),
            TimeoutNode("应用", 200.0, 20.0, "PostgreSQL/Redis/下游"),
            TimeoutNode("应用 → PostgreSQL", 120.0, 5.0),
        ),
        retryBudgetRatio = 0.0,
    )
    goodPlan.forEach {
        println("  %-24s 自身超时 %6.0fms,留给下游 %6.0fms".format(
            it.layer, it.selfTimeoutMs, it.remainingForDownstreamMs))
        it.problems.forEach { p -> println("      ❌ $p") }
    }
    println()
    println("=== 带重试的情况:单次超时必须更短 ===")
    deriveTimeouts(
        listOf(
            TimeoutNode("应用(给下游,允许重试 2 次)", 200.0, 20.0, "下游服务"),
        ),
        retryBudgetRatio = 0.6,   // 留 60% 给重试与退避
    ).forEach {
        println("  %-24s 单次超时 %6.0fms(重试 2 次的总时间预算另算)".format(it.layer, it.selfTimeoutMs))
    }
}

输出要点:

=== 正例:逐层递减 + 重试预算 ===
  客户端 → 网关            自身超时    295ms,留给下游    290ms
  网关                     自身超时    235ms,留给下游    220ms
  应用                     自身超时    200ms,留给下游    180ms
  应用 → PostgreSQL        自身超时    115ms,留给下游    110ms

每一层的超时都比上层小,这就是正确的超时链。

四、把两份代码接进日常

时机 做什么
新增接口时 写一份 LatencyBudget,跑 validate(),超了就重新分配
配置超时时 用 deriveTimeouts() 推导,禁止手写「默认 30 秒」
发布前 把预算表的「实测」列填满(第 4 章有了观测之后),对比是否超预算
线上告警时 看哪一跳超预算,直接定位

五、动手改造

改动 观察什么
把 reserveMs 改成 0.5 校验会提示余量偏少——想想 0.5ms 的余量能吸收什么抖动
给「应用逻辑」加入并行组 检查合计值的变化(相加 → 取最大)
在 deriveTimeouts 里把最后一跳的 selfOverheadMs 调到超过上游预算 观察「必然超时」的报错
把 retryBudgetRatio 从 0 改到 0.8 单次超时被压缩到什么程度?重试还有意义吗?

六、这段代码的局限

  • 预算表用的是 P99,但 P99 不可加(第 1.2 节)。这份代码把各跳 P99 相加当作目标上限来校验自洽性是合理的,但不能用它推算真实的总 P99——真实值必须实测。
  • 并行组的建模比较粗糙(只区分「并行」和「串行」)。真实链路里常有嵌套的并行/串行混合结构,建议用 tracing 的 span 树来表达。
  • 它不知道「重试会放大下游压力」。重试预算只算了时间,没算流量放大——后者要在第 7 节的背压设计里处理。