踩坑系列——PageHelper分页参数未清除导致其他查询报错

背景:

线上报出了一个sql异常,报错语句是这样的:select…from … where … ; limit 10;

分析:

1、定位报错sql语句进行分析

根据报错语句来看是limit前面加了分号导致的,于是根据日志定位到报错语句的mapper,发现代码里的sql确实在最后加了分号,但是这个查询是没有分页的,而实际却莫名其妙给加上了分页参数。

2、PageHelper的分页参数原理

因为项目中的分页使用的PageHelper,这里被加上的分页参数结合自己对PageHelper的了解就想到了大概率是线程中的分页参数没有被清除导致的。分析PageHelper的部分源码如下:

// 项目中使用分页查询的代码
PageHelper.startPage(req.getCurrent(), req.getSize());//开启分页
List<MyResultDTO> data = userRepository.pageList(req);//执行查询

跟踪代码PageHelper.startPage(req.getCurrent(), req.getSize()), 调用的PageMethod部分源码如下:

public abstract class PageMethod {
	protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();
    
    // 开始分页
	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);
	    //当已经执行过orderBy的时候
	    Page<E> oldPage = getLocalPage();
	    if (oldPage != null && oldPage.isOrderByOnly()) {
	        page.setOrderBy(oldPage.getOrderBy());
	    }
	    setLocalPage(page);//设置本地线程变量记录分页参数
	    return page;
	}
	
	// 获取 Page 参数
    public static <T> Page<T> getLocalPage() {
        return LOCAL_PAGE.get();
    }

    // 移除本地变量
    public static void clearPage() {
        LOCAL_PAGE.remove();
    }
    
    // 本地线程变量中记录分页参数
    protected static void setLocalPage(Page page) {
        LOCAL_PAGE.set(page);
    }
}

最后执行查询会被分页拦截器PageInterceptor拦截,拦截器部分源码如下:

@Intercepts(
    {
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
        @Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
    }
)
public class PageInterceptor implements Interceptor {
    private volatile Dialect  dialect;

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        try {
        	// 省略部分代码......
            //调用方法判断是否需要进行分页,如果不需要,直接返回结果
            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);//返回查询结果list
        } finally {
            if(dialect != null){
                dialect.afterAll();
            }
        }
    }
}
  • 拦截器方法里面会判断是否需要进行分页查询,调用链路如下:
    dialect.skip(ms, parameter, rowBounds)
    ----->PageHelper.skip(MappedStatement ms, Object parameterObject, RowBounds rowBounds)
    ----->PageParams.getPage(Object parameterObject, RowBounds rowBounds)
    ----->PageHelper.getLocalPage()
    ----->PageMethod.getLocalPage()
    前面展示的PageMethod的源码里有这个方法,实际就是从本地线程变量中获取分页参数

  • 另外方法finally调用的dialect.afterAll()源码如下:

public class PageHelper extends PageMethod implements Dialect, BoundSqlInterceptor.Chain {
	public void afterAll() {
	    //这个方法即使不分页也会被执行,所以要判断 null
	    AbstractHelperDialect delegate = autoDialect.getDelegate();
	    if (delegate != null) {
	        delegate.afterAll();
	        autoDialect.clearDelegate();
	    }
	    clearPage();
	}
}

这里调用了一个方法clearPage(),方法实现如下:

public abstract class PageMethod {
    protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();
    // 移除本地变量
	public static void clearPage() {
	    LOCAL_PAGE.remove();
	}
}

通过以上源码可以看到PageHelper分页首先会在本地线程变量中记录分页参数,在分页拦截器中拦截查询,判断是否需要进行分页,依据本地线程变量中分页参数是否为空,不为空则需要进行分页,如果需要分页则执行count和select,否则直接执行查询语句,然后将本地线程变量中记录的分页参数清除,最后将查询结果list返回。

了解了分页原理能看出来如果使用不当这里就会存在一个问题,如果开启了分页而又没有执行分页拦截器的finally方法清除线程变量,在使用线程池复用线程的时候,上一个查询设置的参数就会影响下一个查询。

3、分析定位实际有问题的查询

那么线上报错被莫名其妙加上limit 10分页参数的sql是不是因为其他哪个查询没有清除线程变量呢?

带着这个疑问再次搜索日志,查询同一个主机ip+同一个线程id+分页数量10,搜索结果有多条,首先排查报错语句的前一个查询,结果排查代码发现这个查询方法近期修改过,原来的查询代码是这样的:

PageHelper.startPage(req.getCurrent(), req.getSize());
List<OrderDTO> data = orderRepository.pageList(req);//当前服务的查询
//最后返回查询数据

因为项目改造微服务迁移修改后线上运行报错的代码是这样的:

PageHelper.startPage(req.getCurrent(), req.getSize());
List<OrderDTO> data = orderRemoteClient.pageList(req);//这里调用的其他服务远程feign接口
//最后返回查询数据

可以看到修改后的代码设置开启分页,但是后面本地并没有执行查询语句,这样就会导致无法执行分页拦截器的代码,那么本地线程变量中记录的分页参数也就不会被清除,导致了复用这个线程的下一个查询被加上了分页参数。

4、解决方案

定位到问题后对问题查询进行修改,去掉开启分页的代码,在远程服务的查询中进行分页,然后远程服务直接返回分页后的数据,修改后验证通过,问题得以解决。

5、总结
  • 使用第三方工具有很大的便利,但是要知其然也要知其所以然,这样在使用的时候才能避免踩一些坑。
  • 另外针对有分页的查询方法在进行修改的时候一定要注意开启分页后面一定要紧跟着对应的查询否则容易有问题。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值