• 多年以后「PageHelper」又深深的给我上了一课


    自从七月份入职新公司以来,一直没有找到写文章的状态,一来是工作确实较忙,另外还在适应环境,经过20天的尝试,总算找到了舒服的写作姿势,文章继续肝起来😊😊😊.

    下面进入正题,多年不用PageHelper了,最近新入职的公司,采用了此工具集成的框架,作为一个独立紧急项目开发的基础。项目开发起来,还是手到擒来的,但是没想到,最终测试的时候,深深的给我上了一课

    我的项目发生了哪些奇葩现象?

    一切的问题都要从我接受的项目开始说起, 在开发这个项目的过程中,发生了各种奇葩的事情, 下面我简单说给你们听听:

    账号重复注册?

    你肯定在想这是什么意思? 就是字面意思,已经注册的账号,可以再次注册成功!!!

    1. else if (UserConstants.NOT_UNIQUE.equals(userService.checkUserNameUnique(username))
    2. ||"匿名用户".equals(username)){
    3. // 注册用户已存在
    4. msg = "注册用户'" + username + "'失败";
    5. }
    6. 复制代码

    如上所示: checkUserNameUnique(username)用来验证数据库是否存在用户名:

    1. <select id="checkUserNameUnique" parameterType="String" resultType="int">
    2. select count(1) from sys_user where user_name = #{userName} limit 1
    3. select>
    4. 复制代码

    正常来说,是不会有问题的,那么原因我们后面讲,接着看下一个问题。

    查询全部分类的下拉列表只能查出5条数据?

    如上所示,明明有十多个结果,怎么只能返回5个?我也没有添加分页参数啊?

    相信用过PageHelper的同学已经知道问题出在哪里了。

    修改用户密码报错?

    当管理员在后台界面重置用户的密码的时候,居然报错了??

    报错信息清晰的告诉了我:sql语句异常,update语句不认识 “Limit 5”

    到此为止,报错信息已经告诉了我,我的sql被拼接了该死的“limit”分页参数

    小结

    上面提到的几个只是冰山一角,在我使用的过程中,还有各种涉及到sql的地方,会因为这个分页参数导致的问题,我可以分为两种:

    • 1)直接导致报错的:明确报错原因的

      比如insert、update语句等,不支持limit,会直接报错。

    • 2)导致业务逻辑错误,但是代码没有错误提示

      • 如我上面提到的用户可以重复注册,却没有报错,实际在代码当中是有报错的,但是当前方法对异常进行了throw,最终被全局异常捕获了。
      • 不分页的sql被拼接了limit,导致没有报错,但是数据返回量错误。

    注意:异常不是每次出现,是有一定纪律的,但是触发几率较高,原因在后面会逐渐脱出。

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

    PageHelper使用

    我这里只讲解项目基于的框架的使用方式。

    代码如下:

    1. @GetMapping("/cms/cmsEssayList")
    2. public TableDataInfo cmsEssayList(CmsBlog cmsBlog) {
    3. //状态为发布
    4. cmsBlog.setStatus("1");
    5. startPage();
    6. List list = cmsBlogService.selectCmsBlogList(cmsBlog);
    7. return getDataTable(list);
    8. }
    9. 复制代码

    使用起来还是很简单的,通过 startPage()指定分页参数,通过getDataTable(list)对结果数据封装成分页的格式。

    有些同学会问,这也没没传分页参数啊,并且实体类当中也没有,这就是比较有意思的点,下一小结就来聊聊源码。

    startPage()干啥了?

    1. protected void startPage(){
    2. // 通过request去获取前端传递的分页参数,不需控制器要显示接收
    3. PageDomain pageDomain = TableSupport.buildPageRequest();
    4. Integer pageNum = pageDomain.getPageNum();
    5. Integer pageSize = pageDomain.getPageSize();
    6. if (StringUtils.isNotNull(pageNum) && StringUtils.isNotNull(pageSize))
    7. {
    8. String orderBy = SqlUtil.escapeOrderBySql(pageDomain.getOrderBy());
    9. Boolean reasonable = pageDomain.getReasonable();
    10. // 真正使用pageHelper进行分页的位置
    11. PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable);
    12. }
    13. }
    14. 复制代码

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

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

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

    1. /**
    2. * 开始分页
    3. *
    4. * @param pageNum 页码
    5. * @param pageSize 每页显示数量
    6. * @param count 是否进行count查询
    7. * @param reasonable 分页合理化,null时用默认配置
    8. * @param pageSizeZero true且pageSize=0时返回全部结果,false时分页,null时用默认配置
    9. */
    10. public static Page startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
    11. Page page = new Page(pageNum, pageSize, count);
    12. page.setReasonable(reasonable);
    13. page.setPageSizeZero(pageSizeZero);
    14. // 1、获取本地分页
    15. Page oldPage = getLocalPage();
    16. if (oldPage != null && oldPage.isOrderByOnly()) {
    17. page.setOrderBy(oldPage.getOrderBy());
    18. }
    19. // 2、设置本地分页
    20. setLocalPage(page);
    21. return page;
    22. }
    23. 复制代码

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

    getLocalPage()

    进入方法:

    1. /**
    2. * 获取 Page 参数
    3. *
    4. * @return
    5. */
    6. public static Page getLocalPage() {
    7. return LOCAL_PAGE.get();
    8. }
    9. 复制代码

    看看常量LOCAL_PAGE是个什么路数?

    1. protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();
    2. 复制代码

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

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

    setLocalPage(page)

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

    1. protected static void setLocalPage(Page page) {
    2. LOCAL_PAGE.set(page);
    3. }
    4. 复制代码

    小结

    经过前面的分析,我们发现,问题似乎就是这个ThreadLocal导致的。

    是否在使用完之后没有进行清理?导致下一次此线程再次处理请求时,还在使用之前的配置?

    我们带着疑问,看看mybatis时如何使用pageHelper的。

    mybatis使用pageHelper分析

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

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

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

    1. @Override
    2. public Object intercept(Invocation invocation) throws Throwable {
    3. try {
    4. Object[] args = invocation.getArgs();
    5. MappedStatement ms = (MappedStatement) args[0];
    6. Object parameter = args[1];
    7. RowBounds rowBounds = (RowBounds) args[2];
    8. ResultHandler resultHandler = (ResultHandler) args[3];
    9. Executor executor = (Executor) invocation.getTarget();
    10. CacheKey cacheKey;
    11. BoundSql boundSql;
    12. // 由于逻辑关系,只会进入一次
    13. if (args.length == 4) {
    14. //4 个参数时
    15. boundSql = ms.getBoundSql(parameter);
    16. cacheKey = executor.createCacheKey(ms, parameter, rowBounds, boundSql);
    17. } else {
    18. //6 个参数时
    19. cacheKey = (CacheKey) args[4];
    20. boundSql = (BoundSql) args[5];
    21. }
    22. checkDialectExists();
    23. //对 boundSql 的拦截处理
    24. if (dialect instanceof BoundSqlInterceptor.Chain) {
    25. boundSql = ((BoundSqlInterceptor.Chain) dialect).doBoundSql(BoundSqlInterceptor.Type.ORIGINAL, boundSql, cacheKey);
    26. }
    27. List resultList;
    28. //调用方法判断是否需要进行分页,如果不需要,直接返回结果
    29. if (!dialect.skip(ms, parameter, rowBounds)) {
    30. //判断是否需要进行 count 查询
    31. if (dialect.beforeCount(ms, parameter, rowBounds)) {
    32. //查询总数
    33. Long count = count(executor, ms, parameter, rowBounds, null, boundSql);
    34. //处理查询总数,返回 true 时继续分页查询,false 时直接返回
    35. if (!dialect.afterCount(count, parameter, rowBounds)) {
    36. //当查询总数为 0 时,直接返回空的结果
    37. return dialect.afterPage(new ArrayList(), parameter, rowBounds);
    38. }
    39. }
    40. resultList = ExecutorUtil.pageQuery(dialect, executor,
    41. ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
    42. } else {
    43. //rowBounds用参数值,不使用分页插件处理时,仍然支持默认的内存分页
    44. resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
    45. }
    46. return dialect.afterPage(resultList, parameter, rowBounds);
    47. } finally {
    48. if(dialect != null){
    49. dialect.afterAll();
    50. }
    51. }
    52. }
    53. 复制代码

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

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

    此处的skip方法进行设置分页参数,内部调用方法:

    1. Page page = pageParams.getPage(parameterObject, rowBounds);
    2. 复制代码

    继续跟踪getPage(),发现此方法的第一行就获取了ThreadLocal的值:

    1. Page page = PageHelper.getLocalPage();
    2. 复制代码

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

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

    如果count为0,则直接返回,不进行分页:

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

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

    分页:ExecutorUtil.pageQuery

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

    1. resultList = ExecutorUtil.pageQuery(dialect, executor,
    2. ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
    3. 复制代码

    此方法在执行分页之前,会判断是否执行分页,依据就是前面我们通过ThreadLocal的获取的page。

    当然,不分页的查询,以及新增和更新不会走到这个方法当中。

    非分页:executor.query

    而是会走到下面的这个分支:

    1. resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
    2. 复制代码

    我们可以思考一下,如果ThreadLoad在使用后没有被清除,当执行非分页的方法时,那么就会将Limit拼接到sql后面。

    为什么不分也得也会拼接?我们回头看下前面提到的dialect.skip(ms, parameter, rowBounds):

    如上所示,只要page被获取到了,那么这个sql,就会走前面提到的ExecutorUtil.pageQuery分页逻辑,最终导致出现不可预料的情况。

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

    清除TheadLocal

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

    1. finally {
    2. if(dialect != null){
    3. dialect.afterAll();
    4. }
    5. }
    6. 复制代码

    看看这个afterAll方法():

    1. @Override
    2. public void afterAll() {
    3. //这个方法即使不分页也会被执行,所以要判断 null
    4. AbstractHelperDialect delegate = autoDialect.getDelegate();
    5. if (delegate != null) {
    6. delegate.afterAll();
    7. autoDialect.clearDelegate();
    8. }
    9. clearPage();
    10. }
    11. 复制代码

    只关注 clearPage()

    1. /**
    2. * 移除本地变量
    3. */
    4. public static void clearPage() {
    5. LOCAL_PAGE.remove();
    6. }
    7. 复制代码

    小结

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

    整体看下来,似乎不会存在什么问题,但是我们可以考虑集中极端情况:

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

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

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

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

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

    还有人问为什么不是每次请求都出错?

    这个其实取决于我们启动服务所使用的容器,比如tomcat,在其内部处理请求是通过线程池的方式。甚至现在的很多容器是基于netty的,都是通过线程池,复用线程来增加服务的并发量。

    假设线程1持有没有被清除的page参数,不断调用同一个方法,后面两个请求使用的是线程2和线程3没有问题,再一个请求轮到线程1了,此时就会出现问题了。

    总结

    关于PageHelper的介绍就这么多,真的是折磨我好几天,要不是项目紧急,来不及替换,我一定不会使用这个组件。

    莫名其妙的就会有个方法出现问题,一通排查,发现都是这个PageHelper导致的。虽然我已经全局搜索使用的地方,保证startPage()后紧跟sql命令,但是仍然有嫌犯潜逃,只能在有问题的方法使用clearPage()来打补丁。

    虽然PageHelper给我带来一些困扰,耗费了一定的时间,但是定位问题的过程中,也学习了mybatis和pagehepler的实现方式,对于热爱源码阅读的同学来说还是有一定的提升的。

  • 相关阅读:
    Qt写一个文档复制粘贴助手app
    signal
    使用Docker发布部署C# .NET core WebApi到服务器
    gitlab+jenkins+harbor次完整CI链条
    PointSIFT: A SIFT-like Network Module for 3D Point Cloud Semantic Segmentation
    计算机网络笔记之你这个年纪睡得着觉?(1)
    Python基于Mirai开发的QQ机器人保姆式教程(亲测可用)
    DOM元素可编辑自定义样式获取编辑后的数据
    高精度数字源表覆盖12+专业领域,20+研究方向
    map容器使用及员工分组实例
  • 原文地址:https://blog.csdn.net/YYniannian/article/details/126068022