奇葩的现象
天和日丽的一个早上,客服反馈线上客户开通租户后,在系统上只能查看到部分的菜单,我大感诧异,排查后数据,发现初始化租户套餐对应的功能菜单列表,只插入了五十个菜单,但是实际上套餐对应的功能菜单列表是有一百多个的,虾米...
让测试同事测试了客户的操作场景,发现生成的租户菜单数据是正常,那就说明这个是特定场景偶现的,开通租户的菜单业务流程,就是从全局的菜单库拉取一份同步到新开通的租户菜单表,代码很简单,如下
@ApiOperation(value = "feign-根据菜单ID集合查询所有菜单功能列表")
@PostMapping("/findListByMenuIds")
public ResultVO<List<MenuFunVO>> findListByMenuIds(@RequestBody List<String> menuIds){
List<MenuFunVO> menuFunList = this.menuFunService.findListByMenuIds(menuIds);
return RV.success(menuFunList);
}
接口代码也没分页,本地调试一直正常,但是上到生产偶现异常现象,偶现只查到默认分页前五十条的数据。
定位原因
系统运用的PageHelper工具是能力平台集成的框架,分页参数为空时,开启分页,分页参数使用默认配置,默认获取第一页,前五十条的数据,这不刚好对应前面的奇葩现象?每次都精准地只插入五十条记录,定位原因是PageHelper分页出现了异常。
/**
* 创建mybatis分页对象
* @return mybatis分页对象
*/
protected <T> Page<T> startPage(){
int pageNumInt = CharacterUtils.parseInt(request.getParameter("pageNum"),1);
int pageSizeInt = CharacterUtils.parseInt(request.getParameter("pageSize"),50);
boolean isCount = CharacterUtils.parseBoolean(request.getParameter("isCount"),true);
return PageHelper.startPage(pageNumInt, pageSizeInt, isCount);
}
PageHelper是怎么做到上面的问题的?
PageHelper的分页实现逻辑
PageHelper其实就是Mybatis的分页插件,其实现原理就是通过拦截器的方式,PageHelper通PageInterceptor实现分页效果,其效果就是拦截我们需要分页的SQL语句,然后在其后面拼接分页参数,实现分页效果。
基于以上原理,我们在使用PageHelper只需要关注PageHelper的startPage()方法,只需要开启了分页,查询语句就实现了分页效果。
那startPage干了啥
protected void startPage(){
// 通过request去获取前端传递的分页参数,不需控制器要显示接收
PageDomain pageDomain = TableSupport.buildPageRequest();
Integer pageNum = pageDomain.getPageNum();
Integer pageSize = pageDomain.getPageSize();
if (StringUtils.isNotNull(pageNum) && StringUtils.isNotNull(pageSize))
{
String orderBy = SqlUtil.escapeOrderBySql(pageDomain.getOrderBy());
Boolean reasonable = pageDomain.getReasonable();
// 真正使用pageHelper进行分页的位置
PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable);
}
}
PageHelper.startPage(pageNum, pageSize, orderBy).setReasonable(reasonable)的参数分别是:
- pageNum:页数
- pageSize:每页数据量
- orderBy:排序
- reasonable:分页合理化,对于不合理的分页参数自动处理,比如传递pageNum是小于0,会默认设置为1.
继续跟踪,连续点击startpage构造方法到达如下位置:
/**
* 开始分页
*
* @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);
// 1、获取本地分页
Page<E> oldPage = getLocalPage();
if (oldPage != null && oldPage.isOrderByOnly()) {
page.setOrderBy(oldPage.getOrderBy());
}
// 2、设置本地分页
setLocalPage(page);
return page;
}
到达终点位置了,分别是:getLocalPage()和setLocalPage(page),分别来看下:
getLocalPage()
进入方法:
/**
* 获取 Page 参数
*
* @return
*/
public static <T> Page<T> getLocalPage() {
return LOCAL_PAGE.get();
}
看看常量LOCAL_PAGE是个什么东西?
protected static final ThreadLocal<Page> LOCAL_PAGE = new ThreadLocal<Page>();
好家伙,是ThreadLocal,独属于每个线程的本地缓存对象。当一个请求来的时候,会获取持有当前请求的线程的ThreadLocal,调用LOCAL_PAGE.get(),会查看当前线程是否有未执行的分页配置。
setLocalPage(page)
此方法显而易见,设置线程的分页配置:
protected static void setLocalPage(Page page) {
LOCAL_PAGE.set(page);
}
总结原因
经过前面的分析,我们发现,问题似乎就是这个ThreadLocal导致的。是否在使用完之后没有进行清理?导致下一次此线程再次处理请求时,还在使用之前的配置? 我们带着疑问,看看mybatis时如何使用pageHelper的。
PageHelper源码分析
我们需要关注的就是Mybatis在何时使用的这个ThreadLocal,也就是何时将分页参数获取到的。
前面提到过,我们使用PageHelper,是通过在代码使用PageHelper.startPage()方法,然后PageHelper的startPage()方法会进行page缓存的设置,当程序执行sql接口mapper的方法时,就会被PageInterceptor拦截到。
前面说过,PageHelper是mybatis的分页插件,其实现原理是通过拦截器的方式,PageHelper通过PageInterceptor实现分页效果,那么我们只关注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();
//对 boundSql 的拦截处理
if (dialect instanceof BoundSqlInterceptor.Chain) {
boundSql = ((BoundSqlInterceptor.Chain) dialect).doBoundSql(BoundSqlInterceptor.Type.ORIGINAL, boundSql, cacheKey);
}
List resultList;
//调用方法判断是否需要进行分页,如果不需要,直接返回结果
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);
} finally {
if(dialect != null){
dialect.afterAll();
}
}
}
如上所示是intecept的全部代码,我们下面只关注几个终点位置:
设置分页:dialect.skip(ms, parameter, rowBounds)
此处的skip方法进行设置分页参数,方法:
Page page = pageParams.getPage(parameterObject, rowBounds);
继续跟踪getPage(),发现此方法的第一行就获取了ThreadLocal的值:
Page page = PageHelper.getLocalPage();
统计数量:dialect.beforeCount(ms, parameter, rowBounds)
我们都知道,分页需要获取记录总数,所以,这个拦截器会在分页前先进行count操作。 如果count为0,则直接返回,不进行分页:
//处理查询总数,返回 true 时继续分页查询,false 时直接返回
if (!dialect.afterCount(count, parameter, rowBounds)) {
//当查询总数为 0 时,直接返回空的结果
return dialect.afterPage(new ArrayList(), parameter, rowBounds);
}
afterPage其实是对分页结果的封装方法,即使不分页,也会执行,只不过返回空列表。
分页:ExecutorUtil.pageQuery
在处理完count方法后,就是真正的进行分页了:
resultList = ExecutorUtil.pageQuery(dialect, executor,
ms, parameter, rowBounds, resultHandler, boundSql, cacheKey);
此方法在执行分页之前,会判断是否执行分页,依据就是前面我们通过ThreadLocal的获取的page。 当然,不分页的查询,以及新增和更新不会走到这个方法当中。
非分页:executor.query
而是会走到下面的这个分支:
resultList = executor.query(ms, parameter, rowBounds, resultHandler, cacheKey, boundSql);
我们可以思考一下,如果ThreadLoad在使用后没有被清除,当执行非分页的方法时,那么就会将Limit拼接到sql后面。
为什么不分也得也会拼接?我们回头看下前面提到的dialect.skip(ms, parameter, rowBounds):
public boolean skip(MappedStatement ms, Object parameterObject, RowBounds rowBounds) {
if (ms.getId().endsWith("_COUNT")) {
throw new RuntimeException("在系统中发现了多个分页插件,请检查系统配置!");
} else {
Page page = this.pageParams.getPage(parameterObject, rowBounds);
if (page == null) {
return true;
} else {
if (StringUtil.isEmpty(page.getCountColumn())) {
page.setCountColumn(this.pageParams.getCountColumn());
}
this.autoDialect.initDelegateDialect(ms);
return false;
}
}
}
如上所示,只要page被获取到了,那么这个sql,就会走前面提到的ExecutorUtil.pageQuery分页逻辑,最终导致出现不可预料的情况。 其实PageHelper对于分页后的ThreaLocal是有清除处理的。
清除TheadLocal
在intercept方法的最后,会在sql方法执行完成后,清理page缓存:
finally {
if(dialect != null){
dialect.afterAll();
}
}
看看这个afterAll()方法:
@Override
public void afterAll() {
//这个方法即使不分页也会被执行,所以要判断 null
AbstractHelperDialect delegate = autoDialect.getDelegate();
if (delegate != null) {
delegate.afterAll();
autoDialect.clearDelegate();
}
clearPage();
}
只关注 clearPage():
/**
* 移除本地变量
*/
public static void clearPage() {
LOCAL_PAGE.remove();
}
到此为止,关于PageHelper的运行已经分析完了, 整体看下来,似乎不会存在什么问题,但是我们可以考虑几种极端情况:
- 如果使用了
startPage()
,但是没有执行对应的sql,那么就表明,当前线程ThreadLocal被设置了分页参数,可是没有被使用,当下一个使用此线程的请求来时,就会出现问题。 - 如果程序在执行sql前,发生异常了,就没办法执行finally当中的
clearPage()
方法,也会造成线程的ThreadLocal被污染。
所以,官方给我们的建议,在使用PageHelper进行分页时,执行sql的代码要紧跟startPage()方法。
除此之外,我们可以手动调用 clearPage()
方法,在存在问题的方法之前手动调用 `clearPage()方法。
需要注意:不要分页的方法前手动调用clearPage,将会导致你的分页出现问题。
最终原因总结
@ApiOperation(value = "维保数据-详情-分页查询电梯模块列表")
@GetMapping("/detail/elevator/page")
public ResultVO<List<MaintenanceDataDetailElevatorVO>> findDetailElevatorPage(MaintenanceDataDetailElevatorPageDTO dto) {
Page page = super.startPage();
dto.setPageNum(page.getPageNum());
dto.setPageSize(page.getPageSize());
return maintenanceDataService.findDetailElevatorListByParams(dto);
}
@Override
public ResultVO<List<MaintenanceDataDetailElevatorVO>> findDetailElevatorListByParams(MaintenanceDataDetailElevatorPageDTO dto) {
ThrowExceptionUtil.isBlank(dto.getTabType(), ErrCode.ERROR_CODE113);
// 根据维保数据tab类型,获取目标服务的数据
ResultVO<List<MaintenanceDataDetailElevatorVO>> resultVO = maintenanceClient.getMaintenanceDataDetailForElevator(dto);
return resultVO;
}
在远程调用中, Page page = super.startPage();
接口开启了PageHelper.startPage(),但内部实现maintenanceClient.getMaintenanceDataDetailForElevator(dto);
是传递分页参数去调用远程服务接口,PageHelper.startPage()后,没有紧跟要查询的sql,自然就走不到前面说的PageHelper.startPage()之后的流程,clearPage()
方法没被调用,当前线程就带着分页参数缓存了;同理下面的代码写法也是一样的问题:
@ApiOperation(value = "任务管理-分页列表")
@GetMapping("/page")
public ResultVO<List<TaskManageVO>> findPage(TaskManageQueryVO info) {
Page page = super.startPage();
info.setUserId(UserThreadLocal.getUserId());
info.setTenantId(UserThreadLocal.getTenantId());
return taskManageService.findListByParams(info, page.getPageSize(), page.getPageNum());
}
@Override
public ResultVO<List<TaskManageVO>> findListByParams(TaskManageQueryVO info, Integer pageSize, Integer pageNum) {
Criteria criteria = new Criteria();
this.setQueryFiled(criteria, info);
Query query = new Query(criteria);
Long total = mongoTemplate.count(query, TaskManage.class);
ResultVO vo = new ResultVO(ErrCode.SUCCESS.value());
if (pageNum != null && pageSize != null) {
query.skip((pageNum - 1) * pageSize);
query.limit(pageSize);
}
query.with(new Sort(Sort.Direction.DESC, "createTime"));
List<TaskManage> lists = mongoTemplate.find(query, TaskManage.class, TASK_MANAGE_COLL);
vo.setPageNum(pageNum);
vo.setPageSize(pageSize);
vo.setTotal(total);
vo.setResult(BeanUtils.copyList(lists, TaskManageVO.class));
return vo;
}
调用PageHelper.startPage(),但是后面跟着的是MongoDB的查询语句,PageHelper是基于Mybatis的,所以自然也是有着上面远程调用的问题。
那为啥问题是偶现的呢?
这个其实取决于启动服务所使用的容器,比如tomcat,在其内部处理请求是通过线程池的方式。甚至现在的很多容器是基于netty的,都是通过线程池,复用线程来增加服务的并发量以及减少创建线程、销毁线程所带来的资源开销。
场景复现
假设线程1持有没有被清除的page参数,不断调用同一个方法,后面两个请求使用的是线程2和线程3没有问题,再一个请求轮到线程1了,此时就会出现上面异常分页的问题了,所以之前说本地调试一直没问题,那肯定了,一直都是正常线程在调用,而生产环境,可能有用户调用了异常PageHelper用法的接口,导致问题出现了。
解决措施
调用PageHelper.startPage()后,后面要紧跟着要查询的sql语句,远程方法分页查询、MongoDB分页查询等,要把分页参数封装在实体,在实体里取值,不默认使用能力平台封装的工具类。
@ApiOperation(value = "任务管理-分页列表")
@GetMapping("/page")
public ResultVO<List<TaskManageVO>> findPage(TaskManageQueryVO info) {
if (ObjectUtil.isNull(info.getPageSize()) && ObjectUtil.isNull(info.getPageNum())) {
info.setPageNum(1);
info.setPageSize(50);
}
info.setUserId(UserThreadLocal.getUserId());
info.setTenantId(UserThreadLocal.getTenantId());
return taskManageService.findListByParams(info, info.getPageSize(), info.getPageNum());
}