文档目录

1.7 配套代码:环境元数据自动采集

对应小节:1.7 实验元数据 核心原则:手写一定会漏,脚本不会。

一、采集脚本

#!/usr/bin/env bash
# tools/collect-env.sh <EXP_ID>
# 由实验驱动器自动调用,把所有"实验条件"落盘。
set -uo pipefail

EXP_ID="${1:?usage: collect-env.sh <EXP_ID>}"
OUT="docs/experiments/${EXP_ID}/results/env.txt"
mkdir -p "$(dirname "$OUT")"

{
  echo "# 环境元数据(自动采集,请勿手工编辑)"
  echo "collected_at=$(date -Iseconds)"
  echo

  echo "## ① 代码版本"
  echo "git_commit=$(git rev-parse HEAD 2>/dev/null || echo 'not-a-git-repo')"
  echo "git_dirty=$(git status --porcelain 2>/dev/null | wc -l | tr -d ' ')  # >0 表示有未提交改动,实验不可复现!"
  echo "image_tag=${IMAGE_TAG:-n/a}"
  echo

  echo "## ② JVM 版本与完整参数"
  java -version 2>&1 | sed 's/^/jdk: /'
  echo "jvm_args=${JVM_ARGS:-(未设置,使用默认值——这本身也需要记录)}"
  echo

  echo "## ③ 机器规格"
  echo "os=$(uname -s -r -m)"
  if [ -f /proc/cpuinfo ]; then
    echo "cpu_model=$(awk -F': ' '/model name/{print $2; exit}' /proc/cpuinfo)"
    echo "cpu_cores=$(nproc)"
  else
    echo "cpu_model=$(sysctl -n machdep.cpu.brand_string 2>/dev/null || echo unknown)"
    echo "cpu_cores=$(sysctl -n hw.ncpu 2>/dev/null || echo unknown)"
  fi
  echo "mem_total=$(free -h 2>/dev/null | awk '/Mem:/{print $2}' || sysctl -n hw.memsize 2>/dev/null || echo unknown)"
  echo "disk=$(df -h / | awk 'NR==2{print $1, $2}')"
  echo

  echo "## ④ 容器与编排配置(缺这项是「本地好、上线慢」的头号原因)"
  echo "cgroup_cpu_max=$(cat /sys/fs/cgroup/cpu.max 2>/dev/null || echo 'n/a(非 cgroup v2 或非容器)')"
  echo "cgroup_mem_max=$(cat /sys/fs/cgroup/memory.max 2>/dev/null || echo n/a)"
  if [ -f /sys/fs/cgroup/cpu.stat ]; then
    echo "--- cpu.stat ---"
    head -5 /sys/fs/cgroup/cpu.stat | sed 's/^/  /'
    echo "  (nr_throttled / throttled_usec 持续增长 = 被 CPU 配额周期性掐停)"
  fi
  echo

  echo "## ⑤ 操作系统与内核参数"
  echo "ulimit_nofile=$(ulimit -n)"
  echo "somaxconn=$(cat /proc/sys/net/core/somaxconn 2>/dev/null || echo n/a)"
  echo "cpu_governor=$(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor 2>/dev/null || echo n/a)"
  echo "perf_event_paranoid=$(cat /proc/sys/kernel/perf_event_paranoid 2>/dev/null || echo n/a)"
  echo

  echo "## ⑥ 数据库与中间件"
  echo "postgres_version=$(psql -tAc 'select version()' 2>/dev/null | head -1 || echo 'psql 不可用')"
  echo "postgres_max_connections=$(psql -tAc 'show max_connections' 2>/dev/null || echo n/a)"
  echo "postgres_shared_buffers=$(psql -tAc 'show shared_buffers' 2>/dev/null || echo n/a)"
  echo "redis_version=$(redis-cli INFO server 2>/dev/null | awk -F: '/redis_version/{print $2}' | tr -d '\r' || echo n/a)"
  echo

  echo "## ⑦ 数据集:规模 + 分布(最常被忽略的一项)"
  echo "rows=$(psql -tAc 'select count(*) from orders' 2>/dev/null || echo n/a)"
  echo "distribution=${DATA_DISTRIBUTION:-未声明}  # 必须写:uniform / zipf(alpha) / real_replay"
  echo "cache_state=${CACHE_STATE:-未声明}          # 必须写:cold / warm(预热多久)"
  echo

  echo "## ⑧ 压测方式"
  echo "tool=$(k6 version 2>/dev/null | head -1 || echo n/a)"
  echo "model=${LOAD_MODEL:-未声明}  # constant-arrival-rate / ramping-arrival-rate / constant-vus"
  echo "target_qps=${TARGET_QPS:-未声明}"
  echo "warmup_duration=${WARMUP:-未声明}"
  echo "steady_duration=${STEADY:-未声明}"
  echo "script_commit=$(git rev-parse HEAD:loadtest 2>/dev/null || echo n/a)"
  echo

  echo "## ⑨ 测量点与观测工具"
  echo "measurement_point=${MEASUREMENT_POINT:-未声明}  # client / gateway / in_app"
  echo "prometheus_scrape_interval=${SCRAPE_INTERVAL:-未声明}"
  echo "histogram_buckets=${HISTOGRAM_BUCKETS:-未声明}"
} > "$OUT"

echo "环境元数据已写入 $OUT"

# 自检:把所有"未声明"标出来,提醒你补齐
if grep -q "未声明" "$OUT"; then
  echo
  echo "⚠️ 以下项目未声明,实验不可复现:"
  grep -n "未声明" "$OUT" | sed 's/^/   /'
fi

二、为什么每一节都必要

段落 漏了会怎样(真实案例)
① 代码版本 三个月后无法确认「当时测的是哪个版本」
① git_dirty 代码有未提交改动 → 别人 checkout 同一个 commit 也复现不出来
② JVM 参数 把「换了 GC」误认为「优化有效」,提升 20% 其实是环境变了
③ CPU 型号 「都是 8 核」但一代 CPU 差 2 倍单核性能
④ 容器 CPU limit 本地压测无限制、生产 1 核 limit → 数据完全不可迁移
⑤ ulimit -n 并发连接数被文件描述符上限卡住,却以为是应用问题
⑤ perf_event_paranoid 火焰图采不到数据,浪费半天排查工具权限
⑥ 数据库配置 max_connections 和 shared_buffers 直接决定性能天花板
⑦ 数据量 + 分布 均匀分布测出「达标」,真实幂律流量下完全不达标
⑧ 流量模型 闭环模型的 P99 可能比真实值低 500 倍(第 0 章 0.6 节)
⑨ 测量点 拿客户端数据当基线、服务端数据当结果,得出「提升 77%」的假结论

三、配套的实验档案头信息

把 env.txt 粘进档案的同时,这几项要在 YAML 里写清(模板见 docs/experiments/_TEMPLATE/README.md):

---
id: E02
title: 订单查询接口容量曲线
date: 2025-01-15
chapter: 1
kind: capacity
status: done
hypothesis: "拐点约在 700 RPS;预期瓶颈是数据库连接池而非 CPU"
variable: "无(建立容量基线)"
control: "无"
commit: <git rev-parse HEAD>
git_dirty: 0
jvm_args: "-Xms2g -Xmx2g -XX:+UseG1GC -Xlog:gc*:file=gc.log"
env_file: results/env.txt
distribution: "zipf(1.2)"
cache_state: "warm(预热 5 分钟)"
load_model: "ramping-arrival-rate"
measurement_point: client
tags: [capacity, orders, postgres]
---

四、把它接进实验驱动器

#!/usr/bin/env bash
# tools/run-experiment.sh <EXP_ID>
set -euo pipefail
EXP_ID="${1:?usage: run-experiment.sh <EXP_ID>}"
DIR="docs/experiments/${EXP_ID}"
mkdir -p "$DIR/results" "$DIR/scripts"

# ① 环境元数据(自动,最重要的一步)
tools/collect-env.sh "$EXP_ID"

# ② 冻结脚本快照
cp loadtest/*.js "$DIR/scripts/" 2>/dev/null || true
cp observability/prometheus.yml "$DIR/scripts/" 2>/dev/null || true

# ③ 重启服务(避免上一轮的热状态污染)
scripts/restart-app.sh "$DIR/results/gc.log"
sleep 5

# ④ 预热(不计入统计)
BASE_URL=http://127.0.0.1:8080 k6 run --quiet --vus 20 --duration 60s loadtest/read-path.js > /dev/null

# ⑤ 正式压测
BASE_URL=http://127.0.0.1:8080 k6 run \
  --out json="$DIR/results/k6-raw.json" \
  --summary-export="$DIR/results/k6-summary.json" \
  loadtest/capacity-staircase.js | tee "$DIR/results/k6-stdout.txt"

# ⑥ 收尾快照
PID=$(jcmd | grep app.jar | awk '{print $1}')
jcmd "$PID" GC.heap_info > "$DIR/results/heap.txt" 2>/dev/null || true
jcmd "$PID" Thread.print > "$DIR/results/threads.txt" 2>/dev/null || true

echo "✅ 完成 → $DIR"
echo "下一步:填写 $DIR/README.md(模板见 docs/experiments/_TEMPLATE/README.md)"

五、动手改造

改动 观察什么
故意把 DATA_DISTRIBUTION 留空 脚本会报「未声明」——这就是防遗漏的机制
在容器里跑一次 cgroup_cpu_max 会显示真实配额;对比本地跑的差异
把 git_dirty 检查改成「非 0 就退出」 防止在代码有未提交改动时做实验(那种实验无法复现)
加一段采集压测机自身的信息 压测机是瓶颈时,这份记录能帮你第一时间发现

六、这段代码的局限

  • 跨平台差异:/proc、/sys/fs/cgroup 是 Linux 的;macOS 上多数项目会显示 n/a。生产分析建议在 Linux 进行。
  • 它只能采集「可自动获取」的信息。像「业务峰值预估」「数据分布假设」这类只有人知道的信息,必须在 YAML 头里手填——脚本替代不了这部分。
  • 采集脚本本身要跑在被测环境内(容器里),否则拿到的宿主机的配置,与容器实际限制不同。