5.4 配套代码:环境检查与事件时间线
对应小节:5.4 环境隔离清单 两件事:① 实验前逐项检查环境;② 实验期间记录事件时间线(用于解释异常)。
一、环境检查脚本
#!/usr/bin/env bash
# tools/check-environment.sh <EXP_ID>
#
# 实验前运行,逐项检查环境是否"干净"。
set -uo pipefail
EXP_ID="${1:?usage: check-environment.sh <EXP_ID>}"
OUT="docs/experiments/${EXP_ID}/results/env-check.txt"
mkdir -p "$(dirname "$OUT")"
PASS=0; WARN=0; FAIL=0
ok() { echo " ✅ $1"; PASS=$((PASS+1)); }
warn() { echo " ⚠️ $1"; WARN=$((WARN+1)); }
bad() { echo " ❌ $1"; FAIL=$((FAIL+1)); }
{
echo "═══ 环境检查 $(date -Iseconds) ═══"
echo
# ── ① CPU 状态 ──────────────────────────────────────────
echo "① CPU 状态"
GOV=$(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null || echo "n/a")
if [ "$GOV" = "performance" ]; then ok "governor = performance"
elif [ "$GOV" = "n/a" ]; then warn "governor 不可读(非 Linux 或权限不足)"
else warn "governor = $GOV(非 performance,可能降频):sudo cpupower frequency-set -g performance"; fi
echo " 可见核数: $(nproc)"
echo
# ── ② steal 时间(云主机邻居抢占)──────────────────────
echo "② steal 时间(连续采样 5 次)"
ST_VALUES=()
for i in $(seq 1 5); do
ST=$(vmstat 1 2 2>/dev/null | tail -1 | awk '{print $17}')
[ -n "$ST" ] && ST_VALUES+=("$ST")
sleep 1
done
if [ ${#ST_VALUES[@]} -gt 0 ]; then
MAX_ST=$(printf '%s\n' "${ST_VALUES[@]}" | sort -rn | head -1)
echo " 采样值: ${ST_VALUES[*]}"
if [ "$MAX_ST" -gt 3 ] 2>/dev/null; then
bad "st 最高 $MAX_ST% > 3% —— 被邻居抢占 CPU,环境不可控"
elif [ "$MAX_ST" -gt 1 ] 2>/dev/null; then
warn "st 最高 $MAX_ST% —— 轻微抢占,记录下来"
else
ok "st 最高 $MAX_ST% —— 环境干净"
fi
else
warn "无法读取 st(非 Linux)"
fi
echo
# ── ③ 容器限制与节流 ────────────────────────────────────
echo "③ 容器资源限制"
if [ -f /sys/fs/cgroup/cpu.max ]; then
read -r QUOTA PERIOD < /sys/fs/cgroup/cpu.max
if [ "$QUOTA" = "max" ]; then
bad "未设置 CPU limit —— 与生产不一致时,数据不可迁移(第 4.8 节)"
else
awk -v q="$QUOTA" -v p="$PERIOD" 'BEGIN{printf " CPU limit = %.2f 核\n", q/p}'
fi
NR_P=$(awk '/nr_periods/{print $2}' /sys/fs/cgroup/cpu.stat 2>/dev/null || echo 0)
NR_T=$(awk '/nr_throttled/{print $2}' /sys/fs/cgroup/cpu.stat 2>/dev/null || echo 0)
if [ "$NR_P" -gt 0 ] 2>/dev/null; then
RATIO=$(awk -v p="$NR_P" -v t="$NR_T" 'BEGIN{printf "%.2f", 100*t/p}')
if awk -v r="$RATIO" 'BEGIN{exit !(r>25)}'; then bad "累计节流比例 ${RATIO}% > 25%"
elif awk -v r="$RATIO" 'BEGIN{exit !(r>1)}'; then warn "累计节流比例 ${RATIO}%"
else ok "累计节流比例 ${RATIO}%"; fi
fi
else
warn "未检测到 cgroup CPU 限制(非容器环境)"
fi
echo
# ── ④ 后台任务 ──────────────────────────────────────────
echo "④ 后台任务检查"
FOUND=0
for pat in "backup" "pg_dump" "logrotate" "updatedb" "apt" "yum" "dnf" "unattended"; do
if pgrep -a -f "$pat" > /dev/null 2>&1; then
bad "检测到进程: $pat"; pgrep -a -f "$pat" | head -2 | sed 's/^/ /'
FOUND=1
fi
done
[ "$FOUND" -eq 0 ] && ok "未检测到常见后台任务"
echo
# ── ⑤ 高 CPU 进程(可能有其他负载)─────────────────────
echo "⑤ 当前 CPU 占用 Top 5"
ps aux --sort=-%cpu 2>/dev/null | head -6 | tail -5 | awk '{printf " %-20s %5s%% CPU %5s%% MEM\n", substr($11,1,20), $3, $4}' || echo " (ps 格式不同,跳过)"
echo
# ── ⑥ 数据库状态 ────────────────────────────────────────
echo "⑥ 数据库状态"
if command -v psql > /dev/null 2>&1 && [ -n "${PG_CONN:-}" ]; then
echo " 长事务(> 30 秒):"
psql "$PG_CONN" -tAc "
SELECT count(*) FROM pg_stat_activity
WHERE state <> 'idle' AND now() - query_start > interval '30 seconds';" 2>/dev/null | \
sed 's/^/ 数量: /' || warn "无法查询数据库"
echo " 连接使用率:"
psql "$PG_CONN" -tAc "
SELECT round(100.0*count(*)/current_setting('max_connections')::int, 1)
FROM pg_stat_activity;" 2>/dev/null | sed 's/^/ /' || true
else
warn "未设置 PG_CONN 或 psql 不可用,跳过数据库检查"
fi
echo
# ── ⑦ 压测客户端位置 ────────────────────────────────────
echo "⑦ 压测客户端位置"
echo " ⚠️ 需要人工确认:压测客户端必须【独立于】被测服务"
echo " 同机会互相抢 CPU,两个方向都失真(第 4 章 4.1)"
echo
# ── ⑧ 观测配置 ──────────────────────────────────────────
echo "⑧ 观测配置(必须与基线组一致)"
echo " JFR: ${JFR_SETTINGS:-未设置}"
echo " profiler: ${PROFILER:-未启用}"
echo " 日志级别: ${LOG_LEVEL:-未设置}"
echo " ⚠️ 基线组与实验组必须用【同样的观测配置】,否则差异可能来自观测开销"
echo
# ── 汇总 ────────────────────────────────────────────────
echo "═══════════════════════════════════════"
echo "通过 $PASS 项,警告 $WARN 项,失败 $FAIL 项"
if [ "$FAIL" -gt 0 ]; then
echo "❌ 存在失败项 —— 请先修复环境,否则数据不可信"
echo " (仍可继续实验,但必须在报告里声明环境问题)"
elif [ "$WARN" -gt 0 ]; then
echo "⚠️ 存在警告项 —— 建议记录并评估影响"
else
echo "✅ 环境检查通过"
fi
} | tee "$OUT"
echo
echo "结果已保存 → $OUT"
二、环境事件时间线记录
#!/usr/bin/env bash
# tools/record-events.sh <EXP_ID> [INTERVAL_SEC]
#
# 在实验期间后台运行,记录环境事件。
# 事后用它解释异常轮(第 5.3 节)。
set -uo pipefail
EXP_ID="${1:?usage: record-events.sh <EXP_ID> [interval]}"
INTERVAL="${2:-10}"
OUT="docs/experiments/${EXP_ID}/results/events.csv"
mkdir -p "$(dirname "$OUT")"
echo "开始记录环境事件(每 ${INTERVAL}s)→ $OUT"
echo "按 Ctrl-C 停止"
# CSV 表头
echo "timestamp,loadavg,steal_pct,ctx_switch,run_queue,throttled,top_proc,pg_active,pg_long_tx" > "$OUT"
cleanup() {
echo
echo "✅ 事件记录已保存 → $OUT"
echo " 用它来解释异常轮:"
echo " grep -E 'logrotate|backup' $OUT"
exit 0
}
trap cleanup INT TERM
while true; do
TS=$(date +%s)
# 系统负载
LOADAVG=$(cut -d' ' -f1 /proc/loadavg 2>/dev/null || echo "0")
STEAL=$(vmstat 1 2 2>/dev/null | tail -1 | awk '{print $17}' || echo "0")
CTX=$(vmstat 1 2 2>/dev/null | tail -1 | awk '{print $12}' || echo "0")
RUNQ=$(vmstat 1 2 2>/dev/null | tail -1 | awk '{print $1}' || echo "0")
# 节流计数
THROTTLED=$(awk '/nr_throttled/{print $2}' /sys/fs/cgroup/cpu.stat 2>/dev/null || echo "0")
# 占用最高的非 Java 进程(排除自己)
TOPPROC=$(ps aux --sort=-%cpu 2>/dev/null | awk 'NR>1 && $11 !~ /java|ps|awk/ {print $11; exit}' || echo "none")
# 数据库
PG_ACTIVE="n/a"; PG_LONG="n/a"
if command -v psql > /dev/null 2>&1 && [ -n "${PG_CONN:-}" ]; then
PG_ACTIVE=$(psql "$PG_CONN" -tAc "SELECT count(*) FROM pg_stat_activity WHERE state='active';" 2>/dev/null || echo "n/a")
PG_LONG=$(psql "$PG_CONN" -tAc "SELECT count(*) FROM pg_stat_activity WHERE state<>'idle' AND now()-query_start > interval '10 seconds';" 2>/dev/null || echo "n/a")
fi
echo "$TS,$LOADAVG,$STEAL,$CTX,$RUNQ,$THROTTLED,$TOPPROC,$PG_ACTIVE,$PG_LONG" >> "$OUT"
sleep "$INTERVAL"
done
用法:
# 实验开始时启动
tools/record-events.sh E05-noise 10 &
EVENTS_PID=$!
# 跑实验...
# 结束后停止
kill $EVENTS_PID
# 分析:找出压测窗口内的异常事件
python3 - <<'PY'
import csv
from pathlib import Path
rows = list(csv.DictReader(open("docs/experiments/E05-noise/results/events.csv")))
# 找出 steal 突然升高的时刻
for r in rows:
try:
if float(r["steal_pct"]) > 2:
print(f"⚠️ {r['timestamp']}: steal={r['steal_pct']}% loadavg={r['loadavg']} top={r['top_proc']}")
except ValueError:
pass
PY
三、四个最容易被忽略的污染源
## 检查表(脚本未覆盖,需人工确认)
- [ ] **IDE / 浏览器是否关闭?**(本地开发场景,会抢占 CPU)
- [ ] **压测客户端是否独立于服务?**(同机是最常见的错误)
- [ ] **是否有其他人在用这台机器?**(团队共享环境)
- [ ] **自动更新是否关闭?**(`systemctl status unattended-upgrades`)
- [ ] **swap 是否关闭?**(`swapoff -a`,避免内存压力导致抖动)
- [ ] **数据库 autovacuum 状态已知?**(可以临时调整或记录)
# 关闭自动更新(Ubuntu/Debian)
sudo systemctl stop unattended-upgrades
sudo systemctl disable unattended-upgrades
# 关闭 swap
sudo swapoff -a
free -h # 确认 Swap 行是 0
# 检查数据库 autovacuum
psql -c "SHOW autovacuum;" # 是否启用
psql -c "SELECT relname, last_autovacuum, last_autoanalyze FROM pg_stat_user_tables
ORDER BY last_autovacuum DESC NULLS LAST LIMIT 5;"
四、动手改造
| 改动 | 观察什么 |
|---|---|
| 故意开着 IDE 跑实验 | check-environment.sh 的 Top 5 会显示 IDE——这就是"看不见的污染" |
把 record-events.sh 的间隔从 10s 改成 1s |
能捕获更短的事件(比如瞬时 IO 尖刺),但文件变大 |
在实验期间手动触发 logrotate |
事件时间线里能看到,且能验证"归因流程" |
把 PG_CONN 配上 |
检查脚本能看到数据库侧的长事务 |
| 对比两次实验的 env-check.txt | 确认环境是否一致(第 5.1 节的"冻结项") |
五、这段代码的局限
check-environment.sh只能检查"可自动获取"的信息——压测客户端位置、IDE 是否关闭、是否有同事在用,都需要人工确认。vmstat 1 2每次调用要 1 秒:脚本里的多处vmstat调用会让检查变慢(约 15 秒),可以优化为只调用一次。- 事件时间线是定频采样:短于采样间隔的事件可能被漏掉(比如 2 秒的 IO 尖刺)。需要更高精度时,改用
bpftrace或系统日志(journalctl)。 - 非 Linux 环境下大部分检查会跳过(
/proc、/sys/fs/cgroup不可用)——此时应该改用 macOS 的sysctl/powermetrics,或接受"环境不可控"并把它写进报告。