0%

一次 PageHelper 分页引发的血案

最近在测试环境登录的时候,突然出现了下面的异常。

本来这条SQL只查询一条数据的,但是在错误日志中,发现后面莫名其妙的多了一个LIMIT。第一个反应是不是这个查询的前面用错了PageHelper的分页功能,但是查看了项目中的这处查询,发现LIMIT只是在SQL里面写死了LIMIT 1。我就懵逼了,问了下同事,同事说以前项目也出现过这种抽风的现象,然后我一脸黑人问号。

最近几天大致的看了下PageHelper(文章后面给了源码地址)这个分页工具的源码。在项目中,根据PageHelper的文档,一般会使用以下前两种分页方法(官方文档也推荐这样使用,其实我本人使用最多的是第三种):

1
2
3
4
5
6
7
8
PageHelper.startPage(1, 10);
List<User> list = userMapper.selectIf(1);
//或者
PageHelper.offsetPage(1, 10);
List<User> list = userMapper.selectIf(1);

//jdk8 lambda用法
Page<User> page = PageHelper.startPage(1, 10).doSelectPage(()-> userMapper.selectGroupBy());

从上面我们可以看出,分页的数据和SQL查询是分开的,那么PageHelper是如何做到把分页数据准确的拼接到SQL后面呢?如果你在源码中点开 PageHelper 这个类,你会发现这个类是继承 PageMethod 以及实现 Dialect 接口的。在 PageMethod 中,定义了一个 protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>(); 的全局常量,然后在源码中,你会发现PageHelper只是把你的分页数据,放到了ThreadLocal LOCAL_PAGE中了,然后就没然后了。

虽然PageHelper还有别的一些设置,比如数据库方言的选择等,但是我们今天不讨论这些,如果自己感兴趣的话,可以去翻翻源码。如果仔细的看下源码,会发现一个 PageInterceptor 类,这个类实现的是 ibatisInterceptor。在拦截器中,PageHelper 判断是查询语句是否需要分页,是否需要count查询,分页数据拼接查询等操作。下面三个截图大致的说明了一些拦截器中的一些原理(作者源码注释还是挺好理解的)。



从上面的源码解读中,我们知道,PageHelper 的分页数据是存储在ThreadLocal中的,在实现mybatis的拦截器中处理SQL的拼接查询以及count查询,最后在结束的时候,清理ThreadLocal中的数据。所以当我们在 PageHelper 使用不当的时候,只设置PageHelper.startPage(1, 10);了,但是后面的SQL没执行,那么这个 ThreadLocal 中保存的分页等数据,就会留给另外一个复用该线程的请求,当它执行SQL查询的时候,就会把上一次请求中存储 ThreadLocal 中的分页数据拼接到这次请求的SQL上。

说到这里,我们就得聊下关于 Tomcat 中的线程了,使用过 Tomcat 的,应该都知道它是有个线程池,我们不去深入了解线程池的如何使用。在这里,由于 Tomcat 使用了线程池,那么就存在线程复用的问题,这就导致了我们上面出现的问题。如果想尝试Tomcat线程池,spring boot的配置文件中把 server.tomcat.max-threads 参数调小,甚至直接设置为1,那么Tomcat就只有一个线程,这时候尝试ThreadLocal,就会出现不同请求,但是能在第二次中访问到上一次的请求插入到ThreadLocal中的值,这说明2次请求使用的是同一个线程。

项目中全局搜索使用PageHelper.startPage的地方,发现一个地方,在PageHelper.startPage之后的查询SQL是包在一个if语句中的,当不满足查询条件的时候,就不会走查询,然后就把分页数据留给了下一个复用该线程的请求。

解决方法:

  • PageHelper.startPage后面直接跟上SQL查询语句
  • 在每次请求完成后,调用pagehelper提供的清理ThreadLocal方法,或者在每次请求进来的时候,在拦截器中清理掉线程缓存数据。

Reference:

客官,赏一杯coffee嘛~~~~