用 JDK 25 + 虚拟线程实测三款 MyBatis 分页组件,结果出乎意料

程序员辉哥|阅读 3
2026/03/30 10:46
MyBatis 分页组件JDK 25 虚拟线程
用 JDK 25 + 虚拟线程横向评测 g2rain-mybatis-extensions、PageHelper、MyBatis-Plus 三种分页组件

本文基于自研基准工程在同一套数据、同一套调用路径下,对 g2rain-mybatis-extensionsPageHelperMyBatis-Plus 三种分页方案做横向对比,并如实记录高并发下出现的 MySQL「连接数打满」 现象与应对思路。(数据文件:benchmark-output/pagination-benchmark-results-bench-*.csv,跑测时间约 2026-03-28)


一、我们在测什么:流程、方法与结论边界

1. 测试目标

Spring Boot 4JDK 25 虚拟线程 场景下,对比三种分页组件在完成「典型分页查询」时的:

  • 吞吐throughput_ops_per_sec
  • 延迟分布(均值、p50、p95、p99、max)
  • 极限并发下的稳定性(是否出现 PARTIAL、失败次数)

压测跑了两轮、结果追加在同一 CSV 中:① benchmark.count.enabled=trueCOUNT + 列表,与多数分页列表接口一致);② benchmark.count.enabled=false只做 limit 分页,不查总数,语义不同,用于观察去掉 COUNT 后的吞吐上限)。

2. 测试流程(简述)

  1. 环境启动@SpringBootTest 拉起完整 Spring 容器与 MyBatis。
  2. 预热@BeforeAll 中执行固定次数的 queryFirstPage(),避免冷启动干扰。
  3. 分档压测:按配置的并发档位(本批数据为 1 → 100 → 500 → 1000 → 2000 → 3000),使用 Executors.newVirtualThreadPerTaskExecutor() 创建与档位相等的并发任务。
  4. 计划请求数:每个档位总请求数 = 并发数 × 10(即 benchmark.ops.per.concurrency=10 时的行为)。
  5. 数据库侧节流:使用 Resilience4j Bulkhead 限制「同时占用数据库访问许可」的并发(默认 benchmark.db.max.concurrent=128),避免无节制把连接池/数据库打穿;失败会记入 failed_operations,档位仍可落盘为 PARTIAL
  6. 结果落盘:写入 CSV,包含墙钟时间、吞吐、延迟分位数,以及 recorded_operations / failed_operations / run_status

3. 测试环境(本批数据)

项目说明
JDK25.0.2+10-LTS-69
Spring Boot4.0.3
数据库MySQL(JDBC URL 见工程 application.yml,库名可通过环境变量覆盖)
连接池HikariCP(工程中可配置 maximum-pool-size 等)
对比组件版本g2rain-mybatis-extensions 1.0.2;PageHelper 6.1.1(starter 2.1.1);mybatis-plus-spring-boot4-starter 3.5.15

重要说明:本测试比较的是「在同一基准工程、同一 SQL 形态」下的相对表现;若生产 SQL 更复杂(多表 join、子查询、动态 where),排序可能变化,但测试框架与解读方法可复用

4. 关键代码摘录(便于复现)

以下代码均在工程 mybatis-pagination-benchmark 中;Maven Profile(-Pbench-g2rain / -Pbench-pagehelper / -Pbench-mybatisplus)决定注入哪一套 PagedUserQueryService

(1)集成测试入口、JVM 参数、Bulkhead、预热与参数化档位

@TestInstance(TestInstance.Lifecycle.PER_CLASS)@SpringBootTest(classes = BenchmarkApplication.class)class PaginationVirtualThreadBenchmarkIT {     private static final int[] CONCURRENCIES = parseConcurrencies();    private static final boolean TOTAL_OPERATIONS_OVERRIDDEN = System.getProperty("benchmark.total.operations") != null;    private static final int TOTAL_OPERATIONS = Integer.getInteger("benchmark.total.operations", 10_000);    private static final int WARMUP_OPS = Integer.getInteger("benchmark.warmup.operations", 200);    private static final boolean PROGRESS_ENABLED = Boolean.parseBoolean(System.getProperty("benchmark.progress.enabled", "true"));    private static final int PROGRESS_STEP = Integer.getInteger("benchmark.progress.step", 1000);    private static final int OPS_PER_CONCURRENCY = Integer.getInteger("benchmark.ops.per.concurrency", 1);    /**     * 若 true:必须成功完成 {@code total_operations} 次采样,否则测试失败。     * 若 false(默认):允许部分失败(仍会统计成功样本并写入 CSV,便于分析极限并发)。     */    private static final boolean STRICT_COMPLETE = Boolean.parseBoolean(System.getProperty("benchmark.strict.complete", "false"));    /**     * 限制“同时打到数据库”的并发,避免并发线程数远超连接池/数据库承载导致的连接耗尽。     * <p>     * 说明:这是对压测流量的节流(throttle),不是修改连接池本身。     */    private static final int DB_MAX_CONCURRENT = Integer.getInteger("benchmark.db.max.concurrent", 128);    private static final long DB_MAX_WAIT_MS = Long.getLong("benchmark.db.max.wait.ms", TimeUnit.MINUTES.toMillis(30));     @Autowired    private PagedUserQueryService pagedUserQueryService;     private String implementationKey;    private final Bulkhead dbBulkhead = Bulkhead.of(        "db",        BulkheadConfig.custom()            .maxConcurrentCalls(DB_MAX_CONCURRENT)            // Block/wait instead of rejecting when permits are exhausted.            .maxWaitDuration(Duration.ofMillis(DB_MAX_WAIT_MS))            .build()    );     @BeforeAll    void warmupAndProbe() {        implementationKey = System.getProperty("benchmark.impl", "not-set");        System.out.printf("=== [bench] warmup start implementation=%s warmupOps=%d ===%n", implementationKey, WARMUP_OPS);        for (int i = 0; i < WARMUP_OPS; i++) {            BenchmarkPageResult r = withDbPermit(pagedUserQueryService::queryFirstPage);            assertThat(r.rowCount()).isGreaterThanOrEqualTo(0);        }        System.out.printf("=== [bench] warmup done implementation=%s warmupOps=%d ===%n", implementationKey, WARMUP_OPS);    }     static Stream<Integer> concurrencies() {        return Arrays.stream(CONCURRENCIES).boxed();    }     private static int[] parseConcurrencies() {        String raw = System.getProperty("benchmark.concurrencies");        if (raw == null || raw.isBlank()) {            return new int[] {1, 100, 500, 1000, 5000, 10_000};        }        return Arrays.stream(raw.split(",")).map(String::trim).mapToInt(Integer::parseInt).toArray();    }     @ParameterizedTest    @MethodSource("concurrencies")    void benchmarkAtVirtualThreadConcurrency(int concurrency) throws Exception {

(2)每档总请求数、按线程均分余数、虚拟线程压测与单次失败计数

        // 默认行为:每个并发档位的总请求数 = concurrency * 1(可由 benchmark.ops.per.concurrency 调整)        // 若用户显式传了 benchmark.total.operations,则保持旧行为:每个档位总请求数固定。        int totalOps = TOTAL_OPERATIONS_OVERRIDDEN            ? TOTAL_OPERATIONS            : Math.max(1, concurrency * OPS_PER_CONCURRENCY);         int baseOpsPerThread = totalOps / concurrency;        int remainderOps = totalOps % concurrency;        if (PROGRESS_ENABLED) {            System.out.printf(                "=== [bench] concurrency=%d start totalOps=%d baseOpsPerThread=%d remainderThreads=%d warmupOps=%d step=%d ===%n",                concurrency,                totalOps,                baseOpsPerThread,                remainderOps,                WARMUP_OPS,                PROGRESS_STEP            );        }        long[] samples = new long[totalOps];        AtomicInteger sampleIdx = new AtomicInteger(0);        AtomicInteger failedOps = new AtomicInteger(0);        ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();        CountDownLatch ready = new CountDownLatch(concurrency);        CountDownLatch start = new CountDownLatch(1);        CountDownLatch done = new CountDownLatch(concurrency);         for (int t = 0; t < concurrency; t++) {            final int threadIndex = t;            final int opsThisThread = baseOpsPerThread + (threadIndex < remainderOps ? 1 : 0);            executor.submit(() -> {                ready.countDown();                try {                    start.await();                    for (int k = 0; k < opsThisThread; k++) {                        long t0 = System.nanoTime();                        try {                            withDbPermit(pagedUserQueryService::queryFirstPage);                            long t1 = System.nanoTime();                            int i = sampleIdx.getAndIncrement();                            if (i < samples.length) {                                samples[i] = t1 - t0;                                if (PROGRESS_ENABLED && PROGRESS_STEP > 0) {                                    int completed = i + 1;                                    if (completed % PROGRESS_STEP == 0) {                                        System.out.printf(                                            "=== [bench] concurrency=%d progress %d/%d ===%n",                                            concurrency,                                            completed,                                            totalOps                                        );                                    }                                }                            }                        } catch (Exception ex) {                            // Bulkhead 等可能抛出受检异常;吞掉后仍计一次失败,避免线程提前退出导致 recorded 偏少。                            failedOps.incrementAndGet();                        }                    }                } catch (InterruptedException e) {                    Thread.currentThread().interrupt();                } finally {                    done.countDown();                }            });        }

(3)经 Bulkhead 装饰后执行被测接口

    private BenchmarkPageResult withDbPermit(java.util.function.Supplier<BenchmarkPageResult> supplier) {        try {            return Bulkhead.decorateSupplier(dbBulkhead, supplier).get();        } catch (RuntimeException e) {            throw e;        }    }

(4)成功样本上算吞吐与分位数,写入 BenchmarkCsvRow 并追加 CSV

        BenchmarkCsvRow row = new BenchmarkCsvRow(            Instant.now(),            implementationKey,            Runtime.version().toString(),            SpringBootVersion.getVersion(),            concurrency,            totalOps,            wallMs,            throughput,            meanLatencyMs,            p50Ms,            p95Ms,            p99Ms,            maxMs,            recorded,            failed,            runStatus        );        BenchmarkResultWriter.appendRow(row);

(5)结果文件表头(CSV 与 dump 同步追加)

    private static final String[] CSV_HEADER = {        "run_timestamp_utc",        "implementation",        "jdk_version",        "spring_boot_version",        "virtual_thread_concurrency",        "total_operations",        "wall_time_ms",        "throughput_ops_per_sec",        "mean_latency_ms",        "p50_latency_ms",        "p95_latency_ms",        "p99_latency_ms",        "max_latency_ms",        "recorded_operations",        "failed_operations",        "run_status"    };

(6)三套组件统一读取 benchmark.count.enabledtrue = 执行 COUNT,false = 不查总数)

g2rain:

    public BenchmarkPageResult queryFirstPage() {        boolean countEnabled = Boolean.parseBoolean(System.getProperty("benchmark.count.enabled", "true"));        OrderItem orderItem = new OrderItem();        orderItem.setColumn("id");        orderItem.setDirection("desc");        Page<TestUser> page = PageContext.of(1, 20, countEnabled, List.of(orderItem), () -> mapper.selectAll());

PageHelper:

    public BenchmarkPageResult queryFirstPage() {        try {            boolean countEnabled = Boolean.parseBoolean(System.getProperty("benchmark.count.enabled", "true"));            PageHelper.startPage(1, 20, countEnabled);            PageHelper.orderBy("id desc");            List<TestUser> list = mapper.selectAll();            PageInfo<TestUser> info = new PageInfo<>(list);

MyBatis-Plus:

    public BenchmarkPageResult queryFirstPage() {        boolean countEnabled = Boolean.parseBoolean(System.getProperty("benchmark.count.enabled", "true"));        Page<TestUser> page = new Page<>(1L, 20L);        page.setSearchCount(countEnabled);        LambdaQueryWrapper<TestUser> wrapper = new LambdaQueryWrapper<TestUser>().orderByDesc(TestUser::getId);        mapper.selectPage(page, wrapper);

命令行示例(PowerShell):先跑 COUNT 开,再跑 COUNT 关,结果会追加到同一 Profile 的 CSV(注意升级表头后勿与旧列混追加)。

mvn test -Pbench-g2rain "-Dtest=PaginationVirtualThreadBenchmarkIT" "-Dbenchmark.concurrencies=1,100,500,1000,2000,3000" "-Dbenchmark.ops.per.concurrency=10" "-Dbenchmark.count.enabled=true"mvn test -Pbench-g2rain "-Dtest=PaginationVirtualThreadBenchmarkIT" "-Dbenchmark.concurrencies=1,100,500,1000,2000,3000" "-Dbenchmark.ops.per.concurrency=10" "-Dbenchmark.count.enabled=false"

二、数据怎么读:核心指标含义

  • total_operations:该档位计划完成的请求次数。
  • recorded_operations:成功完成并计入延迟样本的次数。
  • failed_operations:单次调用抛异常的次数(本批主要为数据库连接相关)。
  • run_statusSUCCESS 表示计划次数全部成功;PARTIAL 表示存在失败。
  • throughput_ops_per_sec:约等于 recorded_operations / 墙钟秒数,反映成功路径的整体吞吐。
  • 延迟:基于成功样本的纳秒耗时排序后得到;若存在失败,分位数仍只反映成功请求

三、结果解读(基于最新三份 CSV)

1. 汇总对比(成功样本上的吞吐与均值延迟)

下表摘自三份 CSV 的 recorded_operations 与吞吐、均值延迟(并发 1 仅 10 次请求,宜作数量级参考)。

虚拟线程并发g2rain 吞吐 / 平均延迟PageHelper 吞吐 / 平均延迟MyBatis-Plus 吞吐 / 平均延迟
175.2 / 13.35 ms75.8 / 13.19 ms71.4 / 14.05 ms
10086.9 / 962.8 ms90.0 / 961.2 ms85.7 / 1007.2 ms
500408.8 / 903.0 ms449.9 / 781.5 ms398.0 / 953.3 ms
1000432.6 / 1921.0 ms463.5 / 1752.4 ms455.0 / 1790.2 ms
2000472.7 / 3530.9 ms(部分失败504.1 / 3528.4 ms465.8 / 3767.2 ms
3000509.1 / 5297.9 ms(部分失败509.2 / 5361.9 ms(部分失败499.1 / 5400.5 ms

读图小结:

  • 中低并发(100):三者吞吐接近(约 86~90 ops/s),差异更多体现在噪声级;平均延迟都在约 960~1000 ms 量级,说明该档位下主要时间花在排队/数据库侧,而不是 Java 侧分页实现的细微差别。
  • 中高并发(500~1000)PageHelper500 档位吞吐与延迟综合最好;MyBatis-Plus1000 档位吞吐略优于 g2rain,延迟介于 PageHelper 与 g2rain 之间。
  • 高并发(2000~3000):三者吞吐都上到约 465~509 ops/s 区间,说明系统整体进入「高并行、数据库与池化共同约束」的平台期;此时排序会强烈受是否出现连接失败影响(见下一节)。

2. 尾部延迟(观察「尖刺」)

1000 并发为例(样本均满额成功):

  • g2rain:p99 7360 ms,max 21275 ms
  • PageHelper:p99 7373 ms,max 19284 ms
  • MyBatis-Plus:p99 5974 ms,max 20194 ms

解读:高并发下 p95~p99 与 max 往往比均值更能说明「有没有被排队拖尾」;三者都出现毫秒级到秒级的长尾,符合「多虚拟线程 + 分页 + count」在共享数据库上的典型形态。

3. 补充:count=false(不执行 COUNT)

同一工程、同档位(并发 ×10 次请求),关闭 COUNT 后吞吐约 提升一个数量级以上,平均延迟从秒级降到 毫秒级。下表为 CSV 中 第二批时间戳(约 2026-03-28T13:35 UTC)对应数据,本批各档位均为 SUCCESS

吞吐(ops/s)

并发g2rainPageHelperMyBatis-Plus
1833.3666.7714.3
1003378.43424.73472.2
50012562.812690.411764.7
100015384.613888.915337.4
200025220.723015.021052.6
300031088.126525.230364.4

平均延迟(ms)

并发g2rainPageHelperMyBatis-Plus
11.281.541.45
10015.2514.3114.10
50034.5728.3532.08
100057.8563.3755.81
200074.4480.8090.14
300089.83103.2791.79

小结:去掉 COUNT 后,g2rain 在 1000~3000 并发下吞吐与平均延迟多档领先;PageHelper 在 500 档位仍很突出。不能count=false 直接替代「要总条数」的线上接口,但适合用来拆分:多出来的时间有多少花在 COUNT SQL第二条连接路径 上。


四、失败情况说明:为什么会出现 Too many connections

1. 本批数据中的失败统计

档位(并发)g2rainPageHelperMyBatis-Plus
200019965 成功 / 35 失败,PARTIAL20000 成功,SUCCESS20000 成功,SUCCESS
300029969 成功 / 31 失败,PARTIAL29993 成功 / 7 失败,PARTIAL30000 成功,SUCCESS

可见:MySQL 连接数瓶颈在 2000~3000 并发档位开始露头;g2rain 与 PageHelper 出现了少量失败,而 MyBatis-Plus 本批全部成功——这不代表 MP「永远更稳」,而是同一时刻下失败是否被摊到本次跑测里与实现路径、连接占用时序有关,需要结合服务端 max_connections 与连接池配置一起看。

2. 典型错误日志解读(你提供的栈)

关键信息:

  • SQLSTATE(08004), ErrorCode(1040)
  • 服务端提示:Too many connections

含义:MySQL 服务器当前已接受的连接数达到了 max_connections 上限,新的连接请求被拒绝。Hikari 将连接标为 broken,业务侧表现为一次查询失败;基准工程将其记为 failed_operations,档位标记为 PARTIAL

栈中出现 PageHelpercount / PageInterceptor,说明失败发生在分页总数查询或列表查询执行 JDBC 时——本质是 「拿连接」 阶段就失败了,与「分页算法本身」无直接关系。

3. 为什么会和「Bulkhead 已限流」同时存在?

Bulkhead 限制的是同时进入数据库调用的并发度(默认 128),但:

  • 连接池与 MySQL 全局连接上限 是另一层约束;
  • 分页若走 count + 列表两次 SQL,会在时间上错开但仍占用连接与线程调度;
  • 虚拟线程数量很大时,排队、重试、连接创建/归还 仍可能把服务端连接数推到极限。

因此:限流能显著缓解问题,但不能在「服务端 max_connections 很小」时从数学上保证零失败

4. 工程侧可落地的缓解思路(给读者一份检查清单)

  1. 调大 MySQL max_connections(并评估内存与线程成本),或降低压测并发使结果更「纯」。
  2. 收紧客户端连接池 maximum-pool-size,避免「池子允许开很多连接」但服务端不接那么多。
  3. 保持 Bulkhead 与池大小匹配:例如 Bulkhead 许可 ≤ 池大小,并理解分页双 SQL 的占用特征。
  4. 需要「极限并发下的成功率」对比时,建议固定 max_connections、连接池、Bulkhead` 再跑三轮取中位数。

五、收尾:怎么用好这次对比

  • 若你关心 默认列表页(count + 页数据) 的吞吐与尾延迟,本次 CSV 已给出同口径对比。
  • 若你想拆分 「count 成本」与「分页改写成本」,可在同一工程用 benchmark.count.enabled=false 再跑一轮,做 A/B。
  • 若出现 PARTIAL,优先看 failed_operations 与 MySQL 1040,把它当作环境容量信号,而不是单纯给某个组件贴「好/坏」标签。