同样提交 40 个 JDBC 请求,默认 dispatcher 的 40 个请求全部返回 200,专用执行器却有 38 个返回 504。只看状态码,默认模式似乎更好;只看 HTTP 返回吞吐,专用模式又明显更高。数据库最终都提交了 40 行,专用模式中提前返回的超时没有减少 SQL 工作。

这组结果来自相同连接池大小、SQL 和请求数量。差别在于任务在哪里等待,以及计时器从哪里开始计时。容量实验需要同时解释客户端等待、应用排队、数据库完成和资源归还,单个 QPS 数字无法表达这些关系。

固定负载与可复现的观测范围

本篇增量已接入第00篇的累计源码包。共享工程55项JUnit全部执行;容量网络再次跑完三轮48个cell、1440次测量,见 evidence/batch30-35/shared-performance-http。下文数值来自原隔离轮次;重跑受本机并行任务与调度影响,不能将不同轮次百分位混合成一组测量。

累计工程的 lab/performance_checks.py 启动一个真实 production stage,使用回环 HTTP 完整接收响应,再以新数据库连接核对提交行数。本文数字来自隔离工程的 http-03 运行;不是 Helpers 模拟调用,也不代表生产容量。该工程继承第 00 至 17 篇与第 22 至 25 篇实验基线,最终 41 项 JUnit、stage 和网络矩阵通过。共享工程集成与页面浏览器验收属于另外的验证层。

固定版本为 Play 3.0.6、JDK 21.0.11、Pekko 1.0.3、PostgreSQL 17.6、PostgreSQL JDBC 42.7.5、HikariCP 5.0.1、Caffeine 3.1.8。Play 源码提交固定为 2e56aff7d4e7a74af61e4bd39ec9e3ed7f300cd6。依赖名称与 SHA-256 来自实际 stage JAR;运行时又读取了数据库版本、驱动版本、堆上限、dispatcher 参数和连接池参数。

实验主机是 macOS aarch64,JVM 报告 14 个可用处理器,堆参数为 -Xms256m -Xmx768m。默认 dispatcher 的并行度最小值、最大值均为 2;两个专用执行器各有 2 个平台线程;Hikari 池的最大连接数与最小空闲连接数均为 2。PostgreSQL 位于本机 Podman 的回环端口,容器映像摘要保存在原始记录中。

对照组 每个测量单元的固定输入 并发 重复与预热
JDBC 默认、专用、准入 40 请求;每个已接受任务执行 60 ms pg_sleep 并插入一行 1、8 每模式每并发 3 轮;每单元先跑 8 个预热请求
整块、流式响应 24 请求;每份响应均为 262144 字节 1、8 同上;完整读取并校验摘要
无缓存、冷缓存、热缓存 24 请求;固定四个含租户身份的业务键 1、8 同上;热缓存另在计量前填充四个键

合计 48 个测量单元、1440 个测量请求,预热另记。模式顺序随轮次轮换,三模式组循环移位,两模式组交替。每轮请求数量固定,实际测量时长保留在结果中。预热数量很小,不能据此声称 JIT 已达到稳定平台;短轮次也保留了调度与 GC 波动。

负载发生器采用有限闭环:一个工作线程完成一次 HTTP 后才发下一次,每次新建连接。服务变慢时,客户端自动减少新请求到达,这会低估固定外部到达率下的排队压力,通常称为 coordinated omission。三轮闭环比较可以检查此处的执行机制,不能直接给出线上最大到达率或长期 SLO。

JDBC 热路径与排队位置

PerformanceController.task 委托 PerformanceLab.submit。后者为任务登记时点、选择执行器,再安排一个 100 ms 的响应计时器。任务真正执行时才借连接,事务依次执行休眠查询、插入合成记录、提交,离开 try-with-resources 后记录连接归还。

flowchart LR
    Client[客户端并发 1 或 8] --> Action[HTTP 与 Action 调度]
    Action --> Submit[登记 submitted 启动响应预算]
    Submit --> Default[默认 dispatcher 两线程]
    Submit --> Dedicated[专用两线程 队列 64]
    Submit --> Admission[准入两线程 队列 2]
    Admission -->|队列满| Reject[503 拒绝]
    Default --> Pool[Hikari 两连接]
    Dedicated --> Pool
    Admission --> Pool
    Pool --> SQL[pg_sleep 插入 commit]
    SQL --> Return[归还连接 记录任务终态]
    Submit --> Timer[独立计时器 100 ms]
    Timer --> Reply[504 响应决策]

专用与准入模式都使用 JDK ThreadPoolExecutor、ArrayBlockingQueue 和 AbortPolicy。后者在拒绝时抛出 RejectedExecutionException,实验将它映射为 503,没有在调用线程继续执行 JDBC 的 caller-runs 分支。默认 dispatcher 的内部队列并未因此变成有界;此处仅通过每轮固定请求数和最多 100 个登记槽限制实验输入。JDK 21 AbortPolicy 文档

固定 Play 源码的线程池说明将阻塞工作与执行它的线程池联系起来,Java 异步指南也要求为阻塞 API 选择执行上下文。返回 CompletionStage 不会把 JDBC 调用转换为非阻塞数据库协议;它改变的是调用组织和结果交付方式。固定版本线程池说明

应用账本记录 submittedNanos、startedNanos、borrowStartedNanos、borrowedNanos、sqlStartedNanos、committedNanos、returnedNanos。前两个时点之差衡量提交后的执行等待,借连接的两个时点之差衡量池访问耗时。它们都使用服务端 System.nanoTime;客户端另用自身单调时钟,两个进程的时间戳不直接相减。

maxApplicationQueued 是在提交、任务开始和归还等位置采样的 pending - active 最大值。它不等于 Pekko 内部精确队列长度,也不包含 Action 调用之前的等待。Hikari 活跃连接与借连接等待者同样是采样值,可能漏掉采样之间的短暂峰值。每个成功任务另保留了实际借用耗时,以免把采样零等待者解释成零成本。

HTTP 返回吞吐与 SQL 完成吞吐

并发 8 时,三轮 JDBC 状态分布保持一致。表中 p95 为全体 40 个 HTTP 请求的延迟,包含 503 和 504;数字单位为毫秒,顺序对应三轮,未对各轮百分位再取平均。

模式 每轮 HTTP 状态 HTTP p95,三轮 health p95,三轮 每轮最终提交
default 40×200 269.817 / 332.509 / 391.045 291.623 / 292.806 / 331.240 40
dedicated 2×200、38×504 111.365 / 107.251 / 109.885 17.707 / 15.951 / 6.803 40
admission 2×200、2×504、36×503 71.856 / 69.598 / 68.902 5.076 / 3.119 / 2.366 4

默认模式中,HTTP 等待可超过 300 ms,但已提交任务仍可能在 100 ms 预算内完成。计时器是在执行器提交后安排的,并未从连接到达时启动。客户端耗时还包含控制器之前和响应发送阶段的调度、连接建立及网络传输。现有字段只能辨认预算内外的范围,不能把两段框架调度等待进一步精确拆开。

专用模式提交后的队列在三个高并发测量单元中均采样到 26 个任务。响应计时器先完成结果,JDBC 任务继续留在队列或工作线程上。该模式 HTTP 返回吞吐为 73.29 / 75.98 / 74.45 次每秒,SQL 完成吞吐为 30.52 / 30.73 / 30.14 次每秒。提前返回 504 增加了单位时间内已结束的 HTTP 数量,没有增加数据库完成能力。

准入模式每轮只接受四个任务,其余 36 个快速拒绝,因而 HTTP 返回吞吐达到 368.79 / 376.13 / 370.43 次每秒。这个数字包含拒绝,不能与成功业务吞吐混用。原始记录同时给出 httpSuccessPerSecond、committedTasks 和 sqlCompletedPerSecond;每轮排空后,新连接查到的行数与已提交任务数一致。

低并发同样保留三轮。默认模式并发 1 的 HTTP 返回吞吐为 14.11 / 13.78 / 14.62 次每秒。并发 8 时数据库至多同时占用两个连接,增加客户端数量主要增加了等待或拒绝。专用线程池在这次负载下改善了轻量接口的响应,但不能从两个连接的实验推断把生产线程池扩大到任意值都会提高吞吐。

区分完成事件可以直接迁移到 RPC、消息发布或文件处理:分别统计响应已结束、实际工作成功和资源已归还。公式中的分子需要写出事件名称,分母需要写出测量区间。三者混成同一个“完成数”,会把提前失败或超时误读为业务加速。

客户端已经收到 504,SQL 仍然提交

独立的 late_0 实验在专用执行器上并发发出八个请求。客户端完整读取 504 后,再调用 /perf/ack;该接口使用服务端单调时钟登记确认到达时点。任务仍继续运行,排空后比较同一服务端时钟域的确认与提交时点。

sequenceDiagram
    participant C as HTTP 客户端
    participant A as Action 与计时器
    participant E as 专用执行器
    participant D as PostgreSQL
    C->>A: 提交任务
    A->>E: 入队
    A-->>C: 100 ms 后决定并发送 504
    C->>A: 完整读到 504 后请求 ack
    A->>A: 记录 clientAckNanos
    E->>D: 执行 SQL 并提交
    D-->>E: commit 返回
    E->>E: 记录 committedNanos 与 returnedNanos
    C->>A: 等待全部任务终态
    A->>D: 新连接查询提交行数

最终记录中,任务 0、1、2、5、6、7 的提交时点晚于各自的客户端确认到达时点。以任务 1 为例,确认时点为 121258497505666,提交时点为 121258587940708,相差约 90.435 ms。这个先后关系不依赖客户端与服务端时钟同步:确认请求本身已经证明客户端先收到完整 504。

该确认实验单独标为非测量轮次,额外的 ack 请求不会混入 48 个性能测量单元。普通轮次只记录响应决策和 SQL 终态;不能把“计时器已触发”直接改写成“客户端当时已收到 504”。所有接受的任务均等待到终态,超时之后的 CPU、连接占用和提交仍计入对应阶段。

实际业务若在超时后自动重试,还需要 事务与幂等 中的业务键与唯一约束。响应超时本身无法判定业务是否提交。本文没有用 CompletableFuture.cancel 代替数据库取消,也没有把 HTTP 504 当作客户端断开 TCP 的证据。

相同字节量的整块与流式响应

PayloadLab.aggregate 一次填充 262144 字节数组。PayloadLab.stream 从有限整数范围产生 32 个 8192 字节块,每个字节由全局偏移对 251 取模得到。两条路径经 HTTP 发送后,客户端都完整读取 256 KiB,并逐份核对 SHA-256;测量的是完整接收耗时,响应头到达时点也保留在原始行中。

高并发时,整块响应的三轮 p95 为 10.589 / 13.654 / 10.820 ms,流式为 16.673 / 25.495 / 9.453 ms。前两轮流式较慢,第三轮略低于整块响应;小体积、快速消费者和短测量窗口下,构造块与流运行时开销之外仍有调度波动。结果不能推导出流式响应普遍较慢,也没有覆盖大文件慢消费者下的峰值内存优势。

流任务账本必须出现终止状态且每份产出 32 块;客户端必须校验完整字节数与摘要,两项同时通过。服务端流终止只证明对应流阶段结束,客户端是否收到完整结果由另一份 HTTP 记录证明。此处未制造慢读、半关闭或网络中断,相关协议与资源释放行为见 HTTP 流式响应。

相同结果是两条实现可比较的前提。对缓存需要校验身份键与输出,对压缩需要校验解压结果,对流式响应需要校验完整内容。只比较响应头到达而不检查最终内容,可能把部分响应或提前失败当成更低延迟。

缓存减少了哪一部分工作

缓存组固定四个业务键:两个租户各有两个 item,输出为含相应租户与 item 的字符串。无缓存模式每次执行同一条带 10 ms 合成延迟的 PostgreSQL 查询;冷缓存使用新的实验命名空间;热缓存在测量开始前填充同样四个键。命名空间隔离不同轮次,业务身份与输出内容不随模式改变。

PayloadLab.cached 使用注入的 AsyncCacheApi.getOrElseUpdate。固定 Play Caffeine 实现通过底层异步缓存的 get 保存加载 Future,同一键加载期间的调用可以共享该 Future。实验按真实调用记录 loader 次数与 SQL 执行尝试次数,不假设每个并发调用都产生独立查询。固定版本 CaffeineCacheApi

模式 每单元请求数 测量内 loader / SQL 次数 并发 8 的三轮 p95,ms
uncached 24 24 / 24 61.752 / 49.574 / 50.833
cold 24 4 / 4 29.494 / 27.592 / 30.961
hot 24 0 / 0 3.875 / 3.468 / 2.850

冷缓存每轮将数据库加载次数从 24 次降到 4 次,对应三轮 p95 均低于无缓存路径。24 个样本中的 p95 接近尾部,框架调度、连接建立和同机进程噪声仍会影响短轮次。查询计数给出了业务工作量的直接对照;这组固定四键结果没有覆盖大键空间、失效风暴或缓存服务网络延迟。

这些查询计数由应用在执行 JDBC 语句前递增,并在成功返回后校验输出;它们不声称是 PostgreSQL 全服务器的语句总数。热缓存填充的四次加载保留在预填充记录与阶段前快照中,未伪装成没有成本。跨租户键冲突、失败加载和旧加载迟到的语义仍由 缓存加载与失效 的专项实验检查。

分配量、GC 与样本统计

实验通过 com.sun.management.ThreadMXBean 读取平台线程的堆分配估计值。JDBC 字段只覆盖执行该任务的工作线程;整块响应只覆盖字节数组构造;流式响应逐个累加块映射函数的分配差。框架调度、响应转换、其他线程和传输缓冲未全部包括在内。JDK 21 ThreadMXBean 文档

每个 24 请求测量单元中,整块构造记录约 6291840 字节,流块构造记录约 12619776 字节。后者包含数组与 ByteString.fromArray 路径的复制和对象开销;这两列不能充当完整 HTTP 请求的总分配排名。缓存路径没有测量专属构造分配,字段中的零是未采样占位,不能解释为零分配。

GC 次数与累计耗时、进程 CPU 时间按阶段前后做差,覆盖整个 JVM,包含 health 和实验控制请求。最终记录中存在单轮 1 次、12 ms 的 GC,也有大量零次轮次。零次只说明这一短测量窗口未观测到 GC,不能说明该模式不产生垃圾。heapUsedBytes 仅是快照,不参与分配量计算。JDK 21 GarbageCollectorMXBean 文档

health 探针每次完成后等待约 40 ms 再发下一次,因而它也是闭环采样。实验保留了每次状态与延迟,未把其稀疏样本当成连续探测。测量期间不打印逐请求服务端日志,客户端保存原始记录;尚未做关闭全部观测代码的开销对照,也没有采集 JFR 或对外部同机负载进行独占隔离。

百分位统一使用 nearest-rank:将 N 个耗时升序排序,p 分位取第 ceil(p × N) 个样本,从 1 开始编号。40 个请求的 p99 就是最大值。它不等价于插值分位数,三轮 p95 也不能直接平均成“总 p95”;需要总体分位数时应合并对应原始样本重新计算。

下面的完整 Java 类验证这一算法,输入是人工构造的 1–40 ms 序列,不是性能实验结果。系列工程使用 JDK 21;这个独立类只依赖标准库,可直接编译执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import java.util.Arrays;

public final class NearestRank {
private NearestRank() {
}

public static long percentile(long[] samples, double fraction) {
if (samples.length == 0 || !Double.isFinite(fraction)
|| fraction <= 0.0 || fraction > 1.0) {
throw new IllegalArgumentException("nonempty samples and 0 < p <= 1 required");
}
long[] sorted = samples.clone();
for (long sample : sorted) {
if (sample < 0) {
throw new IllegalArgumentException("negative duration");
}
}
Arrays.sort(sorted);
int index = (int) Math.ceil(fraction * sorted.length) - 1;
return sorted[index];
}

public static void main(String[] args) {
long[] samples = new long[40];
for (int index = 0; index < samples.length; index++) {
samples[index] = index + 1L;
}
long median = percentile(samples, 0.50);
long p95 = percentile(samples, 0.95);
long p99 = percentile(samples, 0.99);
if (median != 20 || p95 != 38 || p99 != 40) {
throw new AssertionError("nearest-rank mismatch");
}
System.out.printf("N=%d p50=%d p95=%d p99=%d%n",
samples.length, median, p95, p99);
}
}

保存为 NearestRank.java 后,用 "$JAVA_HOME/bin/javac" -Xlint:all -Werror NearestRank.java 编译,再执行 "$JAVA_HOME/bin/java" NearestRank。本次实际输出为 N=40 p50=20 p95=38 p99=40,编译日志与源码摘要保存在写作收据中。

复现命令与证据入口

从仓库根目录执行。JAVA_HOME 必须指向 JDK 21;PostgreSQL fixture 使用公开的合成数据库与账户,连接 URL 显式提供。端口由已有 fixture 决定,示例 35711 是本次观测值;脚本只管理自己启动的 Play 进程,不启动或停止共享数据库容器。

1
2
3
4
5
6
export JAVA_HOME="$(/usr/libexec/java_home -v 21)"
export PLAY_LAB_DB_ENABLED=true
export PLAY_LAB_JDBC_URL='jdbc:postgresql://127.0.0.1:35711/play_lab?connectTimeout=3&socketTimeout=5'
cd examples/play-lab
bash sbtw test stage
python3 lab/performance_checks.py --evidence evidence/local-performance-run

macOS 命令中的 JDK 选择路径需要与实际安装一致,其他系统直接设置相应 JAVA_HOME。sbtw 的 launcher 准备步骤见 最小应用。证据目录必须尚不存在,脚本拒绝覆盖既有运行;实验在独立 perf_* schema 写入合成行。脚本通过独立 stage 启动命令的两项 -Dpekko.actor.default-dispatcher.fork-join-executor.parallelism-* 参数固定并行度为 2,并在运行时读回核对;常规 application.conf 保持不变。

源码入口为 app/controllers/PerformanceController.java、app/labperf/PerformanceLab.java 与 app/labperf/PayloadLab.java。test/PerformanceTest.java 检查有限任务归账、完整字节一致性与真实缓存查询次数;性能百分位来自真实网络脚本,不来自单元测试耗时。

对应原始记录位于 examples/play-lab/evidence/batch31/isolated/:http-03/requests.jsonl 保存逐请求数据,http-03/observations.json 保存每轮原始任务与派生结果,comparison.csv 提供每轮 p50/p95/p99、状态、吞吐、排队与资源列,manifest.json 保存源码及实际 JAR 摘要。早期 http-01、http-02 记录保留,正文只使用启动参数已隔离、采用轻量排空检查的 http-03,不混合三次样本。

最终核对要求所有已接受任务到达终态、SQL 行数匹配、连接活跃数归零、全部流响应完整接收。stage 进程收到 SIGTERM 后退出码为 143,日志记录两个执行器、计时调度器均终止,连接池关闭。数据库 fixture 保留给其他章节复测,不能把保留容器误写为本次请求仍在运行。

两个改动练习

  1. 保持 SQL、连接池和请求数不变,将专用执行器队列从 64 缩到 4,重复三轮并发 1/8。逐轮报告成功、503、504 与最终提交数,检查降低 HTTP p95 是否只是拒绝占比上升;不得只给合并 QPS。
  2. 保持完整响应摘要不变,把流块从 32×8 KiB 改为 128×2 KiB,并增加有界慢读客户端。分别记录首字节、完整接收、块构造分配与最终流终态。只有额外采集缓冲或内存峰值后,才讨论该慢消费者场景的内存上限。

上一篇:30 生产打包与部署 · 下一篇:32 版本迁移 · 系列起点:最小应用