9.8 步骤七:长稳与回归
上一节:9.7 步骤六:优化与验证 | 下一节:9.9 步骤八:固化门禁与交付报告 用到:第 3.5 节(浸泡测试)、第 7.8 节(回归验证) 配套代码:09-capstone/08-step7-soak-and-regression 产出:
experiments/E13-soak/(长稳数据)+ 回归确认
一句话结论
短跑发现不了的问题,长跑才能发现。 而且这一步还要回答一个问题:我优化了 A,会不会让 B 变慢了?
一、为什么要做这一步
前六步做的都是"短跑"(3–5 分钟的压测)。短跑发现不了:
| 问题类型 | 为什么短跑发现不了 |
|---|---|
| 内存泄漏 | 每小时涨 30 MB,5 分钟只涨 2.5 MB(在噪声内) |
| 连接泄漏 | 每次异常路径漏一个连接,需要累积才显现 |
| 文件描述符泄漏 | 同上 |
| 缓存无界增长 | 需要长时间才能填满 |
| 长周期 GC 问题 | 老年代回收可能需要几十分钟才一次 |
| 缓存 TTL 无抖动的影响 | TTL 是 10 分钟——短跑看不到它失效 |
注意最后一条:埋雷 ③(缓存 TTL 无抖动)只有在这一步才能被观察到。
二、浸泡测试设计
tools/run-soak.sh E13-soak 1h
关键参数:
| 参数 | 值 | 理由 |
|---|---|---|
| 时长 | 1 小时(最低要求) | 覆盖缓存 TTL(10 分钟)× 6 轮、老年代 GC 周期 |
| 负载 | 目标容量的 60%–80% | 不要用满负载(排队会掩盖泄漏信号) |
| 模型 | 恒定到达率 | 不要做阶梯 |
| 采样间隔 | 30 秒 | 太稀会漏掉趋势,太密产生大量数据 |
为什么负载用 60%–80% 而不是满负载:
满负载时:排队、GC 频繁、延迟飙升 → 这些都会掩盖缓慢泄漏的信号
中高负载:系统健康、指标基线稳定 → 泄漏造成的抬升清晰可见
三、要盯的四类指标
# 关键的四个趋势
python3 tools/analyze_soak.py docs/experiments/E13-soak/results/soak-metrics.csv
预期输出(优化后,无泄漏):
样本点 120 个,约 1.00 小时
内存
前 1/4 时段的 GC 后基线 : 385.2 MB
后 1/4 时段的 GC 后基线 : 388.1 MB
变化 : +2.9 MB
斜率 : +2.9 MB/小时
✅ 内存基线稳定,未见泄漏迹象
其他资源(首尾对比)
连接池 active 首 6.0 → 尾 6.0 斜率 +0.00/小时 ✅
连接池 pending 首 0.0 → 尾 0.0 斜率 +0.00/小时 ✅
线程数 首 42.0 → 尾 42.0 斜率 +0.00/小时 ✅
文件描述符 首 312.0 → 尾 313.0 斜率 +1.00/小时 ✅
延迟趋势
P99 首 18.1 ms → 尾 19.4 ms
✅ P99 稳定
关键判读:
| 指标 | 健康 | 危险 |
|---|---|---|
| 堆基线(GC 后最低点) | 稳定(斜率 < 5 MB/小时) | 持续抬升 |
| 连接池 active | 稳定 | 持续上升 |
| 线程数 / FD | 稳定 | 持续上升 |
| P99 | 稳定 | 缓慢恶化 |
注意「堆基线」的判断方法:看「每次 GC 之后的最低点」连成的线,不是看瞬时值——瞬时值的锯齿会骗人(第 3.5 节)。
四、观察埋雷 ③(缓存雪崩)
这是浸泡测试独有的发现:
# 观察数据库 QPS 是否有周期性脉冲
python3 - <<'PY'
import pathlib, re
p = pathlib.Path("docs/experiments/E13-soak/results/soak-metrics.csv")
if not p.exists():
print("(无数据)")
else:
lines = p.read_text().splitlines()[1:] # 跳过表头
print("数据库 QPS 的时间序列(每 5 分钟一个点):")
print(f"{'时间(min)':<12}{'DB QPS':>12}{'注解'}")
print("-" * 50)
for i, line in enumerate(lines):
if i % 10 != 0: # 每 5 分钟(30 秒 × 10)
continue
parts = line.split(",")
if len(parts) < 2:
continue
minute = i * 0.5
# 假设 CSV 里有 db_qps 列
print(f"{minute:<12.0f}{'(需从指标里取)':>12}")
print()
print("观察:是否有周期性脉冲(每 10 分钟一次)?")
print(" 如果有 → 缓存 TTL 无抖动导致的雪崩(埋雷 ③)")
print(" 修复:TTL 加随机抖动(第 7.4 节)")
PY
预期发现(如果埋雷 ③ 还没修):
DB QPS 时间序列:
0 min: 120
5 min: 118
10 min: 480 ← ⭐ 脉冲!缓存集中失效
15 min: 125
20 min: 472 ← ⭐ 又一轮
25 min: 121
...
修复方式:
// ❌ before:固定 TTL,所有 key 同时失效
Caffeine.newBuilder()
.expireAfterWrite(Duration.ofMinutes(10))
.build<String, Link>()
// ✅ after:TTL 加 ±20% 抖动
Caffeine.newBuilder()
.expireAfter(ExpiryWithJitter(baseMinutes = 10, jitterRatio = 0.2))
.build<String, Link>()
五、回归验证(三种)
这一步的核心问题:我优化了 A,会不会让 B 变慢了?
① 功能正确性
# 单元测试 + 集成测试
./gradlew test
# 手工验证关键路径
curl -X POST localhost:8080/links -d '{"url":"https://example.com/test"}' -H 'Content-Type: application/json'
# → 应返回短码
curl -I localhost:8080/<短码>
# → 应返回 302 + Location
② 性能回归(⭐ 最容易忘)
# 用【同一套脚本】复跑基线场景
tools/run-baseline.sh E14-regression
# 与优化前的基线对比
python3 tools/compare_experiments.py \
docs/experiments/E09-baseline/results \
docs/experiments/E14-regression/results \
docs/experiments/E09-noise/results/noise-floor.json
预期检查:
| 接口 | 优化前 P99 | 优化后 P99 | 变化 | 判定 |
|---|---|---|---|---|
GET /{code} |
420.5 ms | 19.2 ms | -95.4% | ✅ 显著改善 |
POST /links |
45.2 ms | 46.8 ms | +3.5% | ✅ 在噪声内 |
GET /health |
380 ms | 8 ms | -97.9% | ✅ 显著改善 |
关键:POST /links 的 +3.5%——这是加索引带来的写入开销(索引维护)。它在噪声底线(5.9%)内,所以可以接受——但必须记录下来(第 7.8 节的"代价")。
如果没有做这一步会怎样:
❌ 你只知道读路径改善了 95%
但不知道写路径慢了 3.5%
→ 如果这个开销累积(比如索引变多),写路径可能成为新瓶颈
③ 长稳回归
已经在浸泡测试里做了——但要注意:浸泡测试要在优化之后跑,用来确认"优化没有引入泄漏"。
六、这一步的验收
## 步骤七验收清单
### 浸泡测试
- [ ] 时长 ≥ 1 小时(覆盖缓存 TTL 与老年代 GC 周期)
- [ ] 负载用目标容量的 60%–80%(不是满负载)
- [ ] 采样间隔 ≤ 30 秒
- [ ] 判读用「**GC 后基线**」而不是瞬时值
### 四类指标趋势
- [ ] 堆基线稳定(斜率 < 5 MB/小时)
- [ ] 连接池 active / pending 稳定
- [ ] 线程数 / FD 稳定
- [ ] P99 稳定
### 埋雷 ③ 的观察
- [ ] 检查了数据库 QPS 是否有周期性脉冲
- [ ] 如果有,确认是缓存 TTL 造成的
- [ ] 已修复(TTL 加抖动)并复测
### 回归验证
- [ ] **功能**:单元测试 + 关键路径手工验证
- [ ] **性能**:用同一套脚本复跑基线,其他接口无退化(或在噪声内)
- [ ] **长稳**:优化后重新跑了浸泡测试
- [ ] 记录了任何"在噪声内"的退化(如写路径 +3.5%)
七、本节小结
- 短跑发现不了的问题,长跑才能发现:内存/连接/FD 泄漏、缓存无界增长、长周期 GC。
- 浸泡测试要跑 ≥ 1 小时,负载用目标容量的 60%–80%(满负载会掩盖泄漏)。
- 判读内存要看「GC 后基线」的趋势,不是瞬时值(锯齿会骗人)。
- 埋雷 ③(缓存雪崩)只有在这一步才能观察到——表现是数据库 QPS 的周期性脉冲。
- 回归验证三种缺一不可:功能、性能(最容易忘)、长稳。
- 「在噪声内的退化」也要记录(如写路径 +3.5%)——它是"代价",也是未来的风险。
八、自测
- 为什么浸泡测试的负载要用「目标容量的 60%–80%」而不是满负载?如果用满负载会有什么问题?
- 你优化了读路径(P99 从 420ms 降到 19ms),但写路径的 P99 从 45.2ms 涨到 46.8ms(+3.5%)。请说明这个变化是否需要担心,以及你会怎么处理。
- 为什么「埋雷 ③(缓存 TTL 无抖动)」只有在这一步才能被观察到?请说明它的症状与验证方法。
- 因为满负载会让排队效应主导一切:① 延迟飙升(P99 可能到几百毫秒甚至超时);② 连接池 pending 上升;③ GC 频繁(因为请求多、分配多)——这些现象都会掩盖"缓慢泄漏"的信号。具体来说:如果内存每小时泄漏 30 MB,在满负载下堆使用的波动可能是 ±200 MB(因为对象分配与 GC 剧烈),30 MB 的趋势完全看不出来。而中高负载(60%–80%)下:① 系统处于健康区间,指标基线稳定(波动小);② 所有代码路径仍被执行到(泄漏才会触发);③ 泄漏造成的抬升(30 MB/小时)在平稳的基线上清晰可见。第二个理由:满负载本身就接近崩溃点,很难稳定跑几小时(可能中途就崩了,或者延迟抖动到无法分析)。
- 不需要担心,但要记录。判断依据:① +3.5% 在噪声底线(5.9%)之内——无法用当前环境的实验证实它是真实退化(它可能只是噪声);② 它的原因是明确的——加索引后
INSERT需要维护索引,这是预期的、合理的代价(不是意外);③ 它的绝对值很小(1.6ms),且写接口的 P99 是 46.8ms,远低于 SLO(150ms)。处理方式:① 记录在优化报告的第二问(代价)里——“加索引使写路径 P99 增加约 3.5%(在噪声内),原因是索引维护开销”;② 不要为此放弃索引——因为读路径改善了 95%,代价是 3.5%,收益/代价比是 27:1;③ 持续观察:如果将来索引数量继续增加(比如加到 5 个),写路径的开销会累积——那时需要重新评估;④ 如果写路径成为瓶颈,可以用"部分索引"(只索引常用查询)或"异步写入"来缓解。关键原则:「在噪声内的退化」不能作为拒绝优化的理由,但必须被记录——因为它是真实存在的代价,只是暂时无法证实其大小。 - 因为它的周期(10 分钟)大于常规压测的时长(3–5 分钟):① 常规基线压测只跑 3–5 分钟,根本覆盖不到一个完整的 TTL 周期——缓存还没失效,压测就结束了;② 即使偶尔覆盖到一次脉冲,单次现象也无法确认是"周期性"的(可能只是噪声)。症状:数据库 QPS 出现周期性脉冲(每 10 分钟一次)——因为所有缓存 key 用同一个 TTL,在同一时刻集体失效,那一瞬间所有请求都穿透到数据库(雪崩)。验证方法:① 跑 ≥ 1 小时的浸泡测试,并记录数据库 QPS 的时间序列(30 秒一个采样点);② 把 DB QPS 的脉冲时刻与 TTL(10 分钟)对齐——如果脉冲间隔恰好是 10 分钟,就确认了;③ 辅助验证:看缓存命中率曲线——脉冲时刻命中率会骤降。修复方式:TTL 加随机抖动(±20%),让 key 在不同时间失效,把"集体失效"变成"平滑分散"。另一个观察:修复后重跑浸泡测试,DB QPS 曲线应该变得平滑(没有周期性脉冲)。