9.2 步骤一:定义目标
上一节:9.1 项目设定与埋雷清单 | 下一节:9.3 步骤二:搭服务与数据 用到:第 1 章(SLO 四要素、延迟预算) 配套代码:09-capstone/02-step1-goals 产出:
docs/slo.md
一句话结论
这一步决定后面所有工作的方向。 没有 SLO,「优化」就是无的放矢——因为你不知道要优化到多少才算够。
一、要回答的三个问题
| 问题 | 为什么必须先答 |
|---|---|
| 峰值 QPS 是多少? | 决定容量规划与压测强度 |
| 用户能接受多慢? | 决定 SLO 阈值(不能拍脑袋) |
| 数据会涨到多大? | 决定测试数据量与容量规划的时间跨度 |
如果这三个答不出来:
① 去问业务方(这是产品决策,不是技术决策)
② 或者从现有监控里取真实数据
③ 实在拿不到 → 在 SLO 表里标注「待确认」,并写明打算怎么获取
「待确认」本身就是一个有价值的产出——它暴露了「目前的性能目标没有依据」这个事实。
二、SLO 表(四要素)
<!-- docs/slo.md -->
# SLO 表
> 最后更新:YYYY-MM-DD | 对应版本:<commit>
> 填写说明:四要素缺一不可。写不出来的标「待确认」,并注明获取方式。
## 接口 1:GET /{code}(跳转)
| 字段 | 值 |
| --- | --- |
| 接口 | `GET /{code}` |
| 关键路径 | 客户端 → 服务 → Redis → PostgreSQL |
| **目标 QPS(峰值)** | 3000 |
| **P50 / P95 / P99 / P999** | 5 / 30 / 100 / 300 ms |
| **错误率阈值** | < 0.1% |
| 超时率阈值 | < 0.05% |
| 负载前提:数据量 | 100 万条 links |
| 负载前提:**数据分布** | **幂律(齐夫,alpha≈1.2)**:前 1% 的 code 承担 40% 访问 |
| 负载前提:缓存状态 | warm(预热 5 分钟) |
| **测量点** | 压测客户端(最接近用户体验) |
| 预热时长 | 60 秒 |
| 稳态时长 | 10 分钟 |
| 验收轮次 | ≥ 3 轮,取中位数 |
| 环境 | 单实例 4C8G;压测客户端独立进程 |
| **崩溃线** | P99 > 1s 或错误率 > 1% 持续 30 秒 |
| 测量日期与版本 | YYYY-MM-DD,commit `abc1234` |
**待确认项**:
- [ ] 峰值 QPS 的真实数据(向业务方确认 / 从监控取值)
- [ ] 数据量的一年增长预期
## 接口 2:POST /links(创建短链)
| 字段 | 值 |
| --- | --- |
| 接口 | `POST /links` |
| 目标 QPS | 60(读:写 ≈ 50:1) |
| P50 / P95 / P99 | 20 / 60 / 150 ms |
| 错误率阈值 | < 0.5%(唯一索引冲突是正常业务,单独统计) |
| 负载前提 | 100 万条已有数据;URL 平均长度 60 字符 |
| 测量点 | 压测客户端 |
| 崩溃线 | P99 > 1s |
注意三处刻意的设计:
| 设计 | 为什么 |
|---|---|
| 写接口的错误率阈值更宽(0.5%) | 唯一索引冲突是正常业务(短码碰撞重试),不是故障 |
| 明确写了数据分布(幂律) | 否则测出的缓存命中率不真实(第 5.5 节) |
| 写了崩溃线 | 压力测试需要明确的终止条件(第 3 章 3.6) |
三、延迟预算表
## 延迟预算:GET /{code}
SLO 目标:P99 ≤ 100 ms
| 环节 | 预算(P99) | 实测 | 状态 | 备注 |
| --- | --- | --- | --- | --- |
| 客户端 → 服务(网络) | 5 ms | 待测 | | 本地压测会偏小 |
| **服务排队** | 10 ms | 待测 | | ⚠️ 没有代码,靠饱和度指标验证 |
| 应用逻辑(含序列化) | 5 ms | 待测 | | |
| ├ Redis 查询 | 2 ms | 待测 | | 命中路径 |
| ├ PostgreSQL 查询 | 40 ms | 待测 | | 未命中路径(走索引后) |
| └ 计数更新(UPDATE hits) | 10 ms | 待测 | | ⚠️ 埋雷 ②,可能超预算 |
| 响应写出 | 3 ms | 待测 | | 302 响应体很小 |
| **余量** | 25 ms | — | | 25% |
**合计检查**:5+10+5+2+40+10+3 = 75 ms,加余量 25 ms = 100 ms ✅
**并行标注**:Redis 与 PostgreSQL 是**互斥路径**(不会同时执行),
所以预算应取两者的较大值(40ms)而不是相加。
**修正后**:5+10+5+40+10+3 = 73 ms,余量 27 ms ✅
注意「并行标注」这一栏:
// ❌ 错误理解:Redis 和 PG 都调用(相加)
val cached = cache.get(code)
val db = if (cached == null) repo.findByCode(code) else null
// ✅ 正确理解:互斥路径(取较大值)
// 命中 → 只查 Redis;未命中 → 只查 PG
这个细节很重要:如果把互斥路径当成并行路径相加,你会高估预算需求,可能因此砍掉本来合理的部分。
四、超时预算
## 超时预算
| 层 | 上游给的时间 | 本层超时 | 留给下游 | 检查 |
| --- | --- | --- | --- | --- |
| 客户端 | — | 5000 ms | — | — |
| 服务 → Redis | 5000 | **10 ms** | — | ✅ < 5000 |
| 服务 → PostgreSQL | 5000 | **80 ms** | — | ✅ < 5000 |
| 服务整体(含业务逻辑) | 5000 | **200 ms** | — | ✅ |
**重试预留**:DB 查询失败重试 1 次,单次超时 80ms → 最坏 160ms + 退避 ≈ 190ms < 200ms ✅
**校验**:用 `auditTimeouts`(第 7.5 节)检查逐层递减
为什么这里要特别小心:
埋雷 ④(Default 上的阻塞调用)会让超时变得关键:
如果 DB 超时是默认的 30 秒,那么数据库变慢时
→ 8 个 Default worker 被占住 30 秒
→ 所有协程排队
→ 服务无法自愈(第 3 章 3.6)
所以:在埋雷服务上,超时配置本身就是优化项之一。
五、这一步的验收
## 步骤一验收清单
- [ ] SLO 表含**四要素**(指标+阈值、负载前提、测量点、持续时长)
- [ ] 「负载前提」写明了**数据分布**(幂律 + alpha)
- [ ] 写接口的错误率阈值区分了「业务冲突」与「系统故障」
- [ ] 测量点是**客户端**(而不是应用内埋点)
- [ ] 定义了**崩溃线**(压力测试的终止条件)
- [ ] 延迟预算做过**加法检查**,余量为正
- [ ] 标注了**并行/互斥路径**(取最大值而不是相加)
- [ ] 写了**超时预算**,且逐层递减
- [ ] 列出了**待确认项**(拿不到的业务数据)
六、本节小结
- SLO 表决定后面所有工作的方向——没有它,「优化」无的放矢。
- 三个必答问题:峰值 QPS、用户容忍度、数据增长预期;答不出来就问业务方,或标注「待确认」。
- 写接口的错误率阈值要区分「业务冲突」与「系统故障」(短码碰撞不是故障)。
- 延迟预算必须做加法检查,并标注并行/互斥路径(互斥取最大值,不相加)。
- 超时预算逐层递减——在埋雷服务上,它本身就是优化项(因为埋雷 ④ 会让长超时导致无法自愈)。
七、自测
- 为什么「写接口的错误率阈值」要比读接口宽?请说明短链服务里哪种"错误"是正常的。
- 延迟预算里,Redis 查询(2ms)和 PostgreSQL 查询(40ms)应该相加还是取最大值?请说明判断依据。
- 如果你拿不到「峰值 QPS」这个业务数据,该怎么办?请在 SLO 表里描述你的处理方式。
- 因为有些"错误"是正常业务逻辑,不是系统故障。在短链服务里:短码碰撞(生成的 6 位 base62 码恰好已存在)会导致唯一索引冲突——这是正常的,正确处理是重新生成(重试几次即可成功),不应该算作错误。如果把它计入错误率,会出现两个问题:① 错误率虚高,掩盖真实的系统故障;② 可能诱导开发者"为了避免冲突而放弃唯一索引"(那是更糟的选择)。正确做法:① 分开统计「业务冲突(冲突后重试成功)」与「系统故障(重试后仍失败 / 超时 / 数据库错误)」;② 只有后者计入 SLO 的错误率;③ 前者可以作为单独的指标观察(比如「平均重试次数」),用来评估短码空间是否足够。宽阈值(0.5%)就是这个考虑——它容许少量重试失败,但系统故障仍会被检出。
- 应该取最大值(40 ms)。判断依据:这两条是互斥路径,不是并行路径——请求要么命中缓存(只查 Redis),要么未命中(只查 PostgreSQL),不会同时执行两者。所以关键路径上的耗时是
max(2, 40) = 40 ms,而不是2 + 40 = 42 ms。判断方法:看代码的执行逻辑——如果两个调用是if/else关系(互斥),取最大值;如果是async/awaitAll(并行),也取最大值;只有串行依赖(A 的输出是 B 的输入)才相加。为什么这个细节重要:如果把互斥路径相加,你会高估预算需求(42 vs 40 差别不大,但如果涉及 5 个互斥的缓存层,差异会很大),可能因此错误地砍掉本来合理的部分,或者得出"当前架构不可能达标"的错误结论。 - 处理方式(按优先级):① 向业务方确认——这是产品决策(他们知道预估的流量);② 从现有监控取真实数据——比如网关的峰值 QPS、历史最高值;③ 从上游推算——如果有入口服务(比如 App 的日活与人均请求数),可以估算;④ 如果实在拿不到,在 SLO 表里明确标注「待确认」,并写明获取方式与负责人(例如「待确认:峰值 QPS;获取方式:向产品确认下季度预估;负责人:@某某」)。关键:不要自己拍一个数字然后当成事实——因为拍出来的数字会让整条实验链失去意义(后面的容量规划、压测强度全都建立在它之上)。另一个好处:把这个「待确认」列在表里,会让团队意识到「我们的性能目标目前缺少业务输入」,这本身就是一个重要的组织发现(第 8.8 节)。