同事问:我这个接口没写 limit,为什么只返回 10 条
1 月底,隔壁工位的同事喊我过去看个怪事。他的导出接口调了两次 Mapper:第一次查订单列表,第二次查订单状态统计。结果统计接口只返回了 10 条数据,而他的 SQL 里压根没有 limit。
我把 MyBatis 的 SQL 日志打开(logging.level.com.xxx.mapper=DEBUG),复现了一次:
==> Preparing: SELECT id, order_no, status FROM t_order WHERE create_time > ? LIMIT ?
==> Parameters: 2019-01-01 00:00:00(Timestamp), 10(Integer)
<== Total: 10
==> Preparing: SELECT status, count(*) FROM t_order GROUP BY status LIMIT ?
==> Parameters: 10(Integer)
<== Total: 4
第二条 SQL 是别人写的一个统计查询,它也被加上了 LIMIT 10。
根因:ThreadLocal 里没被消费掉的分页参数
找到调用那段代码,问题一眼就看得出来:
public List<OrderVO> export(OrderQuery query) {
PageHelper.startPage(query.getPageNum(), query.getPageSize());
if (query.getKeyword() != null) {
return orderMapper.search(query.getKeyword()); // 消费了分页参数
}
return Collections.emptyList(); // 什么都没查,参数留在线程里了
}
PageHelper 的写法是静态的 startPage + 紧跟着一个 Mapper 查询,中间不传任何分页参数,靠的是 ThreadLocal:
// com.github.pagehelper.page.PageMethod
protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();
public static <E> Page<E> startPage(int pageNum, int pageSize) {
Page<E> page = new Page<E>(pageNum, pageSize);
setLocalPage(page);
return page;
}
关键点在于这个 ThreadLocal 是由插件在 Mapper 查询执行完之后清理的。如果 startPage 后面没有跟着任何查询,清理动作就不会发生,Page 对象就一直挂在当前线程上。
而 Tomcat 的工作线程是复用的。这个请求结束后线程回到池子,下一个请求(不管是谁的、干什么)复用这个线程时,只要它执行的第一个 Mapper 查询就会读到那个残留的 Page,于是被莫名其妙地分页。
这也是为什么这个 bug 特别难查:它取决于请求恰好落到哪个线程上,本地怎么点都复现不了,只有压测或者线上流量跑一阵子才零星冒出来一次。
插件是怎么把 LIMIT 拼进去的
要确认这个结论,得知道 PageHelper 在 MyBatis 里处于什么位置。MyBatis 允许插件拦截四大对象上的方法:
| 可拦截对象 | 职责 | 典型用途 |
|---|---|---|
| Executor | 执行 SQL 调度 | 分页、读写分离、慢 SQL 统计 |
| StatementHandler | SQL 预编译 | 改写 SQL、加租户条件 |
| ParameterHandler | 参数设置 | 参数加密 |
| ResultSetHandler | 结果集映射 | 结果脱敏 |
PageHelper 拦的是 Executor.query。看它的签名配置:
@Intercepts({
@Signature(type = Executor.class, method = "query", args = {
MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class
})
})
public class PageInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
try {
Object[] args = invocation.getArgs();
MappedStatement ms = (MappedStatement) args[0];
Object parameter = args[1];
RowBounds rowBounds = (RowBounds) args[2];
ResultHandler resultHandler = (ResultHandler) args[3];
Executor executor = (Executor) invocation.getTarget();
Page page = PageHelper.getLocalPage();
if (page == null) {
return invocation.proceed(); // 没设分页,原样放行
}
// count 查询
page.setCountSignal(...);
Long total = count(executor, ms, parameter, rowBounds, resultHandler);
page.setTotal(total);
// 改写 SQL 加上 limit,再执行
return doProcessPage(executor, page, args);
} finally {
clearPage(); // 清理 ThreadLocal
}
}
@Override
public Object plugin(Object target) {
return Plugin.wrap(target, this); // 用 JDK 动态代理包一层
}
}
Plugin.wrap 用 JDK 动态代理把原始对象包起来,调用方法时先看注解里声明的签名是否匹配,匹配才走 intercept。所以只要线程上有 Page 对象,任何一次 Executor.query 都会被拦下来分页,跟这个 Mapper 方法是不是你想分页的那个没有关系。
这也解释了日志里那条 SELECT status, count(*) ... GROUP BY status LIMIT 10:它不是被"错误地识别成分页查询",而是恰好是那个线程上第一个执行的查询。
正确的写法
我们项目统一改成了这样,三条规则:
public PageInfo<OrderVO> list(OrderQuery query) {
PageHelper.startPage(query.getPageNum(), query.getPageSize());
// 规则一:startPage 与 Mapper 调用之间,不允许有任何其他代码,更不能有条件分支
List<Order> orders = orderMapper.selectByCondition(query);
// 规则二:拿到结果立刻转成 PageInfo,不要跨方法传递 Page
return new PageInfo<>(orders);
}
规则三是给那些"条件分支不可避免"的场景兜底的:在 finally 里主动清理。
public List<OrderVO> export(OrderQuery query) {
try {
PageHelper.startPage(query.getPageNum(), query.getPageSize());
List<Order> orders = orderMapper.selectByCondition(query);
return convert(orders);
} finally {
PageHelper.clearPage(); // 用完手动清掉
}
}
我后来还加了个保险:写了个 MyBatis 拦截器注册在 PageHelper 之后,请求结束时检测 PageHelper.getLocalPage() 是否非空,非空就打 warn 日志。上了这个之后一周扫出 3 处漏写的,全都是 if 分支里提前 return 的情况。
另外三个坑
坑一:PageInfo 的 total 不对
PageHelper 默认会对原始 SQL 包一层 select count(0) from (原SQL) tmp。如果你的原 SQL 带了 GROUP BY,count 出来的就不是你想要的数。我们的订单列表这种场景没问题,但有个报表查询用了 group by,total 一路显示成"每组的数量之和"。
解决方式是给这个 mapper 单独关掉 count,或者自己写 count 语句,用 PageHelper.startPage(...).setOrderByOnly(true) 之外还可以配:
Page<Order> page = PageHelper.startPage(1, 10);
page.setCount(false); // 不做 count,total 保持 -1,前端改用"加载更多"
坑二:reasonable 参数导致页码越界时返回最后一页
配置里有个 reasonable(分页合理化)。默认是 false,页码超出范围返回空。有次产品提了个需求说"用户翻到最后一页删完数据应该自动回到上一页",我开了这个参数,结果连带着把 pageNum <= 0 也修正成了第一页。前端有个地方传 0 表示"不分页",被这个参数改掉之后,导出的数据少了一半才被发现。这个参数建议别开,越界这种事让前端处理。
pagehelper:
helperDialect: mysql
reasonable: false
supportMethodsArguments: true
params: count=countSql
坑三:Page 序列化到前端字段爆炸
Page 继承了 ArrayList,直接用 @ResponseBody 返回会把一堆内部字段(pageNum、pageSize、startRow、endRow、total、pages、reasonable、pageSizeZero)都序列化出去。我们前端同学抱怨过一次。转成 PageInfo 返回就好了,它只有必要的几个字段,而且 PageInfo 的构造会立即消费掉 Page。
小结
PageHelper.startPage()必须紧跟 Mapper 查询,中间不能有任何分支、循环、return。做不到就在 finally 里clearPage()。- 它的分页参数存在 ThreadLocal 里,Web 容器线程复用会让"没清理干净"的影响扩散到别的请求,所以这类 bug 表现为随机、难复现。
- 理解插件机制(拦截 Executor.query + JDK 动态代理)之后,很多"为什么这条 SQL 被改了"的问题就不需要靠猜了。
补充一句版本:我们用的是 PageHelper 5.1.8 配 MyBatis 3.5.0。5.x 和 4.x 的 API 不一样,4.x 里是 PageHelper.startPage 之后用 Page<E> 强转,网上很多老文章还在写 4.x 的写法,照抄会踩坑。