关于PageHelper的坑,导致莫名其妙的sql错误的问题
前言
PageHelper 用得好,能省很多功夫。用得不好会埋下很大的隐患,并且很难发现。本文就是讨论这个很难发现的坑。
症状
- 出现莫名其妙的分页混乱
原来不分页的sql却只查出了部分数据,观察sql发现sql被添加了limit
- 或者sql语句报错
观察该sql语句有limit,但limit后面又被添加了limit
- 上述症状比较难重现。但如果知道其发病机理,则100%可重现。
对阵下药
这是因为PageHelper实现的原理是通过ThreadLocal实现。在PageHelper.startPage()的时候,将分页的信息绑定到线程A,当执行mapper的方法的时候,sql被PageHelper的拦截器拦截并取出放入线程A的分页信息。
接着sql被分解为查询记录总数的sql,如果总数不为0,再执行查询分页数据的sql(原sql被改造成末尾带有limit),最后在拦截器的finally中移除绑定到线程A中的分页信息。
上述是正常情况。
假设分页数据绑定到线程A最终没被移除,那别的方法,甚至是不相关的方法在被请求时,若分配线程A为其执行任务,则可以获取到分页信息,导致PageHelper框架以为需要分页(tomcat连接池的线程对象可能出现复用)
项目使用PageHelper,但是有些同事使用不规范,如下
// 往线程中绑定分页信息,却没有移除,造成复用该线程的方法出现分页的现象
PageHelper.startPage(pageNum, pageSize);
return new ResponseDTO<>(new PageInfo<>(userQueryService.queryPage(name)));
如何避免
1、规范编码
PageHelper.startPage()之后请紧跟mapper的查询方法。中间不要隔任何代码,中间存在任何代码就存在发生异常导致mapper方法未被执行的可能性。
检查mapper.xxx(p.getId) 是否会发生NPE,如p是null或p.getId是null且接收的数据类型是primitive type。
2、全局上设置拦截器移除线程变量
在拦截器里调用移除变量的方法最好是用顺序最靠前的javax.servlet.Filter,调用 PageHelper.startPage()
移除ThreadLocal中的变量
补充其他风险
可能导致本文的"坑"的情况
情况1:
PageHelper.startPage(pageNum, pageSize)
// 后面没有xxxMapper.xxxx()
情况2:
PageHelper.startPage(pageNum, pageSize)
int i = 1/0;// 但是在这里发生了空指针异常
xxxMapper.xxxx()
情况3:
PageHelper.startPage(pageNum, pageSize)
// xxxMapper是null,或p是null,或p.getId是null且接收的数据类型是primitive type
xxxMapper.xxx(p.getId)
情况4:
PageHelper.startPage(pageNum, pageSize)
// 调用了其他service,而这个service还未执行到它的mapper方法的时候发生了异常
xxxService.xxxx()
动手做实验
下面展示了我是如何100%找出这个问题的
1、准备
- 配置tomcat连接池数量为1,永远复用这个,方便重现问题
server.tomcat.max-threads=1
- 写一个方法现查是否真的永远复用这个线程
@RequestMapping(value = "/test/currentThread", method = RequestMethod.GET)
public Object currentThread() {
return Thread.currentThread().getId();
}
在浏览器中反复请求该方法,确认id只有一个。
要注意不要用chrome,chrome有病,当server.tomcat.max-threads=2
的时候经常id不变,本人使用搜狗浏览器。PS: 搜狗浏览器在测负载均衡的时候也是相当好用主要没缓存,每次请求都能看到轮询)
PS:这是比较蠢的方法确定线程服用数是1,请大神们用别的方法。另外从逻辑学上,这个只能证明 “还没出现”,但 “还没出现” 不代表不出现,所以用于证明 “线程池只有这个id的线程” 是有点瑕疵的。
2、设置ThreadLocal变量但不移除
// 该Controller是 @RestController
@RequestMapping(value = "/test/testPageHelper", method = RequestMethod.GET)
public Obect testPageHelper() {
theadIdOfSetThreadLocal = Thread.currentThread().getId();
System.out.println("当前线程id是:" + theadIdOfSetThreadLocal);
PageHelper.startPage(1, 10);
System.out.println("获取绑定在线程的变量===>" + PageHelper.getLocalPage());
return null;
}
3、执行出错
@RequestMapping(value = "/test/testPageHelper2", method = RequestMethod.GET)
public ResponseDTO<Object> testPageHelper2() {
System.out.println("是否有===>" + PageHelper.getLocalPage());
long curThreadId = Thread.currentThread().getId();
System.out.println("当前线程id===>" + curThreadId);
if (curThreadId == theadIdOfSetThreadLocal) {
System.out.println("tomcat的线程池复用了");
}
User cond = new User();
// 情况一:这条语句不带limit,不会出错,但是会查出分页数据,即查不出全部数据
// Object obj = userMapper.select(cond);
// 情况二:这条sql语句本身带了limit,分页追加limit后会有2个limit,会出错
Object obj = userMapper.testLimit(1);
return new ResponseDTO<>(obj);
}
可以看到,查询的sql语句被改造了。如果此时sql语句是带limit的,就会出现sql语法错误!当再调用一次 /test/testPageHelper2
就能正常查询了,这是因为 Object obj = userMapper.testLimit();
执行不管成功还是失败,都会触发PageHelper.clearPage(),第二次查的时候线程绑定的分页信息就被移除了!
4、补充一些出错场景
例 1
@RequestMapping(value = "/test/testPageHelper2", method = RequestMethod.GET)
public ResponseDTO<Object> testPageHelper2() {
System.out.println("是否有===>" + PageHelper.getLocalPage());
long curThreadId = Thread.currentThread().getId();
System.out.println("当前线程id===>" + curThreadId);
if (curThreadId == theadIdOfSetThreadLocal) {
System.out.println("tomcat的线程池复用了");
}
User cond = new User();
// 异常导致未进入mapper方法,ThreadLocal的变量未能清除
int i = 8/0;
//userMapper = null;
Object obj = userMapper.select(cond);
return new ResponseDTO<>(obj);
}
无论是int i = 8/0;
还是 userMapper = null;
,将导致未进入mapper方法,ThreadLocal的变量未能清除
例 2
@RequestMapping(value = "/test/testPageHelper2", method = RequestMethod.GET)
public ResponseDTO<Object> testPageHelper2() {
System.out.println("是否有===>" + PageHelper.getLocalPage());
long curThreadId = Thread.currentThread().getId();
System.out.println("当前线程id===>" + curThreadId);
if (curThreadId == theadIdOfSetThreadLocal) {
System.out.println("tomcat的线程池复用了");
}
// 由于接受参数是primitive类型,null转不了int报错,并未进入mybatis拦截器,所以并不会移除线程的分页数据,当再次进入该方法依然可以获取到绑定在线程的数据
Integer limit = null;
Object obj = userMapper.testLimit(limit);
return new ResponseDTO<>(obj);
}
// userMapper.testLimit 的声明
@Select("SELECT * FROM user LIMIT #{limit}")
User testLimit(int limit);
由于接受参数是primitive类型,null转不了int报错,并未进入mybatis拦截器,所以并不会移除线程的分页数据,当再次进入该方法依然可以获取到绑定在线程的数据
例 3
@RequestMapping(value = "/test/testPageHelper2", method = RequestMethod.GET)
public ResponseDTO<Object> testPageHelper2() {
System.out.println("是否有===>" + PageHelper.getLocalPage());
long curThreadId = Thread.currentThread().getId();
System.out.println("当前线程id===>" + curThreadId);
if (curThreadId == theadIdOfSetThreadLocal) {
System.out.println("tomcat的线程池复用了");
}
// 下面这种情况虽然传了null,但 `userMapper.select(cond)` 接收的时候并不会报错,只是接收后从cond中get字段的时候报错,这时候已经进入了mybatis拦截器,即使异常,也会进入finally块移除线程变量
User cond = null;
Object obj = userMapper.select(cond);
return new ResponseDTO<>(obj);
}
// userMapper.select(cond) 的声明
@SelectProvider(type = SqlTemplate.class,method = "select")
List<T> select(T record);
这种情况虽然传了null,但 userMapper.select(cond)
接收的时候并不会报错,只是接收后从cond中get字段的时候报错,这时候已经进入了mybatis拦截器,即使异常,也会进入finally块移除线程变量