Administrator
发布于 2019-02-05 / 1342 阅读
11

MyBatis 插件与分页:PageHelper 的正确用法

同事问:我这个接口没写 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 统计
StatementHandlerSQL 预编译改写 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 的写法,照抄会踩坑。

参考