文档目录

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,或接受"环境不可控"并把它写进报告。