首页 > 其他分享 >一次因PageHelper引起的多线程复用问题的排查和解决 | 京东物流技术团队

一次因PageHelper引起的多线程复用问题的排查和解决 | 京东物流技术团队

时间:2024-01-30 11:22:24浏览次数:36  
标签:分页 parameter 复用 PageHelper ms 多线程 方法 rowBounds

A、Problem Description

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方法查询结果的错误。

 

B、Problem inspection Steps

1. Code Review

 

 

 

 

 

 

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

 

 

 

 

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

 

2. Log Check and Prove

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

 

 

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

 

 

C、Analysis Steps

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()

 

 

 

 

 

通过上图我们可以发现,当一个请求来的时候,会获取持有当前请求的线程的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):

 

 

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

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

 

vi. clearPage()

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

 

 

看看这个afterAll()方法:

 

 

只关注 clearPage():

 

 

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

@Slf4j
public 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配置
    ="bscJsfAspectForPageHelper" class="com.jdl.bsc.aspect.BscJsfAspectForPageHelper" scope="prototype">
    /bean// 针对Controller的切面

@Aspect
@Component
@Slf4j
public 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);
        }
    }
}


作者:京东物流 王崧

来源:京东云开发者社区 自猿其说 Tech 转载请注明来源

标签:分页,parameter,复用,PageHelper,ms,多线程,方法,rowBounds
From: https://www.cnblogs.com/Jcloud/p/17996739

相关文章

  • 深入浅出Java多线程(三):线程与线程组
    「引言」大家好,我是你们的老伙计秀才!今天带来的是[深入浅出Java多线程]系列的第三篇内容:线程与线程组。大家觉得有用请点赞,喜欢请关注!秀才在此谢过大家了!!!在现代软件开发中,多线程编程已成为提升程序性能和并发能力的关键技术之一。Java作为主流的面向对象编程语言,其对多线程的支......
  • java用多线程批次查询大量数据(Callable返回数据)方式
    我看到有的数据库是一万条数据和八万条数据还有十几万条,几百万的数据,然后我就想拿这些数据测试一下,发现如果用java和数据库查询就连一万多条的数据查询出来就要10s左右,感觉太慢了。然后网上都说各种加索引,加索引貌似是有查询条件时在某个字段加索引比较快一些,但是毕竟是人家的库不......
  • 深入浅出Java多线程(二):Java多线程类和接口
    引言大家好,我是你们的老伙计秀才!今天带来的是[深入浅出Java多线程]系列的第二篇内容:Java多线程类和接口。大家觉得有用请点赞,喜欢请关注!秀才在此谢过大家了!!!在现代计算机系统中,多线程技术是提升程序性能、优化资源利用和实现并发处理的重要手段。特别是在Java编程语言中,多线程机......
  • 多线程
    多线程理论(1)什么是线程在Python中,线程(Thread)是执行单元的最小单位。线程是进程内的一条执行路径,每个线程都有自己的执行序列、执行环境和栈空间,但它们共享同一个进程的地址空间。在多线程编程中,可以同时运行多个线程,每个线程执行不同的任务,从而实现并发执行。相比于多进......
  • C++多线程 第一章 你好,C++并发世界
    第一章你好,C++并发世界C++并发并发(concurrency):主要包括任务切换与硬件并发两类.并发(concurrency)实际上与多线程(multithreading)存在差异.并发的种类任务切换(taskswitching):计算机在某一时刻只可以真正执行一个任务,但它可以每秒切换任务许多次.通过做一......
  • IO多路复用
    在Linux下,I/O多路复用是一种用于高效处理多个I/O操作的技术。它允许一个进程同时监视多个文件描述符或网络套接字,以便在其中任何一个发生I/O事件时能够及时响应。I/O多路复用的主要目标是提高系统的并发性能和资源利用率。通过同时监听多个I/O源,可以减少进程在等待I/O......
  • 实现多线程的方式有哪几种?
    Java虚拟机时是运行所有Java程序的抽象计算机,允许应用并发的运行多个线程。在Java语言中,多线程的实现,一般有以下3中方法:1.实现Runnable接口,并实现该接口的run()方法;主要步骤:1.自定义类并实现Runnable接口,实现run()方法;2.创建Thread类,用实现Runnable接口的对象作为参数实例化......
  • python 多线程运行 串行或并行
    我们知道在python中运行多线程程序很简单,只需要几步,创建线程,start线程即可,下面简单说下多线程的串行或者并行的使用示例:#-*-coding:utf-8-*-#@Time:2024-01-2714:03importthreadingimporttimedefrun(name:str)->None:time.sleep(3)print("Thre......
  • 多线程简单介绍
    线程:是操作系统能够进行运算调度的最小单位。它被包含在进程中,是进程中实际运作单位进程:进程是程序的基本执行实体,一个程序就是一个进程简单理解线程:应用软件中相互独立,可以同时运行的功能有了多线程,就可以让程序同时做多件事线程的生命周期完整的线程状态New(新建状态)->......
  • 多线程sleep、yield、wait、join方法的使用和区别
    使用和介绍sleep方法sleep方法的官方注释(翻译后):/***根据系统计时器和调度程序的精度和准确性,使当前执行的线程休眠(暂时停止执行)指定的毫秒数。线程不会失去任何监视器的所有权(不释放锁)。**@parammillis以毫秒为单位的睡眠时间长度*@throws......