1.4 配套代码:把 SLO 写成代码
对应小节:1.4 SLO 的四要素写法 核心思路:让「SLO 写得不完整」这件事在编译期或测试期就失败,而不是等到验收时才发现。
一、为什么要把 SLO 变成代码
写在文档里的 SLO 会腐烂:改代码的人不看文档,看文档的人不知道代码。
把四要素变成必填字段,就能得到两个好处:
- 无法遗漏:缺字段直接编译不过 / 测试失败。
- 可派生:从 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 写进代码有一个代价:业务方改不动它。建议双向同步——代码是唯一事实来源,但要提供一个能自动生成文档的出口,让业务方能读到最新版本。