《Java 并发编程·性能诊断与事故》1.1 Amdahl 与 USL:扩展性的天花板

并发加到某个数量后吞吐不再上涨,是串行段还是争用?本节从 Amdahl 的串行比例推到 USL 的争用项与一致性项,用两个观测点解出 σ 与 κ,给出一张按公式推导的容量估算表,算出峰值位置与每核边际收益,并说明外推到多大规模型号就失效、什么时候该改代码而不是加机器。

承接的问题

目录里第一条路径从这一节开始。它要回答的是容量规划里最常被拍脑袋的那个问题:现在 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 倍。

串行比例 sN=8N=32N=256N→∞
0.017.4824.4372.11100
0.055.9312.5518.6220.00
0.104.717.809.6610.00
0.203.334.444.925.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 核」,而是三条更具体的判断:

  1. 在 N = 115 之前,扩容有收益,但递减得很快:64 核时每核还能带来 37 rps,96 核时只剩 9.2 rps,115 核时是 1.7 rps。按单核成本折算,96 核之后基本不值得加机器。
  2. 96 到 115 这 19 个核总共只买到 32 rps。与其扩容,不如把 σ 从 0.076 降到 0.05——那把 64 核的吞吐从 10 508 抬到约 12 300,等于白捡 20 个核。
  3. 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 配额

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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