pagehelper踩坑记之分页乱套问题解决

 更新时间:2022年11月28日 17:10:20   作者:等你归去来  
这篇文章主要为大家介绍了pagehelper踩坑记之分页乱套问题解决,有需要的朋友可以借鉴参考下,希望能够有所帮助,祝大家多多进步,早日升职加薪

正文

我们在使用数据库进行查询时,很多时候会用到分页展示功能,因此除了像mybatis这样的完善的orm框架之外,还有pagehelper这样的插件帮助减轻我们的工作。

pagehelper的实现方式是,不需要我们去编写分页代码,只需要调用一个分页方法,出来的结果就是经过分页处理的。一来,我们的xml中的sql编写就会灵活很多,二来,它可以帮我们规避各种不同类型的数据库的分页描述方式。所以,总体来说是个好事。

使用pagehelper遇到的坑说明

现象是这样的:我们有一个场景是查询数据库表中的全量记录返回给第三方,但是突然某一天发现第三方告警说我们给的数据不对了,比如之前会给到200条记录的,某次只给到了10条记录。

随后我们推出了几个猜想:

1. 第三方系统处理数据有bug,漏掉了一些数据;

2. 数据库被人临时改掉过,然后又被复原了;

3. 数据库bug,在全量select时可能不返回全部记录;

其实以上猜想都显得有点无厘头,比如数据库怎么可能有这种低级bug?但是人在没有办法的情况下只能胡猜一通了。最后终于发现是pagehelper的原因,因为分页乱套了,复用了其他场景下的分页设置,丢到数据库查询后返回了10条记录;

pagehelper的至简使用方式

本身pagehelper就是一个辅助工具类,所以使用起来一般很简单。尤其在springboot中,只要引用starter类,依赖就可以满足了。(如果是其他版本,则可能需要配置下mybatis的intercepter)

<!-- https://mvnrepository.com/artifact/com.github.pagehelper/pagehelper-spring-boot-starter -->
        <dependency>
            <groupId>com.github.pagehelper</groupId>
            <artifactId>pagehelper-spring-boot-starter</artifactId>
            <version>${pagehelper.version}</version>
        </dependency>

在使用时只需要加上 Page.startPage(pageNum, pageSize) 即可。

public Object getUsers(int pageNum, int pageSize) {
        PageHelper.startPage(pageNum, pageSize);
        List<UserEntity> list = userMapper.selectAllWithPage(null);
        com.github.pagehelper.Page listWithPage = (com.github.pagehelper.Page) list;
        System.out.println("listCnt:" + listWithPage.getTotal());
        return list;
    }

而真正的sql里只需按没有分页的样式写一下就可以了。

<select id="selectAllWithPage" parameterType="java.util.Map"
        resultType="com.my.mvc.app.dao.entity.UserEntity">
        select * from t_users
    </select>

还是很易用的。少去了一些写死的sql样例。

pagehelper实现原理简说

pagehelper不是什么高深的组件,实际上它就是一个mybatis的一个插件或者拦截器。是mybatis在执行调用时,将请求转发给pagehelper处理,然后由pagehelper包装分页逻辑。

// com.github.pagehelper.PageInterceptor#intercept
    @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();
            }
        }
    }

如果没有分页逻辑需要处理,和普通的没什么差别,如果有分页请求,则会在原来的sql之上套上limit.. offset.. 之类的关键词。从而完成分页效果。

为什么pagehelper的分页会乱套?

现在我们来说说为什么分页会乱套?原因是 PageHelper.startPage(xx) 的原理是将分页信息设置到线程上下文中,然后在随后的查询中使用该值,使用完成后就将该信息清除。

/**
     * 开始分页
     *
     * @param pageNum  页码
     * @param pageSize 每页显示数量
     * @param count    是否进行count查询
     */
    public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count) {
        return startPage(pageNum, pageSize, count, null, null);
    }
    /**
     * 开始分页
     *
     * @param pageNum      页码
     * @param pageSize     每页显示数量
     * @param count        是否进行count查询
     * @param reasonable   分页合理化,null时用默认配置
     * @param pageSizeZero true且pageSize=0时返回全部结果,false时分页,null时用默认配置
     */
    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;
    }
    protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();
    /**
     * 设置 Page 参数
     *
     * @param page
     */
    protected static void setLocalPage(Page page) {
        LOCAL_PAGE.set(page);
    }
    // com.github.pagehelper.PageHelper#afterAll
    @Override
    public void afterAll() {
        //这个方法即使不分页也会被执行,所以要判断 null
        AbstractHelperDialect delegate = autoDialect.getDelegate();
        if (delegate != null) {
            delegate.afterAll();
            autoDialect.clearDelegate();
        }
        clearPage();
    }
    /**
     * 移除本地变量
     */
    public static void clearPage() {
        LOCAL_PAGE.remove();
    }

那么什么情况下会导致分页信息乱套呢?实际上就是线程变量什么情况会被乱用呢?

线程被复用的时候,将可能导致该问题。比如某个请求将某个线程设置了一个线程变量,然后随后另一个请求复用了该线程,那么这个变量就被复用过去了。那么什么情况下线程会被复用呢?

一般是线程池、连接池等等。是的,大概就是这么原理了。

分页问题复现

既然从理论上说明了这个问题,能否稳定复现呢?咱们编写下面的,很快就复现了。

@RestController
@RequestMapping("/hello")
@Slf4j
public class HelloController {
    @Resource
    private UserService userService;
    // 1. 先请求该getUsers接口,将得到异常,pageNum=1, pageSize=1
    @GetMapping("getUsers")
    @ResponseBody
    public Object getUsers(int pageNum, int pageSize) {
        return userService.getUsers(pageNum, pageSize);
    }
    // 2. 多次请求该 getAllActors接口,正常情况下会得到N条全表记录,但将会偶发地得到只有一条记录,现象复现
    @GetMapping("getAllActors")
    @ResponseBody
    public Object getAllActors() {
        return userService.getAllActors();
    }
}
@Service
@Slf4j
public class UserService {
    @Resource
    private UserMapper userMapper;
    public Object getUsers(int pageNum, int pageSize) {
        PageHelper.startPage(pageNum, pageSize);
        // 此处强行抛出异常, 使以上 pagehelper 信息得以保存
        throw new RuntimeException("exception ran");
    }
    public Object getAllActors() {
        // 正常的全表查询
        List<ActorEntity> list = userMapper.selectAllActors();
        return list;
    }
}

验证步骤及结果如下:(数据方面,自己随便找一些表就好了)

// 步骤1: 发送请求: http://localhost:8081/hello/getUsers?pageNum=1&pageSize=1
// 步骤2: 发送请求: http://localhost:8081/hello/getAllActors
// 正常时返回
[{"actorId":1,"firstName":"PENELOPE","lastName":null,"lastUpdate":null},{"actorId":2,"firstName":"NICK","lastName":null,"lastUpdate":null},{"actorId":3,"firstName":"ED","lastName":null,"lastUpdate":null},{"actorId":4,"firstName":"JENNIFER","lastName":null,"lastUpdate":null},{"actorId":5,"firstName":"JOHNNY","lastName":null,"lastUpdate":null},{"actorId":6,"firstName":"BETTE","lastName":null,"lastUpdate":null},{"actorId":7,"firstName":"GRACE","lastName":null,"lastUpdate":null},{"actorId":8,"firstName":"MATTHEW","lastName":null,"lastUpdate":null},{"actorId":9,"firstName":"JOE","lastName":null,"lastUpdate":null},{"actorId":10,"firstName":"CHRISTIAN","lastName":null,"lastUpdate":null},{"actorId":11,"firstName":"ZERO","lastName":null,"lastUpdate":null},{"actorId":12,"firstName":"KARL","lastName":null,"lastUpdate":null},{"actorId":13,"firstName":"UMA","lastName":null,"lastUpdate":null},{"actorId":14,"firstName":"VIVIEN","lastName":null,"lastUpdate":null},{"actorId":15,"firstName":"CUBA","lastName":null,"lastUpdate":null},{"actorId":16,"firstName":"FRED","lastName":null,"lastUpdate":null},... 
// 出异常时返回
[{"actorId":1,"firstName":"PENELOPE","lastName":null,"lastUpdate":null}]

以上,几乎都可以复现该现象。实际上该问题由于tomcat的连接池复用导致的,本身和pagehelper关联不是很大,但是在此处却可能带来比较大的影响。这也警示我们使用ThreadLocal 时,一定要小心清理,否则将产生难以预料的结果。而且将很难排查。供诸君参考,更多关于pagehelper分页乱套解决的资料请关注脚本之家其它相关文章!

相关文章

  • 如何理解Java线程池及其使用方法

    如何理解Java线程池及其使用方法

    线程池是首先创建一些线程,它们的集合称为线程池。使用线程池可以提高性能,它在系统启动时创建大量空闲的线程,程序将一个任务传给线程池,它就会启动一条线程来执行这个任务,执行结束以后,该线程并不会死亡,而是再次返回线程池中成为空闲状态,等待执行下一个任务
    2021-06-06
  • java 中冒泡、二分、快速算法详解

    java 中冒泡、二分、快速算法详解

    这篇文章主要介绍了java 中冒泡、二分、快速算法详解的相关资料,需要的朋友可以参考下
    2017-06-06
  • 动力节点_王勇_DRP项目视频教程完整版292集

    动力节点_王勇_DRP项目视频教程完整版292集

    该视频由国内知名讲师王勇老师主讲,适合掌握Java基础内容的同学学习,本视频共计292集,学习Java Web项目,DRP项目视频是首选,累计下载量已经达到上千万,很多同学通过自学该视频找到了软件开发工作
    2017-04-04
  • Java利用线程工厂监控线程池的实现示例

    Java利用线程工厂监控线程池的实现示例

    这篇文章主要介绍了Java利用线程工厂监控线程池的实现示例,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2021-04-04
  • 使用Shiro实现登录成功后跳转到之前的页面

    使用Shiro实现登录成功后跳转到之前的页面

    这篇文章主要介绍了如何使用Shiro实现不同用户登录成功后跳转到不同主页,实现此功能目前比较好的方法是用ajax的方法登录,第二种方法是把用户未登录前的url存在session中,需要的朋友可以参考下
    2015-07-07
  • java 根据身份证号码判断出生日期、性别、年龄的示例

    java 根据身份证号码判断出生日期、性别、年龄的示例

    这篇文章主要介绍了java 根据身份证号码判断出生日期、性别、年龄的示例,帮助大家更好的理解和使用Java,感兴趣的朋友可以了解下
    2020-10-10
  • java八大经典书籍 你看过几本?

    java八大经典书籍 你看过几本?

    java八大经典书籍,你看过几本?本文为大家分享了java学习书单,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2018-01-01
  • Java 常量与变量的区别详细介绍

    Java 常量与变量的区别详细介绍

    这篇文章主要介绍了Java 常量与变量的区别的相关资料,并附实例代码帮助大家学习理解,需要的朋友可以参考下
    2016-10-10
  • java.lang.Runtime.exec的左膀右臂:流输入和流读取详解

    java.lang.Runtime.exec的左膀右臂:流输入和流读取详解

    这篇文章主要介绍了java.lang.Runtime.exec的左膀右臂:流输入和流读取详解,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教
    2021-11-11
  • spring aop action中验证用户登录状态的实例代码

    spring aop action中验证用户登录状态的实例代码

    本篇文章主要介绍了spring aop action中验证用户登录状态的实例代码,具有一定的参考价值,感兴趣的小伙伴们可以参考一下
    2017-07-07

最新评论