Skip to content

Benchmarking

github-actions[bot] edited this page Oct 1, 2026 · 19 revisions

基准与压力测试

简体中文 | English

本页记录这个插件怎么被测,以及围绕它做出的决定。它是一份计划,不是一份报告:旁边那些页面里的数字——存一个结果要花多少、上限是从哪来的——都是在这些轮次里一个问题一个问题手工量出来的。量过的,这里写“实测”;还没量过的,这里就写还没量。

三个层次,以及为什么必须分开

一条语句的开销不在同一个地方,而这三处要用两种时钟来读:

  • A:只有 JVM。 值转换、方言拼出来的 SQL、批次怎么切、结果怎么变成行、事务外面的那些记账。没有数据库,也没有服务端:用 JMH,单独放在一个模块里,日常构建不为它花时间。
  • B:真数据库。 往返、服务端自己的解析与执行、连接池、提交。用 Testcontainers,每个后端一个真实例,装置就是集成测试已经在用的那一套。
  • C:真 Paper 服务端。 语句对 tick 的占用:从变量里把批次读出来、把结果写进变量、大结果占住的内存、MSPT。一次性服务端,用脚本驱动。

用一个装置去量“这个插件”,等于什么都没量,因为值得找出来的问题都是分层的:驱动每行发一条语句(B)、变量存储每存一个值就花掉一部分 tick(C)、转换器每次调用都分配(A)。

把 TimingStart / TimingEnd 套在一条语句上,量到的是墙钟,而墙钟回答不了“服务端卡没卡”。 它量的是这个触发器等了多久——对“我的数据库快不快”这是诚实的答案,对玩家真正在问的那个问题则是错的答案。数据库的活在服务端线程之外做;tick 花在把答案写进变量、以及把批次从变量里读出来上,这些活都在服务端线程上。数据库变快可能完全不改变那次卡顿,数据库变慢也不一定造成卡顿。每个用例这两个数字都报,而且分开报。

每个用例都要报告什么

指标 用什么读 来自
服务端线程毫秒数 服务端线程上的时间 C
线程外毫秒数 墙钟 B、C
触发器总延迟 脚本看到的那段墙钟 B、C
吞吐 每个值、每一行 A、B、C
交给驱动的语句数与行数 数出来的,不是量出来的 B
服务端真正收到的语句数,以及它写下的行数 数出来的,不是量出来的 B,在安装型服务器的作业里
每次操作的分配量 字节,不是时间 A、B
峰值堆占用 用例结束后、回收一次之后 B、C
MSPT 百分位(p50、p99、最大值) 服务端自己的 tick C

触发器总延迟要和这两半并排报,而不是拿它取代这两半:一条语句可以很慢却不卡——活在线程外做了;也可以很快却卡——时间花在写值上。

为什么语句数用来定论,时间只用来提示

有的指标是噪声,有的不是。共享机器上的墙钟,两次完全一样的运行之间就会漂 10% 到 20%;语句数一动都不动。所以这套东西把计数和分配当作结论,把时间当作提示,理由来自本轮的一次测量。

一次 insert many 的基准——两列、5000 行、重复二十次,每个后端跑的是同一个脚本、同一张表的定义——实测 MySQL 17.04 秒,PostgreSQL 与 MariaDB 各 1.99 秒,也就是每行约 170 微秒对 20 微秒。插件发给 MySQL 和 MariaDB 的 SQL 是同一份(两者共用一个方言),而那次运行里两条连接指向同一台服务器。差别在驱动:没人告诉驱动去改写批次时,批次就是每行一条语句发出去的,所以慢的那次每批发了 5000 条。这个数是服务端的,不是 JDBC 层的:插件交给驱动的只有一个批次,是驱动把它变成五千条语句的,所以一个放在 JDBC 层的计数器会(正确地)报出 1,什么也解释不了。计数就是全部解释,而任何时间指标都指不出这一点——时间指标只说了“MySQL 慢”,而这是本来就已知的。

两层计数现在都量出来了,它们回答的是不同的问题。JDBC 这一层是基准模块里的计数驱动包装:在内存 H2 上走通用 JDBC 路径时,一次 5000 行六列的调用只提交了一条语句,插件报告的 5000 行是精确值,调用返回后表里就是 5000 行,同一次运行实测每次调用 9.960 ± 0.384 毫秒——这就是 benchmarks/baseline.json 里的那次参考运行,它记下的计数行由运行留在 benchmarks/build/benchmarks/insert-many-counters.txt 里。

服务端那一层是 ServerSideCountIntegrationTest,它跑在本来就会起真服务器的那些作业里——mysql-installed、postgres-installed 以及容器作业——所以它自己不需要 Docker,也不新增作业。在 MySQL 系上,它在一次插件调用的前后读服务器自己的 Questions 计数,减去两次探针自身的开销(开销由同一对读围绕一个空块跑一遍量出来),并同时读 Innodb_rows_inserted;Com_insert 与 Com_stmt_execute 说明走的是哪种形状。比值就是答案:5000 行由少数几条语句带走,是改写过的批次;5000 行由 5000 条语句带走,就是逐行发出去的批次。驱动选哪一种,是驱动及其版本的决定,所以这个数字与它被测时的指纹一起报告,从不做门禁。这些计数是全服务器范围的,所以用例会先确认没有别的用户连着;有的话,它把数字连同这个事实一起打出来并中止,而不是去断言——把别人的写入算进去的差值不是这个插件的测量。

PostgreSQL 给不出语句那一半。 它自带的统计数的是行数和事务数——pg_stat_user_tables.n_tup_ins 与 pg_stat_database.tup_inserted,同一个用例会报告它们并轮询等待,因为这些视图是异步填充的——而没有任何自带的东西说明这些行是由多少条语句产生的,所以那里不写语句数,而不是写一个含义不同的数。要补上这一半,需要把 pg_stat_statements 通过 shared_preload_libraries 加载并重启服务器,或者用一个代理去数协议里的语句消息。见 影响行数。

现成的工具,不要造轮子

用途 工具 为什么是它
微基准(A) 手工接入的 JMH JDK 工程师自己在用的那套;预热、进程分离、消除死代码是它的活,不该是我们的
交给驱动的语句数与行数(B) datasource-proxy,或自己包一层计数驱动 在不改动被测代码的前提下数清插件自己的切分——但在这个层级上,一个批次就是一次调用,带一行还是带五千行都一样
服务端真正收到的语句数与往返数(B) 数据库自己的会话计数器,或抓包 驱动自己的批处理只在这一层可见,而 MySQL 那次的差别正住在这里;它随驱动版本和连接参数变化,所以每次运行都报告,但不拿来做门禁
tick 采样的百分位(C) HdrHistogram JMH 自带自己的置信区间;采样循环需要自己的直方图
真数据库(B) 开了复用(reuse)的 Testcontainers 集成测试本来就会起真实例;复用让每次运行不必重付启动成本
历史、对比、PR 评论、失败判定 benchmark-action/github-action-benchmark 存下每次结果、与历史比较、在 PR 里评论,并在越过阈值时让任务失败
分配与停顿 JMH -prof gc、JFR、async-profiler 给出每次操作的字节数,并在某个用例比预期慢时给出 GC 与安全点的细节
MSPT(C) Paper 自己的 tick 采样 量 tick,而不需要先信任一个我们自己写的插件

JMH 手工接,不走 me.champeau.jmh 插件:那个插件支持到 Gradle 8,而本仓库在 Gradle 9.1.0 上。基准模块自己声明 jmh-core 和注解处理器,用一个 JavaExec 任务运行,报告就是 JMH 的 JSON。这个任务是 ./gradlew :benchmarks:jmh,结果写到 build/benchmarks/results.json;-Pbenchmarks.filter=<正则> 可以把一次运行缩小到匹配的用例,-Pbenchmarks.forks=1 能让本地跑得快一些。JMH 的注解处理器生成的是 Java,所以基准类写成 Java。 Kotlin 项目可以走 kapt,也可以走 kotlinx-benchmark,但在这里两者都不划算:这些类只是库外面很薄的一层,而一个需要被绕过去的构建插件,本身就是另一种轮子。

门禁是什么

门禁是会失败、会挡住合并的检查。没人看的报告不是门禁;而因为与改动无关的原因变红的检查比没有更糟——它会被强行合掉,或者被关掉。

本仓库已经有门禁:编译、单元测试、ktlint、集成测试的编译。性能门禁是同一件事对准上面那些指标,只多一条从噪声里推出来的规矩:只有零噪声指标才做硬门禁。 每次操作的语句数、往返数、分配量会让构建失败;时间只报告,给一个告警阈值,不让它失败。在共享 runner 上,一个不管改动如何都会让十次运行里红一次的阈值,一个月之内就会被删掉,接着那些计数的门禁也会一起被删掉。

基线放在哪里

回归是一种比较,所以总得有个地方存着上一次的数字,而且必须是在可比的硬件上量出来的。

  • 历史放在 gh-pages,由 CI 上的那个 action 管,并按 CPU 分开存放。后半句更正的正是这条以前的说法——「门禁和趋势都拿同一型号 runner 的结果互相比」——因为 ubuntu-latest 是覆盖一个池子的标签,不是一台机器:同一个 commit、同一套设置下,这个 workflow 的两次运行分别落到 AMD EPYC 9V74 和 Intel Xeon Platinum 8573C 上,第二次的插入用例是第一次的 1.32 倍、行数上限用例是 1.21 倍,变的只有宿主。这是硬件差异,幅度还比这套东西要抓的大多数回归更大,所以不按 CPU 分开的历史分不清「机器更慢」和「代码更慢」——图上跳一下,可能只是换了一台 runner,而不是代码变了。action 现在把每条结果写到 dev/bench/<slug> 下,slug 由环境步骤已经记下的 CPU 型号行推出,Xeon 的运行只和 Xeon 的点比,EPYC 的运行只和 EPYC 的点比。
  • 仓库里放一份 benchmarks/baseline.json,标明仅供参考,不用于门禁,旁边用 benchmarks/baseline.environment.txt 写下产出它的那台机器。它的用途有两个:让本地跑的人有个量级可以对照;让这些页面里的数字有唯一可引用的出处。这一对是在测量的当下、从一棵干净的树里写下的——一份不知出自哪个 commit 的基线,描述的不是任何人的代码。
  • 跨机器绝不比绝对值,只比比例。 一台笔记本和一台共享 runner 之间的差距,比大多数回归都大。

每条结果都记录它被测时所处的环境,否则“回归”可能只是换了依赖:JDK 厂商与版本、操作系统与 CPU、容器镜像 digest、驱动版本、Paper 与 Skript 版本、commit、工作区是否脏。上面那个 MySQL 的例子就是证明——不知道驱动的名字和版本,同一批数字描述的是三个数据库,而不是两个驱动。

四个阶段

  • 阶段 0——模块与第一批用例。 建基准模块,把上面那些工具接上去,先做文档里已经写着的那几个开销:值转换、按值和按变量种类存结果、从变量里读批次、以及一次 insert many 会发出多少条语句。产出是一份 JMH JSON、一张汇总表,和第一份 baseline.json。
  • 阶段 1——JDBC 矩阵。 插件点名的每个后端、它有的每条语句,配上几种表形状,装上语句计数器。把“每行 170 微秒”变成“每批 5000 条语句”的,就是这个阶段。
  • 阶段 2——tick。已实测。 ./gradlew serverBenchmark 会起一个真实的 Paper 服务端,带着 Skript 和本插件——Paper 构建与插件 jar 跟测试起的那套一样,只是不装测试装置为 NBT 互操作装的 SkBee——用放在单独目录 server-benchmark/skript 里的脚本驱动它,这样那个要求每个自检名字都得申报的检查器就不会被问到基准名字。跑它的那几台机器上,服务端启动大致要用 10 到 14 秒——某一次会话是 10.4 到 11.4 秒,另一次是 12.9 到 13.6 秒——这是那台机器当天的启动开销,不是插件的属性。语句在脚本内部计时,量在 effect 所在的那个 tick 里:它跨了几个 tick,以及这个窗口里最长的一个 tick。脚本通过 SKRIPTORM_BENCH 行上报,任务在遇到 FAIL 行、或者某次写入让表增加的行数对不上时报错失败,所以什么都没量到的运行不会被当成“量到了一次很快的写入”。开销对规模出来的是一条曲线,不是一个点:六列表写 100 行,超出所在 tick 10 毫秒;写 10000 行,超出 100 到 110;写 5000 行——两个常量都落在的那个尺寸——冷启动超出 70 到 90 毫秒;到了 JVM 第二轮热态,一次运行超出约 30 毫秒,另一次整个写入都落在同一个 tick 里,因此完全没有超时——低于 50 毫秒一个 tick 的开销,本来就没有可被拉长的东西。两个常量都站住了:100 到 5000 行每一行都存下了、也从未拉长 tick,10000 行被拒绝、什么都没存;超过 30000 值预算的写入是被拆开而不是被拒绝,预算处曲线上没有台阶——见 读取行 与 写入行。这里不报 MSPT 数字,因为没量过,要报就得编:tick 时长的百分位需要在 tick 循环里放一个观察者,那是服务端里的一个插件,而不是脚本,脚本侧的时钟顶不上——它只有 10 毫秒的分辨率,会被挂起的触发器被唤醒的那个 tick 量化,而“最长 tick”这个数字里也含着观察者自己的开销。这些页面引用的参考数字在 benchmarks/baseline.json 里,产出它的机器记在 benchmarks/baseline.environment.txt。
  • 阶段 3——压力与故障注入。 并发、取消、语句飞到一半把数据库杀掉、查询还在飞就重载插件、超过上限的结果、远超预算的批次、被打满的连接池。这些用例断言,而不是打印:没有泄漏、被拒绝的东西没被存下、静置时没有连接被占着、MSPT 在界内、服务端仍能应答。它们断言的地方就是测试已有的地方——同一套服务端装置把一次运行写下的日志与一份期望行清单对照,所以这里的一条用例就是一段脚本,加上它必须产生的那一行,或者必须不产生的那一行。
  • 阶段 4——CI 与文档。 PR 上跑快的那一小部分,夜间跑全量,结果归档,计数做门禁、时间做告警,这些页面里的数字由运行生成,而不是手抄一遍。

这里不覆盖什么

  • 玩家。 这里不会有人连进来、走路、探索。安静的服务端上量到的一条语句,不等于一百个区块的实体在 tick 时的那条语句。
  • 长时间浸泡。 用例以秒计,不以天计;需要一周才显形的泄漏不在范围内。
  • 跑不起来的后端。 通用 JDBC 连接能接上本仓库没有测过的驱动,在 MySQL 上量到的结果并不能描述它们。
  • PostgreSQL 上的语句数。 它的统计数的是行数与事务数,而不是语句数,所以上面那份服务端计数只存在于 MySQL 系上。PostgreSQL 愿意说的是它记下了多少行,那里报告的就是这个——是行数,不是语句数。
  • 跨机器的绝对值,同上;以及任何还没量过的东西——本页和旁边那些页面里没有写出数字的地方,数字就还没量。

skript-orm

参考

实用指南


skript-orm (English)

Reference

Practical guides


Wiki 由仓库中的 README 和 docs/ 自动生成。修改文档请到仓库提交,直接编辑 Wiki 的内容会在下次同步时被覆盖。

This wiki is generated from the README files and docs/ in the repository. Please submit changes there; direct wiki edits are overwritten on the next sync.

Clone this wiki locally