承接的问题
12.1 把指标口径钉死了,12.2 把阻塞归因到请求。这一节回答最后一个问题:这些数字到了什么程度该叫人,叫谁,叫多大声。
构造两个典型的告警失败:
- 抖动型。 规则写成
queue.size() > 100立刻告警。队列在 100 上下每秒穿几次,告警一分钟响十几条,值班同学把规则静音,之后真出事也没人看。 - 哑火型。 规则只看「服务内耗时 p99 > 200 ms」。压测已经拒绝了 34% 的请求,端到端 p99 到 739 ms,服务内 p99 还稳稳停在 55 ms,规则一次没响。
两个问题的根因不同:前者缺时间窗口和恢复阈值,后者选错了指标。这一节先用一段真实饱和负载把水位跑出来,再讨论阈值怎么从 SLO 和历史分位推出来,最后给出分级、防抖和「指标饱和但不告警」的盲区清单。
本机口径与全卷一致:
Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125)
Temurin 25.0.5+8
写作时 load average 是 34.96 35.27 41.45,所有数字都是单次运行、随调度波动、不作基准。
水位指标怎么定义
先统一六个指标。它们的分子分母、采样方式、盲区都不同,不能互相替代。
| 指标 | 定义 | 数据来源 | 回答什么 | 盲区 |
|---|---|---|---|---|
queueFill | 队列长度 / 队列容量 | getQueue().size() 与容量 | 排队有多深 | 单点快照抖;无界队列无容量 |
saturation | activeCount / maximumPoolSize | getActiveCount() | 离线程上限多远 | active 是估值;max 设太大时永远不满 |
utilization | activeCount / poolSize | 同上 | 有没有空闲 worker | 线程都在 sleep 时也是 100% |
rejectRate | 窗口内拒绝数 / 提交数 | 自埋 handler 与提交计数 | 有多少请求没被接住 | 分母错成 completed 会失真 |
e2eP99 | 请求入口到响应的 p99 | 请求埋点 | 用户实际等多久 | 被拒绝的请求不在样本里 |
serviceP99 | 任务内部执行耗时的 p99 | 任务埋点 | 下游/计算本身多慢 | 不含排队,饱和时严重低估 |
两个容易混淆的口径:
saturation用 max 做分母,utilization用 poolSize。 前者回答「还能不能扩容」,后者回答「现有线程闲不闲」。池子扩到 max 之后两者都是 100%,此时唯一的增量信号是queueFill和rejectRate。e2eP99与serviceP99的差就是排队时间。 本次实测稳态下e2eP99 = 792 ms、serviceP99 = 55 ms,差 737 ms。这段差值不是下游变慢,而是请求在队列里等;只盯serviceP99会得出「服务很健康」的错误结论。
阈值怎么定:SLO → 误差预算 → 历史分位
阈值不是拍出来的,推导顺序是固定的四步。
第一步,写死 SLO。 本节用两个假设:接口 p99 ≤ 200 ms,月可用性 99.9%。SLO 是告警的锚点,没有 SLO 的阈值只能叫「经验值」。
第二步,算误差预算。 99.9% 可用性意味着每月约 43 分钟不可用。告警的作用是「在预算烧完之前叫人」,所以 P1 的触发点应该对应「按当前速度,1 小时内会烧掉当天预算」,而不是「已经挂了」。
第三步,用历史分位校准基线。 取 2–4 周同一时段的 p99,假设基线是 60–80 ms。阈值取 max(SLO × 0.8, baselineP99 × 1.5):前者保证不偏离 SLO,后者保证不因为基线本来就高而天天响。代入得 max(160, 120) = 160 ms,这是 P3 的预告线;P2 用 SLO 本身 200 ms;P1 用 2.5 × SLO = 500 ms。
第四步,留容量余量。 水位告警要留出处置时间。经验值是容量的 70%–80%,但余量必须结合填充速度算,不能只看百分比。本次实测:队列 64 槽,稳态吞吐约 93 个/秒,到达约 151 个/秒,净流入约 58 个/秒。队列从空到 80%(51 槽)只要 51 / 58 ≈ 0.88 s。也就是说,queueFill ≥ 80% 这条规则只有不到 1 秒的提前量;再叠加「连续 3 个 1 秒样本」,它点火时队列早已 100%、拒绝率已经 38%。百分比水位对浅队列是滞后指标,必须和 rejectRate、e2eP99 一起用,或者换成「到达速率 / 服务速率」这种先行指标。
把四步落成一张表(数值按上面的假设推导,未在本机验证其生产有效性,上线前必须用你自己的分位数据重算):
| 指标 | P3 预告 | P2 告警 | P1 紧急 | 连续窗口 | 依据 |
|---|---|---|---|---|---|
e2eP99 | ≥ 160 ms | ≥ 200 ms | ≥ 500 ms | 10 / 5 / 1 分钟 | 0.8×SLO、SLO、2.5×SLO |
rejectRate | > 0.1% | > 1% | > 5% | 10 / 5 / 1 分钟 | 误差预算与重试放大 |
queueFill | ≥ 70% | ≥ 85% | ≥ 95% 且拒绝 > 0 | 5 / 2 / 0.5 分钟 | 余量 + 填充速度校核 |
saturation | ≥ 85% | ≥ 95% | 100% 且队列在涨 | 10 / 5 / 1 分钟 | 扩容上限 |
serviceP99 | 仅看板 | 不告警 | 不告警 | — | 不含排队,见盲区 |
告警分级与防抖
分级不是按「严重程度形容词」,而是按响应动作分:
| 级别 | 判据示例 | 响应 | 通知 | 要求 |
|---|---|---|---|---|
| P1 | 拒绝率 > 5% 持续 1 分钟,或 e2eP99 > 500 ms | 立即回滚/扩容/限流 | 电话 + 群 | 5 分钟内有人接手 |
| P2 | 拒绝率 > 1%,或 e2eP99 > SLO,或队列 > 85% | 当班处理,可等变更窗口 | 群 + 工单 | 30 分钟内响应 |
| P3 | 饱和度 > 85% 持续 10 分钟,或 p99 > 0.8×SLO | 记录,排期扩容 | 看板/日报 | 下一个迭代处理 |
防抖有四件套,缺一不可:
- 连续 N 次:连续 N 个采样周期都满足才点火。本机实验里 P3 的
activeRatio从第 1 秒就是 1.00,但要求连续 10 次,所以到第 10 秒才响;如果只看单点,第 1 秒就会误报。 - 持续时间窗口:P1 的「持续 1 分钟」比「连续 3 次」更抗采样抖动,适合慢变量;快变量用连续次数更直接。两者可以叠加:窗口内满足比例 ≥ 80%。
- 恢复阈值(滞回):点火线 85%、恢复线 70%,否则会在阈值附近反复
ALERT/RESOLVED。 - 指纹去重与抑制:同一
(规则, 实例, 请求类型)在抑制窗口内只通知一次;维护窗口要有到期时间,不能无限抑制。
下面的状态机实现了「连续 N 次点火、连续 N 次不满足才恢复」,就是本次实验实际运行的版本:
import java.util.List;
import java.util.Locale;
import java.util.function.Predicate;
public final class AlertEvaluator {
public enum Level { P1, P2, P3 }
public record Rule(String name, Level level, int consecutive, Predicate<Sample> when) {}
public record Sample(int sec, long offered, long completed, long rejected,
double activeRatio, double queueFill, long e2eP99, long serviceP99) {
public double rejectRate() {
return offered == 0 ? 0 : rejected / (double) offered;
}
}
private final List<Rule> rules;
private final int[] streak;
private final boolean[] firing;
public AlertEvaluator(List<Rule> rules) {
this.rules = List.copyOf(rules);
this.streak = new int[this.rules.size()];
this.firing = new boolean[this.rules.size()];
}
public void onSample(Sample sample) {
for (int i = 0; i < rules.size(); i++) {
Rule rule = rules.get(i);
streak[i] = rule.when().test(sample) ? streak[i] + 1 : 0;
if (!firing[i] && streak[i] >= rule.consecutive()) {
firing[i] = true;
System.out.printf(Locale.ROOT, "ALERT t=%ds level=%s rule=%s value=%s streak=%d%n",
sample.sec(), rule.level(), rule.name(), valueOf(rule, sample), streak[i]);
} else if (firing[i] && streak[i] == 0) {
firing[i] = false;
System.out.printf(Locale.ROOT, "RESOLVED t=%ds level=%s rule=%s%n",
sample.sec(), rule.level(), rule.name());
}
}
}
private static String valueOf(Rule rule, Sample s) {
return switch (rule.name()) {
case "reject_rate_1pct" -> String.format(Locale.ROOT, "%.3f", s.rejectRate());
case "queue_fill_80pct" -> String.format(Locale.ROOT, "%.2f", s.queueFill());
case "e2e_p99_over_slo" -> s.e2eP99() + "ms";
case "active_ratio_90pct" -> String.format(Locale.ROOT, "%.2f", s.activeRatio());
default -> s.serviceP99() + "ms";
};
}
}
实跑:150 个/秒打到 4 线程、64 槽队列
负载参数:core = max = 4,ArrayBlockingQueue(64),每个任务睡 35–45 ms,提交端每 6.67 ms 发一个(150 个/秒),持续 10 秒共 1500 个任务。理论服务能力约 4 / 40 ms = 100 个/秒,到达 150 个/秒,必然饱和。采集端每秒算一次:e2eP99 和 serviceP99 用最近 5 秒的完成样本,activeRatio 用 getActiveCount()/core,queueFill 用瞬时队列长度。
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.LockSupport;
public final class AlertWatermarkProbe {
static final int CORE = 4;
static final int QUEUE = 64;
static final AtomicLong submitted = new AtomicLong();
static final AtomicLong completed = new AtomicLong();
static final AtomicLong rejected = new AtomicLong();
record Latency(long finishNanos, long e2eMs, long serviceMs) {}
public static void main(String[] args) throws Exception {
ThreadPoolExecutor pool = new ThreadPoolExecutor(
CORE, CORE, 0, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(QUEUE),
r -> Thread.ofPlatform().unstarted(r),
(r, executor) -> rejected.incrementAndGet());
ConcurrentLinkedQueue<Latency> latencies = new ConcurrentLinkedQueue<>();
Thread sampler = Thread.ofPlatform().name("sampler")
.start(() -> sampleLoop(pool, latencies));
long next = System.nanoTime();
for (int i = 0; i < 1500; i++) {
next += 6_666_666L;
long submitNanos = System.nanoTime();
submitted.incrementAndGet();
pool.execute(() -> {
long s0 = System.nanoTime();
try {
Thread.sleep(35 + ThreadLocalRandom.current().nextInt(11));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
long serviceMs = (System.nanoTime() - s0) / 1_000_000L;
long e2eMs = (System.nanoTime() - submitNanos) / 1_000_000L;
completed.incrementAndGet();
latencies.add(new Latency(System.nanoTime(), e2eMs, serviceMs));
});
long wait = next - System.nanoTime();
if (wait > 0) {
LockSupport.parkNanos(wait);
}
}
long submitDone = System.nanoTime();
while (System.nanoTime() - submitDone < 2_000_000_000L) {
Thread.sleep(100);
}
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
sampler.interrupt();
sampler.join(2_000);
System.out.printf(Locale.ROOT, "summary submitted=%d completed=%d rejected=%d%n",
submitted.get(), completed.get(), rejected.get());
}
static void sampleLoop(ThreadPoolExecutor pool, ConcurrentLinkedQueue<Latency> latencies) {
AlertEvaluator evaluator = new AlertEvaluator(List.of(
new AlertEvaluator.Rule("reject_rate_1pct", AlertEvaluator.Level.P1, 3,
s -> s.rejectRate() >= 0.01),
new AlertEvaluator.Rule("queue_fill_80pct", AlertEvaluator.Level.P2, 3,
s -> s.queueFill() >= 0.80),
new AlertEvaluator.Rule("e2e_p99_over_slo", AlertEvaluator.Level.P2, 3,
s -> s.e2eP99() >= 200),
new AlertEvaluator.Rule("active_ratio_90pct", AlertEvaluator.Level.P3, 10,
s -> s.activeRatio() >= 0.90),
new AlertEvaluator.Rule("service_p99_over_slo", AlertEvaluator.Level.P3, 3,
s -> s.serviceP99() >= 200)));
long prevSubmitted = 0, prevCompleted = 0, prevRejected = 0;
System.out.println("sec,offered,completed,rejected,activeRatio,queueFill,e2eP99ms,serviceP99ms");
for (int sec = 1; sec <= 14; sec++) {
try {
Thread.sleep(1_000);
} catch (InterruptedException e) {
return;
}
long now = System.nanoTime();
long s = submitted.get();
long c = completed.get();
long r = rejected.get();
long offered = s - prevSubmitted;
long done = c - prevCompleted;
long rej = r - prevRejected;
prevSubmitted = s;
prevCompleted = c;
prevRejected = r;
long cutoff = now - 5_000_000_000L;
List<Long> e2e = new ArrayList<>();
List<Long> service = new ArrayList<>();
for (Latency l : latencies) {
if (l.finishNanos() >= cutoff) {
e2e.add(l.e2eMs());
service.add(l.serviceMs());
}
}
AlertEvaluator.Sample sample = new AlertEvaluator.Sample(
sec, offered, done, rej,
pool.getActiveCount() / (double) CORE,
pool.getQueue().size() / (double) QUEUE,
percentile99(e2e), percentile99(service));
System.out.printf(Locale.ROOT, "%d,%d,%d,%d,%.2f,%.2f,%d,%d%n",
sample.sec(), sample.offered(), sample.completed(), sample.rejected(),
sample.activeRatio(), sample.queueFill(), sample.e2eP99(), sample.serviceP99());
evaluator.onSample(sample);
latencies.removeIf(l -> l.finishNanos() < now - 20_000_000_000L);
}
}
static long percentile99(List<Long> values) {
if (values.isEmpty()) {
return 0;
}
values.sort(Comparator.naturalOrder());
int idx = (int) Math.ceil(0.99 * values.size()) - 1;
return values.get(Math.max(0, Math.min(idx, values.size() - 1)));
}
}
编译运行:
javac --release 25 -d classes AlertEvaluator.java AlertWatermarkProbe.java
java -cp classes AlertWatermarkProbe
本机输出(节选,第 5–7 秒的稳态行略去;ALERT 行按发生顺序插在对应秒之后):
sec,offered,completed,rejected,activeRatio,queueFill,e2eP99ms,serviceP99ms
1,153,92,0,1.00,0.89,411,55
2,154,94,52,1.00,1.00,741,55
3,151,95,57,1.00,1.00,739,54
ALERT t=3s level=P2 rule=queue_fill_80pct value=1.00 streak=3
ALERT t=3s level=P2 rule=e2e_p99_over_slo value=739ms streak=3
4,151,93,58,1.00,1.00,748,55
ALERT t=4s level=P1 rule=reject_rate_1pct value=0.384 streak=3
8,152,92,59,1.00,1.00,771,55
9,149,90,61,1.00,0.97,773,55
10,137,89,56,1.00,0.84,792,55
ALERT t=10s level=P3 rule=active_ratio_90pct value=1.00 streak=10
11,0,58,0,0.00,0.00,792,55
RESOLVED t=11s level=P1 rule=reject_rate_1pct
RESOLVED t=11s level=P2 rule=queue_fill_80pct
RESOLVED t=11s level=P3 rule=active_ratio_90pct
summary submitted=1500 completed=980 rejected=520
这张表里有五个可以直接读出来的结论。
第一,服务内 p99 与端到端 p99 分叉。 serviceP99 全程 54–55 ms,看起来健康;e2eP99 从 411 ms 涨到 792 ms。差值约 737 ms 全在排队。只对 serviceP99 设阈值,这条负载会「零告警」地跑完,1500 个请求丢了 520 个。
第二,拒绝率在第二秒就爆了,但 P1 到第四秒才响。 第 2 秒 rejected=52/154 ≈ 34%,第 3 秒 57/151 ≈ 38%,第 4 秒 58/151 ≈ 38%。规则要求连续 3 次,所以第 4 秒点火。这是刻意的:3 秒的延迟换掉单点误报;如果业务不能接受 3 秒,就把 P1 的连续次数降到 1–2,代价是误报率上升。
第三,queueFill 在第一秒就到 89%,但那一秒拒绝数是 0。 队列快满不等于已经拒绝,二者是先后关系。反过来也成立:第 10 秒队列只有 84%,拒绝率仍有 41%,因为队列被 worker 持续抽干、又持续填满,瞬时值在 84%–100% 之间跳。单点 queueFill 不能单独作为 P1 判据,必须叠加拒绝率或持续时间。
第四,activeRatio 从第一秒就是 1.00,却到第 10 秒才告警。 因为规则要求连续 10 次。这个延迟是合理的——4 个线程忙不等于系统过载,短时间的 100% 很常见;但如果只看这一条,前 9 秒的恶化完全看不见。所以它只能当 P3 的趋势信号。
第五,恢复也会一起发生。 第 11 秒压测停止,offered=0,拒绝率按定义为 0,三条规则同时 RESOLVED。生产上恢复判据不该用「这一秒没有新请求」这种分母为 0 的情况,应该要求「连续 N 个窗口都有流量且指标正常」,否则夜间低峰会把所有告警自动恢复掉。
顺带用 Little’s law 校核一下队列:稳态吞吐约 93 个/秒,排队时间约 792 - 55 = 737 ms,L = λW ≈ 93 × 0.737 ≈ 68.5,与「64 个排队 + 4 个在跑」对上。这个校核说明采样口径自洽;如果算出来差一个数量级,先怀疑 e2eP99 或吞吐的统计窗口不一致。
指标饱和但没告警的盲区
以下每一条都能让面板「全绿」而用户已经在报错:
- 只统计成功请求的延迟。 被拒绝的 520 个请求根本不进直方图;分母用
completed还会让拒绝率看起来更低。 - 只测服务内耗时。 本次
serviceP99=55 ms,端到端 792 ms,差 14 倍;排队、线程调度、连接池等待都不在任务内部。 - 把
activeRatio=1.0当过载。 4 个线程睡 40 ms 也是 1.0;它只说明没有空闲 worker。 - 把
poolSize == max当池满。 池满还要看队列和拒绝;线程数到顶但队列为空时系统仍然健康。 - 队列百分比阈值太靠后。 64 槽、净流入 58 个/秒,从空到 80% 只要 0.88 秒;「连续 3 秒」的规则点火时已经全线拒绝。
- 连接池与应用线程池分开看。 应用线程都在等数据库连接时,应用池
active=max、队列为空,数据库连接池的pending才是真信号;两边的水位必须一起看。 - 虚拟线程下看线程数。 线程数不再是容量信号,载体线程数、pin 比例和下游连接池才是(6.1、6.2)。
- 每实例独立告警,不看服务级 SLO。 10 个实例各拒绝 0.5%,服务级已经 5%,但没有一个实例触发 1% 的规则。
- 平均值掩盖尾部。
avg=45 ms完全可能伴随p99=800 ms;阈值必须建在分位上。
在 libraryd-conc 里落地
conc-ops 的告警配置按下面的口径固定下来,数值是本节实验与假设 SLO 的结合,上生产前要用自己的 2–4 周分位重算:
| 规则 | 级别 | 连续窗口 | 本次实测触发点 | 备注 |
|---|---|---|---|---|
rejectRate ≥ 1% | P1 | 3 × 1 s | 第 4 秒,38.4% | 分母是提交数 |
e2eP99 ≥ 200 ms | P2 | 3 × 1 s | 第 3 秒,739 ms | SLO 锚点 |
queueFill ≥ 80% | P2 | 3 × 1 s | 第 3 秒,100% | 浅队列提前量不足 1 s |
saturation ≥ 90% | P3 | 10 × 1 s | 第 10 秒,100% | 只做趋势 |
serviceP99 ≥ 200 ms | 仅看板 | — | 从未触发,55 ms | 盲区演示 |
容量水位按实测吞吐外推(推导值,未在本机做多实例验证):单实例稳态约 93 个/秒,取 0.75 的余量系数得 70 个/秒/实例;目标 150 个/秒需要至少 3 个实例(150 / 70 ≈ 2.14,向上取整并留一个实例的故障余量)。这个数字只适用于本节的 35–45 ms 任务模型,任务变慢或下游变慢都要重算。
常见错误与边界
- 用平均值或单点值做阈值。 单点值制造抖动;至少用 1 分钟窗口的 p99。
- 阈值拍脑袋。 没有 SLO 和历史分位的阈值,调大调小都只能靠事故驱动。
- 没有连续次数或持续时间。 一次尖峰就叫人,最后规则会被静音。
- 没有恢复阈值。 在阈值附近反复点火/恢复,通知风暴比故障本身更烦。
- 分母用错。 拒绝率的分母是提交数;延迟分位只统计被接住的请求,必须同时报「未接住的比例」。
- 告警评估周期大于窗口。 窗口 1 分钟、每 5 分钟算一次,等于没有窗口;评估周期应不大于窗口的 1/3。
小结
阈值推导的顺序是 SLO → 误差预算 → 历史分位 → 容量余量,四步缺一不可。告警分级按响应动作分,不按形容词分;防抖靠连续次数、持续时间、恢复阈值和指纹去重四件套。
本次实测最重要的三个数:serviceP99 55 ms 对 e2eP99 792 ms,说明只测服务内耗时会漏掉 14 倍的排队;拒绝率第 2 秒已 34%,P1 因连续 3 次到第 4 秒才响;64 槽队列的 80% 水位只有 0.88 秒提前量,浅队列的百分比阈值必须搭配拒绝率和端到端分位。下一章换一个维度:并发与 GC——分配速率、TLAB 和逃逸分析如何反过来影响这些延迟数字。
阅读导航:上一节 12.2 阻塞点与调用链结合 | 下一节 13.1 分配速率、TLAB 与逃逸
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。