PageHelper隐藏Bug又深深给我上了一课!

文章讲述了在项目中使用PageHelper实现分页时遇到的问题,问题在于ThreadLocal可能导致分页配置持久化,影响后续请求。作者详细解析了PageHelper的工作原理,特别是如何通过ThreadLocal存储分页信息,并给出了确保分页正确执行的建议。
摘要由CSDN通过智能技术生成
1、问题复现:

        在项目的查询分类展示接口中,是没有分页条件的,可以展示所有的分类列表,但是现在只能查询出五条数据了?

        明明有十多个分类,怎么只能显示五条呢,我也没有添加分页条件呀?于是,我们进行了问题的排查!

2、项目中PageHelper使用:

代码如下:

public R<PageInfo<OaProjectRisk>> list(OaProjectRiskRequest oaProjectRiskRequest) {
		PageHelper.startPage(oaProjectRiskRequest.getCurrent(), oaProjectRiskRequest.getSize());
		List<OaProjectRisk> projectList = oaProjectRiskMapper.selectProjectRisk(oaProjectRiskRequest);
		return R.data(new PageInfo<>(projectList));
	}

        上述代码就是项目中使用PageHelper实现分页的,使用起来还是很简单的,通过PageHelper.startPage(指定分页的两个参数),执行查询的SQL,封装传入PageInfo对象中即可。

3、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对象中
    Page<E> page = new Page<E>(pageNum, pageSize, count);
    page.setReasonable(reasonable);
    page.setPageSizeZero(pageSizeZero);
    // 从ThreadLocal中获取本地分页
    Page<E> oldPage = getLocalPage();
    if (oldPage != null && oldPage.isOrderByOnly()) {
        page.setOrderBy(oldPage.getOrderBy());
    }
    // 2、设置本地分页到ThreadLocal中
    setLocalPage(page);
    return page;
}


//点进去getLocalPage(),其实就是在ThreadLocal中获取的数据
/**
* 获取 Page 参数
*
* @return
*/
public static <T> Page<T> getLocalPage() {
    return LOCAL_PAGE.get();
}

//LOCAL_PAGE其实就是下面这个:
protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();

//点进去setLocalPage(),其实就是将Page对象设置到线程空间中

/**
* 设置 Page 参数
* @param page
*/
protected static void setLocalPage(Page page) {
    LOCAL_PAGE.set(page);
}

        ThreadLocal对象,学过java基础的都知道吧,独属于每个线程的本地缓存对象。

当一个请求来的时候,会获取持有当前请求的线程的ThreadLocal,调用LOCAL_PAGE.get();查看当前线程是否有未执行的分页配置。

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

   

 

 4、Mybatis使用pageHelper实现分页:

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

        前面提到过,通过PageHelper的startPage()方法进行page缓存的设置,当程序执行sql接口mapper的方法时,就会被拦截器PageInterceptor拦截到。

PageHelper其实就是mybatis的分页插件,其实现原理就是通过拦截器的方式,pageHelper通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();
            }
        }
    }

以上就是intercept的全部代码。我们只需关注几点即可:

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

调用方法判断是否需要进行分页,如果不需要,直接返回结果。我们点进去看源码:

    @Override
    public boolean skip(MappedStatement ms, Object parameterObject, RowBounds rowBounds) {
        if (ms.getId().endsWith(MSUtils.COUNT)) {
            throw new RuntimeException("在系统中发现了多个分页插件,请检查系统配置!");
        }
        //然后接着,继续跟踪getPage()
        Page page = pageParams.getPage(parameterObject, rowBounds);
        if (page == null) {
            return true;
        } else {
            //设置默认的 count 列
            if (StringUtil.isEmpty(page.getCountColumn())) {
                page.setCountColumn(pageParams.getCountColumn());
            }
            autoDialect.initDelegateDialect(ms, page.getDialectClass());
            return false;
        }
    }

然后接着点击getPage()方法,发现此方法的第一行就获取了ThreadLocal的值。

 Page page = PageHelper.getLocalPage();

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

我们都知道,分页需要获取记录总数,所以,这个拦截器会在分页前先进行count操作。

如果count为0,则直接返回,不进行分页,afterPage其实是对分页结果的封装方法,即使不分页,也会执行,只不过返回空列表。源码如下:

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

 3、分页:ExecutorUtil.pageQuery

在处理完count方法后,就是真正的进行分页了,此方法在执行分页之前,会判断是否执行分页,依据就是前面我们通过ThreadLocal的获取的page。当然,不分页的查询,以及新增和更新不会走到这个方法当中。源码如下:

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

 4、非分页:executor.query

而是会走到下面的这个分支,源码如下:

resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);

        我们可以思考一下,如果ThreadLoad在使用后没有清除,我们在执行查询时,它会最开始被PageInterceptor拦截,先判断是否要进行分页。判断的条件是:是否能够在线程空间ThreadLoad中获取到Page对象。所以,如果ThreadLoad在使用后没有清除,里面是有之前存入的Page对象的,所以在执行非分页的方法时,可能就会将分页Limit拼接到sql后面。

        其实PageHelper对于分页后的ThreaLocal是有清除处理的。

 5、清除TheadLocal:dialect.afterAll()

在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();
}
 5、小结

到此为止,关于PageHelper的使用方式就讲解完了

  • 如果使用了startPage(),但是没有执行对应的sql,那么就表明,当前线程ThreadLocal被设置了分页参数,可能没有被使用,当下一个使用此线程的请求来时,就会出现问题。

  • 如果程序在执行sql前,发生异常了,就没办法执行finally当中的clearPage()方法,也会造成线程的ThreadLocal被污染。

所以,官方给我们的建议,在使用PageHelper进行分页时,执行sql的代码要紧跟startPage()方法 。

除此之外,我们可以手动调用clearPage()方法 ,在存在问题的方法之前。

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

  • 39
    点赞
  • 33
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

2024暴富

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值