概述

你大概率经历过这种场景:凌晨三点被告警吵醒,爬起来看 Grafana,发现 CPU 使用率飙到 85%。你紧张了半天,查了一圈,最后发现——这台数据库服务器每天凌晨 2:50 到 3:10 都会跑定时备份任务,CPU 本来就该到这个数。

问题出在哪?你不知道这台机器"正常"是什么样子。没有基线,85% 是高还是低,你根本判断不了。

性能基线(Performance Baseline)解决的就是这个问题:在系统健康的时候,把关键指标记录下来,形成一份"体检报告"。以后出了异常,拿当前数据跟基线一对比,是真故障还是正常波动,一眼就能看出来。

这篇文章讲的是怎么从零建立一套可落地的 Linux 性能基线体系。不是泛泛而谈"监控很重要",而是从方法论选择、指标采集、基线建模、异常识别到自动化落地,每一步都给你能直接用的配置和代码。

为什么静态阈值不靠谱

大多数团队的告警配置是这样的:CPU > 80% 告警,内存 > 90% 告警,磁盘 > 85% 告警。这就是静态阈值——一个拍脑袋定的数字,挂在所有机器上。

静态阈值有三个致命问题:

第一,不同业务、不同时段的正常值不一样。 一台跑批处理任务的服务器,CPU 峰值在工作时间冲到 95% 是正常的;一台 API 网关,CPU 持续 70% 就得警惕了。用同一个 80% 阈值套上去,前者天天误报,后者出了事还不报。

第二,绝对值掩盖趋势。 某台机器 CPU 从 30% 缓慢爬到 60%,持续两周。静态阈值看不到——没到 80% 嘛。但这种缓慢上升往往是内存泄漏或连接堆积的前兆,等真到 80% 的时候已经炸了。

第三,无法识别"不正常的好"。 某个核心服务的 QPS 突然从 5000 掉到 200,CPU 使用率跟着降到 10%。静态阈值觉得一切正常——CPU 低着呢。但实际上上游可能挂了,流量根本没进来。

基线解决这些问题的思路很简单:不设固定阈值,而是让系统自己学什么算正常。当实际表现偏离了历史学到的"正常模式",才触发告警。

USE 方法论:基线采集的骨架

建立基线之前,先得知道采什么指标。乱采一通,采了 200 个指标,80% 没人看,纯属浪费。

Brendan Gregg 提出的 USE 方法(Utilization、Saturation、Errors)是目前最实用的资源检查框架。核心思路:对每一类资源,检查利用率、饱和度和错误三个维度

资源Utilization(利用率)Saturation(饱和度)Errors(错误)
CPUCPU 使用率百分比运行队列长度、load average上下文切换异常飙升
内存已用内存占比换页率(page in/out)、OOM 触发次数OOM kill 事件
磁盘 I/O%util(设备繁忙百分比)iostat 的 await、avgqu-sz磁盘 I/O 错误计数
网络带宽使用率网卡丢弃包数(dropped)网卡错误包数(errs)
文件系统inode 使用率队列等待时间文件系统错误(ext4 errors)

USE 方法的好处是简单粗暴。Brendan 自己说过:“USE 方法能解决 80% 的服务器性能问题,但只需要 5% 的努力。” 不需要一上来就搞火焰图、eBPF 这些重武器,先把四大资源的 U、S、E 采全了,基本就能覆盖日常 90% 的场景。

基线指标清单

基于 USE 方法,以下是建立基线必须采集的核心指标清单。别贪多,先采这些:

CPU 子系统:

  • cpu_usage_user / cpu_usage_system / cpu_usage_idle — 分别记录用户态、内核态、空闲时间占比
  • load_avg_1 / load_avg_5 / load_avg_15 — 1/5/15 分钟平均负载
  • context_switches — 每秒上下文切换次数
  • run_queue_length — 运行队列长度

内存子系统:

  • mem_available_pct — 可用内存百分比(看 available,不是 free)
  • swap_used_pct — Swap 使用率
  • page_in / page_out — 每秒换入/换出页面数
  • oom_kill_count — OOM kill 累计次数

磁盘 I/O 子系统:

  • disk_util — 每块磁盘的 %util
  • disk_await — I/O 请求平均等待时间(毫秒)
  • disk_iops_read / disk_iops_write — 每秒读写 IOPS
  • disk_avgqu_sz — 平均队列长度

网络子系统:

  • net_rx_bytes / net_tx_bytes — 每秒收发字节数
  • net_rx_drop / net_tx_drop — 网卡丢包计数
  • net_rx_errs / net_tx_errs — 网卡错误包计数
  • tcp_retransmit_rate — TCP 重传率

采集工具选型:从手动到自动化

基线采集的核心矛盾是:你需要长期、连续、低开销地采集数据。用 top 手动看一眼不算基线,那叫"瞥一眼"。

下面按三个层次介绍采集工具。

第一层:内核态轻量采集——sar

sar(System Activity Reporter)是 sysstat 包里的经典工具,几乎所有 Linux 发行版都能装。它的原理是后台跑一个 cron 每 10 分钟采集一次系统指标,写到二进制文件里,你随时可以查历史数据。

# 安装 sysstat(包含 sar)
# CentOS/RHEL
yum install -y sysstat
# Ubuntu/Debian  
apt install -y sysstat

# 启用数据采集(编辑 /etc/default/sysstat 或 /etc/sysconfig/sysstat)
# Ubuntu/Debian:
sed -i 's/ENABLED="false"/ENABLED="true"/' /etc/default/sysstat
systemctl restart sysstat
systemctl enable sysstat

# CentOS/RHEL:
sed -i 's/false/true/' /etc/sysconfig/sysstat
systemctl restart sysstat
systemctl enable sysstat

sar 采集的数据默认存在 /var/log/sa/ 目录下,按天滚动(sa28 表示 28 号的数据)。你可以回溯查看任意一天的历史性能数据:

# 查看今天的 CPU 使用率历史
sar -u

# 查看 8 月 28 日的内存使用历史
sar -r -f /var/log/sa/sa28

# 查看磁盘 I/O 历史
sar -d -p

# 查看网络流量历史
sar -n DEV

# 每秒采集一次,持续 60 秒(实时模式)
sar -u 1 60

sar 的优势是极其轻量、零依赖、数据可回溯。劣势是默认 10 分钟采集间隔太粗,而且只覆盖基础指标,没有进程级别的数据。

第二层:定制化采集脚本——collectd / 自研脚本

当你需要更细粒度的采集(比如每分钟),或者需要采集 sar 不覆盖的指标(如 TCP 重传率、进程级数据),自研采集脚本更灵活。

下面是一个用 Bash 写的轻量级基线采集脚本,可以 cron 每分钟跑一次,输出 JSON 格式,方便后续入库:

#!/bin/bash
# perf-baseline-collector.sh
# 每分钟采集一次系统性能基线数据,输出 JSON

TIMESTAMP=$(date '+%Y-%m-%dT%H:%M:%S%z')
HOSTNAME=$(hostname)

# --- CPU 指标 ---
CPU_LINE=$(cat /proc/stat | grep '^cpu ' | awk '{print $2,$3,$4,$5,$6,$7,$8}')
read USER NICE SYSTEM IDLE IOWAIT IRQ SOFTIRQ <<< "$CPU_LINE"
TOTAL=$((USER + NICE + SYSTEM + IDLE + IOWAIT + IRQ + SOFTIRQ))
CPU_USAGE=$(awk "BEGIN {printf \"%.2f\", ($TOTAL - $IDLE) / $TOTAL * 100}")
LOAD_AVG=$(cut -d' ' -f1-3 /proc/loadavg)

# 运行队列长度
RUNNABLE=$(grep -c 'R' /proc/*/stat 2>/dev/null || echo 0)

# 上下文切换次数
CTXT=$(grep ctxt /proc/stat | awk '{print $2}')

# --- 内存指标 ---
MEMINFO=$(cat /proc/meminfo)
MEM_TOTAL=$(echo "$MEMINFO" | grep MemTotal | awk '{print $2}')
MEM_AVAIL=$(echo "$MEMINFO" | grep MemAvailable | awk '{print $2}')
MEM_AVAIL_PCT=$(awk "BEGIN {printf \"%.2f\", $MEM_AVAIL / $MEM_TOTAL * 100}")
SWAP_TOTAL=$(echo "$MEMINFO" | grep SwapTotal | awk '{print $2}')
SWAP_FREE=$(echo "$MEMINFO" | grep SwapFree | awk '{print $2}')
SWAP_USED_PCT=0
if [ "$SWAP_TOTAL" -gt 0 ]; then
    SWAP_USED_PCT=$(awk "BEGIN {printf \"%.2f\", ($SWAP_TOTAL - $SWAP_FREE) / $SWAP_TOTAL * 100}")
fi

# 换页统计
PAGE_IN=$(grep pgpgin /proc/vmstat | awk '{print $2}')
PAGE_OUT=$(grep pgpgout /proc/vmstat | awk '{print $2}')

# --- 磁盘 I/O 指标 ---
# 取第一块非 loop 设备
DISK_DEV=$(ls /sys/block/ | grep -v 'loop\|ram\|sr' | head -1)
DISK_STATS=$(cat /sys/block/$DISK_DEV/stat)
read DISK_IOS_READ DISK_SECT_READ DISK_IOS_WRITE DISK_SECT_WRITE DISK_TICKS <<< "$DISK_STATS"

# --- 网络指标 ---
NET_DEV=$(ip route get 8.8.8.8 2>/dev/null | grep -oP 'dev \K\S+' || echo "eth0")
NET_STATS=$(cat /proc/net/dev | grep "$NET_DEV:")
read RX_BYTES RX_PACKETS RX_ERRS RX_DROP _ _ _ _ TX_BYTES TX_PACKETS TX_ERRS TX_DROP <<< "$NET_STATS"

# TCP 重传统计
TCP_RETRANS=$(grep -c retrans /proc/net/snmp 2>/dev/null || echo 0)

# --- 输出 JSON ---
cat <<EOF
{
  "timestamp": "$TIMESTAMP",
  "hostname": "$HOSTNAME",
  "cpu": {
    "usage_pct": $CPU_USAGE,
    "load_avg": "$(echo $LOAD_AVG | tr ' ' ',')",
    "runnable_tasks": $RUNNABLE,
    "ctxt_per_sec": $CTXT
  },
  "memory": {
    "available_pct": $MEM_AVAIL_PCT,
    "swap_used_pct": $SWAP_USED_PCT,
    "page_in": $PAGE_IN,
    "page_out": $PAGE_OUT
  },
  "disk": {
    "device": "$DISK_DEV",
    "io_read_ops": $DISK_IOS_READ,
    "io_write_ops": $DISK_IOS_WRITE
  },
  "network": {
    "interface": "$NET_DEV",
    "rx_bytes": $RX_BYTES,
    "tx_bytes": $TX_BYTES,
    "rx_drop": $RX_DROP,
    "tx_drop": $TX_DROP,
    "rx_errs": $RX_ERRS,
    "tx_errs": $TX_ERRS,
    "tcp_retrans": $TCP_RETRANS
  }
}
EOF

这个脚本读的全是 /proc/sys 下的文件,不依赖任何外部包,开销极低(单次执行 < 10ms)。配到 cron 里每分钟跑一次,输出追加到文件或推送到时序数据库:

# crontab 配置:每分钟采集一次
* * * * * /opt/scripts/perf-baseline-collector.sh >> /var/log/perf-baseline/$(date +\%Y\%m\%d).jsonl

第三层:时序数据库 + 可视化

采集到的数据最终要进时序数据库,才能做长期存储、聚合分析和异常检测。这里推荐两种方案:

方案一:Prometheus + node_exporter

这是最主流的方案。node_exporter 已经覆盖了 CPU、内存、磁盘、网络的基础指标,你不需要自己写采集脚本:

# node_exporter 启动参数(推荐开启 textfile collector 自定义指标)
node_exporter --collector.textfile.directory=/var/node_exporter/textfile \
              --collector.cpu \
              --collector.meminfo \
              --collector.diskstats \
              --collector.netdev \
              --collector.filesystem \
              --collector.tcpstat \
              --no-collector.infiniband \
              --no-collector.ipvs

Prometheus 侧的采集配置:

scrape_configs:
  - job_name: 'node'
    static_configs:
      - targets: ['localhost:9100']
    scrape_interval: 60s      # 基线数据 60 秒粒度够用
    scrape_timeout: 10s

方案二:直接用 sar 数据 + Python 脚本入库

如果你的环境不允许装 Prometheus(比如某些严格隔离的合规环境),可以用 Python 脚本把 sar 的二进制数据解析后推到 InfluxDB 或直接存 CSV:

#!/usr/bin/env python3
"""
将 sar 历史数据导出为 CSV,用于基线分析和异常检测建模。
依赖: sadf (sysstat 包自带)
"""
import subprocess
import csv
import sys
from datetime import datetime

def export_sar_to_csv(date_str, output_file):
    """导出指定日期的 sar 数据为 CSV"""
    cmd = [
        'sadf', '-d', '--', '-u', '-r', '-d', '-n', 'DEV',
        f'/var/log/sa/sa{date_str}'
    ]
    result = subprocess.run(cmd, capture_output=True, text=True)
    
    if result.returncode != 0:
        print(f"Error reading sar data for {date_str}: {result.stderr}")
        return
    
    with open(output_file, 'w', newline='') as f:
        reader = csv.reader(result.stdout.strip().split('\n'), delimiter=';')
        writer = csv.writer(f)
        for row in reader:
            if row and not row[0].startswith('#'):
                writer.writerow(row)
    
    print(f"Exported {date_str} sar data to {output_file}")

# 批量导出最近 30 天数据
if __name__ == '__main__':
    from datetime import datetime, timedelta
    
    end_date = datetime.now()
    start_date = end_date - timedelta(days=30)
    
    current = start_date
    while current <= end_date:
        day_str = current.strftime('%d')
        output = f'/var/log/perf-baseline/sar-{current.strftime("%Y%m%d")}.csv'
        export_sar_to_csv(day_str, output)
        current += timedelta(days=1)

怎么定义"正常":基线建模方法

数据采到了,下一个问题是:什么算正常?

这听起来像废话——“正常不就是平时那样吗”。但"平时那样"到底怎么量化?下面三种方法,从简单到复杂,按需选择。

方法一:分位数法(最实用)

最简单也最实用的方法。取历史数据的 P50(中位数)作为基线值,P95 和 P99 作为波动上限

举个例子:过去 30 天的 CPU 使用率数据,按从小到大排列:

  • P50 = 42% — 一半时间 CPU 低于这个值
  • P95 = 68% — 95% 的时间 CPU 低于这个值
  • P99 = 75% — 几乎所有时间 CPU 都低于这个值

那基线就是:正常 CPU 在 42% 左右波动,偶尔冲到 68%,极端情况到 75%。超过 P99 才考虑告警

这个方法的好处是简单、不需要机器学习、结果直观可解释。坏处是没法区分时段——凌晨 3 点和下午 3 点的 CPU 基线本来就不一样,混在一起算分位数会把基线"摊平"。

用 PromQL 实现分位数基线:

# CPU 使用率的 30 天 P50 基线
quantile_over_time(0.50, (100 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m]) * 100))[30d:1m])

# CPU 使用率的 30 天 P95 基线(上界)
quantile_over_time(0.95, (100 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m]) * 100))[30d:1m])

# 当前值超过 P95 基线
(100 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m]) * 100)) >
  quantile_over_time(0.95, (100 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m]) * 100))[30d:1m])

方法二:时段分桶法(推荐)

把一天切成若干时段,每个时段单独算基线。比如把一天分成 24 小时,每个小时单独算 P50 和 P95。

这样凌晨 3 点的基线就跟下午 3 点不一样了。批处理任务在工作时间 CPU 飙到 90% 是正常的,凌晨 3 点飙到 90% 就该查了。

# 按小时分桶的 CPU 基线
# 使用 avg_over_time 配合 hour() 函数实现时段基线
avg_over_time(
  (100 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m]) * 100))[30d:1m]
) * on() group_left() (hour(time()) == 14)  # 14 点时段

更优雅的做法是用 Recording Rule 预计算每个小时的基线:

# prometheus rules - perf-baseline.yml
groups:
  - name: performance_baseline
    rules:
      # 每小时 CPU 基线(P50)
      - record: cpu_usage_baseline_p50
        expr: |
          quantile_over_time(0.50,
            (100 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m]) * 100))
          [30d:1m])          
      
      # 每小时 CPU 基线(P95)
      - record: cpu_usage_baseline_p95
        expr: |
          quantile_over_time(0.95,
            (100 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m]) * 100))
          [30d:1m])          
      
      # 偏离基线告警
      - alert: CpuUsageAboveBaseline
        expr: |
          (100 - avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m]) * 100))
          >
          cpu_usage_baseline_p95 * 1.2          
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "{{ $labels.instance }} CPU 超过基线 P95 的 1.2 倍"

方法三:动态基线(进阶)

如果你有时间序列分析的能力(比如有机器学习平台),可以用更高级的方法。核心思路是用滑动窗口预测下一个时间点的预期值,如果实际值偏离预测值超过一定范围,就判定为异常。

一个轻量级的实现是用 Python 的 statsmodels 库做季节性分解:

#!/usr/bin/env python3
"""
基于 STL 季节性分解的动态基线异常检测。
适合有明显日/周周期性的指标(如 QPS、CPU、内存)。
依赖: pip install statsmodels pandas numpy
"""
import numpy as np
import pandas as pd
from statsmodels.tsa.seasonal import seasonal_decompose
from datetime import datetime, timedelta

def build_dynamic_baseline(metric_series, period=1440, window_days=30):
    """
    构建动态基线。
    
    参数:
        metric_series: pd.Series, 带时间索引的指标序列
        period: 季节周期(1440 = 1天的分钟数)
        window_days: 使用多少天历史数据建模
    
    返回:
        baseline: 基线值(预期值)
        upper_bound: 上限(基线 + 3 * 残差标准差)
        lower_bound: 下限
    """
    # 取最近 N 天数据
    cutoff = metric_series.index[-1] - timedelta(days=window_days)
    data = metric_series[metric_series.index >= cutoff]
    
    # 去掉缺失值
    data = data.dropna()
    
    if len(data) < period * 3:
        # 数据不够 3 个周期,退回到简单统计
        baseline = data.rolling(window=period, min_periods=1).median()
        std = data.rolling(window=period, min_periods=1).std()
        return baseline, baseline + 3 * std, baseline - 3 * std
    
    # STL 分解:趋势 + 季节性 + 残差
    decomposition = seasonal_decompose(data, model='additive', period=period)
    
    # 基线 = 趋势 + 季节性
    baseline = decomposition.trend + decomposition.seasonal
    
    # 残差的标准差作为波动范围
    resid_std = decomposition.resid.std()
    upper_bound = baseline + 3 * resid_std
    lower_bound = baseline - 3 * resid_std
    
    return baseline, upper_bound, lower_bound

def detect_anomalies(metric_series, baseline, upper_bound, lower_bound):
    """检测异常点"""
    df = pd.DataFrame({
        'actual': metric_series,
        'baseline': baseline,
        'upper': upper_bound,
        'lower': lower_bound
    })
    
    df['is_anomaly'] = (df['actual'] > df['upper']) | (df['actual'] < df['lower'])
    df['deviation'] = abs(df['actual'] - df['baseline'])
    
    anomalies = df[df['is_anomaly']].copy()
    return anomalies

# 使用示例
if __name__ == '__main__':
    # 模拟数据:生成 30 天的 CPU 使用率,每天有周期性
    np.random.seed(42)
    dates = pd.date_range(start='2026-08-01', end='2026-08-30', freq='1min')
    
    # 日周期 + 周周期 + 噪声
    daily_pattern = 30 * np.sin(2 * np.pi * np.arange(len(dates)) / 1440)
    weekly_pattern = 5 * np.sin(2 * np.pi * np.arange(len(dates)) / (1440 * 7))
    noise = np.random.normal(0, 5, len(dates))
    cpu_values = 50 + daily_pattern + weekly_pattern + noise
    
    series = pd.Series(cpu_values, index=dates)
    
    # 构建基线
    baseline, upper, lower = build_dynamic_baseline(series)
    
    # 检测异常
    anomalies = detect_anomalies(series, baseline, upper, lower)
    
    print(f"基线建模完成,检测到 {len(anomalies)} 个异常点")
    if len(anomalies) > 0:
        print("\n异常点示例:")
        print(anomalies[['actual', 'baseline', 'upper']].head(10))

说实话,方法三在大部分场景下属于"杀鸡用牛刀"。除非你有几十万台机器、告警量巨大、简单分位数法已经扛不住了,才值得上动态基线。方法一和方法二能解决 80% 的问题,先把这两个做扎实。

从基线到告警:异常识别的工程化

基线建好了,怎么用?关键是把"偏离基线"转化为可执行的告警规则。

告警规则设计原则

基于基线的告警,跟静态阈值告警的设计思路不一样。核心区别是:告警条件不是"绝对值超阈",而是"相对偏离超阈"

设计原则有三条:

  1. 持续时间过滤:基线偏离必须持续一段时间才告警。瞬时抖动(GC 停顿、瞬时网络抖动)不该触发告警。建议 for: 5m 起步。
  2. 偏离倍数:不是"超过基线就告警",而是"超过基线的 N 倍"或"超过基线 + K 倍标准差"才告警。N 通常取 1.5-2,K 取 3。
  3. 多指标联动:单个指标偏离不一定是问题,多个关联指标同时偏离才是真问题。比如 CPU 偏离 + 网络重传率偏离 = 可能是网络引起的中断。

下面是一个完整的 Prometheus 基线告警规则集:

# baseline-alerts.yml
groups:
  - name: baseline_anomaly_alerts
    rules:
      # CPU 使用率偏离基线
      - alert: CpuDeviationFromBaseline
        expr: |
          (
            (100 - avg by(instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)
            -
            quantile_over_time(0.50, (100 - avg by(instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100))[30d:1m]
          )
          >
          3 * stddev_over_time((100 - avg by(instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100))[30d:1m]          
        for: 10m
        labels:
          severity: warning
          category: baseline-deviation
        annotations:
          summary: "{{ $labels.instance }} CPU 使用率偏离基线超过 3 倍标准差"
          description: "当前 CPU {{ $value }} 偏离历史 P50 基线,持续时间超过 10 分钟"

      # 磁盘 I/O 等待时间偏离基线
      - alert: DiskIoAwaitDeviation
        expr: |
          rate(node_disk_io_time_weighted_seconds_total[5m])
          /
          rate(node_disk_io_time_seconds_total[5m])
          >
          quantile_over_time(0.95, 
            rate(node_disk_io_time_weighted_seconds_total[5m]) / rate(node_disk_io_time_seconds_total[5m])
          )[30d:5m] * 1.5          
        for: 15m
        labels:
          severity: critical
          category: baseline-deviation
        annotations:
          summary: "{{ $labels.instance }} 磁盘 I/O 等待时间偏离基线"

      # 网络丢包率偏离基线
      - alert: NetworkDropDeviation
        expr: |
          rate(node_network_receive_drop_total[5m]) + rate(node_network_transmit_drop_total[5m])
          >
          quantile_over_time(0.99,
            rate(node_network_receive_drop_total[5m]) + rate(node_network_transmit_drop_total[5m])
          )[30d:5m]          
        for: 5m
        labels:
          severity: critical
          category: baseline-deviation
        annotations:
          summary: "{{ $labels.instance }} 网络丢包率超过 30 天 P99 基线"

      # 内存可用率突降(偏离基线)
      - alert: MemoryAvailableDeviation
        expr: |
          node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes * 100
          <
          quantile_over_time(0.05,
            node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes * 100
          )[30d:5m] * 0.5          
        for: 10m
        labels:
          severity: critical
          category: baseline-deviation
        annotations:
          summary: "{{ $labels.instance }} 可用内存低于历史 P5 基线的 50%"

注意看上面几个规则的共同点:全部用了 quantile_over_timestddev_over_time 这类范围函数,计算的是历史 30 天的统计量,然后跟当前值比较。没有任何一行写了 > 80 这样的固定数字。

告警降噪:别让基线告警变成新的噪声

基线告警有自己的坑。最大的坑是:基线本身会"漂移"

假设你的服务一直在缓慢泄漏内存,过去 30 天的内存使用率从 50% 慢慢爬到 80%。如果你用这 30 天的数据建基线,基线也会跟着往上漂,到头来 80% 的内存使用率反而被认为是"正常"的。

解决方案:

1. 用更长的时间窗口建基线,但用更短的时间窗口检测偏离。

基线用 30 天甚至 90 天的数据建,这样短期的恶化趋势会被拉长到时间轴上看。但检测偏离时用最近 5 分钟的数据。这样基线反映的是长期平均水位,偏离反映的是近期突变。

2. 定期人工校准基线。

每个季度人工 Review 一次基线数据,确认基线是否仍然代表"健康状态"。如果业务发生了重大变更(比如换了新版本、加了新功能、改了部署架构),基线需要重建。

3. 加入变化率告警。

光看绝对偏离不够,还得看变化速度。一个指标在过去 1 小时内的变化量超过历史变化量的 P99,本身就是异常信号,不管它有没有超过基线绝对值。

# CPU 使用率 1 小时变化量超过历史 P99
abs(
  (100 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)
  -
  (100 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m] offset 1h)) * 100)
)
>
quantile_over_time(0.99,
  abs(
    (100 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)
    -
    (100 - avg(rate(node_cpu_seconds_total{mode="idle"}[5m] offset 1h)) * 100)
  )
)[30d:1h]

60 秒快速基线检查清单

不是所有场景都有时间等 30 天数据积累。有时候你刚接手一台新机器,需要快速摸底。Brendan Gregg 在 LISA'19 演讲中提到过一份"60 秒检查清单",我做了本地化调整:

序号命令看什么异常信号
1uptimeload average 三列1min > 5min 说明在上升;load > CPU 核数说明饱和
2`dmesg -Ttail -20`最近内核日志
3vmstat 1 5r 列、si/so 列、wa 列r > CPU 核数 = CPU 饱和;si/so > 0 = 在用 swap;wa > 20% = I/O 瓶颈
4mpstat -P ALL 1 3各 CPU 核使用率单核 100% 但整体低 = 单线程瓶颈
5pidstat 1 5进程级 CPU 占用找出吃 CPU 最多的进程
6iostat -xz 1 3%util、await、r/s、w/s%util > 80% 或 await > 20ms = 磁盘瓶颈
7free -havailable 列available < 总量 10% = 内存告急
8sar -n DEV 1 3各网卡 rxkB/s、txkB/s带宽接近上限 = 网络瓶颈
9ss -sTCP 连接数和状态TIME-WAIT 过多 = 连接复用问题
10`top -b -n 1head -20`系统概览 + top 进程

这份清单不是基线,但它是建立基线之前的第一步摸底。跑完这 10 条命令,你对一台机器的"健康底色"就有了基本认识。之后把 sar 或采集脚本挂上,等数据积累 3-7 天,就能形成初步基线。

生产级落地:一份完整的基线自动化方案

把前面的碎片拼起来,这里给出一份完整的、可以直接落地到生产的基线自动化方案。

架构设计

整体架构分四层:

┌─────────────────────────────────────────────────────┐
                  告警与可视化层                        
   Grafana 基线仪表盘 + Prometheus Alerting Rules     
├─────────────────────────────────────────────────────┤
                  基线计算层                           
   Recording Rules (P50/P95/P99) + Python 异常检测     
├─────────────────────────────────────────────────────┤
                  数据存储层                           
   Prometheus TSDB (短期) + 长期存储 (Thanos/VM)       
├─────────────────────────────────────────────────────┤
                  采集层                               
   node_exporter + 自定义 textfile collector           
   + sar (兜底) + 采集脚本 (/proc 直读)               
└─────────────────────────────────────────────────────┘

完整的 Recording Rules

# /etc/prometheus/rules/baseline-recording-rules.yml
groups:
  - name: cpu_baseline
    interval: 1m
    rules:
      # CPU 使用率(减去 idle)
      - record: instance:cpu_usage:ratio
        expr: 1 - avg by(instance)(rate(node_cpu_seconds_total{mode="idle"}[5m]))
      
      # 30 天 P50 基线
      - record: instance:cpu_usage_baseline:p50
        expr: quantile_over_time(0.50, instance:cpu_usage:ratio[30d:1m])
      
      # 30 天 P95 基线
      - record: instance:cpu_usage_baseline:p95
        expr: quantile_over_time(0.95, instance:cpu_usage:ratio[30d:1m])
      
      # 30 天 P99 基线
      - record: instance:cpu_usage_baseline:p99
        expr: quantile_over_time(0.99, instance:cpu_usage:ratio[30d:1m])
      
      # 30 天标准差
      - record: instance:cpu_usage_baseline:stddev
        expr: stddev_over_time(instance:cpu_usage:ratio[30d:1m])

  - name: memory_baseline
    interval: 1m
    rules:
      - record: instance:mem_available:ratio
        expr: node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes
      
      - record: instance:mem_available_baseline:p50
        expr: quantile_over_time(0.50, instance:mem_available:ratio[30d:1m])
      
      - record: instance:mem_available_baseline:p05
        expr: quantile_over_time(0.05, instance:mem_available:ratio[30d:1m])

  - name: disk_baseline
    interval: 1m
    rules:
      - record: instance:disk_io_util:ratio
        expr: rate(node_disk_io_time_seconds_total[5m])
      
      - record: instance:disk_io_util_baseline:p95
        expr: quantile_over_time(0.95, instance:disk_io_util:ratio[30d:1m])
      
      - record: instance:disk_io_util_baseline:p50
        expr: quantile_over_time(0.50, instance:disk_io_util:ratio[30d:1m])

  - name: network_baseline
    interval: 1m
    rules:
      - record: instance:net_drop_rate
        expr: rate(node_network_receive_drop_total[5m]) + rate(node_network_transmit_drop_total[5m])
      
      - record: instance:net_drop_baseline:p99
        expr: quantile_over_time(0.99, instance:net_drop_rate[30d:1m])

Grafana 基线仪表盘

在 Grafana 里建一个基线仪表盘,核心是把当前值基线范围画在一起,一眼就能看出当前表现是否在基线区间内。

推荐的面板配置:

面板指标可视化方式说明
CPU 使用率当前值 + P50 + P95 + P99折线图四条线,当前值越过 P95 标红
内存可用率当前值 + P50 + P05折线图当前值低于 P05 标红
磁盘 I/O当前 %util + P50 + P95折线图当前值超过 P95 标红
网络丢包当前值 + P99柱状图超过 P99 的柱子标红
偏离倍数(当前 - P50) / stddev折线图超过 3 标红线

一键部署脚本

#!/bin/bash
# deploy-baseline-monitoring.sh
# 一键部署基线监控方案(Prometheus + node_exporter + Recording Rules)

set -euo pipefail

PROMETHEUS_VERSION="2.55.0"
NODE_EXPORTER_VERSION="1.9.0"
ARCH=$(uname -m | sed 's/x86_64/amd64/;s/aarch64/arm64/')

echo "=== 1. 安装 node_exporter ==="
cd /tmp
wget -q "https://github.com/prometheus/node_exporter/releases/download/v${NODE_EXPORTER_VERSION}/node_exporter-${NODE_EXPORTER_VERSION}.linux-${ARCH}.tar.gz"
tar xzf "node_exporter-${NODE_EXPORTER_VERSION}.linux-${ARCH}.tar.gz"
cp "node_exporter-${NODE_EXPORTER_VERSION}.linux-${ARCH}/node_exporter" /usr/local/bin/
chmod +x /usr/local/bin/node_exporter

# 创建 systemd 服务
cat > /etc/systemd/system/node-exporter.service <<'UNIT'
[Unit]
Description=Node Exporter
After=network.target

[Service]
ExecStart=/usr/local/bin/node_exporter \
    --collector.textfile.directory=/var/node_exporter/textfile \
    --collector.tcpstat \
    --collector.processes
Restart=always
RestartSec=5
User=node_exporter

[Install]
WantedBy=multi-user.target
UNIT

useradd -r -s /bin/false node_exporter 2>/dev/null || true
mkdir -p /var/node_exporter/textfile
chown -R node_exporter:node_exporter /var/node_exporter
systemctl daemon-reload
systemctl enable --now node-exporter

echo "=== 2. 创建 Recording Rules ==="
mkdir -p /etc/prometheus/rules
# ... (此处放上面定义的 baseline-recording-rules.yml 内容)
# 实际部署时将 YAML 内容写入文件

echo "=== 3. 创建 Alert Rules ==="
# ... (此处放上面定义的 baseline-alerts.yml 内容)

echo "=== 4. 验证部署 ==="
sleep 2
if curl -s http://localhost:9100/metrics | grep -q "node_cpu_seconds"; then
    echo "✓ node_exporter 正常运行"
else
    echo "✗ node_exporter 启动失败,检查日志: journalctl -u node-exporter"
    exit 1
fi

echo ""
echo "=== 部署完成 ==="
echo "node_exporter: http://$(hostname -I | awk '{print $1}'):9100/metrics"
echo "下一步: 在 Prometheus 配置中添加本机为 scrape target"
echo "基线数据需要积累 3-7 天后才能形成有效基线"

踩坑实录:基线方案落地的 5 个教训

教训一:别在变更窗口期建基线。

有一回在发版窗口建基线,新版本正好引入了一个内存缓慢泄漏的 bug。基线建出来内存使用率的 P95 是 85%——这根本不是"正常",是"带病运行"。等 bug 修了,基线反而成了误报源。

正确做法:在系统稳定运行至少 7 天后才开始建基线。如果刚做完大变更,等一周再说。

教训二:基线窗口不是越长越好。

30 天是个比较好的窗口。试过用 90 天的窗口,结果发现基线对短期变化的敏感度太低——某台机器的磁盘 I/O 从 200 IOPS 涨到 800 IOPS,基线花了两周才"追上去",这段时间全是漏报。

反过来用 7 天窗口也不行,周末和工作日的差异太大,基线波动剧烈。

实测下来,30 天窗口 + 1 分钟采集间隔是性价比最高的组合。

教训三:有些指标不适合建基线。

错误计数类的指标(OOM kill 次数、磁盘错误计数、网卡 hardware error)不适合用分位数法建基线。因为这些指标在正常情况下应该是 0 或者极低值,它们的"正常基线"就是 0,任何非零值都值得注意。

对这类指标,直接用"非零即告警"的策略比基线法更有效:

# 错误类指标:非零即告警
- alert: OomKillDetected
  expr: increase(node_vmstat_oom_kill[5m]) > 0
  for: 0m
  labels:
    severity: critical
  annotations:
    summary: "{{ $labels.instance }} 发生 OOM Kill"

教训四:基线告警必须有收件人路由。

基线告警的语义跟传统阈值告警不一样——它说的是"这东西跟平时不一样",而不是"这东西超了危险线"。运维收到这两类告警的处置方式不同。

建议在 Alertmanager 里给基线告警打上 category: baseline-deviation 标签,路由到独立的接收渠道,或者先走低优先级通知(如 IM 群消息而非电话):

# alertmanager.yml
route:
  receiver: default
  group_by: ['instance', 'category']
  routes:
    - matchers:
        - category = "baseline-deviation"
      receiver: baseline-alerts-channel
      group_wait: 10m       # 基线告警等 10 分钟聚合
      repeat_interval: 4h   # 4 小时不重复

receivers:
  - name: default
    webhook_configs:
      - url: 'https://hooks.slack.com/...'
  - name: baseline-alerts-channel
    webhook_configs:
      - url: 'https://hooks.slack.com/...'

教训五:基线不是建完就不管了。

基线是个活的东西。业务在变、流量在变、架构在变,基线也得跟着变。建议建立一个季度基线 Review 流程

  1. 每季度拉出各机器的基线数据,人工 Review 是否合理
  2. 如果发现基线漂移(比如 CPU 基线从 40% 漂到了 60%),调查原因
  3. 确认原因合理后,用最近 30 天数据重建基线
  4. 如果原因不合理(比如内存泄漏),先修问题再重建基线

总结

性能基线不是什么新概念,但很多团队一直没做起来。原因不是技术难——分位数法 + Prometheus Recording Rules 就能跑——而是没有形成"建基线 → 用基线 → 调基线"的闭环习惯

实操路径建议这样走:

  1. 先跑 60 秒检查清单摸底,确认当前系统有没有明显问题
  2. 装好 node_exporter + sar,开始采集数据,至少积累 7 天
  3. 上分位数基线 Recording Rules(方法一),先做 CPU 和内存两个子系统
  4. 配基线告警规则,用低优先级通道收告警,观察一周看误报率
  5. 逐步扩展到磁盘 I/O、网络、TCP 指标
  6. 跑稳一个月后,考虑上时段分桶法(方法二),区分工作时间和非工作时间
  7. 机器规模超过 500 台或告警量超过日均 100 条时,再考虑动态基线(方法三)

核心思想就一句话:让数据告诉你什么算正常,而不是拍脑袋定阈值。这件事前期投入不大(node_exporter + 几条 Recording Rules),但长期收益极高——告警少了,MTTR 降了,凌晨三点的电话也不那么频繁了。

参考资料与致谢

本文在撰写过程中参考了以下资料,感谢原作者的贡献:

  1. Performance Analysis Methodology — Brendan Gregg,USE 方法论的原始定义和多种性能分析方法概述
  2. Linux Systems Performance (LISA'19) — Brendan Gregg / USENIX,Linux 性能分析六大领域的系统性总结
  3. perf-tools (GitHub) — Brendan Gregg,基于 perf_events 和 ftrace 的性能分析工具集
  4. 第6 篇|系统性能基线建立与异常识别 — CSDN/zhoucoolqi,从"救火队员"到"健康管理师"的性能基线实践
  5. What Is AIOps? Guide to Artificial Intelligence for IT Operations — phoenixNAP,AIOps 异常检测中的动态基线能力说明
  6. How to Optimize Linux Device Performance in 2026 — fosslinux/Liam,建立基线后再做优化的实践建议