前言
MyBatis PageHelper作为广泛使用的分页插件,其看似简单的API背后隐藏着严格的使用约束。生成中大多数的分页异常都源于同一个根本问题:分页开启与查询执行之间存在间隙。本文通过事故案例分析,揭示PageHelper"分页即清除"的源码机制,提炼出"开启分页必须立即查询"的核心铁律。遵守此原则是避免分页错乱、线程污染的最简单、规范且可靠的方法,任何插入操作都将导致不可预知的SQL异常。
1. 问题现象
在使用MyBatis PageHelper插件时,主要出现两类异常分页问题:
1.1 同线程污染(错误分页)
-
典型表现:同一请求中,非目标查询被分页,而目标查询未被分页
-
重现步骤 :
- 调用
PageHelper.startPage()
- 执行非目标查询(被错误分页)
- 执行目标查询(未被分页)
- 调用
-
日志特征 :
log[DEBUG] - ==> Preparing: SELECT * FROM orders LIMIT ? [DEBUG] - ==> Preparing: SELECT * FROM users // 目标查询未分页
1.2 线程污染(跨请求分页)
-
典型表现:未调用分页的请求出现分页SQL
-
重现步骤 :
- 请求A调用
startPage()
但未执行查询(异常中断) - 请求B复用同一线程执行查询
- 请求B的查询被错误分页
- 请求A调用
-
日志特征 :
log# 请求A(未执行查询) [INFO] - PageHelper.startPage() called # 请求B(未调用分页) [DEBUG] - ==> Preparing: SELECT * FROM logs LIMIT ? // 意外分页
-
关键特性:随机出现,难以稳定重现
2. 源码级原理分析
2.1 PageHelper核心机制(基于5.3.2源码)
分页生命周期:
sequenceDiagram
participant App as 应用代码
participant PH as PageHelper
participant PI as PageInterceptor
participant Exec as Executor
App->>PH: startPage(1,10)
activate PH
PH->>PH: LOCAL_PAGE.set(page)
deactivate PH
App->>Exec: executeQuery()
Exec->>PI: intercept()
activate PI
alt 存在分页参数
PI->>PH: getLocalPage()
PH->>PI: return page
PI->>PI: 修改SQL添加分页
PI->>Exec: 执行分页查询
Exec-->>PI: 返回结果
PI->>PH: clearPage() // 关键!
else
PI->>Exec: 执行原始查询
end
PI-->>App: 返回结果
deactivate PI
关键源码解析:
java
// PageInterceptor拦截器核心逻辑
public Object intercept(Invocation invocation) throws Throwable {
// 获取分页参数
Page page = pageParams.getPage();
if (page == null) {
return invocation.proceed(); // 无分页直接执行
}
try {
// 执行分页查询
Object result = afterPage(...);
return result;
} finally {
// 关键:执行后立即清空ThreadLocal
PageHelper.clearPage();
}
}
// PageHelper清理方法
public static void clearPage() {
LOCAL_PAGE.remove(); // 彻底移除ThreadLocal中的page对象
}
2.2 问题根本原因
2.2.1 同线程污染(错误分页)
问题本质 :startPage()
调用位置不当导致分页应用到非目标查询
java
// 错误代码示例
public void flawedMethod() {
PageHelper.startPage(1, 10); // 设置分页
// 非目标查询(被错误分页)
List<Order> orders = orderMapper.findRecent();
// ↑ 分页执行后ThreadLocal被清空
// 目标查询(未被分页)
List<User> users = userMapper.selectUsers();
}
执行流程:
startPage()
设置分页参数到ThreadLocal- 执行
orderMapper.findRecent()
:- PageInterceptor检测到分页参数 → 执行分页
- 分页后立即清除ThreadLocal
- 执行
userMapper.selectUsers()
:- ThreadLocal已被清除 → 正常查询
核心矛盾:分页被应用到第一个查询而非目标查询
2.2.2 线程污染(跨请求分页)
问题本质 :startPage()
后未执行任何查询导致ThreadLocal未被清除
java
// 请求A(异常中断)
public void requestA() {
PageHelper.startPage(1, 10); // 设置分页
if (error) {
throw new BusinessException(); // 未执行查询直接异常
// ThreadLocal未被清除!
}
userMapper.selectUsers(); // 未执行
}
// 请求B(复用线程)
public void requestB() {
// 未调用分页
logMapper.getLogs(); // 被错误分页
}
污染链路:
graph TD
subgraph 线程池
T[线程T-01]
end
A[请求A] --> T
T --> S1[调用startPage]
S1 --> E[发生异常]
E --> RT[返回线程池]
RT -->|ThreadLocal残留| T
B[请求B] --> T
T --> Q[执行查询]
Q --> PI[PageInterceptor]
PI -->|检测残留参数| FP[错误分页]
FP --> C[清除ThreadLocal]
关键机制:
- 分页清除依赖查询执行触发拦截器
- 未执行查询 → 未触发拦截器 → ThreadLocal未被清除
- 线程池复用导致参数泄漏到后续请求
3. 系统化解决方案
3.1 同线程污染解决方案
最佳代码实践:
java
public PageInfo<User> safeQuery(int pageNum, int pageSize) {
// 前置查询(无分页)
List<Order> orders = orderMapper.findRecentOrders();
// 分页紧贴目标查询
PageHelper.startPage(pageNum, pageSize);
// 唯一目标查询
List<User> users = userMapper.selectTargetUsers();
return new PageInfo<>(users);
// 后续查询(安全)
List<Log> logs = logMapper.getOperationLogs();
}
关键原则:
- 紧贴原则 :
startPage()
与目标查询零距离 - 单一查询:分页块内只执行一个查询
3.2 线程污染解决方案
方案1:Servlet过滤器(全局防护)
java
@WebFilter("/*")
public class PageHelperCleanFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
throws IOException, ServletException {
try {
chain.doFilter(req, res);
} finally {
clearWithRetry(3); // 三重清理
}
}
private void clearWithRetry(int times) {
for (int i = 0; i < times; i++) {
try {
PageHelper.clearPage();
} catch (Exception e) {
// 静默处理
}
}
}
}
方案2:Spring AOP增强
java
@Aspect
@Component
public class PageHelperAspect {
// 清理入口:Controller层
@AfterReturning("within(@org.springframework.stereotype.Controller *)")
public void cleanAfterController() {
safeClear();
}
// 清理出口:Service层
@After("within(@org.springframework.stereotype.Service *)")
public void cleanAfterService() {
safeClear();
}
// 安全清理方法
private void safeClear() {
try {
if (PageHelper.getLocalPage() != null) {
PageHelper.clearPage();
}
} catch (Exception e) {
log.warn("PageHelper清理异常", e);
}
}
}
4. PageHelper分页插件使用总结
核心原则:开启分页,立即查询
PageHelper分页插件必须严格遵循startPage()
与查询语句零距离原则:
startPage()
调用后必须立即执行目标查询- 中间不允许插入任何其他代码或方法调用
- 每个分页块仅包含一个查询语句
任何违反此原则的操作都将导致:
- 非目标查询被错误分页
- 目标查询分页失效
- 跨请求线程污染风险
正确范例:
java
PageHelper.startPage(page, size); // 开启分页
List<User> list = userMapper.select(); // 立即查询
遵循"紧贴查询"原则是避免分页异常的最简单、规范且可靠的方法。