使用PageHelper异常分页的错误案例

600 阅读9分钟

奇葩的现象


  天和日丽的一个早上,客服反馈线上客户开通租户后,在系统上只能查看到部分的菜单,我大感诧异,排查后数据,发现初始化租户套餐对应的功能菜单列表,只插入了五十个菜单,但是实际上套餐对应的功能菜单列表是有一百多个的,虾米...

  让测试同事测试了客户的操作场景,发现生成的租户菜单数据是正常,那就说明这个是特定场景偶现的,开通租户的菜单业务流程,就是从全局的菜单库拉取一份同步到新开通的租户菜单表,代码很简单,如下

@ApiOperation(value = "feign-根据菜单ID集合查询所有菜单功能列表")
@PostMapping("/findListByMenuIds")
public ResultVO<List<MenuFunVO>> findListByMenuIds(@RequestBody List<String> menuIds){
    List<MenuFunVO> menuFunList = this.menuFunService.findListByMenuIds(menuIds);
    return RV.success(menuFunList);
}

接口代码也没分页,本地调试一直正常,但是上到生产偶现异常现象,偶现只查到默认分页前五十条的数据。

定位原因


  系统运用的PageHelper工具是能力平台集成的框架,分页参数为空时,开启分页,分页参数使用默认配置,默认获取第一页,前五十条的数据,这不刚好对应前面的奇葩现象?每次都精准地只插入五十条记录,定位原因是PageHelper分页出现了异常。

/**
 * 创建mybatis分页对象
 * @return mybatis分页对象
 */
protected <T> Page<T> startPage(){
    int pageNumInt = CharacterUtils.parseInt(request.getParameter("pageNum"),1);
    int pageSizeInt = CharacterUtils.parseInt(request.getParameter("pageSize"),50);
    boolean isCount = CharacterUtils.parseBoolean(request.getParameter("isCount"),true);
    return PageHelper.startPage(pageNumInt, pageSizeInt, isCount);
}

PageHelper是怎么做到上面的问题的?

PageHelper的分页实现逻辑

  PageHelper其实就是Mybatis的分页插件,其实现原理就是通过拦截器的方式,PageHelper通PageInterceptor实现分页效果,其效果就是拦截我们需要分页的SQL语句,然后在其后面拼接分页参数,实现分页效果。

  基于以上原理,我们在使用PageHelper只需要关注PageHelper的startPage()方法,只需要开启了分页,查询语句就实现了分页效果。

那startPage干了啥

protected void startPage(){
    // 通过request去获取前端传递的分页参数,不需控制器要显示接收
    PageDomain pageDomain = TableSupport.buildPageRequest();
    Integer pageNum = pageDomain.getPageNum();
    Integer pageSize = pageDomain.getPageSize();
    if (StringUtils.isNotNull(pageNum) &amp;&amp; StringUtils.isNotNull(pageSize))
    {
        String orderBy = SqlUtil.escapeOrderBySql(pageDomain.getOrderBy());
        Boolean reasonable = pageDomain.getReasonable();
        // 真正使用pageHelper进行分页的位置
        PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable);
    }
}

PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable)的参数分别是:

  • pageNum:页数
  • pageSize:每页数据量
  • orderBy:排序
  • reasonable:分页合理化,对于不合理的分页参数自动处理,比如传递pageNum是小于0,会默认设置为1.

继续跟踪,连续点击startpage构造方法到达如下位置:

/**
 * 开始分页
 *
 * @param pageNum      页码
 * @param pageSize     每页显示数量
 * @param count        是否进行count查询
 * @param reasonable   分页合理化,null时用默认配置
 * @param pageSizeZero true且pageSize=0时返回全部结果,false时分页,null时用默认配置
 */
public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
    Page<E> page = new Page<E>(pageNum, pageSize, count);
    page.setReasonable(reasonable);
    page.setPageSizeZero(pageSizeZero);
    // 1、获取本地分页
    Page<E> oldPage = getLocalPage();
    if (oldPage != null &amp;&amp; oldPage.isOrderByOnly()) {
        page.setOrderBy(oldPage.getOrderBy());
    }
     // 2、设置本地分页
    setLocalPage(page);
    return page;
}

到达终点位置了,分别是:getLocalPage()和setLocalPage(page),分别来看下:

getLocalPage()

进入方法:

/**
 * 获取 Page 参数
 *
 * @return
 */
public static <T> Page<T> getLocalPage() {
    return LOCAL_PAGE.get();
}

看看常量LOCAL_PAGE是个什么东西? protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();

好家伙,是ThreadLocal,独属于每个线程的本地缓存对象。当一个请求来的时候,会获取持有当前请求的线程的ThreadLocal,调用LOCAL_PAGE.get(),会查看当前线程是否有未执行的分页配置。

setLocalPage(page)

此方法显而易见,设置线程的分页配置:

protected static void setLocalPage(Page page) {
    LOCAL_PAGE.set(page);
}

总结原因

经过前面的分析,我们发现,问题似乎就是这个ThreadLocal导致的。是否在使用完之后没有进行清理?导致下一次此线程再次处理请求时,还在使用之前的配置? 我们带着疑问,看看mybatis时如何使用pageHelper的。

PageHelper源码分析

  我们需要关注的就是Mybatis在何时使用的这个ThreadLocal,也就是何时将分页参数获取到的。

前面提到过,我们使用PageHelper,是通过在代码使用PageHelper.startPage()方法,然后PageHelper的startPage()方法会进行page缓存的设置,当程序执行sql接口mapper的方法时,就会被PageInterceptor拦截到。

前面说过,PageHelper是mybatis的分页插件,其实现原理是通过拦截器的方式,PageHelper通过PageInterceptor实现分页效果,那么我们只关注PageInterceptor的intercept方法:

@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();
        CacheKey cacheKey;
        BoundSql boundSql;
        // 由于逻辑关系,只会进入一次
        if (args.length == 4) {
            //4 个参数时
            boundSql = ms.getBoundSql(parameter);
            cacheKey = executor.createCacheKey(ms, parameter, rowBounds, boundSql);
        } else {
            //6 个参数时
            cacheKey = (CacheKey) args[4];
            boundSql = (BoundSql) args[5];
        }
        checkDialectExists();
        //对 boundSql 的拦截处理
        if (dialect instanceof BoundSqlInterceptor.Chain) {
            boundSql = ((BoundSqlInterceptor.Chain) dialect).doBoundSql(BoundSqlInterceptor.Type.ORIGINAL, boundSql, cacheKey);
        }
        List resultList;
        //调用方法判断是否需要进行分页,如果不需要,直接返回结果
        if (!dialect.skip(ms, parameter, rowBounds)) {
            //判断是否需要进行 count 查询
            if (dialect.beforeCount(ms, parameter, rowBounds)) {
                //查询总数
                Long count = count(executor, ms, parameter, rowBounds, null, boundSql);
                //处理查询总数,返回 true 时继续分页查询,false 时直接返回
                if (!dialect.afterCount(count, parameter, rowBounds)) {
                    //当查询总数为 0 时,直接返回空的结果
                    return dialect.afterPage(new ArrayList(), parameter, rowBounds);
                }
            }
            resultList = ExecutorUtil.pageQuery(dialect, executor,
                    ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
        } else {
            //rowBounds用参数值,不使用分页插件处理时,仍然支持默认的内存分页
            resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
        }
        return dialect.afterPage(resultList, parameter, rowBounds);
    } finally {
        if(dialect != null){
            dialect.afterAll();
        }
    }
}

如上所示是intecept的全部代码,我们下面只关注几个终点位置:

设置分页:dialect.skip(ms, parameter, rowBounds)

此处的skip方法进行设置分页参数,方法: Page page = pageParams.getPage(parameterObject, rowBounds); 继续跟踪getPage(),发现此方法的第一行就获取了ThreadLocal的值: Page page = PageHelper.getLocalPage();

统计数量:dialect.beforeCount(ms, parameter, rowBounds)

我们都知道,分页需要获取记录总数,所以,这个拦截器会在分页前先进行count操作。 如果count为0,则直接返回,不进行分页:

//处理查询总数,返回 true 时继续分页查询,false 时直接返回
if (!dialect.afterCount(count, parameter, rowBounds)) {
    //当查询总数为 0 时,直接返回空的结果
    return dialect.afterPage(new ArrayList(), parameter, rowBounds);
}

afterPage其实是对分页结果的封装方法,即使不分页,也会执行,只不过返回空列表。

分页:ExecutorUtil.pageQuery

在处理完count方法后,就是真正的进行分页了:

resultList = ExecutorUtil.pageQuery(dialect, executor,
        ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);

此方法在执行分页之前,会判断是否执行分页,依据就是前面我们通过ThreadLocal的获取的page。 当然,不分页的查询,以及新增和更新不会走到这个方法当中。

非分页:executor.query

而是会走到下面的这个分支: resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql); 我们可以思考一下,如果ThreadLoad在使用后没有被清除,当执行非分页的方法时,那么就会将Limit拼接到sql后面。 为什么不分也得也会拼接?我们回头看下前面提到的dialect.skip(ms, parameter, rowBounds):

public boolean skip(MappedStatement ms, Object parameterObject, RowBounds rowBounds) {
    if (ms.getId().endsWith("_COUNT")) {
        throw new RuntimeException("在系统中发现了多个分页插件,请检查系统配置!");
    } else {
        Page page = this.pageParams.getPage(parameterObject, rowBounds);
        if (page == null) {
            return true;
        } else {
            if (StringUtil.isEmpty(page.getCountColumn())) {
                page.setCountColumn(this.pageParams.getCountColumn());
            }

            this.autoDialect.initDelegateDialect(ms);
            return false;
        }
    }
}

如上所示,只要page被获取到了,那么这个sql,就会走前面提到的ExecutorUtil.pageQuery分页逻辑,最终导致出现不可预料的情况。 其实PageHelper对于分页后的ThreaLocal是有清除处理的。

清除TheadLocal

在intercept方法的最后,会在sql方法执行完成后,清理page缓存:

finally {
    if(dialect != null){
        dialect.afterAll();
    }
}

看看这个afterAll()方法:

@Override
public void afterAll() {
    //这个方法即使不分页也会被执行,所以要判断 null
    AbstractHelperDialect delegate = autoDialect.getDelegate();
    if (delegate != null) {
        delegate.afterAll();
        autoDialect.clearDelegate();
    }
    clearPage();
}

只关注 clearPage():

/**
 * 移除本地变量
 */
public static void clearPage() {
    LOCAL_PAGE.remove();
}

到此为止,关于PageHelper的运行已经分析完了, 整体看下来,似乎不会存在什么问题,但是我们可以考虑几种极端情况:

  • 如果使用了 startPage() ,但是没有执行对应的sql,那么就表明,当前线程ThreadLocal被设置了分页参数,可是没有被使用,当下一个使用此线程的请求来时,就会出现问题。
  • 如果程序在执行sql前,发生异常了,就没办法执行finally当中的 clearPage() 方法,也会造成线程的ThreadLocal被污染。

所以,官方给我们的建议,在使用PageHelper进行分页时,执行sql的代码要紧跟startPage()方法。 除此之外,我们可以手动调用 clearPage() 方法,在存在问题的方法之前手动调用 `clearPage()方法。

需要注意:不要分页的方法前手动调用clearPage,将会导致你的分页出现问题

最终原因总结

@ApiOperation(value = "维保数据-详情-分页查询电梯模块列表")
@GetMapping("/detail/elevator/page")
public ResultVO<List<MaintenanceDataDetailElevatorVO>> findDetailElevatorPage(MaintenanceDataDetailElevatorPageDTO dto) {
    Page page = super.startPage();
    dto.setPageNum(page.getPageNum());
    dto.setPageSize(page.getPageSize());
    return maintenanceDataService.findDetailElevatorListByParams(dto);
}
@Override
public ResultVO<List<MaintenanceDataDetailElevatorVO>> findDetailElevatorListByParams(MaintenanceDataDetailElevatorPageDTO dto) {
    ThrowExceptionUtil.isBlank(dto.getTabType(), ErrCode.ERROR_CODE113);
    // 根据维保数据tab类型,获取目标服务的数据
    ResultVO<List<MaintenanceDataDetailElevatorVO>> resultVO = maintenanceClient.getMaintenanceDataDetailForElevator(dto);
    return resultVO;
}

在远程调用中, Page page = super.startPage();接口开启了PageHelper.startPage(),但内部实现maintenanceClient.getMaintenanceDataDetailForElevator(dto);是传递分页参数去调用远程服务接口,PageHelper.startPage()后,没有紧跟要查询的sql,自然就走不到前面说的PageHelper.startPage()之后的流程,clearPage() 方法没被调用,当前线程就带着分页参数缓存了;同理下面的代码写法也是一样的问题:

@ApiOperation(value = "任务管理-分页列表")
@GetMapping("/page")
public ResultVO<List<TaskManageVO>> findPage(TaskManageQueryVO info) {
    Page page = super.startPage();
    info.setUserId(UserThreadLocal.getUserId());
    info.setTenantId(UserThreadLocal.getTenantId());
    return taskManageService.findListByParams(info, page.getPageSize(), page.getPageNum());
}
@Override
public ResultVO<List<TaskManageVO>> findListByParams(TaskManageQueryVO info, Integer pageSize, Integer pageNum) {
    Criteria criteria = new Criteria();
    this.setQueryFiled(criteria, info);
    Query query = new Query(criteria);
    Long total = mongoTemplate.count(query, TaskManage.class);

    ResultVO vo = new ResultVO(ErrCode.SUCCESS.value());
    if (pageNum != null &amp;&amp; pageSize != null) {
        query.skip((pageNum - 1) * pageSize);
        query.limit(pageSize);
    }
    query.with(new Sort(Sort.Direction.DESC, "createTime"));
    List<TaskManage> lists = mongoTemplate.find(query, TaskManage.class, TASK_MANAGE_COLL);
    vo.setPageNum(pageNum);
    vo.setPageSize(pageSize);
    vo.setTotal(total);
    vo.setResult(BeanUtils.copyList(lists, TaskManageVO.class));
    return vo;
}

调用PageHelper.startPage(),但是后面跟着的是MongoDB的查询语句,PageHelper是基于Mybatis的,所以自然也是有着上面远程调用的问题。

那为啥问题是偶现的呢?

  这个其实取决于启动服务所使用的容器,比如tomcat,在其内部处理请求是通过线程池的方式。甚至现在的很多容器是基于netty的,都是通过线程池,复用线程来增加服务的并发量以及减少创建线程、销毁线程所带来的资源开销。

场景复现

  假设线程1持有没有被清除的page参数,不断调用同一个方法,后面两个请求使用的是线程2和线程3没有问题,再一个请求轮到线程1了,此时就会出现上面异常分页的问题了,所以之前说本地调试一直没问题,那肯定了,一直都是正常线程在调用,而生产环境,可能有用户调用了异常PageHelper用法的接口,导致问题出现了。

解决措施

  调用PageHelper.startPage()后,后面要紧跟着要查询的sql语句,远程方法分页查询、MongoDB分页查询等,要把分页参数封装在实体,在实体里取值,不默认使用能力平台封装的工具类。

@ApiOperation(value = "任务管理-分页列表")
@GetMapping("/page")
public ResultVO<List<TaskManageVO>> findPage(TaskManageQueryVO info) {
    if (ObjectUtil.isNull(info.getPageSize()) && ObjectUtil.isNull(info.getPageNum())) {
        info.setPageNum(1);
        info.setPageSize(50);
    }
    info.setUserId(UserThreadLocal.getUserId());
    info.setTenantId(UserThreadLocal.getTenantId());
    return taskManageService.findListByParams(info, info.getPageSize(), info.getPageNum());
}