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 解析等影响。