《Java 服务端通信工程》14.3 成本、扩缩容与预算

成本不是月底看账单。本节按核数、内存、出口流量拆开 linkd-v3 的月度成本,给出可复算的估算模型与两个算例,讨论 CPU 之外的扩缩容信号、冷启动的代价、最小副本与预算上限,以及预算与 SLO 之间的取舍,并附两段可编译的 Java 代码。

本节目标:把「这台机器多少钱」变成「每个请求多少钱、每多一个 9 多少钱」,并知道什么时候该加机器、什么时候该改架构。
价格口径:下文单价是假设值,仅用于演示算法,不代表任何云厂商的实际报价。

承接的问题

容量规划回答「能不能扛住」,成本规划回答「值不值得扛」。两者经常被分开处理:容量团队按峰值配机器,财务团队月底看到账单再砍预算,中间没有人对「用多少冗余换多少可用性」负责。

服务端通信的成本有三个特点:

  • 大头是常驻资源,不是请求数。空闲实例也在计费,按 QPS 线性估算会严重偏离。
  • 出口流量按字节计费,响应体大小和压缩策略直接影响账单。
  • 冗余是保险,不是浪费。为了扛住峰值而多留的 50% 容量,买到的是故障时的切换空间。

把这三件事写进一个模型,才能在「加机器」和「改代码」之间做比较。

成本三块:核数、内存、出口流量

一个可以手算的模型:

月度成本 = 实例数 × (vCPU × 单价_vcpu × 730 + 内存GiB × 单价_gib × 730)
         + 月度请求数 × 平均响应KB / 1,000,000 × 出口单价_GB × 出口占比

730 是一个月的小时数(365 × 24 / 12)。三个输入都要写清楚口径:

输入口径常见错误
实例数稳态实例数,不含临时扩容用峰值实例数算全年
vCPU / 内存实际请求量,不是节点规格把整台宿主机算给一个服务
月度请求数日均 QPS × 86400 × 30用峰值 QPS × 全月
平均响应大小压缩后的字节数用未压缩的 JSON 大小
出口占比真正走公网的流量比例把内网流量也算出口

内网流量通常不计出口费,但跨可用区可能有传输费。模型里用「出口占比」把两部分分开,避免把内网调用也算成公网账单。

GB 按十进制 1e9 字节折算(1 GB = 1,000,000 KB),与多数云厂商账单口径一致;如果账单用 GiB,同样字节数折算出的数值会少约 6.9%,模型里不要混用两套单位。

一个算例,假设单价为 vCPU 0.04 美元/小时、内存 0.005 美元/GiB/小时、出口 0.09 美元/GB:

实例:4 vCPU / 8 GiB,2 个副本
请求:200 rps,平均响应 2.5 KB(gzip 后),80% 走公网
月度请求数 = 200 × 86400 × 30 = 518,400,000
月度流量   = 518,400,000 × 2.5 KB = 1,296,000,000 KB = 1,296 GB
计算成本   = 2 × (4 × 0.04 × 730 + 8 × 0.005 × 730)
           = 2 × (116.8 + 29.2) = 292.0
出口成本   = 1,296 × 0.09 × 0.8 ≈ 93.3
月度合计   ≈ 385.3(假设口径)

如果不做 gzip,响应从 2.5 KB 涨到 8 KB,出口成本变成约 299 美元,略高于 292 美元的计算成本。这就是「压缩算法」在 4.3 节出现的原因:它不是性能优化,是成本优化。

反过来,如果 90% 流量走内网,出口成本降到约 11.7 美元,成本结构立刻变成以计算为主。同一个服务,部署形态不同,优化重点完全不同。

单位经济性:每个请求多少钱

月度总额不方便比较不同服务,换成「每百万请求成本」更直观。仍用上面的假设:

分项月度成本(美元)每百万请求(美元)占比
计算292.00.56376%
出口93.30.18024%
合计385.30.743100%

每百万请求不到 1 美元,听起来很低;但乘以真实请求量后,任何一项的百分比变化都会被放大。把响应从 2.5 KB 压到 1.8 KB,出口成本降到约 67 美元,每月省 26 美元;如果请求量是十倍,就是每月 260 美元。

降本的顺序应该按「改动成本 / 收益」排,而不是按「听起来高级」排:

  1. 去掉不必要的响应字段。 改一行序列化代码,收益立即可见,风险最低。
  2. 开启或调优压缩。 4.3 节的 gzip 对文本型响应通常有效,但要确认 CPU 余量。
  3. 加缓存。 命中率高的读接口能同时降计算和出口,代价是一致性。
  4. 调整实例规格。 从 8 vCPU 降到 4 vCPU 可能省一半计算费,但要先确认 p99。
  5. 预留实例 / 长期承诺。 适合稳态负载,不适合波动大的服务。
  6. 提高部署密度。 多个服务共享节点,省的是碎片,不是单服务成本,且会放大故障半径。

前三条是代码和配置层面的优化,通常比「再买一台机器」便宜;后三条涉及采购和架构,需要财务与容量一起决策。把顺序写进评审模板,能避免每次降本都从「砍机器」开始。

扩缩容指标:CPU 不是唯一信号

CPU 利用率是最容易拿到的指标,也是最容易误导的指标。线程卡在锁、连接池或下游等待时,CPU 很低,但服务已经饱和;GC 频繁时,CPU 很高,但吞吐不一定上得去。

信号含义扩容阈值示例局限
CPU计算密集度5 分钟平均 > 60%等待型瓶颈不敏感
在途请求 / 实例并发压力> 50需要按实例聚合
p99 延迟用户体验> 300 ms 持续 2 分钟受下游影响,可能误判
队列等待时间本地排队> 50 ms需要应用埋点
连接池等待下游连接不足> 20 ms应优先扩下游或调池
错误率过载或故障> 1% 持续 1 分钟分不清是容量还是 bug

扩缩容策略应该是「多信号取最大值」,而不是只看一个。一个可用的组合:

目标副本数 = max(
  按 CPU 需求,
  按在途请求需求,
  按 p99 需求,
  最小副本数
)
上限 = min(最大副本数, 预算允许的副本数)

扩容要快、缩容要慢。扩容冷却 60 秒,缩容冷却 300 秒,并且要求「连续 10 分钟低负载」才缩。缩得太快,流量稍一回升又要扩,反复抖动比多跑两台机器更贵。

还有一个容易忽略的信号:每实例 QPS 上限。如果单实例在 80 rps 时 p99 开始上升,那么 200 rps 至少需要 3 个实例,即使 CPU 只有 40%。这个上限应该由压测得出,写进配置,而不是靠 CPU 反推。

冷启动:扩容不是即时的

JVM 服务从进程启动到能稳定处理请求,需要经历:类加载、JIT 预热、连接池建立、TLS 握手、缓存填充。这个过程的耗时取决于应用,不能给一个统一数字;本节按「数十秒级」作为假设口径,实际值必须在本机或预发测量。

冷启动对扩缩容的影响:

  • 尖峰到来时,扩容决策 + 启动 + 就绪检查可能需要几十秒,这期间流量仍打在旧实例上。
  • 新实例刚上线时性能未达稳态,如果立刻按权重接满流量,p99 会短暂升高。
  • 频繁扩缩会让冷启动成本反复出现,均摊下来可能比常驻两台更贵。

缓解手段按成本从低到高:

  1. 保留最小副本数,不让服务缩到 0。
  2. 就绪检查通过后先接少量流量,30 秒内逐步加权重。
  3. 预热关键路径:启动时建立到 libraryd 的连接、加载常用图书缓存。
  4. 使用类数据共享(CDS)缩短类加载时间,但收益要用启动日志验证,不能假设。

第 1 条最有效,也最容易被预算砍掉。需要算清楚:常驻 2 台的成本,和尖峰时扩容失败造成的损失,哪个更大。

预算上限与 SLO 的取舍

SLO 不是越高越好。从 99.9% 提到 99.99%,可用时间从每月约 43 分钟降到约 4 分钟,代价通常不是线性增加,而是需要多可用区、冗余链路、快速切换和更复杂的发布流程。

SLO月度不可用常见架构成本特征
99.0%≈ 7.2 小时单可用区、单副本最低
99.9%≈ 43 分钟多副本、健康检查、自动重启约 1.5–2 倍
99.95%≈ 22 分钟多可用区、熔断降级约 2–3 倍
99.99%≈ 4.3 分钟多区域、流量切换、演练常态化显著更高

表里的倍数是量级估计,不是报价。它想说明的是:每提高一个 9,成本曲线都会变陡。

预算上限的正确用法是「先定 SLO,再定预算」,而不是「先砍预算,再看剩下多少可用性」。如果预算被强制下调,应该明确写出「放弃哪个 SLO、接受多长的不可用时间」,让取舍可见。

一个实用的框架是错误预算:99.9% 的月度错误预算约 43 分钟。发布、演练、故障都从这个预算里扣。预算还剩很多时,可以加快发布;预算快用完时,冻结变更,优先稳定性。这把「成本」和「风险」放进了同一个账户。

在 linkd-v3 里落地

成本模型写成代码,输入假设,输出分项,方便做敏感性分析:

package com.plume.linkd.v3.capacity;

public final class CostModel {

    public record Instance(int vcpu, int memoryGib) {
    }

    /** 单价均为假设值,用于演示算法,不代表实际云厂商报价。 */
    public record Prices(double vcpuHour, double gibHour, double egressGb) {
        public static Prices assumed() {
            return new Prices(0.04, 0.005, 0.09);
        }
    }

    public record Workload(long requestsPerMonth, double responseKb, double egressShare) {
    }

    public static double monthlyCompute(Instance instance, Prices prices, int replicas) {
        double hours = 730.0;
        return replicas * (instance.vcpu() * prices.vcpuHour() * hours
                + instance.memoryGib() * prices.gibHour() * hours);
    }

    public static double monthlyEgress(Workload workload, Prices prices) {
        double kb = workload.requestsPerMonth() * workload.responseKb();
        double gb = kb / 1_000_000.0; // 1 GB = 1e9 字节 = 1e6 KB,按云厂商十进制口径
        return gb * prices.egressGb() * workload.egressShare();
    }

    public static double total(Instance instance, Prices prices, int replicas, Workload workload) {
        return monthlyCompute(instance, prices, replicas) + monthlyEgress(workload, prices);
    }

    public static void main(String[] args) {
        Instance instance = new Instance(4, 8);
        Prices prices = Prices.assumed();
        Workload workload = new Workload(518_400_000L, 2.5, 0.8);
        System.out.printf("compute=%.2f egress=%.2f total=%.2f%n",
                monthlyCompute(instance, prices, 2),
                monthlyEgress(workload, prices),
                total(instance, prices, 2, workload));
        // 假设口径输出:compute=292.00 egress=93.31 total=385.31
    }
}

扩缩容决策也用代码表达,避免把规则散落在告警脚本里:

package com.plume.linkd.v3.capacity;

public final class ScalingPolicy {

    public record Signals(double cpuUtilization, int inflight, double p99Millis,
                          double queueWaitMillis, double errorRate) {
    }

    public static int desiredReplicas(Signals signals, int current,
                                      int minReplicas, int maxReplicas,
                                      int perInstanceInflight) {
        int byCpu = signals.cpuUtilization() > 0.60
                ? (int) Math.ceil(current * signals.cpuUtilization() / 0.60)
                : current;
        int byInflight = (int) Math.ceil(
                (double) signals.inflight() / Math.max(1, perInstanceInflight));
        int byP99 = signals.p99Millis() > 300 && current < maxReplicas
                ? current + 1
                : current;
        int byErrors = signals.errorRate() > 0.01 && current < maxReplicas
                ? current + 1
                : current;

        int desired = Math.max(Math.max(byCpu, byInflight), Math.max(byP99, byErrors));
        return Math.max(minReplicas, Math.min(maxReplicas, desired));
    }

    public static boolean shouldScaleIn(Signals signals, int lowLoadMinutes) {
        return lowLoadMinutes >= 10
                && signals.cpuUtilization() < 0.30
                && signals.inflight() == 0
                && signals.p99Millis() < 200
                && signals.errorRate() < 0.001;
    }
}

这两个类的组合能回答三个问题:当前配置每月多少钱、目标流量下需要几个副本、预算是硬上限时能撑到哪个 SLO。它们不替代真实账单,但能在架构评审时把假设摆到桌面上。

perInstanceInflight 要来自压测,不是拍脑袋。14.1 的 L = λW 可以给初值,14.2 的阶梯压测给出真实上限,两者对不上时以压测为准。

常见错误与边界

错误一:用峰值 QPS × 全月算请求数。 峰值只持续几分钟,按它算月度流量会高估几十倍。用日均或月度总量。

错误二:只看计算成本。 响应体大、出口占比高的服务,流量费可能超过计算费。压缩和缓存要先算收益再实施。

错误三:只按 CPU 扩容。 等待型瓶颈下 CPU 很低,但服务已经饱和。要结合在途请求、队列等待、p99。

错误四:缩容和扩容一样快。 缩容太快会导致抖动,扩容太慢会丢流量。扩容冷却短、缩容冷却长,是更稳的默认值。

错误五:把最小副本数砍到 0。 冷启动期间没有实例接流量,尖峰会直接打到上游或返回错误。省下的钱通常小于一次故障的损失。

错误六:承诺 SLO 时不看预算。 99.99% 需要多区域和常态化演练,这些都要人力和机器。SLO 是合同,不是口号。

边界:单价会变。 预留实例、竞价实例、阶梯计价、免费额度都会改变结果。模型里的单价要按实际合同更新,不能长期用示例值。

边界:成本优化可能伤害可观测性。 砍掉日志、追踪、冗余探针能省钱,但故障时的定位时间会变长,MTTR 上升。这类削减要单独评估,不能混在「降本 20%」里。

小结

  • 成本按核数、内存、出口流量三块拆,单价和口径都要写清楚,示例值不等于报价。
  • 响应体大小和压缩策略直接影响出口成本;内网占比高的服务,优化重点在计算。
  • 扩缩容看多信号:CPU、在途、p99、队列等待、错误率,取最大值;扩容快、缩容慢。
  • 冷启动是数十秒级的假设,实际要测;最小副本、渐进接流、预热比事后扩容可靠。
  • SLO 每提高一个 9,成本曲线变陡;预算下调时要写明放弃了哪个 SLO。
  • 成本模型和扩缩容策略都应该写成可复算的代码,而不是散落在脚本和经验里。

阅读导航:上一节 14.2 压测剖面与瓶颈定位 | 下一节 15.1 链路架构与边界

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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