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

京东云开发者
• 阅读 72

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

一次因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引起的多线程复用问题的排查和解决 | 京东物流技术团队

C、Analysis Steps

1. How to use PageHelper

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

@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配置
    <bean id="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 转载请注明来源

点赞
收藏
评论区
推荐文章
ThreadLocal源码解析及实战应用
ThreadLocal是一个关于创建线程局部变量的类。通常情况下,我们创建的变量是可以被任何一个线程访问并修改的。而使用ThreadLocal创建的变量只能被当前线程访问,其他线程则无法访问和修改。ThreadLocal在设计之初就是为解决并发问题而提供一种方案,每个线程维护一份自己的数据,达到线程隔离的效果。
Wesley13 Wesley13
2年前
Java ThreadLocal的内存泄漏问题
ThreadLocal提供了线程独有的局部变量,可以在整个线程存活的过程中随时取用,极大地方便了一些逻辑的实现。常见的ThreadLocal用法有:\存储单个线程上下文信息。比如存储id等;\使变量线程安全。变量既然成为了每个线程内部的局部变量,自然就不会存在并发问题了;\减少参数传递。比如做一个trace工具,能够输出工程从开始到结
Stella981 Stella981
2年前
Python中的@函数装饰器到底是什么?
在解释@函数装饰器之前,先说一下,类中的类方法和静态方法。在Python中完全支持定义类方法、静态方法。这两种方法很相似,Python它们都使用类来调用(ps:用对象调用也可以)。区别在于:Python会自动绑定类方法的第一个参数,类方法的第一个参数会自动绑定到类本身;但对于静态方法则不会自动绑定。类方法用@classmethod
Wesley13 Wesley13
2年前
Java多线程与并发之ThreadLocal原理解析
1\.ThreadLocal是什么?使用场景ThreadLocal简介ThreadLocal是线程本地变量,可以为多线程的并发问题提供一种解决方式,当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,
Wesley13 Wesley13
2年前
JAVA ThreadLocal对象浅析
最近在开发过程中,在做一个字典项服务的时候,最开始采用了ThreadLocal对象来缓存数据。在使用ThreadLocal过程中遇到一些问题,这里和大家分享一下。一、什么是ThreadLocal?顾名思义它是localvariable(线程局部变量)。它的功用非常简单,就是为每一个使用该变量的线程都提供一个变量值
Wesley13 Wesley13
2年前
Java并发编程的艺术笔记(四)——ThreadLocal的使用
ThreadLocal,即线程变量,是一个以ThreadLocal对象为键、任意对象为值的存储结构。这个结构被附带在线程上,也就是说一个线程可以根据一个ThreadLocal对象查询到绑定在这个线程上的一个值。目的就是为了让线程能够有自己的变量可以通过set(T)方法来设置一个值,在当前线程下再通过get()方法获取到原先设置的值
Wesley13 Wesley13
2年前
Java多线程与并发之ThreadLocal
1\.ThreadLocal是什么?使用场景ThreadLocal简介ThreadLocal是线程本地变量,可以为多线程的并发问题提供一种解决方式,当使用ThreadLocal维护变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,所以每一个线程都可以独立地改变自己的副本,
Easter79 Easter79
2年前
ThreadLocal设计模式
ThreadLocal设计模式使用的也很频繁,会经常在各大框架找到它们的踪影,如struts2以及最近正在看的SpringAOP等。ThreadLocal设计模式也有很多误解,我的理解是(1)ThreadLocal所操作的数据是线程间不共享的。它不是用来解决多个线程竞争同一资源的多线程问题。(2)ThreadLocal所操作的数据主要
Easter79 Easter79
2年前
ThreadLocal的深入理解及应用
是什么?ThreadLocal很容易让人望文生义,想当然地认为是一个“本地线程”。其实,ThreadLocal并不是一个Thread,它类似(Map),用来存储当前运行线程及对应的变量。在WEB应用中每次Http请求,都相当于从线程池取一个空闲线程对请求的方法作处理。此时当前线程的所有方法中Thread.currentThread
Wesley13 Wesley13
2年前
JAVA程序设计练习题集答案
一、判断题1.String字符串在创建后可以被修改。(0)2.引用一个类的属性或调用其方法,必须以这个类的对象为前缀。(0final类名)3.当调用一个正在进行线程的stop()方法时,该线程便会进入休眠状态。(0)4.如果一个类声明实现一个接口,但没有实现接口中的所有方法,那么这个类必须是abst