9.9 步骤八:固化门禁与交付报告
上一节:9.8 步骤七:长稳与回归 | 下一节:9.10 Lab 9:总编排 用到:第 8 章(门禁、告警、容量规划)、第 6.8 节(结论写法) 配套代码:09-capstone/09-step8-gate-and-report 产出:门禁 + 告警 + 容量表 +
PERF-REPORT.md(最终交付物)
一句话结论
前七步产出了数据和分析;这一步把它们变成"机制"和"一份能被决策的报告"。 判断标准是:不需要人记得,它自己会运行;以及报告能被非性能工程师用于决策。
一、要交付的五样东西
| # | 交付物 | 来自 | 判断标准 |
|---|---|---|---|
| 1 | CI 微基准门禁 | 第 8.2 节 | 代码不变时零误报 |
| 2 | 生产告警规则 | 第 8.5 节 | 每条都有 runbook、含领先指标 |
| 3 | 容量表 | 第 8.7 节 | 每个数字能追溯到实验 |
| 4 | 性能报告 | 第 6.8 节 | 能被非性能工程师用于决策 |
| 5 | 回归基线 | 本步骤 | 优化后的版本成为新基线 |
二、① CI 微基准门禁
复用第 8 章的流程,但只保留与本项目相关的基准:
// src/main/kotlin/bench/ShortlinkBenchmark.kt
@State(Scope.Benchmark)
class ShortlinkBenchmark {
private val base62 = Base62Codec()
@Benchmark
fun generateCode(bh: Blackhole) {
bh.consume(base62.encode(Random.nextLong(1, 62L.pow(6))))
}
@Benchmark
fun encodeUrl(bh: Blackhole) {
bh.consume(base62.encode(Random.nextLong()))
}
}
// 序列化基准(短链的响应体很小,但仍值得测)
@State(Scope.Benchmark)
class SerializationBenchmark {
private val format = Json
private lateinit var response: CreateLinkResponse
@Setup
fun setup() {
response = CreateLinkResponse(code = "aB3xK9")
}
@Benchmark
fun encode(bh: Blackhole) {
bh.consume(format.encodeToString(response))
}
}
门禁配置(沿用第 8 章的脚本,阈值用实测的 CI 噪声底线):
# 使用第 8 章的 compare_benchmark.py,阈值来自 Lab 8 的噪声测量
- name: Compare against baseline
run: |
python3 tools/compare_benchmark.py \
build/reports/benchmarks/main.json perf/baseline.json \
--threshold 0.25 --report-only
验收:代码不变时连续跑 5 次,零误报(Lab 8 的验证脚本)。
三、② 生产告警规则
针对短链服务的关键指标:
# observability/alerts.yml
groups:
- name: shortlink-slo
rules:
# P0:错误预算燃烧过快
- alert: ErrorBudgetBurnFast
expr: |
slo:redirect_availability:burn_rate_1h > 14.4
and
slo:redirect_availability:burn_rate_5m > 14.4
for: 2m
labels: { severity: page }
annotations:
summary: "跳转接口错误预算燃烧过快"
runbook: "runbooks/ErrorBudgetBurnFast.md"
- name: shortlink-saturation
rules:
# P1:连接池排队(本项目最重要的领先指标——因为埋雷 ① 会让它上升)
- alert: ConnectionPoolSaturated
expr: max(db_pool_pending) > 5
for: 10m
labels: { severity: ticket }
annotations:
summary: "连接池排队 —— ⚠️ 先查慢查询,不要先加池"
runbook: "runbooks/ConnectionPoolSaturated.md"
# P1:数据库查询变慢(本项目的核心风险:索引可能失效)
- alert: SlowQueryRatioHigh
expr: |
sum(rate(pg_stat_statements_mean_exec_time_ms{query=~".*WHERE code.*"}[5m])) > 50
for: 10m
labels: { severity: ticket }
annotations:
summary: "按 code 查询的平均耗时 > 50ms(可能索引失效)"
runbook: |
① 检查执行计划:EXPLAIN (ANALYZE, BUFFERS) SELECT ... WHERE code = ?
② 如果显示 Seq Scan → 索引可能被删除或统计信息过期
③ 检查统计信息:ANALYZE links;
④ 详见第 6.6 节
# P1:缓存命中率下降(可能导致数据库压力上升)
- alert: CacheHitRateDrop
expr: app_cache_hit_rate < 0.6
for: 15m
labels: { severity: ticket }
annotations:
summary: "缓存命中率 < 60%(基线约 75%)"
runbook: |
① 检查缓存是否被频繁驱逐(容量不足)
② 检查访问分布是否变化(热点转移)
③ 详见第 7.4 节
# P1:容器 CPU 节流
- alert: ContainerCpuThrottled
expr: |
rate(container_cpu_cfs_throttled_periods_total[5m])
/ rate(container_cpu_cfs_periods_total[5m]) > 0.25
for: 10m
labels: { severity: ticket }
- name: shortlink-observe
rules:
# P2:只记录(GC 经常与 P99 尖刺不对齐,先观察)
- alert: GcPauseHigh
expr: |
histogram_quantile(0.99,
sum by (le) (rate(jvm_gc_pause_seconds_bucket[5m]))) > 0.2
for: 10m
labels: { severity: log }
annotations:
summary: "GC 停顿 P99 > 200ms(先观察,确认与 P99 尖刺是否对齐)"
注意 SlowQueryRatioHigh 这条:它是针对本项目的特有告警——因为索引是这次优化的核心,如果它失效(被误删、统计信息过期),性能会立刻退化 20 倍。
这就是「从实验里学到的知识,转化为告警」——不是抄一个通用模板。
四、③ 容量表
<!-- docs/capacity.md -->
# 容量表
> 最后更新:YYYY-MM-DD | 下次复测:YYYY-MM-DD(建议每季度)
## 服务:shortlink
| 版本 | 实例规格 | 拐点(实测) | headroom | 安全容量 | 峰值需求 | 所需副本 | 数据来源 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| 优化前 | 4C8G | ~600 RPS | 40% | 360 RPS | 3000 | 9 | E10-capacity |
| **优化后** | 4C8G | **~1800 RPS** | 40% | **1080 RPS** | 3000 | **3+1=4** | E15-capacity-retest |
**当前采用**:4C8G × 4(含 1 台冗余)
**容量提升**:拐点从 600 → 1800 RPS(**3 倍**),所需副本从 9 → 4(**成本降 55%**)
## 约束条件
| 约束 | 上限 | 当前使用 | 余量 | 备注 |
| --- | --- | --- | --- | --- |
| 数据库 max_connections | 100 | 4 × 20 = 80 | 20 | 每实例池 20 |
| Redis QPS | 50000 | 9600 | 40400 | 每请求 2 次缓存操作 |
| 数据库存储 | — | 100 万行 ≈ 120 MB | 充足 | 年增长预估 3 倍 |
## 数据量趋势与拐点关系
| 日期 | links 行数 | 拐点 | headroom 后安全容量 | 备注 |
| --- | --- | --- | --- | --- |
| 2025-01-10 | 100 万 | 600 RPS | 360 RPS | 优化前(无索引) |
| 2025-01-15 | 100 万 | 1800 RPS | 1080 RPS | 优化后(加索引) |
| 2025-01-22(模拟) | 300 万 | 1650 RPS | 990 RPS | 数据量增长的影响 |
**结论**:**加索引的收益(3 倍)远大于数据量增长的损失(8%)**——所以索引这个优化在很长时间内都有效。
## 扩容决策记录
| 日期 | 决策 | 依据 | 成本影响 |
| --- | --- | --- | --- |
| 2025-01-10 | 初始 9 台(优化前拐点) | E10 | — |
| 2025-01-15 | 降到 4 台(优化后拐点) | E15 | 月成本 -55% |
注意「容量提升」这一行:它把优化的价值量化成了成本——“3 倍容量,成本降 55%” 比 “P99 降了 95%” 更能说服业务方。
五、④ 性能报告(最终交付物)
<!-- docs/PERF-REPORT.md -->
# 短链服务性能评估报告
> 版本:v1.0 | 日期:YYYY-MM-DD | 作者:@某某
> 关联实验:E09–E15 | 代码版本:commit `abc1234`
---
## 一、摘要(给决策者)
**一句话结论**:通过给 `code` 列加索引并隔离阻塞调用,
短链服务的 P99 从 **420 ms 降至 19 ms(-95%)**,
拐点从 **600 RPS 提升到 1800 RPS(3 倍)**,
按同样峰值(3000 RPS)计算,所需副本从 **9 台降到 4 台(成本 -55%)**。
**投入**:1 名工程师 × 2 天
**风险**:新增一个索引(占用 45 MB 磁盘;写入开销 +3.5%,在噪声范围内)
**建议**:上线,并保留 `SlowQueryRatioHigh` 告警(防止索引失效)
---
## 二、目标与前提
### SLO(详见 `docs/slo.md`)
| 接口 | 目标 QPS | P99 | 错误率 |
| --- | --- | --- | --- |
| GET /{code} | 3000 | < 100 ms | < 0.1% |
| POST /links | 60 | < 150 ms | < 0.5% |
### 负载前提
- 数据量:100 万条 links
- 分布:**幂律(齐夫,alpha≈1.2)**——前 1% 的 code 承担 40% 访问
- 缓存状态:warm(预热 5 分钟)
- 测量点:压测客户端
---
## 三、环境与方法
### 环境
| 项 | 值 |
| --- | --- |
| 实例 | 4C8G,单实例 |
| JVM | Temurin 21.0.2,`-Xms2g -Xmx2g -XX:+UseG1GC` |
| PostgreSQL | 16-alpine,`max_connections=100` |
| Redis | 7-alpine,`maxmemory=512mb` |
| 压测 | k6,恒定到达率模型,独立进程 |
**⚠️ 局限**:压测客户端与服务**在同一台物理机**上(用不同的 CPU 集隔离)。
→ 数据可用于**相对比较**,**不能用于绝对容量规划**。
### 方法
- 基线:3 轮 × 5 分钟,取中位数
- 噪声底线:**10 轮**,代码不变
- **噪声底线:P99 ±5.9%;最小可检测差异(MDD):±11.8%**
- 容量曲线:阶梯加压,每级稳态 2 分钟
---
## 四、基线数据
### 优化前(500 RPS)
| 指标 | P50 | P95 | P99 | 错误率 |
| --- | --- | --- | --- | --- |
| GET /{code} | 12.3 ms | 68.4 ms | **420.5 ms** | 0.00% |
| 数据库查询 | — | — | 380 ms | — |
### 容量曲线(优化前)
| 到达率 | P50 | P99 | 增长率 | 判断 |
| --- | --- | --- | --- | --- |
| 200 | 12.0 | 180.0 | 1.00 | 基线 |
| 400 | 12.5 | 310.0 | 0.86 | |
| **700** | **18.2** | **620.0** | **1.72** | **← 拐点** |
| 1000 | 45.0 | 1200.0 | 3.33 | 排队区 |
| 2000 | 420.0 | 5200.0 | 23.1 | 接近崩溃 |
**拐点:约 600 RPS**(SLO 要求 3000,差 5 倍)
---
## 五、瓶颈分析
### 瓶颈 1:`code` 列无索引导致全表扫描(贡献 90%)⭐
**证据链**:
EXPLAIN (ANALYZE, BUFFERS) SELECT id, url FROM links WHERE code = ‘c123456’;
Seq Scan on links (actual time=0.015..385.234 rows=1 loops=1) Rows Removed by Filter: 999999 Buffers: shared hit=10890 Execution Time: 385.456 ms
**吻合验证**:`Execution Time 385ms` ≈ 依赖指标 `dep.postgres` 的 P99 380ms ✅
**贡献占比**:约 90%
**测量方法**:逐个关闭(加索引后 P99 从 420ms 降到 45ms)
### 瓶颈 2:`Dispatchers.Default` 上的 JDBC 阻塞调用(贡献 6%)
**证据链**:
1. `wall` 火焰图:8 个 Default worker 全停在 `SocketInputStream.read`
2. **`cpu` 火焰图:几乎为空**(关键对比)
3. 线程 dump(连续 3 份):持续 8 个 RUNNABLE 停在 JDBC 栈
4. **关键巧合:RUNNABLE 线程数(8) == CPU 核数(8)**
**贡献占比**:对读接口 P99 约 6%,但对**全局卡顿**接近 100%
### 被排除的假设 ⭐
| 假设 | 排除依据 |
| --- | --- |
| **GC 停顿** | 停顿仅 18ms(P99 是 420ms);且停顿时间戳与 P99 尖刺**不对齐** |
| **连接池太小** | `pending` 上升是慢查询的**结果**——加索引后 pending 自然消失 |
| **CPU 不足** | CPU 42%;wall 火焰图显示时间花在等待而非计算 |
| **线程池饥饿** | 队列深度全程为 0 |
| **网络问题** | 客户端与服务的网络延迟 P99 < 5ms |
---
## 六、优化措施
| # | 措施 | 层级 | 收益 | 代价 | 采纳 |
| --- | --- | --- | --- | --- | --- |
| 1 | `code` 加唯一索引 | ② 算法/数据 | P99 -89% | 磁盘 45MB;写入 +3.5% | ✅ |
| 2 | JDBC 切到 `Dispatchers.IO` + 限流 | ③ 架构/并发 | 其他接口 P99 -98% | 需调并行度参数 | ✅ |
| 3 | 日志降级 + 异步 appender | ⑤ 微观常数 | P99 -1.2% | 无 | ✅ |
| 4 | `synchronized` → `Mutex` | ③ 架构/并发 | 写路径 BLOCKED 消失 | 无 | ✅ |
| 5 | 缓存 TTL 加抖动 | ④ 缓存 | 消除周期性尖刺 | 无 | ✅ |
| 6 | `UPDATE hits` 改异步批量 | ② 算法/数据 | P99 -2.1% | 引入计数队列 | ❌ **否决** |
**否决记录**(详见 `rejections.json`):
- **#6 否决理由**:收益 2.1% **低于 MDD(11.8%)**——无法用实验证实;代价是引入计数队列(复杂度高)
- **重新评估条件**:如果 `hits` 变成核心业务,或写 QPS 增长 10 倍
---
## 七、验证
### 优化后(500 RPS,3 轮中位数)
| 指标 | 优化前 | 优化后 | 变化 | 显著性 |
| --- | --- | --- | --- | --- |
| P50 | 12.3 ms | 2.1 ms | -82.9% | p=0.008 ✅ |
| P95 | 68.4 ms | 8.3 ms | -87.9% | p=0.008 ✅ |
| **P99** | **420.5 ms** | **19.2 ms** | **-95.4%** | **p=0.008 ✅** |
| `GET /health` P99 | 380 ms | 8 ms | -97.9% | ✅ |
| `POST /links` P99 | 45.2 ms | 46.8 ms | +3.5% | 噪声内 |
**与噪声底线对比**:改善 95.4%,**是 MDD(11.8%)的 8 倍**,结论可信。
### 容量复测(优化后)
| 到达率 | P99(优化前) | P99(优化后) |
| --- | --- | --- |
| 700 | 620 ms | 22 ms |
| 1000 | 1200 ms | 31 ms |
| 1500 | 2800 ms | 58 ms |
| **1800** | (崩溃) | **95 ms** ← 新拐点 |
**新拐点:约 1800 RPS**(提升 3 倍)
### 长稳(1 小时浸泡)
| 指标 | 结果 |
| --- | --- |
| 堆基线 | 稳定(+2.9 MB/小时) |
| 连接池 active/pending | 稳定(6 / 0) |
| 线程数 / FD | 稳定(42 / 313) |
| P99 | 稳定(18.1 → 19.4 ms) |
---
## 八、风险与未验证的场景
| 项 | 说明 |
| --- | --- |
| **压测客户端与服务同机** | 数据可用于相对比较,**不能用于绝对容量规划** |
| **未测冷启动路径** | 缓存为空时的表现(发布期风险) |
| **未测多副本** | 负载分配与共享数据库的争用 |
| **未测数据量增长 10 倍** | 模拟显示拐点降约 8%,但未实测 |
| **长期内存稳定性** | 只跑了 1 小时(未覆盖日周期) |
| **写路径的锁竞争** | 本次主要压测读路径,写路径未充分验证 |
---
## 九、结论与建议
### 结论
1. **优化有效**:P99 从 420 ms 降到 19 ms(-95%),远超噪声底线与 MDD
2. **容量提升 3 倍**:拐点从 600 → 1800 RPS
3. **成本降低 55%**:所需副本从 9 台降到 4 台
4. **根因明确**:90% 的延迟来自一个缺失的索引——**这是一个"低成本、高收益"的典型优化**
### 建议
| 优先级 | 行动 |
| --- | --- |
| **立即** | 上线;确认索引在部署脚本里(防止环境重建时丢失) |
| **立即** | 配置 `SlowQueryRatioHigh` 告警(防止索引失效) |
| **1 周内** | 补测冷启动路径(发布期风险) |
| **1 月内** | 补测多副本场景;建立季度容量复测流程 |
| **持续** | 监控 P99 与缓存命中率趋势 |
### 一句话总结
**"一个缺失的索引让容量差了 3 倍"**——这个案例最好的说明是:
**性能工作的价值往往不在"写更快的代码",而在"发现那个本不该存在的问题"**。
六、⑤ 更新基线
优化后的版本成为新基线:
# 更新性能门禁基线
./gradlew benchmark -q
cp build/reports/benchmarks/main.json perf/baseline.json
# 更新端到端基线
cp docs/experiments/E14-regression/results/k6-summary.json perf/e2e-baseline.json
# 单独提交(第 8.2 节的纪律)
git add perf/
git commit -m "perf: update baseline after index optimization
P99: 420.5ms → 19.2ms (-95.4%)
拐点: 600 RPS → 1800 RPS (+200%)
关联实验: E09-baseline, E12-optimization, E15-capacity-retest
人工确认: 已核对所有指标变化,均为预期改善
"
注意 commit message 的格式:包含了变化幅度、关联实验、人工确认——这样将来 review 基线变更时能追溯(第 8.2 节)。
七、这一步的验收
## 步骤八验收清单
### 门禁
- [ ] CI 微基准门禁经**噪声验证**(代码不变时零误报)
- [ ] 基线在仓库里(`perf/baseline.json`),且有更新流程
### 告警
- [ ] 每条告警都有 **runbook**
- [ ] 有**分级**(page / ticket / log)
- [ ] **含领先指标**(饱和度类)
- [ ] **含本项目的特有告警**(如"按 code 查询变慢")
### 容量表
- [ ] 拐点、headroom、安全容量、副本数齐全
- [ ] 每个数字**能追溯到实验编号**
- [ ] 有**约束条件检查**(数据库连接数等)
- [ ] 有**数据量趋势**与扩容决策记录
### 性能报告
- [ ] 含摘要(一句话结论 + 投入 + 风险 + 建议)
- [ ] 含目标与前提(SLO + 负载前提)
- [ ] 含环境与方法(**含局限声明**)
- [ ] 含基线数据与容量曲线
- [ ] 含瓶颈分析(**证据链 + 贡献占比 + 被排除的假设**)
- [ ] 含优化措施(**含代价与否决记录**)
- [ ] 含验证(优化后数据 + 容量复测 + 长稳)
- [ ] 含**风险与未验证的场景**
- [ ] 含结论与建议(**分级:立即 / 1 周 / 1 月 / 持续**)
### 基线更新
- [ ] 优化后的结果已更新为新基线
- [ ] 更新基线**单独提交**,commit message 写明理由与变化
### 决策可用性(最终检验)
- [ ] 报告能被**非性能工程师**读懂并用于决策
八、本节小结
- 要交付五样东西:CI 门禁、生产告警、容量表、性能报告、回归基线。
- 告警要包含"从实验里学到的知识"——比如本项目的
SlowQueryRatioHigh(因为索引是这个优化的核心)。 - 容量表要把优化的价值量化成成本——“3 倍容量,成本降 55%” 比 “P99 降 95%” 更能说服业务方。
- 性能报告九段结构:摘要 → 目标 → 环境 → 基线 → 瓶颈 → 优化 → 验证 → 风险 → 建议。
- 摘要要给决策者看:一句话结论 + 投入 + 风险 + 建议。
- 风险与未验证场景必须写——这体现专业性,也界定结论的适用范围。
- 基线更新要单独提交,commit message 写明理由与变化幅度(便于 review 与追溯)。
九、自测
- 为什么性能报告的"摘要"要写「投入」「风险」「建议」,而不只是「P99 降了 95%」?
- 本项目新增了一条告警
SlowQueryRatioHigh(按 code 查询变慢)。为什么这条告警比通用的"P99 超标"更有价值? - 为什么基线更新必须单独提交,且 commit message 要写明理由?如果和功能改动混在一起会有什么后果?
- 因为决策者需要的是「做不做这个决定」,而不只是一个技术数字。具体来说:① 「投入」——“1 名工程师 × 2 天"告诉决策者成本是多少,能否承担;② 「风险」——“新增索引占用 45MB,写入开销 +3.5%“让决策者知道代价(而不是只看收益);③ 「建议」——“上线,并保留 SlowQueryRatioHigh 告警"直接给出下一步行动。如果只写"P99 降了 95%”:决策者无法判断——① 花了多少人力?② 有什么副作用?③ 我该批准吗?更重要的:性能工作的价值最终要通过业务决策体现(要不要投人力、要不要上线、要不要买机器),而技术指标本身不是决策语言(第 8.8 节)。补充:摘要里还可以加上"成本 -55%“这类业务指标——它比"P99 -95%“更能推动决策(因为直接对应钱)。
- 因为它更具体、更早、更可行动:① 更具体——
SlowQueryRatioHigh直接指向"按 code 查询变慢"这个具体的、已知的故障模式(索引失效/统计信息过期),值班同学一看就知道该查什么(EXPLAIN+ANALYZE);而"P99 超标"可能有几十种原因。② 更早——它是领先指标:查询耗时从 3ms 涨到 50ms 时,P99 可能还没超标(因为只有一部分请求走这条路),但它已经预警了;等 P99 超标时,用户已经受影响。③ 更可行动——它有明确的 runbook(检查执行计划、更新统计信息),而"P99 超标"的 runbook 往往是"看 Grafana、查依赖、查发布记录"这种泛泛的指引。更深层的价值:它是从这次实验里学到的知识——我们发现"90% 的延迟来自一个缺失的索引”,那么**“索引失效"就是本系统最大的风险**,应该有一条专门的告警守护它。这就是「把实验知识转化为机制」的具体体现——而不是抄一个通用告警模板。 - 因为基线是「性能标准的定义」——它决定了什么算退化。如果和功能改动混在一起:① 可以悄悄放宽标准——比如某次改动让序列化慢了 20%,开发者顺手更新基线(把"变慢后"的值作为新基线),CI 就会通过;② 无法 review——reviewer 看不到基线变更,因为它埋在大段功能代码里;③ 无法追溯——事后不知道"标准是什么时候、因为什么被放宽的”;④ 累积侵蚀(温水煮青蛙)——每次放宽一点点,一年后标准完全变了。单独提交 + 写明理由的好处:① 可 review——reviewer 会看到"基线从 420ms 改到 19ms,理由是加索引优化”;② 可追溯——
git log perf/baseline.json能看到所有基线变更的历史与原因;③ 需要人工确认——基线变更应该由性能负责人 review(防止误把退化当基线)。commit message 要含的要素:变化幅度(420.5 → 19.2ms)、关联实验(E09/E12/E15)、人工确认(已核对所有指标变化)(第 8.2 节)。