文档目录

3.6 配套代码:破坏性测试的取证与恢复测量

对应小节:3.6 破坏性测试 核心:崩溃形态比崩溃点更重要,而崩溃现场无法事后复现,必须当场采集。

一、压到崩溃:带终止条件的加压脚本

// loadtest/breakdown.js
import http from 'k6/http';
import { check } from 'k6';

/**
 * 破坏性测试:持续加压直到满足终止条件。
 *
 * ⚠️ 这不是"找拐点",而是"找崩溃形态"。所以:
 *   - 每级稳态时间短(30 秒),因为不关心精确的拐点
 *   - 终止条件由外部脚本判断(k6 自己不会因为错误率高就停)
 *   - 必须有人在旁边盯着,随时准备中止
 */
export const options = {
  scenarios: {
    breakdown: {
      executor: 'ramping-arrival-rate',
      startRate: 200,
      timeUnit: '1s',
      preAllocatedVUs: 3000,
      maxVUs: 20000,
      stages: [
        { target: 200,  duration: '30s' },
        { target: 500,  duration: '30s' },
        { target: 1000, duration: '30s' },
        { target: 2000, duration: '30s' },
        { target: 3000, duration: '30s' },
        { target: 4000, duration: '30s' },
        { target: 5000, duration: '60s' },   // 通常到这里已经崩了
      ],
    },
  },
  discardResponseBodies: true,
  // 不要设会在中途失败的 thresholds —— 我们就是要让它崩
};

export default function () {
  const res = http.get(`${__ENV.BASE_URL}/orders/1`, {
    tags: { name: 'GET /orders/{id}' },
    timeout: '10s',
  });
  check(res, {
    'status 200': (r) => r.status === 200,
    'not timeout': (r) => r.status !== 0,
  });
}

二、现场取证脚本(最关键的部分)

#!/usr/bin/env bash
# tools/crash-forensics.sh <EXP_ID> <APP_PID> [interval_seconds] [max_samples]
#
# 在破坏性测试期间持续采集"崩溃现场"。
# 崩溃后的线程栈是最有价值的证据,而且【无法事后复现】。
set -uo pipefail

EXP_ID="${1:?usage: crash-forensics.sh <EXP_ID> <PID> [interval] [max]}"
PID="${2:?}"
INTERVAL="${3:-5}"
MAX="${4:-120}"
DIR="docs/experiments/${EXP_ID}/results/forensics"
mkdir -p "$DIR"

echo "开始取证:PID=$PID,每 ${INTERVAL}s 一次,最多 ${MAX} 次"
echo "输出目录:$DIR"

for i in $(seq 1 "$MAX"); do
  TS=$(date +%s)
  STAMP="$(date -Iseconds)"

  # ① 线程快照 —— 回答"线程都卡在哪里"
  jcmd "$PID" Thread.print > "$DIR/threads-$TS.txt" 2>/dev/null || {
    echo "[$STAMP] ❌ 进程已不可用(可能崩溃或 OOM),停止取证"
    break
  }

  # ② 关键指标 —— 回答"哪类资源先耗尽"
  {
    echo "# $STAMP"
    curl -s --max-time 3 http://127.0.0.1:8080/metrics 2>/dev/null \
      | grep -E "db_pool_(active|idle|pending|total)|executor_queue|jvm_memory_used_bytes|jvm_threads_live"
  } > "$DIR/metrics-$TS.txt" 2>/dev/null

  # ③ 简要摘要:打印一次,方便实时观察趋势
  QUEUE=$(grep executor_queue_depth "$DIR/metrics-$TS.txt" 2>/dev/null | awk '{printf "%.0f", $2}')
  PEND=$(grep db_pool_pending "$DIR/metrics-$TS.txt" 2>/dev/null | awk '{printf "%.0f", $2}')
  BLOCKED=$(grep -c "BLOCKED" "$DIR/threads-$TS.txt" 2>/dev/null || echo 0)
  echo "[$STAMP] 队列=${QUEUE:-?} 连接池pending=${PEND:-?} BLOCKED线程=${BLOCKED}"

  # ④ 自动判断是否已进入崩溃状态
  if [ "${PEND:-0}" -gt 20 ] 2>/dev/null; then
    echo "[$STAMP] ⚠️  连接池 pending=${PEND} —— 已进入连接池雪崩迹象"
  fi

  sleep "$INTERVAL"
done

echo
echo "✅ 取证结束。分析要点:"
echo "   1. 崩溃时刻最后几份 threads-*.txt 里,线程都停在哪里?"
echo "      - socketRead0 / 等数据库 → 数据库慢或连接池耗尽"
echo "      - BLOCKED (on object monitor) → 锁竞争"
echo "      - 等连接池 (HikariPool.getConnection) → 连接池雪崩"
echo "   2. metrics-*.txt 里,哪一类饱和度指标最先上升?"
echo "   3. 队列深度是否持续增长(无界队列 OOM 的前兆)?"

三、恢复能力测量(最容易被忽略的一步)

#!/usr/bin/env bash
# tools/measure-recovery.sh <EXP_ID>
#
# 破坏性测试结束后,持续打低负载,测量"多久恢复"。
# 这一步决定了故障的严重程度,比崩溃点本身更重要。
set -uo pipefail

EXP_ID="${1:?usage: measure-recovery.sh <EXP_ID>}"
DIR="docs/experiments/${EXP_ID}/results"
mkdir -p "$DIR"

echo "开始测量恢复能力(低负载 50 req/s,持续 10 分钟)..."
echo "判据:P99 回到崩溃前基线的 1.2 倍以内,且 pending 回到 0"
echo
echo "elapsed_s,p99_ms,error_rate,pending,queue_depth"

START=$(date +%s)
RECOVERED_AT=""
for i in $(seq 1 120); do
  ELAPSED=$(( $(date +%s) - START ))

  # 用 curl 做一次简单测量(真实场景应该用 k6 短跑)
  RESULT=$(curl -s -o /dev/null -w "%{time_total}" --max-time 5 http://127.0.0.1:8080/orders/1 2>/dev/null || echo "5.0")
  P99_MS=$(echo "$RESULT * 1000 / 1" | bc 2>/dev/null || echo "0")

  METRICS=$(curl -s --max-time 3 http://127.0.0.1:8080/metrics 2>/dev/null || echo "")
  PEND=$(echo "$METRICS" | awk '/db_pool_pending/{printf "%.0f", $2; exit}')
  QUEUE=$(echo "$METRICS" | awk '/executor_queue_depth/{printf "%.0f", $2; exit}')

  echo "$ELAPSED,$P99_MS,,${PEND:-0},${QUEUE:-0}"

  # 判定恢复
  if [ -z "$RECOVERED_AT" ] && [ "${PEND:-1}" = "0" ] && [ "${P99_MS%%.*}" -lt 100 ] 2>/dev/null; then
    RECOVERED_AT="$ELAPSED"
  fi

  sleep 5
done

echo
if [ -n "$RECOVERED_AT" ]; then
  echo "✅ 恢复时间:约 ${RECOVERED_AT} 秒(pending 归零且延迟回到 100ms 以内)"
else
  echo "❌ 10 分钟内未恢复 —— 系统【无法自愈】,需要人工介入或重启"
  echo "   重点检查:超时配置是否过长?连接是否被长期占用?"
fi

四、超时链自检(决定能否自愈)

// src/main/kotlin/resilience/TimeoutAudit.kt
package resilience

/**
 * 超时链审计:找出"本层超时 > 上游超时"的配置。
 *
 * 这是决定"系统崩溃后能否自愈"的关键:
 *   如果应用给数据库的超时(30s)远大于网关给应用的超时(1s),
 *   那么网关超时返回后,应用线程仍然被占住 30 秒 → 连接池持续满 → 无法自愈。
 */
data class TimeoutLayer(
    val name: String,
    val upstreamBudgetMs: Long,      // 上游给这一层的时间
    val selfTimeoutMs: Long,         // 这一层给下游/自己的超时
)

fun auditTimeouts(layers: List<TimeoutLayer>): List<String> {
    val problems = mutableListOf<String>()
    var previousBudget = Long.MAX_VALUE

    layers.forEach { layer ->
        // ① 本层超时不应超过上游给的预算
        if (layer.selfTimeoutMs > layer.upstreamBudgetMs) {
            problems += buildString {
                append("❌ ${layer.name}: 本层超时 ${layer.selfTimeoutMs}ms > 上游预算 ${layer.upstreamBudgetMs}ms")
                append("\n     后果:上游已超时返回,本层的调用仍在继续,资源被占住 → 可能无法自愈")
            }
        }
        // ② 上游预算不应超过更上游给的预算(逐层递减)
        if (layer.upstreamBudgetMs > previousBudget) {
            problems += "❌ ${layer.name}: 上游预算 ${layer.upstreamBudgetMs}ms 大于再上游的 ${previousBudget}ms(超时链未递减)"
        }
        previousBudget = layer.selfTimeoutMs
    }

    return problems
}

fun main() {
    println("=== 反例:会导致无法自愈的配置 ===")
    auditTimeouts(listOf(
        TimeoutLayer("客户端 → 网关", upstreamBudgetMs = 5_000, selfTimeoutMs = 3_000),
        TimeoutLayer("网关 → 应用",   upstreamBudgetMs = 3_000, selfTimeoutMs = 1_000),
        TimeoutLayer("应用 → 数据库", upstreamBudgetMs = 30_000, selfTimeoutMs = 30_000),  // ← 灾难
        TimeoutLayer("应用 → Redis",  upstreamBudgetMs = 30_000, selfTimeoutMs = 5_000),
    )).forEach { println(it); println() }

    println("=== 正例:逐层递减,可自愈 ===")
    val ok = auditTimeouts(listOf(
        TimeoutLayer("客户端 → 网关", upstreamBudgetMs = 5_000, selfTimeoutMs = 500),
        TimeoutLayer("网关 → 应用",   upstreamBudgetMs = 500,   selfTimeoutMs = 300),
        TimeoutLayer("应用 → 数据库", upstreamBudgetMs = 300,   selfTimeoutMs = 60),
        TimeoutLayer("应用 → Redis",  upstreamBudgetMs = 300,   selfTimeoutMs = 10),
    ))
    if (ok.isEmpty()) println("✅ 超时链自洽,逐层递减")
    else ok.forEach { println(it) }

    println()
    println("核心规则:本层超时必须小于上游预算,且逐层递减。")
    println("超时不是『容忍时间』,而是『放弃的时机』——放弃得早,系统才能自愈。")
}

五、崩溃形态的记录模板

## 破坏性测试记录

### 时间线
| 时刻 | 事件 |
| --- | --- |
| T+0 | 开始加压(200 req/s) |
| T+2:30 | 到达 2000 req/s,P99 = 320 ms |
| T+3:10 | P99 开始非线性上升(>1s),**连接池 pending 从 0 → 18** |
| T+3:40 | 错误率超过 5%,触发终止条件,停止加压 |
| T+4:05 | 负载回落到基线,但 P99 仍 >2s |
| T+8:30 | P99 回到基线 1.2 倍以内,pending = 0 |

### 崩溃点
- 崩溃负载:约 **3000 req/s**(P99 > 1s,错误率 > 5%)

### 崩溃形态 ⭐(比崩溃点更重要)
- **首要形态**:连接池雪崩
  - 证据:`forensics/metrics-*.txt` 显示 pending 从 0 涨到 18,同时 active 触顶在 10
  - 证据:`forensics/threads-*.txt` 中 47 个线程停在 `HikariPool.getConnection`
- **次要形态**:无界队列积压(队列深度从 0 涨到 1200)
- **是否级联**:是 —— 所有接口(包括不查数据库的)都开始超时

### 恢复能力 ⭐
- **恢复时间**:约 4 分 25 秒
- **能否自愈**:⚠️ 否 —— pending 在负载回落后仍 >0 达 3 分钟
- **根因**:应用给数据库的超时是 30 秒(默认值),负载回落后这些连接仍在等待
- **改进**:把数据库超时改为 3 秒(见 `TimeoutAudit`),预计恢复时间降到 10 秒内

### 记录到的证据文件
- `results/forensics/threads-*.txt`(崩溃瞬间线程栈)
- `results/forensics/metrics-*.txt`(连接池与队列趋势)

六、动手改造

改动 观察什么
把数据库超时从 30 秒改成 3 秒,重跑 恢复时间是否大幅缩短?(这是「超时决定自愈能力」的验证)
把取证间隔从 5 秒改成 1 秒 能看到更细的崩溃过程,但会产生大量文件
在崩溃时把连接池从 10 加到 50 崩溃点会后移,但崩溃形态会变成"数据库被打垮"——问题只是被转移了
给队列加上界 + 拒绝策略,重跑 崩溃形态从「OOM」变成「快速失败(503)」——这就是优雅降级
去掉「恢复测量」这一步 你会以为「停止加压就恢复了」,而事实可能相反

七、这段代码的局限

  • measure-recovery.sh 的延迟测量非常粗糙(用 curl 单次请求),只能判断量级。严谨的做法是用 k6 短跑取 P99。
  • 崩溃状态有时不可复现:同样的负载,第二次可能不会崩(因为缓存、连接池状态不同)。这正是为什么必须当场取证。
  • 不要在生产做这些测试。即使在测试环境,也要确保:有终止条件、能重启、数据可重置。
  • TimeoutAudit 只检查了链路中的静态配置,实际的超时行为还受重试、连接获取超时、DNS 解析等影响。