
一、问题来得猝不及防
事情是这样的,我帮朋友搭建了一个网站,配置是四核四G,理论上这配置还挺不错的。最开始运行得好好的,只有几个人访问,完全没问题。
但突然从某一天开始,网站就不行了——网络延迟极其严重,CPU动不动就报超负荷。

图:网络延迟严重,响应时间飙升

图:CPU报错提示超负荷
这就很奇怪了,四核CPU就算再烂,只跑一个服务,在只有两三个人访问的情况下,CPU怎么可能超负荷呢?
我开始排查代码、检查配置、看日志,以为是程序哪里出了问题。后来经过别人点拨,才知道——原来是服务器超售。
二、什么是服务器超售?
超售的本质
服务器超售,简单来说就是云服务商卖给你的资源,实际上并没有完全属于你。
打个比方:一台物理机有32核CPU,理论上可以开8台"4核"虚拟机。但服务商为了赚更多钱,可能会开16台甚至20台"4核"虚拟机,让这些虚拟机共享同一台物理机的CPU资源。
平时大家使用率不高,看起来没问题。但一到晚高峰,所有虚拟机都开始抢CPU资源,你的虚拟机就会被"饿着"——这就是为什么配置看起来够用,实际跑起来却很卡。
超售的两种形式
1. CPU超售
物理CPU核心数 < 所有虚拟机CPU核心数总和
表现:CPU占用不高,但程序执行很慢
难以检测:常规监控工具看不出来
2. 内存超售
物理内存 < 所有虚拟机内存总和
表现:频繁swap、OOM
相对容易检测:有现成脚本

图:服务器超售的本质 - 多个虚拟机抢同一台物理机的CPU资源
三、为什么我以前没遇到过?
因为我以前都是买腾讯云、阿里云这种大厂的服务器。大厂为了口碑和稳定性,超售比例控制得比较合理(不是说完全不超售,只是程度可接受)。
现在想着如果配置高一点,大厂就贵了,就去买了小厂的服务器,结果就踩坑了。
小厂超售的常见套路:
下午测试时一切正常(因为用户少)
晚上7-10点性能骤降(晚高峰)
跑分脚本测试时故意"放水"
只在特定时间段超售严重
四、如何检测服务器超售?
内存超售检测(简单)
内存超售比较容易检测,网上有一键脚本:
PLAINTEXT
# 内存超售检测脚本(网上可搜到)原理是检测实际可用内存和标称内存是否一致。
CPU超售检测(重点)
CPU超售就比较隐蔽了,这里的关键指标是:Steal时间(st)。
五、Steal时间:揭开CPU超售的面纱
什么是Steal时间?
在虚拟化环境中,你的虚拟机运行在宿主机(Hypervisor)上。当你的虚拟机想用CPU时,需要向宿主机"申请"。
Steal时间就是:你的虚拟机想用CPU,但宿主机没给你,CPU时间被其他虚拟机"偷走"的时间占比。
技术原理
Linux系统通过 sar 命令可以查看CPU的各项指标:
PLAINTEXT
sar -u 1 5输出中有几个关键指标:
%user:用户态程序占用%system:内核态占用%iowait:等待I/O%idle:空闲%steal:被宿主机偷走的时间
当 %steal 持续偏高时,说明:
宿主机上其他虚拟机在抢CPU
你的虚拟机经常"排不上队"
即使你的虚拟机CPU占用显示不高,实际性能也会很差
为什么Steal能判断超售?
假设你买了4核服务器:
正常情况(无超售或轻度超售):
宿主机物理核心充足
你的虚拟机几乎总能立即获得CPU时间
%steal接近0或很低(< 2%)
严重超售:
宿主机上可能同时运行了几十台虚拟机
大家都在抢有限的物理CPU核心
你的虚拟机经常要"等待"才能获得CPU时间
%steal飙升(> 5%甚至 > 10%)

图:虚拟机排队等待CPU时间,等待的时间就是Steal时间
超售程度判断标准
根据steal-monitor工具的判断规则:
Steal日均值 | 超售等级 | 说明 |
|---|---|---|
< 2% | 正常 | 基本无争抢,性能稳定 |
2% - 5% | 轻度争抢 | 偶尔卡顿,一般业务可接受 |
5% - 10% | 明显争抢 | 会影响低延迟业务 |
≥ 10% | 严重争抢 | 严重影响性能,建议换服务商 |

图:不同Steal值对应的服务器性能状态
额外判断:
如果峰值 ≥ 15%,超售等级再上调一级
晚高峰(19:00-22:00)是重点观察时段
六、空载CPU占用50%的真相
我遇到的情况就很典型:

图:空载状态下CPU就占用50%
这是超售的典型表现:
不是说你的程序真的占用了50% CPU,而是:
系统想用CPU时,有50%的时间在"等待"
这50%的等待时间被算作"占用"
实际上这部分时间是被宿主机上其他虚拟机抢走了
用 top 命令看,会发现 %st (steal)指标很高:
PLAINTEXT
%Cpu(s): 2.0 us, 1.5 sy, 0.0 ni, 88.5 id, 0.0 wa, 0.0 hi, 0.0 si, 8.0 st
↑
关键指标这里的 st 8.0 就说明有8%的CPU时间被偷走了。

图:监控显示的CPU占用其实是等待时间
七、我做的Steal时间监控工具
发现这个问题后,我做了一个监控网站,专门用来持续监控Steal时间的历史数据。

图:Steal时间监控曲线
为什么要做监控?
因为我发现超售有时间规律:
下午4点测试:跑分正常,Steal时间低
晚上8点使用:性能骤降,Steal时间飙升
服务商可能在晚高峰故意超售更严重,或者其他用户在晚上更活跃,导致争抢加剧。
所以单次测试不够,必须持续监控24小时以上,看晚高峰的表现。
系统架构
整个监控系统由三个部分组成:
PLAINTEXT
sysstat 采集数据 → Python脚本生成JSON → HTML页面展示
↓ ↓ ↓
每分钟采样 每分钟刷新 浏览器访问核心优势:
轻量级:不需要Prometheus/Grafana这种重型监控
历史数据:可以查看过去任意一天的数据
自动判断:根据日均值和峰值自动判断超售等级
零依赖:只需要系统自带的工具+一个静态HTML
下面我教大家怎么从零搭建这个系统~
八、从零搭建Steal监控系统(详细教程)
第一步:安装并开启sysstat采集
1. 安装sysstat
PLAINTEXT
# Debian/Ubuntu
apt update
apt install -y sysstat
# CentOS/RHEL
yum install -y sysstat验证安装:
PLAINTEXT
sar -V
sadf -V2. 开启每分钟采样
sysstat默认是每10分钟采样一次,我们需要改成每分钟:
创建文件 /etc/cron.d/sysstat-steal:
PLAINTEXT
# 每分钟采样一次CPU数据
* * * * * root /usr/lib/sysstat/sa1 1 1 >/dev/null 2>&1说明:
sa1 1 1表示采样1次,间隔1秒数据会自动保存到
/var/log/sysstat/saXX(XX是日期,如15号是sa15)
等待几分钟后,检查数据是否在采集:
PLAINTEXT
sar -u 1 3如果能看到输出,说明采集成功了。
第二步:创建数据生成脚本
这个脚本的作用是:读取sysstat的历史数据,解析成JSON格式,供前端页面使用。
创建文件 /usr/local/bin/render-steal-data.sh:
PLAINTEXT
#!/bin/sh
set -e
# 配置输出目录(根据你的实际路径修改)
OUT=/var/www/steal-monitor
DATA_DIR="$OUT/data"
mkdir -p "$DATA_DIR"
# 用Python解析sysstat数据
python3 - <<'PY'
import json
import subprocess
from pathlib import Path
# 配置路径
OUT = Path('/var/www/steal-monitor')
DATA_DIR = OUT / 'data'
DATA_DIR.mkdir(parents=True, exist_ok=True)
SA_DIR = Path('/var/log/sysstat')
# 清理旧的JSON文件(可选,避免文件堆积)
for old in DATA_DIR.glob('*.json'):
if old.name != 'available.json':
old.unlink()
available = []
latest_date = None
# 遍历所有sysstat数据文件
for sa_file in sorted(SA_DIR.glob('sa[0-9][0-9]')):
try:
# 用sadf将二进制数据转为JSON
raw = subprocess.check_output(
['sadf', '-j', str(sa_file), '--', '-u', 'ALL'],
text=True
)
doc = json.loads(raw)
host = doc['sysstat']['hosts'][0]
file_date = host.get('file-date')
stats = host.get('statistics', [])
except Exception:
continue
# 提取每个时间点的CPU数据
points = []
for item in stats:
ts = item.get('timestamp')
loads = item.get('cpu-load') or []
cpu_all = next((x for x in loads if x.get('cpu') == 'all'), None)
if not ts or not cpu_all:
continue
points.append({
'time': ts.get('time'),
'steal': float(cpu_all.get('steal', 0.0)),
'usr': float(cpu_all.get('usr', 0.0)),
'sys': float(cpu_all.get('sys', 0.0)),
'iowait': float(cpu_all.get('iowait', 0.0)),
'idle': float(cpu_all.get('idle', 0.0)),
})
if not file_date or not points:
continue
# 计算统计数据
steals = [p['steal'] for p in points]
summary = {
'samples': len(points),
'latest': round(steals[-1], 2) if steals else None,
'avg': round(sum(steals) / len(steals), 2) if steals else None,
'min': round(min(steals), 2) if steals else None,
'max': round(max(steals), 2) if steals else None,
}
# 保存当天的数据
payload = {
'date': file_date,
'points': points,
'summary': summary,
}
with open(DATA_DIR / f'{file_date}.json', 'w', encoding='utf-8') as f:
json.dump(payload, f, ensure_ascii=False)
available.append(file_date)
latest_date = file_date if latest_date is None or file_date > latest_date else latest_date
# 保存可用日期列表
with open(OUT / 'available.json', 'w', encoding='utf-8') as f:
json.dump({'dates': available, 'latest': latest_date}, f, ensure_ascii=False)
PY脚本原理解释:
遍历sysstat文件:
/var/log/sysstat/sa*每个文件对应一天的数据用sadf解析:
sadf -j可以把二进制数据转成JSON格式提取steal字段:从JSON中提取每个时间点的
%steal值计算统计值:日均值、峰值、最新值等
生成JSON文件:每天一个文件,如
2024-06-22.json生成索引文件:
available.json记录有哪些日期的数据
赋予执行权限:
PLAINTEXT
chmod +x /usr/local/bin/render-steal-data.sh测试运行:
PLAINTEXT
/usr/local/bin/render-steal-data.sh运行后检查:
PLAINTEXT
ls -lh /var/www/steal-monitor/
# 应该能看到 available.json 和 data/ 目录
ls -lh /var/www/steal-monitor/data/
# 应该能看到 2024-06-22.json 这样的文件第三步:设置定时刷新
让脚本每分钟运行一次,实时更新数据:
创建文件 /etc/cron.d/render-steal-data:
PLAINTEXT
* * * * * root /usr/local/bin/render-steal-data.sh >/dev/null 2>&1这样每分钟都会刷新JSON数据,页面刷新就能看到最新值。
第四步:创建HTML监控页面
在 /var/www/steal-monitor/ 目录创建 index.html:
核心功能:
日期选择器:可以切换查看不同日期的数据
统计卡片:显示最新值、日均值、峰值、样本数
超售等级判断:根据规则自动判断并用颜色标识
曲线图:SVG绘制steal时间的趋势曲线
详细表格:列出每个时间点的CPU各项指标
由于HTML代码比较长(约400行),我把关键部分拆解讲解:
(1) 页面结构
PLAINTEXT
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<title>CPU Steal 监控</title>
<style>
/* CSS样式 */
</style>
</head>
<body>
<main>
<!-- 标题和日期选择器 -->
<div class="header">...</div>
<!-- 统计卡片 -->
<section class="grid">...</section>
<!-- 曲线图 -->
<section class="panel chart-wrap">
<div id="chartMount"></div>
</section>
<!-- 详细数据表格 -->
<section class="panel">
<table class="table">...</table>
</section>
</main>
<script>
/* JavaScript逻辑 */
</script>
</body>
</html>(2) 超售等级判断逻辑
PLAINTEXT
function getOversellLevel(summary) {
const avg = summary.avg;
const max = summary.max;
let level = 0;
if (avg >= 10) level = 3; // 严重争抢
else if (avg >= 5) level = 2; // 明显争抢
else if (avg >= 2) level = 1; // 轻度争抢
// 如果峰值>=15%,等级上调一级
if (max != null && max >= 15 && level < 3) {
level += 1;
}
// 根据等级返回样式和描述
if (level === 0) {
return {
cls: 'normal',
label: '正常',
desc: `按日均 ${avg.toFixed(2)}% 判断,当前没有明显宿主争抢迹象。`
};
}
// ... 其他等级
}(3) 数据加载和渲染
PLAINTEXT
async function init() {
// 1. 加载available.json,获取有哪些日期的数据
const res = await fetch(`available.json?ts=${Date.now()}`);
const meta = await res.json();
availableDates = meta.dates || [];
// 2. 默认加载最新一天的数据
const selected = meta.latest || availableDates[availableDates.length - 1];
await loadDate(selected);
}
async function loadDate(selectedDate) {
// 3. 加载指定日期的数据
const res = await fetch(`data/${selectedDate}.json?ts=${Date.now()}`);
const data = await res.json();
// 4. 渲染统计卡片
setSummary(data.summary);
// 5. 渲染曲线图
renderChart(data.points, selectedDate);
// 6. 渲染表格(显示最近24条数据)
renderRows(data.points);
}(4) SVG曲线图绘制
关键代码:
PLAINTEXT
function renderChart(points, selectedDate) {
// 计算坐标点
const polyline = points.map((p, i) => {
const x = pad.left + (stepX * i);
const y = pad.top + innerH - (p.steal / yMax) * innerH;
return `${x},${y}`;
}).join(' ');
// 生成SVG
chartMount.innerHTML = `
<svg viewBox="0 0 ${width} ${height}">
<!-- 背景网格 -->
${yTicks.map(v => `<line ... />`).join('')}
<!-- 填充区域 -->
<path d="${area}" fill="url(#fillSteal)"/>
<!-- 曲线 -->
<polyline points="${polyline}" stroke="#1d7a47"/>
</svg>`;
}(5) 完整的HTML代码
由于代码比较长,我提供一个精简但完整可用的版本。
创建文件 /var/www/steal-monitor/index.html,粘贴以下代码:
注意:完整的HTML代码约400行,这里提供核心框架版本。如果需要完整版(包含精美样式和动画),可以关注公众号回复"steal监控"获取。
PLAINTEXT
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>CPU Steal 监控</title>
<style>
:root {
--bg: #f6f7f3;
--panel: #ffffff;
--ink: #182018;
--muted: #667066;
--line: #d9ddcf;
--accent: #1d7a47;
--accent-soft: #e7f4ec;
--warn-soft: #fff3df;
--warn-ink: #a45c00;
--bad-soft: #ffe8de;
--bad-ink: #b34820;
--critical-soft: #ffe1e1;
--critical-ink: #b42318;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: ui-sans-serif, -apple-system, sans-serif;
background: linear-gradient(180deg, #eef1e8 0%, var(--bg) 100%);
color: var(--ink);
}
main {
width: min(1180px, calc(100% - 32px));
margin: 24px auto 40px;
}
.header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
margin-bottom: 20px;
}
.title { font-size: 30px; font-weight: 700; }
.subtitle { color: var(--muted); font-size: 14px; }
input[type="date"], button {
border: 1px solid var(--line);
background: var(--panel);
border-radius: 12px;
padding: 10px 12px;
font: inherit;
}
button { background: var(--accent); color: #fff; border: 0; cursor: pointer; }
.grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.card {
background: var(--panel);
border: 1px solid rgba(24,32,24,0.08);
border-radius: 18px;
padding: 16px;
box-shadow: 0 10px 30px rgba(33,42,33,0.05);
}
.card .label { color: var(--muted); font-size: 13px; margin-bottom: 8px; }
.card .value { font-size: 30px; font-weight: 700; }
.badge {
display: inline-flex;
padding: 8px 12px;
border-radius: 999px;
font-size: 14px;
font-weight: 700;
}
.badge.normal { background: var(--accent-soft); color: var(--accent); }
.badge.warn { background: var(--warn-soft); color: var(--warn-ink); }
.badge.bad { background: var(--bad-soft); color: var(--bad-ink); }
.badge.critical { background: var(--critical-soft); color: var(--critical-ink); }
.level-desc { margin-top: 10px; color: var(--muted); font-size: 13px; line-height: 1.5; }
.panel { background: var(--panel); border-radius: 18px; padding: 18px; margin-bottom: 16px; }
svg { width: 100%; height: auto; display: block; }
.table { width: 100%; border-collapse: collapse; }
.table th, .table td {
text-align: left;
padding: 10px 0;
border-bottom: 1px solid var(--line);
font-size: 14px;
}
.table th { color: var(--muted); font-weight: 600; }
@media (max-width: 900px) {
.grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
</style>
</head>
<body>
<main>
<div class="header">
<div>
<div class="title">CPU 被偷时间监控</div>
<div class="subtitle">按天查看 steal 历史,判断宿主机资源争抢</div>
</div>
<div>
<input id="datePicker" type="date">
<button id="todayBtn">今天</button>
</div>
</div>
<section class="grid">
<div class="card"><div class="label">最新值</div><div class="value" id="latestValue">-</div></div>
<div class="card"><div class="label">日均值</div><div class="value" id="avgValue">-</div></div>
<div class="card"><div class="label">峰值</div><div class="value" id="maxValue">-</div></div>
<div class="card"><div class="label">样本数</div><div class="value" id="samplesValue">-</div></div>
<div class="card">
<div class="label">疑似超售等级</div>
<div id="levelBadge" class="badge normal">-</div>
<div id="levelDesc" class="level-desc">-</div>
</div>
</section>
<section class="panel">
<div id="chartMount"></div>
<div style="color: var(--muted); font-size: 13px; margin-top: 12px;">
判断规则:日均 steal 小于 2% 视为正常;2%-5% 轻度争抢;5%-10% 明显争抢;≥10% 严重争抢。
</div>
</section>
<section class="panel">
<table class="table">
<thead>
<tr>
<th>时间</th><th>Steal</th><th>User</th><th>System</th><th>IOwait</th><th>Idle</th>
</tr>
</thead>
<tbody id="rows"></tbody>
</table>
</section>
</main>
<script>
const datePicker = document.getElementById('datePicker');
const todayBtn = document.getElementById('todayBtn');
const chartMount = document.getElementById('chartMount');
const rows = document.getElementById('rows');
const latestValue = document.getElementById('latestValue');
const avgValue = document.getElementById('avgValue');
const maxValue = document.getElementById('maxValue');
const samplesValue = document.getElementById('samplesValue');
const levelBadge = document.getElementById('levelBadge');
const levelDesc = document.getElementById('levelDesc');
let availableDates = [];
const fmt = (v) => v == null ? '-' : `${Number(v).toFixed(2)}%`;
function getOversellLevel(summary) {
const avg = summary.avg;
const max = summary.max;
if (avg == null) return { cls: 'normal', label: '-', desc: '暂无数据' };
let level = 0;
if (avg >= 10) level = 3;
else if (avg >= 5) level = 2;
else if (avg >= 2) level = 1;
if (max != null && max >= 15 && level < 3) level += 1;
const descs = [
{ cls: 'normal', label: '正常', desc: `按日均 ${avg.toFixed(2)}% 判断,没有明显争抢。` },
{ cls: 'warn', label: '轻度争抢', desc: `按日均 ${avg.toFixed(2)}% 判断,有一定抢占。` },
{ cls: 'bad', label: '明显争抢', desc: `按日均 ${avg.toFixed(2)}%、峰值 ${max.toFixed(2)}% 判断,会影响低延迟业务。` },
{ cls: 'critical', label: '严重争抢', desc: `按日均 ${avg.toFixed(2)}%、峰值 ${max.toFixed(2)}% 判断,强烈怀疑过度争抢。` }
];
return descs[level];
}
function setSummary(summary) {
latestValue.textContent = fmt(summary.latest);
avgValue.textContent = fmt(summary.avg);
maxValue.textContent = fmt(summary.max);
samplesValue.textContent = summary.samples ?? '-';
const level = getOversellLevel(summary || {});
levelBadge.className = `badge ${level.cls}`;
levelBadge.textContent = level.label;
levelDesc.textContent = level.desc;
}
function renderChart(points) {
if (!points.length) {
chartMount.innerHTML = '<div style="height:200px;display:grid;place-items:center;color:var(--muted)">暂无数据</div>';
return;
}
const width = 1000, height = 320;
const pad = { top: 18, right: 18, bottom: 42, left: 48 };
const innerW = width - pad.left - pad.right;
const innerH = height - pad.top - pad.bottom;
const values = points.map(p => p.steal);
const yMax = Math.max(10, Math.ceil(Math.max(...values) / 5) * 5);
const stepX = points.length > 1 ? innerW / (points.length - 1) : innerW;
const polyline = points.map((p, i) => {
const x = pad.left + stepX * i;
const y = pad.top + innerH - (p.steal / yMax) * innerH;
return `${x},${y}`;
}).join(' ');
const yTicks = Array.from({ length: 5 }, (_, i) => Math.round((yMax / 4) * i));
chartMount.innerHTML = `
<svg viewBox="0 0 ${width} ${height}">
<defs>
<linearGradient id="fillSteal" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#1d7a47" stop-opacity="0.26"/>
<stop offset="100%" stop-color="#1d7a47" stop-opacity="0.02"/>
</linearGradient>
</defs>
<rect x="0" y="0" width="${width}" height="${height}" rx="14" fill="#fbfcf9"/>
${yTicks.map(v => {
const y = pad.top + innerH - (v / yMax) * innerH;
return `<line x1="${pad.left}" y1="${y}" x2="${pad.left + innerW}" y2="${y}" stroke="#d9ddcf" stroke-dasharray="4 6"/><text x="${pad.left - 10}" y="${y + 4}" text-anchor="end" fill="#667066" font-size="12">${v}%</text>`;
}).join('')}
<polyline points="${polyline}" fill="none" stroke="#1d7a47" stroke-width="3"/>
</svg>`;
}
function renderRows(points) {
rows.innerHTML = points.slice().reverse().slice(0, 24).map(p => `
<tr>
<td>${p.time}</td>
<td>${p.steal.toFixed(2)}%</td>
<td>${p.usr.toFixed(2)}%</td>
<td>${p.sys.toFixed(2)}%</td>
<td>${p.iowait.toFixed(2)}%</td>
<td>${p.idle.toFixed(2)}%</td>
</tr>`).join('');
}
async function loadDate(selectedDate) {
const res = await fetch(`data/${selectedDate}.json?ts=${Date.now()}`);
if (!res.ok) {
setSummary({});
renderChart([]);
rows.innerHTML = '';
return;
}
const data = await res.json();
setSummary(data.summary || {});
renderChart(data.points || []);
renderRows(data.points || []);
}
async function init() {
const res = await fetch(`available.json?ts=${Date.now()}`);
const meta = await res.json();
availableDates = meta.dates || [];
if (!availableDates.length) {
renderChart([]);
return;
}
datePicker.min = availableDates[0];
datePicker.max = availableDates[availableDates.length - 1];
const selected = meta.latest || availableDates[availableDates.length - 1];
datePicker.value = selected;
await loadDate(selected);
}
datePicker.addEventListener('change', () => loadDate(datePicker.value));
todayBtn.addEventListener('click', () => {
if (!availableDates.length) return;
const latest = availableDates[availableDates.length - 1];
datePicker.value = latest;
loadDate(latest);
});
init();
</script>
</body>
</html>这个HTML文件是完全独立可用的,不依赖任何外部CSS/JS库,只需要:
数据生成脚本定时生成JSON文件
放在Web服务器目录下
浏览器访问即可
第五步:配置Web服务器
方案A:用Nginx
PLAINTEXT
server {
listen 80;
server_name steal.yourdomain.com;
root /var/www/steal-monitor;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}方案B:用1Panel可视化面板
在1Panel中创建站点
站点根目录指向:
/var/www/steal-monitor绑定域名
访问域名即可
第六步:验证和使用
1. 访问监控页面
浏览器打开你配置的域名,应该能看到:
顶部有日期选择器
5个统计卡片(最新值、日均值、峰值、样本数、超售等级)
中间是steal时间的曲线图
底部是详细数据表格
2. 查看历史数据
点击日期选择器,可以切换到任意一天查看历史曲线。
3. 判断超售
重点看:
超售等级卡片的颜色和文字
曲线图的峰值和波动情况
晚高峰(19:00-22:00)时段的steal值
4. 保存证据
如果发现超售严重,直接截图保存:
曲线图截图
超售等级卡片截图
可以作为找服务商理论的证据
如何手工查看Steal数据(不部署监控页面)
如果觉得部署网页太麻烦,也可以直接用命令行查看:
PLAINTEXT
# 实时查看(每秒刷新,共5次)
sar -u 1 5
# 查看历史数据(15号的数据)
sar -u -f /var/log/sysstat/sa15
# 查看top输出中的st指标
top重点看输出中的 %steal 或 st 字段。
但命令行不如网页直观,而且不能看曲线图,所以我还是建议部署监控页面。
九、拿到新服务器后的正确检测流程
基于我踩过的坑,给大家一个建议的检测流程:
1. 选择晚高峰测试(关键!)
不要在下午4点测试,要在晚上7-10点测试,这时候最能看出超售情况。
2. 运行综合跑分脚本
融合怪脚本(包含性能、网络、硬件测试):
PLAINTEXT
bash <(wget -qO- --no-check-certificate https://gitlab.com/spiritysdx/za/-/raw/main/ecs.sh)这个脚本会测试:
CPU性能(单核/多核跑分)
内存读写速度
硬盘IO性能
网络上传下载速度
重点看CPU跑分:
如果晚高峰跑分明显低于宣传值或其他用户的跑分,可能超售
对比同款CPU在其他平台的跑分数据
3. 网络测试
三网测速脚本:
PLAINTEXT
bash <(curl -sL https://run.NodeQuality.com)测试到电信/联通/移动的网络质量。
4. 部署Steal监控(强烈推荐)
按照前面的步骤部署监控工具,持续观察至少24小时,重点看:
晚高峰(19:00-22:00)的Steal曲线
日均Steal值
峰值Steal值
5. 对比分析
如果发现以下情况,说明超售严重:
✅ 下午跑分正常,晚上跑分骤降
✅ Steal日均值 > 5%
✅ 晚高峰Steal峰值 > 10%
✅ 空载时CPU占用就很高(但实际进程占用很低)
✅ ping延迟正常,但HTTP请求响应很慢
建议:
5% < Steal < 10%:可以用,但避免跑延迟敏感业务
Steal > 10%:建议退款或换服务商
Steal > 15%:必须换,这种超售会严重影响体验
十、常见误区
很多人在排查性能问题时会陷入这些误区:
误区1:监控工具(如Nezha)显示CPU占用低,就认为没问题
真相:
监控工具看到的是"虚拟机内部"的CPU占用
它不知道有多少时间在"等待"宿主机调度
关键要看
%steal指标
误区2:ping延迟低,就认为网络没问题
真相:
ICMP ping走的是系统网络栈,优先级高
HTTP流式API、WebSocket等业务流量优先级更低
超售导致CPU不足时,业务流量会明显变慢
ping正常 ≠ 业务正常
误区3:单次测速正常,就认为服务器性能没问题
真相:
超售有时间规律性(晚高峰更严重)
单次测试可能正好赶上"空闲时段"
必须测试晚高峰时段,或持续监控24小时
误区4:相信服务商的"不超售"承诺
真相:
几乎所有云服务商都会超售,区别只是程度
"不超售"往往是营销话术
要用数据说话,实测Steal时间才是硬道理
十一、我的服务器检测结果
回到我朋友的那台服务器,通过监控工具测得:
日均Steal: 5.44%
峰值Steal: 9.87%
按照判断标准,属于明显争抢级别。
这完全能解释为什么:
上游API返回时间正常(比如调用OpenAI API)
但我的newapi中转服务首token延迟很长
总体响应时间比直连上游还慢
原因分析:
newapi统计的是它完整处理请求所花的时间,包括:
接收客户端请求
转发给上游API
接收上游响应
返回给客户端
任何一个环节的CPU被"偷走",都会导致总时间变长:
Nginx转发层阻塞(CPU等待)
Docker调度抖动(宿主机CPU争抢)
网络栈处理缓慢(CPU不足)
虽然上游API本身响应快,但在我这台超售严重的服务器上,前后处理环节都变慢了,最终体现为总响应时间变长。
十二、总结与建议
核心要点
服务器超售很常见,小厂尤其严重
Steal时间是检测CPU超售的关键指标
必须测试晚高峰时段,单次测试不可靠
监控工具显示正常 ≠ 真的正常,要看steal
持续监控24小时以上,看历史曲线
购买新服务器时的建议
优先选大厂:虽然贵一点,但超售控制更合理
小厂必须测试:拿到服务器后立即部署Steal监控
晚高峰跑分:晚上7-10点测试最准确
保留证据:截图保存Steal曲线、跑分结果
善用退款:发现严重超售,趁退款期内果断退
可接受的超售程度
根据我的经验:
< 2%:优秀,可以跑任何业务
2-5%:良好,一般业务无感知
5-8%:可接受,但延迟敏感业务会受影响
8-10%:较差,建议仅跑离线任务
> 10%:不可接受,建议退款
工具下载
完整的HTML代码和脚本我已经在文章中详细讲解了核心原理,你可以根据上面的教程自己搭建。
或者参考我的实现思路,核心就是:
用
sysstat定时采样用
sadf解析历史数据Python脚本生成JSON供前端展示
静态HTML+JavaScript实现数据可视化
根据阈值自动判断超售等级
希望这篇文章能帮你避开服务器超售的坑!如果你也遇到过类似问题,欢迎留言分享~
如果觉得有用,记得点个"在看"哦~❤️