一、为什么我们需要新的分页方案?

1.1 虚拟线程带来的挑战

Java 25 即将引入的虚拟线程(Virtual Threads)是继 Lambda 表达式之后 Java 并发编程的又一次重大变革。虚拟线程允许我们以极低的成本创建百万级并发线程,但这也带来了一个关键问题:

传统 ThreadLocal 在虚拟线程场景下会"串线"

想象一下这个场景:你的应用使用了虚拟线程池处理高并发请求,每个请求都需要执行数据库分页查询。如果使用 ThreadLocal 存储分页上下文,当虚拟线程复用底层载体线程时,分页参数可能会"泄漏"到其他请求中,导致严重的数据错误。

1.2 PageHelper 的局限性

PageHelper 作为 MyBatis 分页领域的经典方案,已经服务了开发者十余年。但在新时代背景下,它面临着以下局限:

维度

PageHelper

问题描述

线程安全

基于 ThreadLocal

虚拟线程场景下存在上下文泄漏风险

JDK 版本

兼容 JDK 8+

未针对 JDK 25 新特性优化

扩展能力

单一分页功能

难以扩展多租户、数据隔离等企业级需求

使用方式

静态方法调用

PageHelper.startPage() 隐式状态管理,调试困难

Spring Boot 集成

需手动配置

无官方 Starter,集成步骤繁琐

1.3 我们的解决方案

g2rain-mybatis-extensions 应运而生,它专为 JDK 25 设计,利用最新的 ScopedValue API 解决虚拟线程上下文传递问题,同时提供强大的插件链扩展能力。


二、项目核心特性

2.1 技术栈一览

  • JDK 要求:25 及以上(强制要求,拥抱未来)
  • MyBatis 版本:3.5.19
  • Spring Boot 版本:4.x
  • 核心依赖:JSqlParser 5.3(SQL 解析)、Caffeine 3.2.3(SQL 缓存)

2.2 四大核心优势

✅ 优势一:虚拟线程原生支持

使用 ScopedValue 替代 ThreadLocal,在虚拟线程场景下:

  • 零泄漏风险:每个虚拟线程拥有独立的 ScopedValue 绑定
  • 更高性能:避免了 ThreadLocal 在线程池复用时的清理开销
  • 更安全:编译期类型检查,减少运行时错误
// 虚拟线程中使用分页,完全安全
Thread.ofVirtual().start(() -> {
    Page<User> page = PageContext.of(1, 20, () -> {
        userMapper.selectList(...);
    });
    // 这里可以安全读取 page.getTotal() / page.getResult()
});
✅ 优势二:低侵入性设计

无需修改现有 Mapper 代码,只需将查询包裹在 PageContext.of() 回调中:

// 原有代码
List<User> users = userMapper.selectByCondition(condition);

// 分页改造(仅一行变化)
Page<User> page = PageContext.of(1, 10, () -> 
    userMapper.selectByCondition(condition)
);
✅ 优势三:智能 SQL 优化

内置 JSqlParser 引擎,自动处理复杂 SQL 的 count 查询优化:

  • 自动移除可安全删除的 ORDER BY
  • 智能处理 DISTINCTGROUP BY 场景
  • 针对 Union 查询自动降级为子查询 count
  • 内置 Caffeine 缓存,避免重复解析
✅ 优势四:企业级扩展能力

基于 CompositeInterceptor + PluginProcessor 的插件链设计:

  • 分页只是一个插件,可自由组合其他拦截逻辑
  • 轻松扩展多租户数据隔离、审计日志、动态表名等功能
  • 插件执行顺序可控,确保业务逻辑正确性

三、PageHelper vs g2rain-mybatis-extensions 深度对比

3.1 使用方式对比

PageHelper 用法:

// 第一步:设置分页参数(隐式状态)
PageHelper.startPage(1, 10);

// 第二步:执行查询(隐式捕获状态)
List<User> users = userMapper.selectList();

// 第三步:获取分页信息(需要从 PageInfo 包装)
PageInfo<User> pageInfo = new PageInfo<>(users);
long total = pageInfo.getTotal();

g2rain-mybatis-extensions 用法:

// 一步到位:分页参数 + 查询 + 结果封装
Page<User> page = PageContext.of(1, 10, () -> 
    userMapper.selectList()
);

// 直接访问分页信息
long total = page.getTotal();
List<User> records = page.getResult();

对比分析:

  • PageHelper 采用"设置状态 → 执行查询 → 获取结果"三步走,状态隐式传递,调试困难
  • g2rain 采用函数式回调,作用域清晰,代码可读性更强

3.2 虚拟线程安全性对比

场景

PageHelper

g2rain-mybatis-extensions

传统平台线程

✅ 安全

✅ 安全

虚拟线程

❌ 可能串线

✅ 完全隔离

线程池复用

⚠️ 需手动 clear

✅ 自动清理

3.3 功能特性对比

功能

PageHelper

g2rain-mybatis-extensions

基础分页

✅ 支持

✅ 支持

Count 查询优化

✅ 支持

✅ 支持(更智能)

排序支持

字符串拼接

List 类型安全

是否查总数

总是查询

可控制(count=false 优化性能)

Spring Boot Starter

❌ 无官方

✅ 开箱即用

插件扩展能力

❌ 弱

✅ 强(插件链设计)

JDK 25 虚拟线程

❌ 不支持

✅ 原生支持

3.4 理论性能分析与基准测试计划

重要说明:JDK 25 已于 2025 年 9 月 16 日由 Oracle 正式发布,提供了包括 ScopedValue(JEP 506)、结构化并发(JEP 505)在内的 18 个 JDK 增强建议。以下分析基于 JDK 25 正式版的 API 特性,实际基准测试数据将在后续公开报告中补充。

技术优势分析

根据 Oracle 官方技术文档,ScopedValue 相比 ThreadLocal 具有以下理论优势:

维度

ThreadLocal 方案

ScopedValue 方案

官方说明

上下文切换开销

较高(需手动 remove() 清理)

极低(作用域结束自动释放)

JEP 506:"作用域值比线程局部变量更易于推理,空间和时间成本更低"

内存泄漏风险

高(忘记清理会导致泄漏)

零(编译期保证作用域隔离)

JEP 506:"尤其在与虚拟线程和结构化并发共同使用时表现更优"

类型安全

运行时检查(Object 强制转换)

编译期检查(泛型约束)

编译期类型安全,减少运行时错误

代码可维护性

隐式状态管理,调试困难

显式作用域绑定,调用链清晰

JEP 506:"支持在线程内和线程之间共享不可变数据"

预期性能提升场景

基于 JDK 25 官方 JEP 文档的技术特性分析,我们预期在以下场景中 g2rain-mybatis-extensions 将展现出显著优势:

场景一:高并发虚拟线程环境

  • 技术依据:JEP 506 明确指出 ScopedValue"在与虚拟线程共同使用时空间和时间成本更低"
  • 预期效果:消除因 ThreadLocal 在虚拟线程复用时的上下文继承问题导致的"串线"错误

场景二:长生命周期应用

  • 技术依据:作用域结束自动清理机制消除了手动 ThreadLocal.remove() 的遗漏风险
  • 预期效果:降低长期运行应用的内存泄漏概率,减少 Full GC 频率

场景三:复杂调用链场景

  • 技术依据:ScopedValue 支持嵌套 where() 绑定,调用链清晰可追溯
  • 预期效果:降低多线程调试难度,缩短问题定位时间

四、快速上手指南

4.1 Maven 依赖

在你的 Spring Boot 项目中引入:

<dependency>
    <groupId>com.g2rain</groupId>
    <artifactId>g2rain-starter-mybatis-pagination</artifactId>
    <version>1.0.1</version>
</dependency>

4.2 配置文件(可选)

g2rain:
  mybatis:
    pagination:
      # 分页处理器在插件链中的执行顺序,数字越小越靠前
      order: 20000

4.3 基础用法

场景一:最简单的分页查询
import com.g2rain.mybatis.pagination.model.Page;
import com.g2rain.mybatis.pagination.PageContext;

@GetMapping("/users")
public Page<User> getUsers() {
    return PageContext.of(1, 10, () -> 
        userMapper.selectList()
    );
}
场景二:带排序的分页查询
import com.g2rain.mybatis.pagination.model.OrderItem;

List<OrderItem> orderBy = List.of(
    new OrderItem("id", "desc"),           // 按 id 降序
    new OrderItem("create_time", "asc")    // 按创建时间升序
);

Page<User> page = PageContext.of(1, 10, orderBy, () -> 
    userMapper.selectList()
);
场景三:不查询总数的分页(性能优化)

对于列表翻页场景,如果不需要显示总页数,可以跳过 count 查询:

Page<User> page = PageContext.of(1, 10, false, () -> 
    userMapper.selectList()
);
// page.getTotal() 将返回 -1,但查询性能提升约 50%
场景四:完整参数控制
Page<User> page = PageContext.of(
    1,                              // 页码
    10,                             // 每页条数
    false,                          // 是否查询总数
    orderBy,                        // 排序规则
    () -> userMapper.selectList()   // 查询回调
);

4.4 虚拟线程集成示例

@Service
public class UserService {
    
    @Autowired
    private UserMapper userMapper;
    
    public void processWithVirtualThreads() {
        // 创建 1000 个虚拟线程并发处理分页查询
        Stream<Integer> pages = IntStream.rangeClosed(1, 1000);
        
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            pages.forEach(pageNum -> executor.submit(() -> {
                Page<User> page = PageContext.of(pageNum, 10, () -> 
                    userMapper.selectByStatus("ACTIVE")
                );
                
                // 处理分页数据...
                log.info("Page {}: users", pageNum, page.size());
            }));
        }
    }
}

五、最佳实践建议

5.1 何时使用分页?

推荐场景:

  • 后台管理系统的列表查询
  • C 端用户的 feeds 流展示
  • 报表数据分页导出

不推荐场景:

  • 数据量小于 100 条的查询(直接全量返回)
  • 需要精确总页数的深度翻页(考虑游标分页)

5.2 性能优化技巧

技巧一:禁用不必要的 count 查询

// 下拉加载场景,不需要总数
Page<Item> page = PageContext.of(pageNum, 20, false, () -> 
    itemMapper.selectByCategory(categoryId)
);

技巧二:使用类型安全的排序

// 推荐:避免 SQL 注入风险
List<OrderItem> orderBy = List.of(
    new OrderItem("create_time", "desc")
);

// 不推荐:字符串拼接
PageContext.of(1, 10, "create_time desc", () -> ...);

技巧三:合理设置插件执行顺序

如果同时使用多租户、数据隔离等插件:

g2rain:
  mybatis:
    pagination:
      order: 20000  # 分页在第 20000 位执行
      
# 建议顺序:
# 10000: 多租户数据隔离
# 20000: 分页
# 30000: 审计日志

六、总结

g2rain-mybatis-extensions 不是一个简单的分页工具,它是面向 JDK 25 虚拟线程时代的 MyBatis 扩展基础设施

如果你:

  • ✅ 正在探索虚拟线程在高并发场景的应用
  • ✅ 需要一个可扩展的企业级分页方案
  • ✅ 厌倦了 PageHelper 的隐式状态管理
  • ✅ 计划构建多租户 SaaS 平台

那么,g2rain-mybatis-extensions 值得你尝试!

立即开始:

git clone https://github.com/g2rain/g2rain-mybatis-extensions.git

关于谷雨

谷雨开源SaaS平台,专注于高性能分布式系统与企业级中间件研发。欢迎关注公众号获取更多技术干货!

参考资料

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐