一次因PageHelper引起的多线程复用问题的排查和解决


一次因PageHelper引起的多线程复用问题的排查和解决
Tech

导读

本文不仅对遇到类似问题的开发者提供了实际的解决思路,也为希望深入理解PageHelper工作机制和多线程编程的读者提供了丰富的技术细节。无论是对于中级开发者还是有经验的架构师,本文的内容都具有一定的参考价值。




01 
Problem Description


在今年的敏捷团队建设中,我通过Suite执行器实现了一键自动化单元测试。Juint除了Suite执行器还有哪些执行器呢?由此我的Runner探索之旅开始了!

1. PageHelper方法使用了静态的ThreadLocal参数,在startPage()调用紧跟MyBatis查询方法后,才会自动清除ThreadLocal存储的对象。

2. 当一个线程先执行了A方法的PageHelper.startPage(int pageNum, int pageSize)后,在未执行到SQL语句前,因为代码抛异常而提前结束。

3. 这个线程被另一个请求复用,根据当前的pageNum和pageSize参数,执行了B方法中的SQL语句。

4. B方法的SQL是全表扫描并查询出所有符合条件的数据,所以因为A方法的分页参数限定<<实际B方法中符合条件的数据量,导致了B方法查询结果的错误。


02 

  Problem inspection Steps  



理解,首先 MCube 会依据模板缓存状态判断是否需要网络获取最新模板,当获取到模板后进行模板加载,加载阶段会将产物转换为视图树的结构,转换完成后将通过表达式引擎解析表达式并取得正确的值,通过事件解析引擎解析用户自定义事件并完成事件的绑定,完成解析赋值以及事件绑定后进行视图的渲染,最终将目标页面展示到屏幕。

1. Code Review

一次因PageHelper引起的多线程复用问题的排查和解决

一次因PageHelper引起的多线程复用问题的排查和解决

先看一下A方法的代码就会发现,在使用了PageHelper.startPage之后,Mybatis查询SQL之前,有很多判断逻辑,并且问题就发生在中间标红的异常情况判断。

一次因PageHelper引起的多线程复用问题的排查和解决
B方法在执行到第一个SQL查询语句的时候,就会因为复用线程中 PageMethod 所带有A方法中ThreadLocal的(pageNum,pageSize)参数导致B方法的查询也限定了分页参数。

2. Log Check and Prove

a. A方法提前抛异常,且没执行MyBatis查询方法的日志截图

一次因PageHelper引起的多线程复用问题的排查和解决

b.B方法执行到MyBatis查询方法的截图

一次因PageHelper引起的多线程复用问题的排查和解决


03 
  Analysis Steps 

 



理解,首先 MCube 会依据模板缓存状态判断是否需要网络获取最新模板,当获取到模板后进行模板加载,加载阶段会将产物转换为视图树的结构,转换完成后将通过表达式引擎解析表达式并取得正确的值,通过事件解析引擎解析用户自定义事件并完成事件的绑定,完成解析赋值以及事件绑定后进行视图的渲染,最终将目标页面展示到屏幕。    1.位图原理

1. How to use PageHelper

a. Github Official Document Link

https://github.com/pagehelper/Mybatis-PageHelper/blob/master/wikis/zh/HowToUse.md
PageHelper 方法使用了静态的 ThreadLocal 参数,分页参数和线程是绑定的。
只要你可以保证在 PageHelper 方法调用后紧跟 MyBatis 查询方法,这就是安全的。因为 PageHelper 在 finally 代码段中自动清除了 ThreadLocal 存储的对象。

b. Analysis Source Code of PageHelper

    i. startPage() and getLocalPage()

一次因PageHelper引起的多线程复用问题的排查和解决

一次因PageHelper引起的多线程复用问题的排查和解决
通过上图我们可以发现,当一个请求来的时候,会获取持有当前请求的线程的ThreadLocal,调用LOCAL_PAGE.get(),查看当前线程是否有未执行的分页配置,再通过setLocalPage(page)方法设置线程的分页配置。
    ii. Intercept Method in PageInterceptor
@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();
List resultList; //调用方法判断是否需要进行分页,如果不需要,直接返回结果 if (!dialect.skip(ms, parameter, rowBounds)) { //判断是否需要进行 count 查询 if (dialect.beforeCount(ms, parameter, rowBounds)) { //查询总数 Long count = count(executor, ms, parameter, rowBounds, resultHandler, 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(); } } }
我们需要关注mybatis什么时候使用的这个ThreadLocal,也就是何时将分页参数获取的?
前面提到过,通过PageHelper的startPage()方法进行page缓存的设置,当程序执行sql接口mapper的方法时,就会被拦截器PageInterceptor拦截到。
PageHelper其实就是mybatis的分页插件,其实现原理就是通过拦截器的方式,pageHelper通PageInterceptor实现分页,我们只关注intercept方法。
    iii. dialect.skip(ms, parameter, rowBounds)
此处的skip方法进行设置分页参数,内部调用方法:
Page page = pageParams.getPage(parameterObject, rowBounds);
继续跟踪getPage(),发现此方法的第一行就获取了ThreadLocal的值:
Page page = PageHelper.getLocalPage();

    iv. ExecutorUtil.pageQuery

resultList = ExecutorUtil.pageQuery(dialect, executor, ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
这是分页方法,此方法在执行分页之前,会判断是否执行分页,依据就是前面我们通过ThreadLocal的获取的page。
    v. executor.query
resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
这是非分页方法,我们可以思考一下,如果ThreadLoad在使用后没有被清除,当执行非分页的方法时,那么就会将Limit拼接到sql后面。

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

一次因PageHelper引起的多线程复用问题的排查和解决
如上所示,只要page被获取到了,那么这个sql,就会走前面提到的ExecutorUtil.pageQuery分页逻辑,最终导致出现不可预料的情况。
其实PageHelper对于分页后的ThreaLocal是有清除处理的。
    vi. clearPage()

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

一次因PageHelper引起的多线程复用问题的排查和解决

看看这个afterAll()方法:

一次因PageHelper引起的多线程复用问题的排查和解决

只关注 clearPage():

一次因PageHelper引起的多线程复用问题的排查和解决

    vii. Conclusion

整体看下来,似乎不会存在什么问题,但是我们可以考虑集中极端情况:
  • 如果使用了startPage(),但是没有执行对应的sql,那么就表明,当前线程ThreadLocal被设置了分页参数,可是没有被使用,当下一个使用此线程的请求来时,就会出现问题。
  • 如果程序在执行sql前,发生异常了,就没办法执行finally当中的clearPage()方法,也会造成线程的ThreadLocal被污染。
所以,官方给我们的建议,在使用PageHelper进行分页时,执行sql的代码要紧跟startPage()方法。
除此之外,我们可以手动调用clearPage()方法 ,在存在问题的方法之前。

2. How to solve the problem

1. 确保PageHelper 方法调用后紧跟 MyBatis 查询方法,在查询前不要写任何逻辑处理,因为任何代码都可能产生Exception并发生线程复用的问题。
2. 如果原有不合理的代码太多,没办法一一修改,可以考虑Controller层增加切面,JSF接口增加Filter,手动调用clearPage()方法。代码示例如下:
// 针对JSF接口的Filter
@Slf4jpublic class BscJsfAspectForPageHelper extends AbstractFilter {
public BscJsfAspectForPageHelper(){}
@Override public ResponseMessage invoke(RequestMessage requestMessage) { try { log.info("BscJsfAspectForPageHelper.invoke For JSF PageHelper.clearPage()"); PageHelper.clearPage(); }catch (Exception e){ log.error("BscJsfAspectForPageHelper.invoke发生异常,error msg:", e); }
return getNext().invoke(requestMessage); }}
// XML配置 <bean id="bscJsfAspectForPageHelper" class="com.jdl.bsc.aspect.BscJsfAspectForPageHelper" scope="prototype"> </bean>
// 针对Controller的切面
@Aspect@Component@Slf4jpublic class BscAspectForPageHelper{
@Pointcut("execution(public * com.jdl.bsc.controller.*.*(..)) ") public void bscAspectForPageHelper(){}
@Before("bscAspectForPageHelper()") public void doBefore(JoinPoint joinPoint) { try { log.info("BscAspectForPageHelper.doBefore For PageHelper.clearPage()"); PageHelper.clearPage(); }catch (Exception e){ log.error("BscAspectForPageHelper.doBefore发生异常,error msg:", e); } }}

一次因PageHelper引起的多线程复用问题的排查和解决

一次因PageHelper引起的多线程复用问题的排查和解决

推荐阅读
哎呀,当时怎么没有想到
得嘞,分页插件PageHelper返回记录总数total竟然出错了!
京东零售数据资产能力升级与实践
扯淡的DevOps,我们开发根本不想做运维!

一次因PageHelper引起的多线程复用问题的排查和解决

求分享

一次因PageHelper引起的多线程复用问题的排查和解决

求点赞

一次因PageHelper引起的多线程复用问题的排查和解决

求在看

打造SAAS化服务的会员徽章体系,可以作为标准的产品化方案统一对外输出。结合现有平台的通用能力,实现会员行为全路径覆盖,并能结合企业自身业务特点,规划相应的会员精准营销活动,提升会员忠诚度和业务的持续增长。
底层能力:维护用户基础数据、行为数据建模、用户画像分析、精准营销策略的制定

功能支撑:会员成长体系、等级计算策略、权益体系、营销底层能力支持

▪用户活跃:会员关怀、用户触达、活跃活动、业务线交叉获客、拉新促

本篇文章来源于微信公众号:京东技术

本文来自投稿,不代表TakinTalks稳定性技术交流平台立场,如若转载,请联系原作者。

(0)
上一篇 2023年12月5日 下午5:00
下一篇 2024年3月21日 下午2:05

相关推荐

发表评论

邮箱地址不会被公开。

评论列表(1条)