承接的问题
目录里第一条路径从这一节开始。它要回答的是容量规划里最常被拍脑袋的那个问题:现在 8 个线程跑 5 000 rps,加到 16 个线程能不能到 10 000?
这个问题有两个层次。第一个层次是「理论上限在哪」,答案由串行比例决定,工具是 Amdahl 定律。第二个层次是「加过头为什么会变慢」,Amdahl 回答不了,因为它的曲线单调上升,永远不下降;能回答的是 USL(Universal Scalability Law),它在串行项之外多了一个随 (N^2) 增长的项。
本节所有数字都是按公式推导,非实测。推导用的输入是示例值,我会把每个假设写出来,你可以换成自己机器的数重算。实测的部分在 1.2 和 1.3:那两节的数字来自本机,带「单次运行、随调度波动、不作基准」的标注。
先给一个完整的事故骨架,后面四节都在拆它。
症状:conc-core 的 SynchronizedLoanService 在压测里从 8 线程加到 16 线程,
吞吐只从 5 200 rps 涨到 5 600 rps,再往上加开始回落。
证据:同一份压测脚本、同一台机器、同一段预热;8 线程时 CPU 利用率先到 70%,
16 线程时 CPU 仍然只有 75%,但 p99 从 40 ms 涨到 180 ms。
根因:临界区里有一次 3 ms 的库存快照重建,串行比例约 5%;
同时所有线程对同一份库存 Map 的读写让缓存行在核间来回搬,
这部分开销随线程数平方增长。
修复:把快照移出临界区(降低串行比例),把单 Map 拆成 16 个分片(降低一致性开销)。
验证:重跑同一脚本,重新取 8 线程与 16 线程两个点,拟合新的 σ 与 κ。
这段骨架里的「5%」「3 ms」是示例值。真实项目里要先把它们量出来,量的方法就是下面的拟合。
Amdahl:一条串行段就把上界定死
设一份工作里必须串行执行的比例是 s,其余 1 - s 可以完美并行,那么 N 个处理器上的加速比是:
S(N) = 1 / (s + (1 - s) / N)
N 趋于无穷时 S(∞) = 1 / s。这是 Amdahl 的全部内容,但它的推论比公式本身重要:串行比例决定的是上界,不是斜率。 当 s = 0.05 时,无论堆多少核,加速比都不可能超过 20 倍。
| 串行比例 s | N=8 | N=32 | N=256 | N→∞ |
|---|---|---|---|---|
| 0.01 | 7.48 | 24.43 | 72.11 | 100 |
| 0.05 | 5.93 | 12.55 | 18.62 | 20.00 |
| 0.10 | 4.71 | 7.80 | 9.66 | 10.00 |
| 0.20 | 3.33 | 4.44 | 4.92 | 5.00 |
表是按公式算的。读法有两层:s = 0.05 时,从 8 核到 256 核,核数涨 32 倍,加速比只从 5.93 涨到 18.62,离 20 的上界很近;s = 0.20 时,从 32 核到 256 核几乎没有收益,因为上界只有 5。
串行段在 Java 服务里有三种常见来源,量的时候要分开:
- 临界区:
synchronized块或ReentrantLock保护的代码。它的耗时可以直接量:在锁内打点,统计持锁时间占请求总时间的比例。 - 单线程阶段:请求前后必须由同一个线程完成的部分,比如序列化、日志落盘、单线程的缓存刷新。这部分不占锁,但也不并行。
- 共享资源:数据库连接池、下游限流器、单个
ConcurrentHashMap的同一个桶。它们不是代码层面的串行,但效果等价。
第二类和第三类最容易被漏掉。一个只统计锁持有时间的 profiler 会告诉你「锁竞争不严重」,而实际串行比例可能来自连接池只有 10 个连接。
三种来源的量法不一样,混在一起算会得到错误的 s:
| 来源 | 打点位置 | 典型量级(示例值) | 常见误判 |
|---|---|---|---|
| 临界区 | 锁获取到释放之间 | 请求总耗时的 2%–10% | 只统计 BLOCKED 时间,漏掉持锁执行的时间 |
| 单线程阶段 | 请求入口到提交执行器之前 | 5%–15% | 被算成「框架开销」,没进模型 |
| 共享资源 | 连接池等待、限流器拒斥 | 取决于池大小 | 归因到「下游慢」,其实是我们自己排队 |
表里的量级是示例值,不要直接抄。它的用处是提醒你:串行比例是把几段时间加起来,不是只看最长的那个锁。
Amdahl 的失效点要说清楚:它假设 s 不随 N 变化,且并行部分是真的线性扩展。这两个假设在真实系统里都容易破。前者在「锁竞争导致临界区变长」时破,后者在「共享缓存行」「内存带宽」上破。破了之后,模型要换成 USL。
USL:再加一项随 N² 增长的一致性开销
USL 在 Amdahl 的串行项之外加了一个二次项:
C(N) = N / (1 + σ(N - 1) + κN(N - 1))
σ是争用项,取值[0, 1)。它对应 Amdahl 里的串行比例,但含义更宽:任何让 N 个执行单元排队的因素都算,包括锁、信号量、单点资源。σ = 0时这一项消失,模型退化成「线性扩展」。κ是一致性项,取值[0, 1)。它对应 N 个执行单元之间两两同步的成本:缓存行在核间迁移、内存屏障、跨核的原子操作。为什么是N(N-1)而不是N?因为需要保持一致的参与者是成对的,对数随 N 平方增长。
术语上有一个容易混的地方:Gunther 的原始记号里 σ 是争用、κ 是一致性,但不少二手资料把二次项整体叫「USL 的 σ 项」。本节按原始记号走,看到别的文章写「σ 项导致回落」时,先确认它指的是哪一个。
USL 曲线有一个峰值,位置可以对 C(N) 求导得到:
N* = sqrt((1 - σ) / κ)
N < N* 时加一个执行单元还有正收益;N > N* 时每加一个都是净损失。这个位置只由两个参数决定,和绝对吞吐无关——也就是说,它可以从少量观测点估出来,不需要真的加到那么多核。
三条性质值得记住:
κ = 0时N*无穷大,曲线单调上升,和 Amdahl 形状一致,只是上界不同。κ越大,峰值越靠左。跨核共享写越多的系统,越早到达天花板。- 峰值处不是「性能最好」,只是「吞吐最高」。如果关心 p99,拐点通常比
N*更靠左,1.3 会给出实测。
容量估算:先量两点,再外推
这一节按「假设 → 计算 → 表 → 结论」四段走。
假设。 单核基线吞吐 B = 1 000 rps(示例值,换成你的一核压测值);串行比例 s = 0.05(示例值,来自锁内打点);两次观测:8 核跑到 5.2 倍、16 核跑到 7.4 倍(示例值,代表一次真实压测的两个点,单位是相对单核的加速比)。这两点必须来自同一份负载、同一台机器、同一个预热状态,否则拟合没有意义。
计算。 把 USL 改写成线性形式。令 C(N) 是加速比:
1 / C(N) = 1/N + σ(N - 1)/N + κ(N - 1)
于是 y = 1/C - 1/N 对 σ 和 κ 是线性的:
y(N) = σ · (N - 1)/N + κ · (N - 1)
两个观测点就是两个方程、两个未知数,直接解。下面的程序做三件事:解方程、算 N*、把曲线外推到 256 核。
/**
* 从两个观测点拟合 USL,并外推容量。
* 输入是示例值:单核基线 1000 rps,8 核 5.2 倍、16 核 7.4 倍。
*/
public final class UslFit {
static final double BASE = 1000.0;
public static void main(String[] args) {
double s = 0.05;
double c8 = 5.2, c16 = 7.4;
double y8 = 1.0 / c8 - 1.0 / 8;
double y16 = 1.0 / c16 - 1.0 / 16;
double a8 = 7.0 / 8, a16 = 15.0 / 16;
double det = a8 * 15 - a16 * 7;
double sigma = (y8 * 15 - y16 * 7) / det;
double kappa = (a8 * y16 - a16 * y8) / det;
double nStar = Math.sqrt((1 - sigma) / kappa);
System.out.printf("fit: sigma=%.5f kappa=%.7f N*=%.1f%n", sigma, kappa, nStar);
double peak = usl(nStar, sigma, kappa);
System.out.printf("peak: C(N*)=%.2f thr=%.0f rps%n", peak, peak * BASE);
System.out.println(" N amdahl usl usl_rps eff% marginal_rps/core");
int[] ns = {1, 2, 4, 8, 16, 32, 64, 96, 115, 128, 192, 256};
double prev = 0;
int prevN = 0;
for (int n : ns) {
double a = 1.0 / (s + (1 - s) / n);
double u = usl(n, sigma, kappa);
double rps = u * BASE;
double eff = 100.0 * u / n;
double marginal = prevN == 0 ? rps : (rps - prev) / (n - prevN);
System.out.printf("%4d %8.2f %6.2f %9.0f %5.0f%% %12.1f%n", n, a, u, rps, eff, marginal);
prev = rps;
prevN = n;
}
}
static double usl(double n, double sigma, double kappa) {
return n / (1 + sigma * (n - 1) + kappa * n * (n - 1));
}
}
$ javac --release 25 -d out UslFit.java && java -cp out UslFit
fit: sigma=0.07637 kappa=0.0000693 N*=115.4
peak: C(N*)=10.83 thr=10834 rps
N amdahl usl usl_rps eff% marginal_rps/core
1 1.00 1.00 1000 100% 1000.0
2 1.90 1.86 1858 93% 857.9
4 3.48 3.25 3252 81% 697.2
8 5.93 5.20 5200 65% 487.0
16 9.14 7.40 7400 46% 275.0
32 12.55 9.31 9313 29% 119.5
64 15.42 10.51 10508 16% 37.4
96 16.70 10.80 10802 11% 9.2
115 17.16 10.83 10834 9% 1.7
128 17.41 10.82 10824 8% -0.8
192 18.20 10.59 10591 6% -3.6
256 18.62 10.24 10241 4% -5.5
表。 上面这段就是表。三个列要一起读:
amdahl列是同一组输入下 Amdahl 的预测。它一路涨到 18.62,永远不回头。usl列在 115 核见顶,10.83 倍,之后回落。marginal_rps/core是每多一个核带来的吞吐增量。128 核时是-0.8,意思是再加一个核,总吞吐反而少 0.8 rps。
结论。 这张表给出的决策不是「加到 115 核」,而是三条更具体的判断:
- 在
N = 115之前,扩容有收益,但递减得很快:64 核时每核还能带来 37 rps,96 核时只剩 9.2 rps,115 核时是 1.7 rps。按单核成本折算,96 核之后基本不值得加机器。 - 96 到 115 这 19 个核总共只买到 32 rps。与其扩容,不如把
σ从 0.076 降到 0.05——那把 64 核的吞吐从 10 508 抬到约 12 300,等于白捡 20 个核。 - Amdahl 和 USL 在这组输入下给出相反建议。差别全部来自
κ项。哪一个对?看你有没有「跨线程共享写」的证据:如果 16 核时缓存未命中率、总线流量、CAS 重试次数在明显上升,USL 更接近;如果这些都没有,只是锁排队,Amdahl 就够。
表里每一行都可以手算验算。取 N = 64:
σ(N-1) = 0.07637 × 63 = 4.8113
κN(N-1) = 0.0000693 × 64 × 63 = 0.2794
分母 = 1 + 4.8113 + 0.2794 = 6.0907
C(64) = 64 / 6.0907 = 10.508
和表里的 10.51 对得上。这一步值得自己做一遍:拟合出来的参数一旦写错一位小数,峰值位置会偏出去几十个核,而曲线在峰值附近很平,肉眼看不出来。
什么时候这张表不能信
USL 是一把尺子,不是预言机。下面五种情况,拟合出来的 σ、κ 没有外推价值:
- 系统不在稳态。 JIT 没热、连接池还在扩容、缓存还在预热,这些阶段测出来的吞吐偏低,会把
σ抬高。 - 观测点太少或太近。 8 核和 16 核两点如果只差 10%,测量误差会直接变成负的
κ。经验上两点至少差 2 倍,且每个点重复三次取中位数。 - 瓶颈在外部。 数据库只有 50 个连接时,加到 64 线程吞吐当然不动。这不是
κ,是下游的上限,USL 把这个损失记到κ头上,会诱导你去优化一个不存在的缓存一致性问题。 - 负载变了。 8 核跑的是读多写少,16 核跑的是写多读少,两点不是同一条曲线上的点,拟合没有意义。
- 单次测量。 本机 load average 常在 70 到 170 之间,任何单次吞吐都带 30% 以上的抖动。这就是为什么 1.2 要用多次运行的区间而不是单点。
反过来说,什么时候不要用 USL:当你已经知道瓶颈在哪(一条慢 SQL、一个单点服务)时,先修瓶颈。USL 的价值在于「还不知道天花板在哪、需要决定要不要扩容」的时刻,而不是替代根因分析。
在 libraryd-conc 里落地
conc-ops 里放一个 CapacityModel,输入是两次压测的观测点,输出是峰值位置和「再加一核值不值」的判断。它不做 IO,纯计算,可以直接被压测报告调用。
package com.plume.conc.ops;
/** 两个观测点拟合出的 USL 参数,以及对「再加一核」的判断。 */
public record CapacityModel(double baseRps, double sigma, double kappa) {
public static CapacityModel fit(double baseRps, int n1, double speedup1, int n2, double speedup2) {
if (n1 == n2) {
throw new IllegalArgumentException("two distinct observation points required");
}
double y1 = 1.0 / speedup1 - 1.0 / n1;
double y2 = 1.0 / speedup2 - 1.0 / n2;
double a1 = (n1 - 1.0) / n1, a2 = (n2 - 1.0) / n2;
double det = a1 * (n2 - 1) - a2 * (n1 - 1);
double sigma = (y1 * (n2 - 1) - y2 * (n1 - 1)) / det;
double kappa = (a1 * y2 - a2 * y1) / det;
if (sigma < 0 || kappa < 0) {
throw new IllegalStateException("fit produced negative coefficients: sigma="
+ sigma + " kappa=" + kappa);
}
return new CapacityModel(baseRps, sigma, kappa);
}
public double speedup(double n) {
return n / (1 + sigma * (n - 1) + kappa * n * (n - 1));
}
public double throughput(double n) {
return baseRps * speedup(n);
}
public double peakN() {
return kappa == 0 ? Double.POSITIVE_INFINITY : Math.sqrt((1 - sigma) / kappa);
}
/** 从 n 加到 n+1 的吞吐增量。 */
public double marginalRpsPerCore(double n) {
return throughput(n + 1) - throughput(n);
}
/** 扩容判据:每加一核至少换来单核基线的 2%。 */
public boolean worthScalingOut(double n) {
return marginalRpsPerCore(n) >= 0.02 * baseRps;
}
}
两个细节。第一,fit 在拟合出负数系数时直接抛异常,而不是返回一个看起来能用的模型——负的 κ 意味着两点不在同一条 USL 曲线上,继续外推只会得到荒谬的结论。第二,worthScalingOut 的阈值是 2% 而不是 0,因为扩容有边际成本(机器、部署、监控),吞吐增量覆盖不了成本时,加机器是负收益。
拿 1.2 的实测数据回填一次:单线程 621 Mop/s,64 线程 5 232 Mop/s,10 核。用 Amdahl 反解:
S = 5232 / 621 = 8.42
s = (1/S - 1/N) / (1 - 1/N) = (0.1188 - 0.1) / 0.9 = 0.021
得到 s ≈ 0.021。这个数不能当结论用:本机同时在跑别的负载,s 里混进了调度损失。它能说明的只有一件事——用同一台机器上的两个点做粗算,串行比例的量级在 2% 上下,而不是千分之几。要精确,需要在独占机器上重测。
压测报告里建议把三个数一起打出来,缺一个都会导致误读:
capacity: base=1000 rps n=8 speedup=5.20 n=16 speedup=7.40
fitted: sigma=0.07637 kappa=0.0000693 peak_n=115.4 peak_rps=10834
verdict: stop at n=96 (marginal 9.2 rps/core), fix sigma before adding cores
verdict 这一行是给人看的。没有人会在凌晨两点去读拟合参数,但所有人都能读懂「96 核之后别加了」。
常见错误与边界
- 把 Amdahl 当预测曲线。 它给的是上界,不是「加 16 核就得到 9.14 倍」。真实系统在达到上界之前就会因为
κ回落。 - 把 USL 的峰值当目标。
N*是吞吐最高点,不是性价比最高点,更不是延迟最低点。按 2% 边际判据,可用的核数通常只有N*的一半左右。 - 用 USL 外推到 10 倍规模。
σ和κ会随规模变化:分片数变了、缓存层级变了、网络跳数变了。外推超过观测范围 4 倍,结论只能当方向。 - 只拟合一次。 每次大改(拆锁、换 GC、换容器规格)都要重新取两点。旧的
κ不适用于新代码。 - 忽略测量本身的成本。 压测客户端自己也会成为瓶颈。1024 并发下,如果客户端 CPU 先满了,你测到的是客户端的
κ。
小结
- Amdahl 用串行比例给出加速比上界
1/s,曲线单调上升,解释不了「加线程反而更慢」。 - USL 多了
κN(N-1)这个一致性项,曲线在N* = sqrt((1-σ)/κ)见顶,之后每加一个执行单元都是净损失。 - 两个观测点就能解出
σ和κ,从而在不真正扩到那么大之前,算出天花板在哪。 - 拟合的前提是同一份负载、同一台机器、稳态、两点相距足够远;外部瓶颈和测量噪声都会伪装成
κ。 - 决策看边际收益,不看峰值:每加一核带来的吞吐低于单核基线的 2% 时,该改代码而不是加机器。
阅读导航:上一节 目录与学习路径 | 下一节 1.2 上下文切换、调度延迟与 CPU 配额
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。