Mybatis-PageHelper icon indicating copy to clipboard operation
Mybatis-PageHelper copied to clipboard

未进行PageHelper.startPage指定翻页时,查询携带LIMIT参数,ThreadLocal清理失败

Open bushuaigege opened this issue 2 years ago • 4 comments

  • [x] 我已在 issues 搜索类似问题,并且不存在相同的问题.

异常模板

无明显异常,已尽力排查,并提供目前排查结论,但还是对根本问题不够清楚,还望赐教

使用环境

  • PageHelper 版本: 5.2.0
  • 数据库类型和版本: mysql5.7
  • JDBC_URL: jdbc:mysql://x:x/x?characterEncoding=UTF-8&useAffectedRows=true&allowMultiQueries=true&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&useSSL=false${database.url-param}

SQL 解析错误

分页参数

String themeKey = dto.getThemeKey();
        List<String> productNoList = dto.getProductNoList();
        if(CollectionUtils.isEmpty(productNoList)){
            return Collections.emptyList();
        }
        Example example = new Example(AscriptionProductDO.class);
        Example.Criteria criteria = example.createCriteria();
        //todo 单传themekey有问题
        if(StringUtils.isNotEmpty(themeKey)){
            criteria.andEqualTo("themeKey", themeKey);
        }
        if(!CollectionUtils.isEmpty(productNoList)){
            criteria.andIn("productNo", productNoList);
        }
        criteria.andEqualTo("isDelete", 0);
        List<AscriptionProductDO> ascriptionProductList = ascriptionProductDOMapper.selectByExample(example);
        if(CollectionUtils.isEmpty(ascriptionProductList)){
            return Collections.emptyList();
        }
        return JSON.parseArray(JSON.toJSONString(ascriptionProductList), AscriptionProductDTO.class);

原 SQL

由example组装sql
DEBUG 2022-07-19 19:09:59,784 []  [http-nio-8033-exec-9] c.t.o.m.A.selectByExample:137 - ==>  Preparing: SELECT id,batch_no,store_no,platform,theme_key,ascription_scene,ascription_no,product_code,product_no,product_name,version,arrival_time,is_invoke_chain,gmt_invoke,audit_status,level,level_name,channel,terminal,contract_name,author,allocate_status,is_delete,ext_info,gmt_create,gmt_modify,privilege_status,extra_data,video_file,model_file FROM `ascription_product` WHERE ( ( product_no in ( ? , ? , ? , ? , ? , ? , ? , ? , ? , ? ) and is_delete = ? ) )

期望的结果:

DEBUG 2022-07-19 19:09:59,784 []  [http-nio-8033-exec-9] c.t.o.m.A.selectByExample:137 - ==>  Preparing: SELECT id,batch_no,store_no,platform,theme_key,ascription_scene,ascription_no,product_code,product_no,product_name,version,arrival_time,is_invoke_chain,gmt_invoke,audit_status,level,level_name,channel,terminal,contract_name,author,allocate_status,is_delete,ext_info,gmt_create,gmt_modify,privilege_status,extra_data,video_file,model_file FROM `ascription_product` WHERE ( ( product_no in ( ? , ? , ? , ? , ? , ? , ? , ? , ? , ? ) and is_delete = ? ) )
DEBUG 2022-07-19 19:07:40,110 []  [http-nio-8033-exec-9] c.t.o.m.A.selectByExample:137 - ==>  Preparing: SELECT id,batch_no,store_no,platform,theme_key,ascription_scene,ascription_no,product_code,product_no,product_name,version,arrival_time,is_invoke_chain,gmt_invoke,audit_status,level,level_name,channel,terminal,contract_name,author,allocate_status,is_delete,ext_info,gmt_create,gmt_modify,privilege_status,extra_data,video_file,model_file FROM `ascription_product` WHERE ( ( product_no in ( ? , ? , ? , ? , ? , ? , ? , ? , ? , ? ) and is_delete = ? ) ) LIMIT ?

完整异常信息

无异常打印,主要表现为:不是每次都有limit,但重试多次就会出现

初步排查过程及结论

1、java代码并不存在翻页代码,但sql却存在limit,推断线程变量串用

重现场景: 1、抓取打印sql时业务线程编号 image

从上图查看源码得知,总共打印2条sql的源码出处为 com.github.pagehelper.Dialect#beforeCount com.github.pagehelper.util.ExecutorUtil#pageQuery

//调用方法判断是否需要进行分页,如果不需要,直接返回结果
            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);
            }

正常不添加limit情况走else分支,论证如图

2、跟进dialect.skip 发现时LocalPage会获取到Page对象 com.github.pagehelper.page.PageParams#getPage com.github.pagehelper.page.PageMethod#getLocalPage 方法执行轨迹显示(只显示进入方法行,足以证明page不为空) image

Page page = PageHelper.getLocalPage();
        if (page == null) {
            if (rowBounds != RowBounds.DEFAULT) {
                if (offsetAsPageNum) {
                    page = new Page(rowBounds.getOffset(), rowBounds.getLimit(), rowBoundsWithCount);
                } else {
                    page = new Page(new int[]{rowBounds.getOffset(), rowBounds.getLimit()}, rowBoundsWithCount);
                    //offsetAsPageNum=false的时候,由于PageNum问题,不能使用reasonable,这里会强制为false
                    page.setReasonable(false);
                }
                if(rowBounds instanceof PageRowBounds){
                    PageRowBounds pageRowBounds = (PageRowBounds)rowBounds;
                    page.setCount(pageRowBounds.getCount() == null || pageRowBounds.getCount());
                }
            } else if(parameterObject instanceof IPage || supportMethodsArguments){
                try {
                    page = PageObjectUtil.getPageFromObject(parameterObject, false);
                } catch (Exception e) {
                    return null;
                }
            }
            if(page == null){
                return null;
            }
            PageHelper.setLocalPage(page);
        }
        //分页合理化
        if (page.getReasonable() == null) {
            page.setReasonable(reasonable);
        }
        //当设置为true的时候,如果pagesize设置为0(或RowBounds的limit=0),就不执行分页,返回全部结果
        if (page.getPageSizeZero() == null) {
            page.setPageSizeZero(pageSizeZero);
        }
        return page;

3、实际是否有执行线程变量清除操作 com.github.pagehelper.PageInterceptor#intercept

 finally {
            if(dialect != null){
                dialect.afterAll();
            }
        }

向上推断调用栈正常 image

所以 protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>(); 的问题实在是。。不知道在哪~

其他类型的错误

功能建议

详细说明,尽可能提供(伪)代码示例。

bushuaigege avatar Jul 21 '22 08:07 bushuaigege

看看这里:https://github.com/pagehelper/Mybatis-PageHelper/blob/master/wikis/zh/HowToUse.md#3-pagehelper-%E5%AE%89%E5%85%A8%E8%B0%83%E7%94%A8

有可能其他地方使用不规范导致没有消费和清理。

abel533 avatar Jul 21 '22 10:07 abel533

感谢您的回复!

  1. 什么时候会导致不安全的分页? PageHelper 方法使用了静态的 ThreadLocal 参数,分页参数和线程是绑定的。

只要你可以保证在 PageHelper 方法调用后紧跟 MyBatis 查询方法,这就是安全的。因为 PageHelper 在 finally 代码段中自动清除了 ThreadLocal 存储的对象。

如果代码在进入 Executor 前发生异常,就会导致线程不可用,这属于人为的 Bug(例如接口方法和 XML 中的不匹配,导致找不到 MappedStatement 时), 这种情况由于线程不可用,也不会导致 ThreadLocal 参数被错误的使用。

但是如果你写出下面这样的代码,就是不安全的用法:

PageHelper.startPage(1, 10);
List<User> list;
if(param1 != null){
    list = userMapper.selectIf(param1);
} else {
    list = new ArrayList<User>();
}
这种情况下由于 param1 存在 null 的情况,就会导致 PageHelper 生产了一个分页参数,但是没有被消费,这个参数就会一直保留在这个线程上。当这个线程再次被使用时,就可能导致不该分页的方法去消费这个分页参数,这就产生了莫名其妙的分页。

上面这个代码,应该写成下面这个样子:


List<User> list;
if(param1 != null){
    PageHelper.startPage(1, 10);
    list = userMapper.selectIf(param1);
} else {
    list = new ArrayList<User>();
}
这种写法就能保证安全。

如果你对此不放心,你可以手动清理 ThreadLocal 存储的分页参数,可以像下面这样使用:

List<User> list;
if(param1 != null){
    PageHelper.startPage(1, 10);
    try{
        list = userMapper.selectAll();
    } finally {
        PageHelper.clearPage();
    }
} else {
    list = new ArrayList<User>();
}

这么写很不好看,而且没有必要。

1、之前考虑过线程复用threadLocal问题,但连续请求每次都打印的不同线程号, 2、是否有使用pageHelper.start但是未消费情况?目前看了没有,只有一个start,但是mapper在service中,代码行距离较远但这不是问题把,A线程的threadlocal遇到sql在PageIntercepter拦截始终会被清理的 3、目前根据文章排查使用的springboot-pageHelper-start,配置保持是一致,还有其他思路提供嘛?

bushuaigege avatar Jul 22 '22 01:07 bushuaigege

容易复现的情况下可以加断点看看何时创建和消费。

abel533 avatar Jul 22 '22 03:07 abel533

容易复现的情况下可以加断点看看何时创建和消费。

目前最大的问题是本地很难复现~只能在测试环境;我试试直接断点远程把

bushuaigege avatar Jul 22 '22 03:07 bushuaigege

新版本增加了debug=true的调试模式,执行真正查询时,会输出设置分页参数时的堆栈信息,通过比对可以找到错误调用的地方。

abel533 avatar Sep 18 '22 12:09 abel533