PageHelper 又给我上了一课!

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

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

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

账号重复注册?

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

 
 

else if (UserConstants.NOT_UNIQUE.equals(userService.checkUserNameUnique(username))||"匿名用户".equals(username)){ // 注册用户已存在 msg = "注册用户'" + username + "'失败";}

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

 
 

<select id="checkUserNameUnique" parameterType="String" resultType="int"> select count(1) from sys_user where user_name = #{userName} limit 1</select>

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

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

图片

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

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

修改用户密码报错?

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

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

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

小结

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

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

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

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

    • 如我上面提到的用户可以重复注册,却没有报错,实际在代码当中是有报错的,但是当前方法对异常进行了throw,最终被全局异常捕获了。

    • 不分页的sql被拼接了limit,导致没有报错,但是数据返回量错误。

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

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

PageHelper使用

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

代码如下:

 

@GetMapping("/cms/cmsEssayList")public TableDataInfo cmsEssayList(CmsBlog cmsBlog) { //状态为发布 cmsBlog.setStatus("1"); startPage(); List<CmsBlog> list = cmsBlogService.selectCmsBlogList(cmsBlog); return getDataTable(list);}

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

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

startPage()干啥了?

protected void startPage(){ // 通过request去获取前端传递的分页参数,不需控制器要显示接收 PageDomain pageDomain = TableSupport.buildPageRequest(); Integer pageNum = pageDomain.getPageNum(); Integer pageSize = pageDomain.getPageSize(); if (StringUtils.isNotNull(pageNum) && 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 && 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,学过java基础的都知道吧,独属于每个线程的本地缓存对象。

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

setLocalPage(page)

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

 
 

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

小结

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

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

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

# mybatis使用pageHelper分析

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

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

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

 
 

@Overridepublic 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):

图片

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

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

清除TheadLocal

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

 

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

看看这个afterAll方法():

 

@Overridepublic 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,将会导致你的分页出现问题。

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

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

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

# 总结

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

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

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

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/440934.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

SpringCloud-SpringBoot读取Nacos上的配置文件

在 Spring Boot 应用程序中&#xff0c;可以使用 Spring Cloud Nacos 来实现从 Nacos 服务注册中心和配置中心读取配置信息。以下是如何在 Spring Boot 中读取 Nacos 上的配置文件的步骤&#xff1a; 1. 引入依赖 首先&#xff0c;在 Spring Boot 项目的 pom.xml 文件中添加 …

2007-2022年上市公司迪博内部控制评价缺陷数量数据

2007-2022年上市公司迪博内部控制评价缺陷数量数据 1、时间&#xff1a;2007-2022年 2、范围&#xff1a;上市公司 3、指标&#xff1a;证券代码、证券简称、辖区、证监会行业、申万行业、是否存在财报内控重大缺陷、财报内控重大缺陷数量、是否存在财报内控重要缺陷、财报内…

亚信安慧AntDB:“融合+实时”引领数据库创新

在当今多变的数据应用场景中&#xff0c;AntDB作为行业领先的超融合流式实时数仓&#xff0c;秉承着“融合实时”的研发理念&#xff0c;全面应对企业日益复杂的数据处理需求。通过SQL接口访问多种执行引擎&#xff0c;AntDB在实现交易、分析等多重能力的“超融合”方面取得了显…

智能指针基础知识【C++】【RAII思想 || unique_ptr || shared_ptrweak_ptr || 循环引用问题】

目录 一&#xff0c;为什么需要智能指针 二&#xff0c;内存泄露的基本认识 1. 内存泄露分类 2. 常见的内存检测工具 3&#xff0c;如何避免内存泄露 三&#xff0c;智能指针的使用与原理 1. RAII思想 2. 智能指针 &#xff08;1. unique_ptr &#xff08;2. shared_…

CSS补充(下),弹性布局(上)

高级选择器 1.兄弟选择器 2.同时满足 div.bg{background-color: red;}p.bg{background-color: green;}spam.bg{background-color: blue;}注&#xff1a;选择器中间没有空格&#xff0c;有明确标识的选择器写在后面 3.各种伪类的应用 3.1作为第几个子元素 选择器:nth-child…

达梦数据库——如何查看数据库大字段中的数据内容

今天get到一个小知识点 分享给大家&#xff0c;如何在数据库查看大字段中的数据内容。 以下为演示步骤&#xff0c;简单易懂&#xff0c;操练起来吧 首先创建一个含有CLOB、TEXT的大字段测试表 create table "SYSDBA"."CS"("COLUMN_1" CLOB,&qu…

Xilinx 7系列 FPGA硬件知识系列(九)——FPGA的配置

1.3 端口配置 每组配置模式都有一组特定的接口管脚&#xff0c;它们跨越7系列FPGA上的一个或多个I/O BANK。7系列器件支持3.3V、2.5V、1.8V或1.5V的I/O配置&#xff0c;包括&#xff1a; BANK 0 中的JTAG管脚、BANK0中的专用配置管脚 BANK14和BANK15中与特定配置模式相关的管…

Windows下Node.js安装保姆级教程

一、Node.js 下载 访问Node.js官网&#xff0c;点击下载Node.js 下载完成后即可在下载文件中查看安装包 二、安装 一&#xff09;点击安装包开始安装&#xff0c;进入Weclcome界面点击Next 二&#xff09;勾选同意协议&#xff0c;点击Next 三&#xff09;根据需要选择安装路…

a-calendar的日历如何汉化

ant design中的日历默认拿来用时英文的&#xff0c;如何汉化&#xff0c;如下操作&#xff0c;在日历组件外面包一个<a-config-provider></a-config-provider> &#xff0c;如下操作&#xff1a; <template><a-config-provider :locale"zhCN"&…

合并有序数组

合并有序数组 题目描述&#xff1a;解法思路&#xff1a;解法代码&#xff1a;运行结果&#xff1a; 题目描述&#xff1a; 输入两个升序排列行的序列&#xff0c;将两个序列合并为一个有序序列并输出。 输入包含三行&#xff0c;第一包含两个正整数n, m&#xff0c;用空格分隔…

【Web安全】htaccess攻击

.htaccess攻击 文章目录 .htaccess攻击1. .htaccess文件2. 常见用法2.1. 自定义出错界面2.2. 强制文件执行方式2.3. PCRE绕过正则匹配2.4. php_value修改php设定2.5. php_value文件包含2.6. 把htaccess当作php 1. .htaccess文件 .htaccess是Apache网络服务器一个配置文件&#…

JavaWeb——014SpringBoot原理(配置优先级、Bean管理、SpringBoot原理)

SpingBoot原理 目录 SpingBoot原理1. 配置优先级2. Bean管理2.1 获取Bean2.2 Bean作用域2.3 第三方Bean 3. SpringBoot原理3.1 起步依赖3.2 自动配置3.2.1 概述3.2.2 常见方案3.2.2.1 概述3.2.2.2 方案一3.2.2.3 方案二 3.2.3 原理分析3.2.3.1 源码跟踪3.2.3.2 Conditional 3.2…

【Linux】第四十站:线程概念

文章目录 一、线程二、Linux中线程应该如何理解三、重新定义线程四、四谈进程地址空间&#xff08;页表相关&#xff09;五、Linux线程周边的概念1. 线程与进程切换2.线程优点3.线程缺点4.线程异常5.线程用途 一、线程 线程&#xff1a;是进程内的一个执行分支。线程的执行粒度…

事务失效问题

1&#xff0c;事务方法非public修饰 由于Spring的事务是基于AOP的方式结合动态代理来实现的。因此事务方法一定要是public的&#xff0c;这样才能便于被Spring做事务的代理和增强。 2&#xff0c;非事务方法调用事务方法 Service public class OrderService {public void creat…

Linux grep

文章目录 1. 基本用法2.字符转义3.二进制文件查找4.打印目标字段的附近行4. 多条件过滤5. 目录中过滤——用于在文件夹中筛选/排除指定后缀文件6.反向过滤——用于筛选7.只输出匹配内容——用于统计8. 筛选出包含字段的文件9.正则匹配10.管道和grep11.grep和wc/uniq/sort的合用…

【考研数学】李林《880》vs 李永乐《660》完美使用搭配

没有说谁一定好&#xff0c;只有适不适合自身情况&#xff0c;针对自身弱点选择性价比才最高。 两者侧重点不同&#xff0c;660适合强化前期&#xff0c;弥补基础的不足&#xff0c;880适合强化后期&#xff0c;题型全面&#xff0c;提高我们对综合运用知识的能力。 选择习题…

2.4_1 死锁的概念

文章目录 2.4_1 死锁的概念&#xff08;一&#xff09;什么是死锁&#xff08;二&#xff09;死锁、饥饿、死循环的区别&#xff08;三&#xff09;死锁产生的必要条件&#xff08;四&#xff09;什么时候会发生死锁&#xff08;五&#xff09;死锁的处理策略 总结 2.4_1 死锁的…

MySQL 存储过程(超详细)

一、什么是存储过程&#xff1f; 存储过程可称为过程化SQL语言&#xff0c;是在普通SQL语句的基础上增加了编程语言的特点&#xff0c;把数据操作语句(DML)和查询语句(DQL)组织在过程化代码中&#xff0c;通过逻辑判断、循环等操作实现复杂计算的程序语言。换句话说&#xff0c…

JVM-虚拟机栈概述

背景&#xff1a;由于跨平台的设计&#xff0c;java指令都是根据栈来设计的。不同平台CPU架构不同&#xff0c;所以不能设计为基于寄存器。 栈是运行时单位&#xff0c;而堆是存储的单位。即&#xff1a;栈解决程序运行的问题&#xff0c;即程序如何执行&#xff0c;或者说如何…

瑞芯微 | I2S-音频基础 -1

最近调试音频驱动&#xff0c;顺便整理学习了一下i2s、alsa相关知识&#xff0c;整理成了几篇文章&#xff0c;后续会陆续更新。 喜欢嵌入式、Li怒晓得老铁可以关注一口君账号。 1. 音频常用术语 名称含义ADC&#xff08;Analog to Digit Conversion&#xff09;模拟信号转换…
最新文章