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

京东云开发者 · 2024年01月30日 · 2633 次阅读

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

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配置
    <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 转载请注明来源

暂无回复。
需要 登录 后方可回复, 如果你还没有账号请点击这里 注册