《Java 并发编程·性能诊断与事故》12.3 告警阈值与容量水位

把 150 个/秒的负载打进 4 线程、64 槽的有界池,按秒采集队列填充率、拒绝率、活跃占比与端到端 p99,用可运行的状态机验证持续时间窗口、连续次数与分级告警,并指出服务内 p99 只有 55 ms 而端到端已 792 ms 的指标盲区。

承接的问题

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() 与容量排队有多深单点快照抖;无界队列无容量
saturationactiveCount / maximumPoolSizegetActiveCount()离线程上限多远active 是估值;max 设太大时永远不满
utilizationactiveCount / 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 ms10 / 5 / 1 分钟0.8×SLO、SLO、2.5×SLO
rejectRate> 0.1%> 1%> 5%10 / 5 / 1 分钟误差预算与重试放大
queueFill≥ 70%≥ 85%≥ 95% 且拒绝 > 05 / 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记录,排期扩容看板/日报下一个迭代处理

防抖有四件套,缺一不可:

  1. 连续 N 次:连续 N 个采样周期都满足才点火。本机实验里 P3 的 activeRatio 从第 1 秒就是 1.00,但要求连续 10 次,所以到第 10 秒才响;如果只看单点,第 1 秒就会误报。
  2. 持续时间窗口:P1 的「持续 1 分钟」比「连续 3 次」更抗采样抖动,适合慢变量;快变量用连续次数更直接。两者可以叠加:窗口内满足比例 ≥ 80%。
  3. 恢复阈值(滞回):点火线 85%、恢复线 70%,否则会在阈值附近反复 ALERT/RESOLVED。
  4. 指纹去重与抑制:同一 (规则, 实例, 请求类型) 在抑制窗口内只通知一次;维护窗口要有到期时间,不能无限抑制。

下面的状态机实现了「连续 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%P13 × 1 s第 4 秒,38.4%分母是提交数
e2eP99 ≥ 200 msP23 × 1 s第 3 秒,739 msSLO 锚点
queueFill ≥ 80%P23 × 1 s第 3 秒,100%浅队列提前量不足 1 s
saturation ≥ 90%P310 × 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 与逃逸

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收