文档目录

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%)

七、本节小结

  1. 短跑发现不了的问题,长跑才能发现:内存/连接/FD 泄漏、缓存无界增长、长周期 GC。
  2. 浸泡测试要跑 ≥ 1 小时,负载用目标容量的 60%–80%(满负载会掩盖泄漏)。
  3. 判读内存要看「GC 后基线」的趋势,不是瞬时值(锯齿会骗人)。
  4. 埋雷 ③(缓存雪崩)只有在这一步才能观察到——表现是数据库 QPS 的周期性脉冲。
  5. 回归验证三种缺一不可:功能、性能(最容易忘)、长稳。
  6. 「在噪声内的退化」也要记录(如写路径 +3.5%)——它是"代价",也是未来的风险。

八、自测

  1. 为什么浸泡测试的负载要用「目标容量的 60%–80%」而不是满负载?如果用满负载会有什么问题?
  2. 你优化了读路径(P99 从 420ms 降到 19ms),但写路径的 P99 从 45.2ms 涨到 46.8ms(+3.5%)。请说明这个变化是否需要担心,以及你会怎么处理。
  3. 为什么「埋雷 ③(缓存 TTL 无抖动)」只有在这一步才能被观察到?请说明它的症状与验证方法。
  1. 因为满负载会让排队效应主导一切:① 延迟飙升(P99 可能到几百毫秒甚至超时);② 连接池 pending 上升;③ GC 频繁(因为请求多、分配多)——这些现象都会掩盖"缓慢泄漏"的信号。具体来说:如果内存每小时泄漏 30 MB,在满负载下堆使用的波动可能是 ±200 MB(因为对象分配与 GC 剧烈),30 MB 的趋势完全看不出来。而中高负载(60%–80%)下:① 系统处于健康区间,指标基线稳定(波动小);② 所有代码路径仍被执行到(泄漏才会触发);③ 泄漏造成的抬升(30 MB/小时)在平稳的基线上清晰可见。第二个理由:满负载本身就接近崩溃点,很难稳定跑几小时(可能中途就崩了,或者延迟抖动到无法分析)。
  2. 不需要担心,但要记录。判断依据:① +3.5% 在噪声底线(5.9%)之内——无法用当前环境的实验证实它是真实退化(它可能只是噪声);② 它的原因是明确的——加索引后 INSERT 需要维护索引,这是预期的、合理的代价(不是意外);③ 它的绝对值很小(1.6ms),且写接口的 P99 是 46.8ms,远低于 SLO(150ms)。处理方式:① 记录在优化报告的第二问(代价)里——“加索引使写路径 P99 增加约 3.5%(在噪声内),原因是索引维护开销”;② 不要为此放弃索引——因为读路径改善了 95%,代价是 3.5%,收益/代价比是 27:1;③ 持续观察:如果将来索引数量继续增加(比如加到 5 个),写路径的开销会累积——那时需要重新评估;④ 如果写路径成为瓶颈,可以用"部分索引"(只索引常用查询)或"异步写入"来缓解。关键原则:「在噪声内的退化」不能作为拒绝优化的理由,但必须被记录——因为它是真实存在的代价,只是暂时无法证实其大小。
  3. 因为它的周期(10 分钟)大于常规压测的时长(3–5 分钟):① 常规基线压测只跑 3–5 分钟,根本覆盖不到一个完整的 TTL 周期——缓存还没失效,压测就结束了;② 即使偶尔覆盖到一次脉冲,单次现象也无法确认是"周期性"的(可能只是噪声)。症状:数据库 QPS 出现周期性脉冲(每 10 分钟一次)——因为所有缓存 key 用同一个 TTL,在同一时刻集体失效,那一瞬间所有请求都穿透到数据库(雪崩)。验证方法:① 跑 ≥ 1 小时的浸泡测试,并记录数据库 QPS 的时间序列(30 秒一个采样点);② 把 DB QPS 的脉冲时刻与 TTL(10 分钟)对齐——如果脉冲间隔恰好是 10 分钟,就确认了;③ 辅助验证:看缓存命中率曲线——脉冲时刻命中率会骤降。修复方式:TTL 加随机抖动(±20%),让 key 在不同时间失效,把"集体失效"变成"平滑分散"。另一个观察:修复后重跑浸泡测试,DB QPS 曲线应该变得平滑(没有周期性脉冲)。