Skip to content

Benchmarking

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

基准与压力测试

简体中文 | English

这一页记录这个插件是怎么被测的,以及测试时定下的规矩。它是一份计划,不是一份成绩单:旁边那些页面里的数字——存一个结果要花多少、上限是从哪来的——都是一个一个问题、一轮一轮手工量出来的。量过的,这里写「实测」;还没量过的,这里就写还没量。

这些数字对服务端意味着什么

只看这一段也能知道个大概。大到存不下的请求会被拒绝,而不是硬着头皮跑——拒绝是在保护服务端,玩家在意的是卡顿,不是一条报错——这些上限就是保护本身:10000 行的读取被拒绝、什么都不存,超过 30000 值预算的写入被拆开而不是被拒绝。

「快」到底有多快,这里给了三把可以对照的尺子,每一把都标明它是怎么来的:

  • 吞吐。 5000 行 insert many 在 AMD EPYC 9V74 上实测 6.058 毫秒、在 Intel Xeon Platinum 8573C 上实测 8.022 毫秒——这两台是记下数字的 CI 主机,Xeon 那个点就是持久化在 dev/bench/intel-xeon-platinum-8573c/data.js 里的那一个。拿这个区间倒推,插件自己的热路径大约在每秒 62 万到 82.5 万行。这是从 CI 记下的实测数推出来的,不是一个独立的成绩。
  • 服务端自己的心跳。 一个 tick 是 50 毫秒。CI 断言的上限就是保护本身:10000 行的读取是直接拒绝、什么都不存;超过 30000 值预算的写入是被拆开而不是被拒绝。所谓「快」,真正要紧的地方就是不卡服务器;会卡的那些尺寸,它直接拒绝。
  • 不同数据库的对比。 同一份 SQL、同一个服务器,MySQL 用了 17.04 秒,PostgreSQL 和 MariaDB 各 1.99 秒。服务端自己的计数说出了原因:MySQL 收到 5038 条语句,MariaDB 只收到 2 条。这是事实,不是建议。

这里本来空着的第四把尺子,现在仍然没有计时数字:插件自己的路径,对上同一台服务器、同一个驱动、同样的行、同一个后端上的等价裸 SQL。关于这个对比,目前知道的是代码层面的结构性事实。插件的 insert many 是 JDBC 批次——一行一条、每条都是单行准备好的语句;而裸 SQL 元素没有批次的写法,所以等价的裸写法只能是一条把值全写进去的多行 INSERT——于是对这两者计时,量到的会是「一批五千条单行语句」对「一条多行语句」的形状差别,而不是映射层本身。

手写的 Skript 循环是刻意没去比的:往普通变量里存,是拿内存变量去对数据库,干的不是同一份活。上面比的,是走插件自己连接的裸 SQL——唯一不同的是路径,不是行,也不是后端。

下面全部内容都是:这些数字怎么测的、各自来自哪里、由哪台机器产出、以及哪些东西是有意不测的。

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

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

  • 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 行。同一个基准在 AMD EPYC 9V74 上实测 6.058 毫秒、在 Intel Xeon Platinum 8573C 上实测 8.022 毫秒——这两台是记下数字的 CI 主机,Xeon 那个点就是持久化在 dev/bench/intel-xeon-platinum-8573c/data.js 里的那一个。

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

那张表:同一个批次,一个驱动发了五千条语句,另一个发了两条。 第一次把这些数字送进作业日志的那次运行,用同一份插件代码、同一次 insert many 调用,在五台服务器上量了同样一次 5000 行的写入。MySQL——安装的 8.0.46 与容器里的 8.4.11——记下的是 5038 条语句写 5000 行,即每行 1.0076 条语句,comInsert=5000 而 comStmtExecute=0;也就是说,批次是一行一条发出去的。MariaDB 11.4.13 为同样的工作记下的是 2 条语句,comInsert=1、comStmtExecute=1,一条多行插入。它对那个用户不报告写入行数,所以它的「每语句行数」是不可得的,那里就不写比值,而不是编一个。PostgreSQL 16.15 是第三种答案:它自带的计数根本无法把这些行归到语句上,所以那一半是不可得、并把理由印在旁边;而它能报告的都写在这里——表上记下 5000 行、库上记下 5057 行,等待异步填充的视图用了 814 毫秒。每个后端都报告插件的自计数为 5000 且精确,每张表都实实在在存着 5000 行。

这就是时间差所指的那个差别——同一份 SQL、同一个服务器,17 秒对 2 秒——它住在驱动里。所以形状只报告、从不做门禁,也所以那条曾经给它设上界的断言是错的,并在被这个数字撞红之后删掉了。这是每个后端各一台 runner 上的一次运行:单点就是单点,不是区间。指纹与数字同行,所以这里可以引用:os=Linux 6.17.0-1022-azure arch=amd64 cores=4 java=25.0.4.1 commit=81526798498f5ad30068fcb3299f2085062f868d。cpu 那一栏值得读两遍——MySQL 与 MariaDB 两行写的是 Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz,而同一个标签、同一次运行里的 PostgreSQL 那行写的是 AMD EPYC 7763 64-Core Processor。也就是说,一次运行的几个数字,并不都出自同一台机器。

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 的点比。上面那张表的那次运行,把同一件事从一次运行内部再证了一遍:它的 MySQL 与 MariaDB 作业跑在 Intel(R) Xeon(R) Platinum 8370C 上,而同一次运行、同一个标签下的 PostgreSQL 作业跑在 AMD EPYC 7763 上。池子是按作业发主机的,不是按运行,所以连一次运行里的几个数字,都不都出自同一台机器。
  • 那条分支是存储,不是站点。 它不发布任何页面,也没有可以浏览的图表。它的顶端只有一个 README 和 action 的数据文件 dev/bench/<slug>/data.js:那是一条 JavaScript 赋值语句,里面是原始 JSON,写给制图工具,也写给日后夜里跑够了、要据此定下告警阈值的人。里面的名字是那个 action 的默认值,不是本项目选的——分支叫 gh-pages,是因为那是 benchmark-action/github-action-benchmark 的默认值,也沿用 GitHub Pages 的惯例(发布静态站点的分支叫这个名字);而本项目需要的,只是一个不属于任何源码分支的存储位置。它是手工建的,不是某次运行建的:建分支就是一次推送,而一次运行若把自己的存储推上去,就等于在它被触发去做的那个改动之外写东西。读懂 JSON 不是信任这个插件的门槛:想看数字的人应该读 README 里的那张表或资源页的描述,数据文件是留给工具用的。
  • 仓库里放一份 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 里的脚本驱动它,这样那个要求每个自检名字都得申报的检查器,就不会被问到基准名字。语句在脚本内部计时,量在 effect 所在的那个 tick 里:它跨了几个 tick,以及这个窗口里最长的一个 tick。脚本通过 SKRIPTORM_BENCH 行上报,任务在遇到 FAIL 行、或者某次写入让表增加的行数对不上时报错失败。所以什么都没量到的运行,不会被当成「量到了一次很快的写入」。GitHub Actions 通过 server-test 套件断言的是这些上限,而不是计时:5000 行的写入每一行都存下;10000 行的读取被拒绝、什么都没存;超过 30000 值预算的写入是被拆开而不是被拒绝,预算处曲线上没有台阶——见 读取行 与 写入行。这里不报 MSPT 数字,因为没量过,要报就得编:tick 时长的百分位需要在 tick 循环里放一个观察者,那是服务端里的一个插件,而不是脚本;脚本侧的时钟顶不上——它只有 10 毫秒的分辨率,会被挂起的触发器被唤醒的那个 tick 量化,而「最长 tick」这个数字里也含着观察者自己的开销。
  • 阶段 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