《Java 服务端通信工程》14.1 容量模型与 Little's law

容量不是「多开几个线程」。本节用 Little's law 的 L=λW 把 200 rps × 800 ms = 160 在途算清楚,给出线程、连接、队列与并发的对应关系、Amdahl 定律下的瓶颈判断,以及一段可运行的 Java 容量模型代码和完整算例。

本节目标:把「需要多少并发」从经验判断变成可复算的算式,并能指出链路上的第一个瓶颈。
口径:所有数字要么由公式推导,要么标注为假设;没有执行过的压测不写成「实测」。

承接的问题

扩容讨论里最常见的两句话是「CPU 才 40%,不用加」和「线程池加到 500 就好了」。前者忽略了等待时间,后者忽略了内存和上下文切换。两者都没有回答一个基本问题:在目标吞吐下,系统里同时有多少个请求在处理或等待?

Little’s law 就是回答这个问题的工具。它不关心请求到达是否服从泊松分布,也不关心服务时间是什么分布,只要求系统处于稳定状态:

L = λ × W
  • L:系统中的平均请求数(在途数,包括正在处理和排队)。
  • λ:平均到达率(requests per second)。
  • W:单个请求在系统中的平均停留时间(seconds)。

三个量单位一致即可。200 rps × 0.8 s = 160,意思是如果每个请求平均停留 800 ms、系统每秒收到 200 个请求,那么任意时刻平均有 160 个请求在系统里。

L = λW 在链路上的三种用法

同一个公式,在不同层要看不同的「系统边界」。

层LλW用途
客户端并发请求数目标 QPS端到端延迟需要多少并发连接/虚拟线程
网关在途请求数入口 QPS网关内停留时间线程池、队列、连接池大小
服务端正在处理的 RPC 数RPC QPS业务处理时间并发上限、Bulkhead
下游在途 HTTP 调用数下游 QPS单跳延迟libraryd 连接数

边界选错,算出来的数就没有意义。例如用「端到端 800 ms」去算服务端并发,会把客户端重试、网络传输、网关排队都算进业务处理时间,得出一个偏大的线程数。每一层要用这一层的 W。

稳定状态是前提。如果流量在上升、队列在增长,系统不满足稳态,L 会随时间变化,公式只能给出瞬时近似。压测的恒定负载段适合用 Little’s law;尖峰段更适合看队列增长曲线。

线程、连接与队列的对应关系

并发不是一个池子,而是三种资源的组合:

资源数量由什么决定太小太大
工作线程同时在执行的请求数排队、吞吐上不去上下文切换、栈内存、GC 压力
连接同时在途的远端调用数连接池等待、队头阻塞下游 fd 耗尽、握手开销
队列已到达但未开始处理的请求尖峰直接拒绝排队延迟、超时雪崩

三者的关系可以写成:

在途 L = 正在执行 + 正在排队
正在执行 ≤ 线程数(或并发上限)
正在排队 ≤ 队列容量

只加线程不加连接,线程会卡在连接池的 acquire() 上;只加连接不加线程,请求会在执行器队列里等待。容量的短板是最小的那个 数量 / 单请求占用时间。

对 gRPC 来说,一个 ManagedChannel 复用 HTTP/2 连接,多个并发 RPC 共享同一条连接。连接数不等于并发数:一条连接可以承载几百个 stream,但服务端的并发上限、流量控制窗口和下游处理能力才是真正的约束。第 6 章讲过连接池,这里只强调容量口径:连接池大小按「同时在途的下游调用数」算,不按 QPS 算。

算例:200 rps × 800 ms = 160 在途

假设目标入口流量 200 rps,客户端端到端 deadline 800 ms,链路是 HTTP 客户端 → 9094 网关 → 9093 gRPC → 8080 libraryd。

第一层,客户端到网关:

λ = 200 rps
W = 0.8 s(端到端预算,也是客户端愿意等待的上限)
L = 200 × 0.8 = 160

客户端至少要有 160 个并发在途请求,才能在不超时的前提下维持 200 rps。如果客户端是同步调用、只有 32 个线程,那么最大吞吐是 32 / 0.8 = 40 rps,剩下的请求只能排队或失败。这不是服务端的问题,是客户端并发不够。

第二层,网关内部。假设网关自身的处理与序列化开销是 20 ms,向下游发起 gRPC 调用占 300 ms 预算:

网关停留时间 ≈ 20 ms + 下游耗时
若下游耗时 280 ms,W_gateway ≈ 0.3 s
L_gateway = 200 × 0.3 = 60

网关需要约 60 个并发在途请求。注意这里用的是「网关内停留时间」,不是端到端的 800 ms;客户端的重试和网络延迟不在网关的边界内。

第三层,comm-server 的 Library 服务。假设 GetBook 的业务处理(查库 + 组装)平均 30 ms:

L_server = 200 × 0.03 = 6

服务端并发上限 64,看起来绰绰有余。但这是平均值:如果 p99 是 300 ms,那么 1% 的请求会占住 300 ms,瞬时并发会明显高于 6。容量规划要用「目标分位」而不是均值,或者按均值算完再乘一个 3–5 倍的余量。

第四层,libraryd 的 HTTP 接口。假设单次查询 60 ms:

L_libraryd = 200 × 0.06 = 12

连接池至少 12 条,考虑突发和长尾,配 24–32 条更稳。配 8 条就会在高峰出现 acquire 等待,表现为网关 p99 上升但 CPU 不高。

把四层放在一起,第一个瓶颈由「最大利用率」决定:

层并发需求 L配置上限利用率
客户端16032 线程5.00(已饱和)
网关60128 线程0.47
comm-server6(均值)64 并发0.09
libraryd1232 连接0.38

这张表说明:先扩容客户端并发,再谈服务端。很多「服务端扛不住」的结论,实际是客户端同步模型把并发限制在了 32。

队列:把等待时间算进去

上面的 L 只算了「正在执行」。一旦到达率超过服务能力,多出来的请求进入队列,W 变成「排队时间 + 服务时间」。

设服务端并发上限 C,单请求服务时间 S,最大吞吐是:

λ_max = C / S

如果 C = 64、S = 0.8 s,那么 λ_max = 80 rps。此时入口 200 rps,超出 120 rps 会持续堆积,队列 128 在 120 rps 下 1.07 秒就满,随后开始拒绝。

队列不是容量,是缓冲。它只能吸收短时突发,不能把「服务能力不足」变成「服务能力足够」。判断队列是否健康,看两个量:

  • 队列深度是否持续大于 0。持续大于 0 说明到达率长期高于服务率。
  • 排队时间是否接近 deadline。排队 700 ms + 服务 300 ms 已经超过 800 ms 预算,请求即使最终成功,客户端也早已超时。

一个实用的护栏是「队列等待时间上限」,而不是「队列长度上限」。长度 128 在快服务下可能只等 10 ms,在慢服务下可能等 2 秒。按时间算,才能和 deadline 对齐。

Amdahl 定律:加机器之前先找串行段

Amdahl 定律描述「固定问题规模下,并行化能带来多少加速」:

speedup(N) = 1 / (s + (1 - s) / N)

s 是不能并行的比例,N 是并行度。s = 0.2、N = 10 时,加速比是 1 / (0.2 + 0.08) = 3.57,不是 10。

串行比例 sN=2N=4N=10N=100
0.011.983.889.1750.25
0.051.903.486.9016.81
0.101.823.085.269.17
0.201.672.503.574.81
0.501.331.601.821.98

表里的数字是公式计算结果,不是压测数据。它说明一个反直觉的结论:串行段占 20% 时,加到 100 个核也只有 4.8 倍。链路上的串行段通常是:

  • 单连接上的队头阻塞(HTTP/1.1)。
  • 全局锁或单例缓存。
  • 单库写入、单分区。
  • 一次请求里必须串行执行的多次下游调用。

找串行段的方法不是看代码猜,而是看「加了并发之后,哪个资源的利用率先到 100%」。如果 CPU 只有 40%、线程都在等同一把锁,加核没有用。

在 linkd-v3 里落地

把上面的算式写成代码,参数化后就能对不同流量做敏感性分析:

package com.plume.linkd.v3.capacity;

import java.util.List;

public final class CapacityModel {

    public record Stage(String name, double serviceMillis, int concurrency, int queueCapacity) {

        public double maxThroughputRps() {
            return serviceMillis <= 0
                    ? Double.POSITIVE_INFINITY
                    : concurrency * 1000.0 / serviceMillis;
        }

        public double requiredConcurrency(double rps) {
            return rps * serviceMillis / 1000.0;
        }

        public double utilization(double rps) {
            double max = maxThroughputRps();
            return max == 0 ? Double.POSITIVE_INFINITY : rps / max;
        }

        public double queueWaitSeconds(double rps) {
            double max = maxThroughputRps();
            if (queueCapacity == 0 || rps <= max) {
                return 0.0;
            }
            // 简化口径:超出服务能力的部分按 FIFO 排空,忽略突发与调度抖动。
            double backlogPerSecond = rps - max;
            double fullQueueSeconds = queueCapacity / backlogPerSecond;
            return Math.min(fullQueueSeconds, queueCapacity * serviceMillis / 1000.0);
        }
    }

    public static Stage bottleneck(List<Stage> stages, double rps) {
        Stage worst = stages.get(0);
        for (Stage stage : stages) {
            if (stage.utilization(rps) > worst.utilization(rps)) {
                worst = stage;
            }
        }
        return worst;
    }

    public static void main(String[] args) {
        double rps = 200;
        List<Stage> stages = List.of(
                new Stage("gateway", 300, 128, 256),
                new Stage("comm-server", 30, 64, 128),
                new Stage("libraryd", 60, 32, 0));

        for (Stage stage : stages) {
            System.out.printf("%-12s required=%6.1f max=%8.1f util=%.2f%n",
                    stage.name(),
                    stage.requiredConcurrency(rps),
                    stage.maxThroughputRps(),
                    stage.utilization(rps));
        }
        Stage first = bottleneck(stages, rps);
        System.out.printf("bottleneck=%s util=%.2f queueWait=%.2fs%n",
                first.name(), first.utilization(rps), first.queueWaitSeconds(rps));
    }
}

这段 main 的输出由公式决定,可以直接复算:gateway 需求 60、上限 426.7、利用率 0.47;comm-server 需求 6、上限 2133.3、利用率 0.09;libraryd 需求 12、上限 533.3、利用率 0.38。瓶颈是 gateway,但利用率只有 0.47,说明还有余量。

再看 Amdahl 的计算:

package com.plume.linkd.v3.capacity;

public final class Amdahl {

    public static double speedup(double serialFraction, int processors) {
        if (serialFraction < 0 || serialFraction >= 1) {
            throw new IllegalArgumentException("serialFraction must be in [0, 1)");
        }
        if (processors < 1) {
            throw new IllegalArgumentException("processors must be >= 1");
        }
        return 1.0 / (serialFraction + (1.0 - serialFraction) / processors);
    }

    public static void main(String[] args) {
        for (double serial : new double[]{0.01, 0.05, 0.10, 0.20, 0.50}) {
            System.out.printf("serial=%.2f speedup@10=%.2f%n",
                    serial, speedup(serial, 10));
        }
    }
}

参数 serialFraction 要来自测量,不能凭感觉。一个可操作的测法是:固定 QPS,逐步增加工作线程,记录吞吐;当线程数翻倍而吞吐不再增长时,剩余部分就是串行段。

常见错误与边界

错误一:用 QPS 直接当并发。 200 rps 不是 200 并发;并发是 QPS × 停留时间。服务越快,需要的并发越少。

错误二:用端到端延迟算服务端线程数。 端到端包含网络、重试、客户端排队;服务端只应该用自己边界内的处理时间。

错误三:把队列当扩容。 队列只能吸收突发,不能提高服务率。队列满之后,延迟会以超时的方式转嫁给客户端。

错误四:只看 CPU 利用率。 线程卡在锁、连接池或下游等待时,CPU 很低但容量已经耗尽。容量看的是「哪个资源的利用率先到 1」。

错误五:忽略长尾。 均值 30 ms、p99 300 ms 的服务,瞬时并发可能是均值的数倍。按均值配线程,长尾请求会挤占后续请求。

边界:Little’s law 只适用于稳态。 流量上升、队列持续增长时不适用;此时应该看排队论模型或直接压测。

边界:公式给的是下界。 它假设请求之间没有干扰;真实系统有锁竞争、GC、连接池等待,实际需要的并发通常高于计算值。留 1.5–2 倍余量是工程惯例,不是精确科学。

小结

  • L = λW:200 rps × 0.8 s = 160 在途,这是客户端并发和网关线程池的下界。
  • 每一层用自己的 W:端到端、网关停留、业务处理、单跳下游,不能混用。
  • 线程、连接、队列是三种资源,短板由最小的 数量 / 单请求占用时间 决定。
  • 队列只吸收突发;判断健康看排队时间,不只看队列长度。
  • Amdahl 说明串行段决定加速上限;找串行段看资源利用率,不靠猜。
  • 公式结果是下界,真实配置要留 1.5–2 倍余量,并用压测验证。

阅读导航:上一节 13.3 演练设计与复盘 | 下一节 14.2 压测剖面与瓶颈定位

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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