本文基于自研基准工程在同一套数据、同一套调用路径下,对 g2rain-mybatis-extensions、PageHelper、MyBatis-Plus 三种分页方案做横向对比,并如实记录高并发下出现的 MySQL「连接数打满」 现象与应对思路。(数据文件:
benchmark-output/pagination-benchmark-results-bench-*.csv,跑测时间约 2026-03-28)
在 Spring Boot 4 与 JDK 25 虚拟线程 场景下,对比三种分页组件在完成「典型分页查询」时的:
throughput_ops_per_sec)PARTIAL、失败次数)压测跑了两轮、结果追加在同一 CSV 中:① benchmark.count.enabled=true(COUNT + 列表,与多数分页列表接口一致);② benchmark.count.enabled=false(只做 limit 分页,不查总数,语义不同,用于观察去掉 COUNT 后的吞吐上限)。
@SpringBootTest 拉起完整 Spring 容器与 MyBatis。@BeforeAll 中执行固定次数的 queryFirstPage(),避免冷启动干扰。1 → 100 → 500 → 1000 → 2000 → 3000),使用 Executors.newVirtualThreadPerTaskExecutor() 创建与档位相等的并发任务。benchmark.ops.per.concurrency=10 时的行为)。benchmark.db.max.concurrent=128),避免无节制把连接池/数据库打穿;失败会记入 failed_operations,档位仍可落盘为 PARTIAL。recorded_operations / failed_operations / run_status。| 项目 | 说明 |
|---|---|
| JDK | 25.0.2+10-LTS-69 |
| Spring Boot | 4.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),排序可能变化,但测试框架与解读方法可复用。
以下代码均在工程 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.enabled(true = 执行 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_status:SUCCESS 表示计划次数全部成功;PARTIAL 表示存在失败。throughput_ops_per_sec:约等于 recorded_operations / 墙钟秒数,反映成功路径的整体吞吐。下表摘自三份 CSV 的 recorded_operations 与吞吐、均值延迟(并发 1 仅 10 次请求,宜作数量级参考)。
| 虚拟线程并发 | g2rain 吞吐 / 平均延迟 | PageHelper 吞吐 / 平均延迟 | MyBatis-Plus 吞吐 / 平均延迟 |
|---|---|---|---|
| 1 | 75.2 / 13.35 ms | 75.8 / 13.19 ms | 71.4 / 14.05 ms |
| 100 | 86.9 / 962.8 ms | 90.0 / 961.2 ms | 85.7 / 1007.2 ms |
| 500 | 408.8 / 903.0 ms | 449.9 / 781.5 ms | 398.0 / 953.3 ms |
| 1000 | 432.6 / 1921.0 ms | 463.5 / 1752.4 ms | 455.0 / 1790.2 ms |
| 2000 | 472.7 / 3530.9 ms(部分失败) | 504.1 / 3528.4 ms | 465.8 / 3767.2 ms |
| 3000 | 509.1 / 5297.9 ms(部分失败) | 509.2 / 5361.9 ms(部分失败) | 499.1 / 5400.5 ms |
读图小结:
以 1000 并发为例(样本均满额成功):
解读:高并发下 p95~p99 与 max 往往比均值更能说明「有没有被排队拖尾」;三者都出现毫秒级到秒级的长尾,符合「多虚拟线程 + 分页 + count」在共享数据库上的典型形态。
count=false(不执行 COUNT)同一工程、同档位(并发 ×10 次请求),关闭 COUNT 后吞吐约 提升一个数量级以上,平均延迟从秒级降到 毫秒级。下表为 CSV 中 第二批时间戳(约 2026-03-28T13:35 UTC)对应数据,本批各档位均为 SUCCESS。
吞吐(ops/s)
| 并发 | g2rain | PageHelper | MyBatis-Plus |
|---|---|---|---|
| 1 | 833.3 | 666.7 | 714.3 |
| 100 | 3378.4 | 3424.7 | 3472.2 |
| 500 | 12562.8 | 12690.4 | 11764.7 |
| 1000 | 15384.6 | 13888.9 | 15337.4 |
| 2000 | 25220.7 | 23015.0 | 21052.6 |
| 3000 | 31088.1 | 26525.2 | 30364.4 |
平均延迟(ms)
| 并发 | g2rain | PageHelper | MyBatis-Plus |
|---|---|---|---|
| 1 | 1.28 | 1.54 | 1.45 |
| 100 | 15.25 | 14.31 | 14.10 |
| 500 | 34.57 | 28.35 | 32.08 |
| 1000 | 57.85 | 63.37 | 55.81 |
| 2000 | 74.44 | 80.80 | 90.14 |
| 3000 | 89.83 | 103.27 | 91.79 |
小结:去掉 COUNT 后,g2rain 在 1000~3000 并发下吞吐与平均延迟多档领先;PageHelper 在 500 档位仍很突出。不能用 count=false 直接替代「要总条数」的线上接口,但适合用来拆分:多出来的时间有多少花在 COUNT SQL 与 第二条连接路径 上。
Too many connections| 档位(并发) | g2rain | PageHelper | MyBatis-Plus |
|---|---|---|---|
| 2000 | 19965 成功 / 35 失败,PARTIAL | 20000 成功,SUCCESS | 20000 成功,SUCCESS |
| 3000 | 29969 成功 / 31 失败,PARTIAL | 29993 成功 / 7 失败,PARTIAL | 30000 成功,SUCCESS |
可见:MySQL 连接数瓶颈在 2000~3000 并发档位开始露头;g2rain 与 PageHelper 出现了少量失败,而 MyBatis-Plus 本批全部成功——这不代表 MP「永远更稳」,而是同一时刻下失败是否被摊到本次跑测里与实现路径、连接占用时序有关,需要结合服务端 max_connections 与连接池配置一起看。
关键信息:
SQLSTATE(08004), ErrorCode(1040)Too many connections含义:MySQL 服务器当前已接受的连接数达到了 max_connections 上限,新的连接请求被拒绝。Hikari 将连接标为 broken,业务侧表现为一次查询失败;基准工程将其记为 failed_operations,档位标记为 PARTIAL。
栈中出现 PageHelper 的 count / PageInterceptor,说明失败发生在分页总数查询或列表查询执行 JDBC 时——本质是 「拿连接」 阶段就失败了,与「分页算法本身」无直接关系。
Bulkhead 限制的是同时进入数据库调用的并发度(默认 128),但:
因此:限流能显著缓解问题,但不能在「服务端 max_connections 很小」时从数学上保证零失败。
max_connections(并评估内存与线程成本),或降低压测并发使结果更「纯」。maximum-pool-size,避免「池子允许开很多连接」但服务端不接那么多。max_connections、连接池、Bulkhead` 再跑三轮取中位数。benchmark.count.enabled=false 再跑一轮,做 A/B。PARTIAL,优先看 failed_operations 与 MySQL 1040,把它当作环境容量信号,而不是单纯给某个组件贴「好/坏」标签。