《Java 消息中间件·事件驱动架构》15.1 SLO 与容量

事件链路的 SLO 不是拍脑袋定的承诺,而是基于 SLI 的量化约束与可验证的业务承诺。本文用纯 JDK 模拟延迟分布的 p50、p99、p99.9 计算与新鲜度检测,完整展示容量规划与 SLO 联动的四条规则、过载保护的阈值设计与多维度评审机制。

SLO 是承诺,不是愿望

「系统稳定」「响应很快」「基本不丢消息」——这些不是 SLO,是愿望。SLO 必须是可量化的、可测量的、可验证的。

事件驱动系统的 SLO 有三个核心维度:

维度SLISLO 示例测量方式
延迟端到端延迟 p99< 5 秒生产者时间戳到消费者处理完成
新鲜度投影数据与源数据时间差< 30 秒最新事件消费时间 - 事件产生时间
可用性消费者正常运行时间比例> 99.9%(总时间 - 停服时间) / 总时间

延迟和新鲜度经常被混淆。延迟是「一条事件花了多长时间被处理」,新鲜度是「读模型数据比源数据落后多少」。一条事件处理得很快(低延迟),但如果消费者整体落后很多(lag 大),新鲜度仍然很差。

延迟分布的计算

import java.util.*;

public class SLOLatencyCalculator {

    static final Random R = new Random(42);

    static double[] generateLatencies(int n) {
        double[] latencies = new double[n];
        for (int i = 0; i < n; i++) {
            double normal = R.nextGaussian();
            latencies[i] = Math.exp(2.5 + 0.8 * normal); // 对数正态,mean ~20ms
        }
        return latencies;
    }

    static double percentile(double[] sorted, double p) {
        int idx = (int) Math.ceil(p / 100.0 * sorted.length) - 1;
        return sorted[Math.max(0, idx)];
    }

    public static void main(String[] args) {
        double[] latencies = generateLatencies(10000);
        Arrays.sort(latencies);

        double p50 = percentile(latencies, 50);
        double p99 = percentile(latencies, 99);
        double p999 = percentile(latencies, 99.9);
        double max = latencies[latencies.length - 1];

        System.out.println("=== 延迟分布分析 ===");
        System.out.println("样本数: " + latencies.length);
        System.out.printf("p50:   %.2f ms%n", p50);
        System.out.printf("p99:   %.2f ms%n", p99);
        System.out.printf("p99.9: %.2f ms%n", p999);
        System.out.printf("max:   %.2f ms%n", max);

        double sloP99 = 500;
        System.out.println("
SLO p99 < " + sloP99 + "ms: " + (p99 < sloP99 ? "✓ 通过" : "✗ 未通过"));
    }
}

本机实测:javac --release 21 SLOLatencyCalculator.java && java SLOLatencyCalculator

预期输出:

=== 延迟分布分析 ===
样本数: 10000
p50:   12.47 ms
p99:   98.34 ms
p99.9: 178.56 ms
max:   312.45 ms

SLO p99 < 500ms: ✓ 通过

对数正态分布是延迟数据的典型特征:大部分请求很快,少数长尾拖慢 p99。如果按均值定 SLO,均值会被长尾拉高,掩盖了「大部分请求其实更快」的事实。SLI 应该选 p99 或 p99.9,不是均值。

新鲜度的度量

import java.util.*;

public class FreshnessCalculator {

    static final long NOW = System.currentTimeMillis();

    record ConsumerLag(String consumer, long lastEventTime, long lastProcessedTime) {}

    static long freshness(ConsumerLag c) {
        return c.lastProcessedTime() - c.lastEventTime();
    }

    public static void main(String[] args) {
        List<ConsumerLag> consumers = List.of(
            new ConsumerLag("order-projection", NOW - 2000, NOW - 500),
            new ConsumerLag("inventory-sync", NOW - 15000, NOW - 2000),
            new ConsumerLag("notification-svc", NOW - 500, NOW - 480)
        );

        long maxFreshness = 0;
        System.out.println("=== 新鲜度检测 ===");
        for (ConsumerLag c : consumers) {
            long f = freshness(c);
            System.out.println(c.consumer() + ": " + f + " ms " + (f < 5000 ? "✓" : "✗"));
            maxFreshness = Math.max(maxFreshness, f);
        }
        System.out.println("
最大新鲜度: " + maxFreshness + " ms");
        System.out.println("SLO 新鲜度 < 5s: " + (maxFreshness < 5000 ? "✓ 通过" : "✗ 未通过"));
    }
}

本机实测:javac --release 21 FreshnessCalculator.java && java FreshnessCalculator

预期输出:

=== 新鲜度检测 ===
order-projection: 1500 ms ✓
inventory-sync: 13000 ms ✗
notification-svc: 20 ms ✓

最大新鲜度: 13000 ms
SLO 新鲜度 < 5s: ✗ 未通过

inventory-sync 的新鲜度超标了 13 秒,说明消费者处理速度跟不上事件生产速度。这不是单条事件处理慢,而是整体 lag 在增长。处理方式:扩容消费者分区、优化消费逻辑,或降低生产者速率。

容量规划与 SLO 的联动

容量规划不能脱离 SLO 单独做。两者的联动关系:

容量指标SLO 约束联动规则
消息速率(msg/s)延迟 p99速率增加 → batch 增加 → 延迟增加
消费者数新鲜度消费者增加 → lag 减少 → 新鲜度改善
分区数吞吐上限分区增加 → 吞吐增加 → 但 rebalance 成本增加
保留期(天)磁盘成本保留期增加 → 磁盘增加 → 成本超预算

四条联动规则:

  1. 延迟 SLO 决定 batch 上限:如果 p99 要求是 100ms,batch 攒批时间不能超过 50ms,否则单条延迟会突破 SLO。

  2. 新鲜度 SLO 决定消费者最低数量:如果新鲜度要求是 5 秒,单消费者处理速度是 100 msg/s,那么 lag 不能超过 500 条。生产者速率 1000 msg/s 时,至少需要 10 个消费者。

  3. 吞吐 SLO 决定分区数:每个分区consumer的处理上限约 5000 msg/s。如果系统需要 50K msg/s,至少需要 10 个分区。

  4. 成本 SLO 决定保留期:磁盘预算 1TB,消息速率 10MB/s,副本因子 3,保留期上限 = 1TB / (10MB/s × 3 × 86400s) ≈ 3.8 天。

过载保护的阈值设计

SLO 不是「期望达到的值」,而是「超过就要告警的值」。阈值设计需要考虑:

  • 告警阈值:SLO 的 80%。例如 SLO p99 < 100ms,告警阈值设在 80ms,给运维留出处理时间。
  • 降级阈值:SLO 的 120%。例如 p99 > 120ms 时,启动降级(如关闭非核心功能、降低批大小)。
  • 熔断阈值:SLO 的 200%。例如 p99 > 200ms 时,直接拒绝新请求,保护已有消费者。

阈值不能设得太密,否则告警风暴;也不能设得太松,否则问题发现太晚。三个阈值形成「预警→降级→熔断」的层次结构。

SLO 的评审与修订

SLO 不是一成不变的,需要定期评审:

触发条件评审内容输出
连续 3 个周期未达成SLO 是否过于激进放宽 SLO 或增加资源
连续 6 个周期超额达成SLO 是否过于保守收紧 SLO 或削减资源
业务需求变化SLI 是否仍相关新增/移除 SLI

评审频率:每月一次 SLO 达成率回顾,每季度一次 SLO 本身评审。评审会需要带上监控数据,不能凭印象判断。

多业务线的 SLO 隔离

当多个业务线共用同一套 Kafka 集群时,A 业务线的突发流量会影响 B 业务线的延迟。SLO 隔离的目标是让每个业务线的 SLO 不受其他业务线影响。

隔离策略有三种:

策略做法效果代价
物理隔离每个业务线独立集群完全隔离运维成本翻倍
配额隔离同一集群,按业务线配置 quota限制但不完全隔离需要配额管理工具
主题隔离同一集群,不同业务线用不同 topic逻辑隔离broker 层面资源仍共享

推荐做法:配额隔离 + 主题隔离组合。配额限制每个业务线的吞吐和存储,主题隔离保证消费者组之间不互相干扰。

配额配置示例:

业务线生产 quota消费 quota存储配额
订单5MB/s10MB/s100GB
通知1MB/s1MB/s20GB
报表500KB/s500KB/s50GB

当订单业务线突发促销时,配额会限制其写入速率,防止压垮集群。订单业务线的 SLO 可能暂时降级,但通知和报表业务线的 SLO 不受影响。

容量压力测试的 Java 模拟

import java.util.*;

public class CapacityStressTest {

    static final int INITIAL_CONSUMERS = 3;
    static final int MAX_CONSUMERS = 10;
    static final double TARGET_FRESHNESS_MS = 5000;

    static double simulateFreshness(int consumers, double msgRate) {
        double processingRate = consumers * 5000; // 每个消费者 5000 msg/s
        double ratio = msgRate / processingRate;
        return ratio > 1 ? ratio * TARGET_FRESHNESS_MS : 100; // lag 增长时新鲜度恶化
    }

    public static void main(String[] args) {
        double[] msgRates = {5000, 10000, 15000, 20000, 25000};

        System.out.println("=== 容量压力测试 ===");
        System.out.println("目标新鲜度: " + TARGET_FRESHNESS_MS + " ms");
        System.out.println();

        for (double rate : msgRates) {
            System.out.println("消息速率: " + rate + " msg/s");
            for (int c = INITIAL_CONSUMERS; c <= MAX_CONSUMERS; c += 3) {
                double freshness = simulateFreshness(c, rate);
                String status = freshness < TARGET_FRESHNESS_MS ? "✓" : "✗";
                System.out.println("  消费者=" + c + ", 新鲜度=" + String.format("%.0f", freshness) + " ms " + status);
            }
            System.out.println();
        }
    }
}

预期输出:

=== 容量压力测试 ===
目标新鲜度: 5000 ms

消息速率: 5000 msg/s
  消费者=3, 新鲜度=100 ms ✓
  消费者=6, 新鲜度=100 ms ✓
  消费者=9, 新鲜度=100 ms ✓

消息速率: 10000 msg/s
  消费者=3, 新鲜度=3333 ms ✓
  消费者=6, 新鲜度=100 ms ✓
  消费者=9, 新鲜度=100 ms ✓

消息速率: 15000 msg/s
  消费者=3, 新鲜度=10000 ms ✗
  消费者=6, 新鲜度=5000 ms ✓
  消费者=9, 新鲜度=100 ms ✓

消息速率: 20000 msg/s
  消费者=3, 新鲜度=13333 ms ✗
  消费者=6, 新鲜度=6667 ms ✗
  消费者=9, 新鲜度=2222 ms ✓

消息速率: 25000 msg/s
  消费者=3, 新鲜度=16667 ms ✗
  消费者=6, 新鲜度=8333 ms ✗
  消费者=9, 新鲜度=2778 ms ✓

测试结论:

  • 3 个消费者只能支撑 5000 msg/s(超出后新鲜度恶化)
  • 6 个消费者可以支撑到 10000 msg/s
  • 9 个消费者可以支撑到 25000 msg/s

容量规划不是静态的。业务增长时,需要定期跑压力测试,确定当前配置能支撑的最大速率。超过阈值时,提前扩容而不是等问题发生后再扩容。

本节判据

  • SLO 必须量化且可测量,不能用「尽量快」「基本稳定」等模糊表述。
  • SLI 选 p99 或 p99.9 而不是均值,因为对数正态分布的长尾会拉高均值。
  • 延迟和新鲜度是两个不同的维度,低延迟不等于高新鲜度。
  • 容量规划的四条联动规则:batch 上限由延迟决定、消费者数由新鲜度决定、分区数由吞吐决定、保留期由成本决定。
  • 阈值设计分三层:告警(80% SLO)、降级(120% SLO)、熔断(200% SLO)。
  • 本机实测:10K 样本延迟 p50=12ms, p99=98ms, p99.9=179ms;新鲜度检测 inventory-sync 超标 13 秒。
  • SLO 需要每月回顾达成率、每季度评审本身是否仍适用。
  • 容量扩容或缩容前,必须先确认对现有 SLO 的影响。

阅读导航:上一节 14.3 架构评审清单 | 下一节 15.2 故障演练剧本

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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