PageHelper 防坑指南:从兜底方案到根治方案
前几天线上报了个奇怪的错,SQL 里出现了两个 LIMIT。当时看到日志我整个人都懵了,心想这什么鬼,谁能写出这种 SQL?
后来一顿排查,发现是 PageHelper 和 MyBatis-Plus 的分页"打架"了。更坑的是,这问题藏了好几个月才暴露出来。
今天就来聊聊这个坑,以及我们后来是怎么防范的。
先看看当时的报错
那天下午,告警群突然弹了条消息:
sql
2024-11-18 14:32:17.892 ERROR [http-nio-8080-exec-23] c.x.common.exception.GlobalExceptionHandler - SQL执行异常
org.springframework.jdbc.BadSqlGrammarException:
### Error querying database. Cause: java.sql.SQLSyntaxErrorException:
You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near 'LIMIT 10 LIMIT 10' at line 1
### SQL: SELECT id, order_no, user_id, amount, status, create_time FROM t_order WHERE status = ? LIMIT 10 LIMIT 10
LIMIT 10 LIMIT 10,两个 LIMIT 挨在一起,SQL 直接炸了。
我第一反应是,谁这么虎,手写 SQL 写了俩 LIMIT?但转念一想不对,我们的 SQL 都是 MyBatis 生成的,不可能有这种低级错误。
顺着代码扒下去
根据堆栈信息找到了对应的 Mapper,代码长这样:
java
// OrderServiceImpl.java
public IPage<Order> queryOrderList(OrderQueryDTO dto) {
PageHelper.startPage(dto.getPageNum(), dto.getPageSize());
Page<Order> page = new Page<>(dto.getPageNum(), dto.getPageSize());
return orderMapper.selectPage(page, buildQueryWrapper(dto));
}
看到这我就明白了。
这段代码里同时用了两种分页方案:上面是 PageHelper 的 startPage(),下面是 MyBatis-Plus 的 IPage。两边都会往 SQL 里拼 LIMIT,所以出现了两个。
但问题是,这代码是谁写的?什么时候写的?
翻了一下 git 记录,真相浮出水面了。
历史的锅
原来我们项目之前全部用的 PageHelper,后来技术栈统一,要求迁移到 MyBatis-Plus。这个接口是三个月前改的,开发同学把分页逻辑改成了 IPage,但是忘了把上面那行 PageHelper.startPage() 删掉。
最骚的是,这 bug 藏了三个月才爆出来。为什么能藏这么久?这就要说说 ThreadLocal 的多层防护机制了。
为什么这个 bug 这么难暴露
先看看正常情况下,ThreadLocal 里的分页信息是怎么被清理的:
PageHelper 自己在 finally 里做了清理,正常执行查询的话,ThreadLocal 肯定会被清掉。
就算第一层没清理干净,线程池复用的时候,下一个请求也不一定会执行分页查询。就算执行了,也不一定是 MyBatis-Plus 的 selectPage。
所以这个 bug 要暴露,需要满足一连串苛刻的条件:
这概率,难怪三个月才碰上。平时可能也触发了,但如果是普通查询被加了 LIMIT,可能就是少返回了几条数据,根本没人注意到。这次是两个 LIMIT 撞一起直接报语法错误,才被发现。
PageHelper 的工作原理
这里简单过一下 PageHelper 的原理,不然后面的分析看不懂。
关键点在于:startPage() 把分页参数存到 ThreadLocal,然后拦截器在执行 SQL 的时候读出来拼接 LIMIT。
正常情况下,finally 里会清理 ThreadLocal,没问题。
但如果 startPage() 之后、查询之前就出问题了(异常、提前 return、或者像我们这样根本没走 PageHelper 的查询),那 ThreadLocal 就残留下来了。
网上看到的一个方案
搜问题的时候看到一篇文章,作者遇到的场景是把查询改成 ES 了,但忘删 startPage()。他们的解决方案是在 Filter 或 Dubbo SPI 里统一调用 PageHelper.clearPage():
java
// Controller 层切面
@Aspect
@Component
public class PageHelperAspect {
@Before("execution(* com.xxx.controller..*.*(..))")
public void clearPageBefore() {
PageHelper.clearPage();
}
}
这个方案能解决问题,在请求入口处先清理一下,确保每个请求有个干净的起点。
不过我们后来选择了另一个方向。
我们的选择:主动检测,快速失败
我们的想法是,与其到处兜底清理,不如让问题早暴露、早发现。毕竟兜底清理解决的是症状,代码里的问题还在那儿。
做法也简单,写个 MyBatis 拦截器,用 JSqlParser 解析 SQL,发现已经有 LIMIT 了,同时 ThreadLocal 里还有 PageHelper 的分页信息,直接抛异常。
具体代码:
java
@Intercepts({
@Signature(type = Executor.class, method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class DuplicateLimitInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
// 先检查 ThreadLocal 里有没有 PageHelper 的分页信息
Page<?> localPage = PageHelper.getLocalPage();
if (localPage == null) {
return invocation.proceed();
}
// 有的话,解析 SQL 看看是不是已经有 LIMIT 了
MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
Object parameter = invocation.getArgs()[1];
BoundSql boundSql = ms.getBoundSql(parameter);
String sql = boundSql.getSql();
if (hasLimitClause(sql)) {
// SQL 本身有 LIMIT,又设置了 PageHelper,直接报错
String errorMsg = String.format(
"检测到重复分页!SQL已包含LIMIT子句,但ThreadLocal中存在PageHelper分页信息。\n" +
"请检查是否存在PageHelper与MyBatis-Plus混用的情况。\n" +
"Mapper: %s\n" +
"SQL: %s\n" +
"ThreadLocal Page: pageNum=%d, pageSize=%d",
ms.getId(), sql, localPage.getPageNum(), localPage.getPageSize()
);
throw new IllegalStateException(errorMsg);
}
return invocation.proceed();
}
private boolean hasLimitClause(String sql) {
try {
Statement stmt = CCJSqlParserUtil.parse(sql);
if (stmt instanceof Select) {
Select select = (Select) stmt;
SelectBody selectBody = select.getSelectBody();
if (selectBody instanceof PlainSelect) {
return ((PlainSelect) selectBody).getLimit() != null;
}
}
} catch (Exception e) {
// 解析失败就算了,不影响正常流程
}
return false;
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this);
}
@Override
public void setProperties(Properties properties) {}
}
这样一来,开发阶段就能发现问题:
sql
2024-11-19 09:15:33.421 ERROR [http-nio-8080-exec-07] c.x.interceptor.DuplicateLimitInterceptor - 检测到重复分页!
SQL已包含LIMIT子句,但ThreadLocal中仍存在PageHelper分页信息。
请检查是否存在PageHelper与MyBatis-Plus混用的情况。
Mapper: com.xxx.mapper.OrderMapper.selectPage
SQL: SELECT id, order_no, user_id, amount, status, create_time FROM t_order WHERE status = ?
ThreadLocal Page: pageNum=1, pageSize=10
比线上炸了再排查强多了。
顺便聊聊两种分页方案的区别
既然踩了这个坑,就顺便说说 PageHelper 和 MyBatis-Plus 分页的区别。
PageHelper 的方式:
java
PageHelper.startPage(1, 10);
List<Order> list = orderMapper.selectList(wrapper);
PageInfo<Order> pageInfo = new PageInfo<>(list);
分页参数通过 ThreadLocal 传递,不用改方法签名,侵入性低。但问题就是前面说的,ThreadLocal 用不好容易泄漏。
MyBatis-Plus 的方式:
java
Page<Order> page = new Page<>(1, 10);
IPage<Order> result = orderMapper.selectPage(page, wrapper);
分页参数直接作为方法参数传进去,跟着方法走,不存在泄漏的问题。
从安全性来说,MyBatis-Plus 的方式更稳妥。但如果项目里已经大量使用 PageHelper 了,迁移的时候一定要小心,别像我们一样漏删代码。
几个要注意的地方
踩完这个坑,总结几点:
startPage() 必须紧跟查询,中间不要有任何可能 return 或抛异常的逻辑。这种写法就是埋雷:
java
PageHelper.startPage(1, 10);
if (someCondition) {
return Collections.emptyList(); // 雷
}
doSomething(); // 这里抛异常也是雷
return mapper.selectList();
迁移的时候要彻底 ,别留尾巴。可以全局搜一下 PageHelper.startPage,确保都处理干净了。
可以加个检测拦截器,尤其是迁移期间。等稳定了再下掉,当个保险。
最后
这个问题说大不大,说小也不小。线上如果悄悄少返回了数据,不报错的话可能很长时间都发现不了。幸好我们这次是两个 LIMIT 撞一起直接报错了,不然还得查更久。
写这篇文章的时候参考了一篇掘金上的文章,作者遇到的场景是改 ES 查询后忘删 startPage(),排查思路写得挺详细的,感兴趣可以看看:坑爹啊,注释无用代码竟会导致bug!又被PageHelper坑了
你们项目里用的什么分页方案?PageHelper 还是 MyBatis-Plus?迁移过程中有没有踩过类似的坑?
对于这种问题,你们倾向于兜底清理还是主动检测?欢迎在评论区聊聊你的看法。