1. 背景

最近在用 MyBatis Plus 写 CRUD 的时候,遇到了分页场景的一个奇怪问题。

当我使用 PageHelper 进行分页查询时,第一次调试发现查询结果竟然为空,但如果我在调试器里手动执行一次相同的查询,结果就能正常返回数据。

离谱现象

第一次查询时没有数据,但第二次同样的查询就有数据,表现非常反直觉。

为了更直观,伪代码可以简化为:

1
2
3
PageHelper.startPage(page, size);
mapper.updateReadStatus(userId, type); // 更新已读状态
List list = mapper.selectByUserId(userId, type); // 查询分页数据

第一次执行时 list 是空的,用调试器 Evaluate 再执行一次就有数据。

经过排查,问题核心在于 PageHelper 的内部实现机制。

2. PageHelper 实现原理

核心机制:ThreadLocal(线程局部变量)

PageHelper 内部通过一个 ThreadLocal 变量保存分页信息:

1
ThreadLocal<Page> LOCAL_PAGE;
  • 线程隔离:每个请求都有自己独立的分页对象,互不干扰。
  • 无侵入性:Mapper 方法无需显示传入分页参数,直接调用 startPage 即可生效。
提示

只要在同一线程里,分页信息会被自动传递到 SQL 执行层,而无需修改原有 Mapper 方法签名。

分页触发流程

分页执行大致分为三个阶段:

1. 存入参数

调用 PageHelper.startPage(1, 10) 时,它会:

  1. 创建一个 Page 对象。
  2. 放入当前线程的 ThreadLocal。
  3. 注意:此时 SQL 还没有执行,只是把分页参数记住。

2. 拦截 SQL

PageHelper 会通过 MyBatis 拦截器拦截查询操作:

  1. 从 ThreadLocal 中获取分页参数。
  2. 自动生成分页 SQL:
    • 查询总数:SELECT COUNT(*) ...
    • 查询当前页数据:根据数据库类型拼接 LIMIT / ROWNUM
  3. 执行 SQL 并返回结果。
小提示

分页查询必须紧跟在 startPage 调用之后,如果中间插入其他逻辑,可能导致分页参数失效。

3. 清理现场

执行完成后,PageHelper 会清理 ThreadLocal:

  • 避免线程池复用时带上旧的分页参数。
  • 保证下一次请求分页正常。
常见坑

分页参数如果传 0,查询结果永远为空;此外,如果 startPage 和查询方法不在同一线程,分页也不会生效。

3. 解决思路

总结一下踩坑经验:

  1. 确保分页参数正确:页码从 1 开始,不要传 0。
  2. 保证调用顺序:PageHelper.startPage 必须紧跟查询方法。
  3. 避免中间逻辑干扰:更新、计算、其他查询最好放在分页查询之后。
  4. 理解 ThreadLocal机制:PageHelper 的分页依赖线程局部变量,线程池复用时可能带来副作用。