本节目标:把「需要多少并发」从经验判断变成可复算的算式,并能指出链路上的第一个瓶颈。
口径:所有数字要么由公式推导,要么标注为假设;没有执行过的压测不写成「实测」。
承接的问题
扩容讨论里最常见的两句话是「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 | 配置上限 | 利用率 |
|---|---|---|---|
| 客户端 | 160 | 32 线程 | 5.00(已饱和) |
| 网关 | 60 | 128 线程 | 0.47 |
| comm-server | 6(均值) | 64 并发 | 0.09 |
| libraryd | 12 | 32 连接 | 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。
| 串行比例 s | N=2 | N=4 | N=10 | N=100 |
|---|---|---|---|---|
| 0.01 | 1.98 | 3.88 | 9.17 | 50.25 |
| 0.05 | 1.90 | 3.48 | 6.90 | 16.81 |
| 0.10 | 1.82 | 3.08 | 5.26 | 9.17 |
| 0.20 | 1.67 | 2.50 | 3.57 | 4.81 |
| 0.50 | 1.33 | 1.60 | 1.82 | 1.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 压测剖面与瓶颈定位
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。