文档目录

1.4 配套代码:把 SLO 写成代码

对应小节:1.4 SLO 的四要素写法 核心思路:让「SLO 写得不完整」这件事在编译期或测试期就失败,而不是等到验收时才发现。

一、为什么要把 SLO 变成代码

写在文档里的 SLO 会腐烂:改代码的人不看文档,看文档的人不知道代码。

把四要素变成必填字段,就能得到两个好处:

  1. 无法遗漏:缺字段直接编译不过 / 测试失败。
  2. 可派生:从 SLO 自动生成直方图桶边界、告警表达式、验收脚本参数。

二、SLO 的数据结构

// src/main/kotlin/slo/Slo.kt
package slo

import java.time.Duration

/** 测量点:必须显式指定,不能默认 */
enum class MeasurementPoint(val label: String) {
    CLIENT("压测客户端 / 真实用户"),
    GATEWAY("网关 / 负载均衡"),
    IN_APP("应用内埋点(仅用于定位,不能用于验收)"),
}

/** 数据分布:必须写明,因为它会显著影响缓存命中率与锁竞争 */
enum class DataDistribution(val label: String) {
    UNIFORM("均匀分布"),
    ZIPF("幂律(齐夫)分布"),
    REAL_REPLAY("真实流量回放"),
}

data class Slo(
    // ① 指标 + 阈值
    val endpoint: String,
    val latencyP99Ms: Long,
    val latencyP999Ms: Long,          // 至少两个分位数
    val errorRateMax: Double,

    // ② 负载与数据前提
    val targetQps: Int,
    val dataRows: Long,
    val distribution: DataDistribution,
    val cacheState: String,           // "预热 5 分钟" / "冷缓存"

    // ③ 测量点
    val measurementPoint: MeasurementPoint,

    // ④ 持续时长与验收方式
    val warmup: Duration,
    val steadyState: Duration,
    val rounds: Int,                  // 跑几轮、取中位数

    // 附加:环境与崩溃线
    val environment: String,
    val crashLine: String,
)

三、校验:把「不完整」变成错误

// src/main/kotlin/slo/SloValidation.kt
package slo

fun Slo.validate(): List<String> {
    val problems = mutableListOf<String>()

    // ① 指标 + 阈值
    if (latencyP99Ms <= 0) problems += "P99 阈值必须为正数"
    if (latencyP999Ms <= latencyP99Ms) {
        problems += "P999 阈值(${latencyP999Ms}ms)必须大于 P99(${latencyP99Ms}ms)——否则不是长尾指标"
    }
    if (errorRateMax < 0 || errorRateMax > 1) problems += "错误率阈值必须在 0~1 之间"

    // ② 负载与数据前提
    if (targetQps <= 0) problems += "目标 QPS 必须为正数(不要留空或写 0)"
    if (dataRows <= 0) problems += "数据量必须明确(1 万行和 1 亿行的执行计划不同)"
    if (cacheState.isBlank()) problems += "缓存状态必须写明(冷缓存和热缓存的 P99 可以差几十倍)"

    // ③ 测量点
    if (measurementPoint == MeasurementPoint.IN_APP) {
        problems += "测量点不能是「应用内埋点」:它不含网络与排队,无法代表用户体验,只能用于定位"
    }

    // ④ 持续时长
    if (warmup.isZero) problems += "必须声明预热时长,且预热数据不计入统计"
    if (steadyState < Duration.ofMinutes(3)) {
        problems += "稳态时长 ${steadyState.toMinutes()} 分钟偏短:至少 3 分钟才能覆盖一个 GC/缓存周期"
    }
    if (rounds < 3) problems += "至少跑 3 轮,否则无法区分真实差异与噪声"

    // 附加
    if (environment.isBlank()) problems += "必须写明环境(实例规格、副本数、容器 CPU limit)"
    if (crashLine.isBlank()) problems += "必须定义崩溃线(压力测试的终止条件)"

    return problems
}

用法:写成单元测试,SLO 定义有问题时 CI 直接红。

// src/test/kotlin/slo/SloTest.kt
package slo

import org.junit.jupiter.api.Test
import java.time.Duration
import kotlin.test.assertTrue

class SloTest {

    private val goodSlo = Slo(
        endpoint = "GET /orders/{id}",
        latencyP99Ms = 200, latencyP999Ms = 500, errorRateMax = 0.001,
        targetQps = 500, dataRows = 10_000_000,
        distribution = DataDistribution.ZIPF, cacheState = "预热 5 分钟",
        measurementPoint = MeasurementPoint.CLIENT,
        warmup = Duration.ofMinutes(1), steadyState = Duration.ofMinutes(10), rounds = 3,
        environment = "单实例 4C8G,无外部缓存",
        crashLine = "P99 > 1s 或错误率 > 1% 持续 30 秒",
    )

    @Test
    fun `完整的 SLO 应该通过校验`() {
        assertTrue(goodSlo.validate().isEmpty(), goodSlo.validate().toString())
    }

    @Test
    fun `用应用内埋点当验收标准应该被拒绝`() {
        val bad = goodSlo.copy(measurementPoint = MeasurementPoint.IN_APP)
        assertTrue(bad.validate().any { it.contains("应用内埋点") })
    }

    @Test
    fun `缺少数据量应该被拒绝`() {
        assertTrue(goodSlo.copy(dataRows = 0).validate().any { it.contains("数据量") })
    }

    @Test
    fun `只跑一轮应该被拒绝`() {
        assertTrue(goodSlo.copy(rounds = 1).validate().any { it.contains("3 轮") })
    }
}

这四个测试用例,正好对应第 1.8 节 案例里踩的坑。

四、从 SLO 派生直方图桶边界

这一步解决「桶边界拍脑袋」的问题:

// src/main/kotlin/slo/Buckets.kt
package slo

import java.time.Duration
import kotlin.math.roundToLong

/**
 * 从 SLO 派生桶边界:
 * 在 P99 附近加密(这是要判定的地方),同时覆盖从典型值到崩溃线的范围。
 */
fun Slo.histogramBuckets(): List<Duration> {
    val p99 = latencyP99Ms
    val p999 = latencyP999Ms
    val ratios = listOf(0.10, 0.25, 0.50, 0.75, 1.0, 1.5, 2.5, 5.0, 10.0)
    val buckets = (ratios.map { (p99 * it).roundToLong() } + p999)
        .filter { it > 0 }
        .distinct()
        .sorted()
    return buckets.map { Duration.ofMillis(it) }
}

fun main() {
    val slo = Slo(
        endpoint = "GET /orders/{id}",
        latencyP99Ms = 200, latencyP999Ms = 500, errorRateMax = 0.001,
        targetQps = 500, dataRows = 10_000_000,
        distribution = DataDistribution.ZIPF, cacheState = "预热 5 分钟",
        measurementPoint = MeasurementPoint.CLIENT,
        warmup = Duration.ofMinutes(1), steadyState = Duration.ofMinutes(10), rounds = 3,
        environment = "单实例 4C8G", crashLine = "P99 > 1s",
    )
    println("SLO: P99 < ${slo.latencyP99Ms}ms")
    println("派生桶边界(ms): " + slo.histogramBuckets().joinToString(", ") { it.toMillis().toString() })
    println()
    println("注意 100/150/200/300 这几个边界都在 P99 附近 —— ")
    println("如果只用默认桶,这一段几乎无法区分,P99 会失真。")
}

输出:

SLO: P99 < 200ms
派生桶边界(ms): 20, 50, 100, 150, 200, 300, 500, 500, 1000, 2000

注意 100/150/200/300 这几个边界都在 P99 附近 ——
如果只用默认桶,这一段几乎无法区分,P99 会失真。

五、动手改造

改动 观察什么
把 steadyState 改成 1 分钟 校验会报错——想想为什么 30 秒的压测结论不可用
把 distribution 去掉换成自由字符串 失去类型安全后,你很容易写出「随便吧」这种无意义的值
给 Slo 加一个字段 peakMultiplier(峰值是平均的几倍) 容量规划会更完整(第 6 节用到)
把 validate() 接进 CI 从此 SLO 文档不会腐烂——代码改不动它,它也不跟不上代码

六、这段代码的局限

  • 它只能校验完整性,不能校验合理性。「500 RPS」「200 ms」这些数字是不是业务真实需要的,只能靠问业务方。
  • 把 SLO 写进代码有一个代价:业务方改不动它。建议双向同步——代码是唯一事实来源,但要提供一个能自动生成文档的出口,让业务方能读到最新版本。