接手若依项目,想给列表页加个 MyBatis-Plus 的 LambdaQueryWrapper 条件查询------毕竟 eq()、like() 链式写法比手写 XML 舒服太多,于是引入 MyBatis-Plus。代码跑起来,列表能出数据,可一翻页就出问题:要么第 2 页和第 1 页一模一样,要么 total 永远是 0,要么干脆把整张表全捞出来。
最要命的是:它不报错。就是悄悄查错,等你上线了才发现列表接口把几万行全查出来。
先说结论:分页失效几乎都是同一个原因------若依默认的 PageHelper 分页,和 MyBatis-Plus 的分页拦截器,两个机制在抢同一条 SQL 的分页权。
一、若依默认的分页是怎么做的
若依生成的列表接口,标准写法是 startPage() + getDataTable(list):
bash
@PostMapping("/list")
@ResponseBody
public TableDataInfo list(SysUser user) {
// startPage 配合前端完成自动分页
startPage();
List<SysUser> list = userService.selectUserList(user);
return getDataTable(list);
}
背后是 PageHelper 。startPage() 干的事,是 new 一个 Page 对象塞进 ThreadLocal ;等 MyBatis 执行下一条 SQL 时,PageHelper 的拦截器把这个 Page 拿出来拼上 LIMIT,再把 total 查出来,getDataTable 包成 {total, rows} 返回。
这套东西在纯 MyBatis 环境里没问题。但它的命门就藏在 ThreadLocal 里。
二、一上 MyBatis-Plus,为什么就悄悄失效了
两个坑,都跟这个 ThreadLocal 有关。
坑一:Page 没被消费,污染下一条 SQL。
startPage() 创建 Page 之后,如果后面那条 SQL 因为分支判断没执行(比如 if (user != null) 里才查,user 为 null 就跳过),这个 Page 就留在 ThreadLocal 里没人消费。等下一次请求复用这个线程,第一条 SQL 会被意外套上分页------数据莫名其妙变少,甚至报 LIMIT 语法错误。
坑二:两个分页插件互相抢 SQL。
MyBatis-Plus 自己也带一个分页拦截器 PaginationInnerInterceptor,靠识别 selectPage(new Page<>(current, size), ...) 里的 IPage 参数来拼 LIMIT。若依的 PageHelper 靠 ThreadLocal 里的 Page 来拼。两个拦截器同时挂在 MyBatis 的执行链上,同一个查询被拼两次分页,或者 MP 的分页被 PageHelper 残留的 Page 干扰,结果就是错乱的。
一句话:PageHelper 和 MyBatis-Plus 的分页,只能留一个。 混用必失效,而且是静默失效------这也是它最坑的地方。
三、正确姿势:彻底移除 PageHelper,分页收口到基 类
我的做法就一条原则:分页这件事,全项目只写一次。
第一步,全局只留 MyBatis-Plus 的分页 插件 :
bash
@Configuration
public class MybatisPlusConfig {
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 只保留 MP 自己的分页插件,MySQL 方言
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
// 若项目需要多租户行级隔离,再追加 TenantLineInnerInterceptor
return interceptor;
}
}
第二步,分页参数统一收口到一个基类,每个查询 DTO 继承它,默认值给好:
bash
@Data
public class BaseQuery {
// 当前页,从 1 开始
private long current = 1;
// 每页大小
private long size = 10;
}
第三步,service 层同样抽一个基类,分页逻辑只写一次:
bash
public abstract class BaseServiceImpl<M extends BaseMapper<E>, E extends BaseEntity,
Q extends BaseQuery, C, U extends BaseUpdateRequest>
extends ServiceImpl<M, E> {
// 子类只实现查询条件构造
protected abstract LambdaQueryWrapper<E> buildWrapper(Q query);
public IPage<E> page(Q query) {
// 分页参数直接进 Page 对象,绕开 ThreadLocal 那套
return baseMapper.selectPage(new Page<>(query.getCurrent(), query.getSize()), buildWrapper(query));
}
}
这里 C、U 是新增/更新 DTO 的泛型(与分页无关,一并列出保持签名完整)。Controller 直接调 page(),返回 ApiDataResult<IPage<E>>:
bash
@RestController
@RequestMapping("/system/user")
public class SysUserController {
private final SysUserService userService;
@GetMapping("/page")
public ApiDataResult<IPage<SysUser>> page(SysUserQuery query) {
// 返回结构:ApiDataResult { code, msg, data: IPage{records, total} }
return ApiDataResult.success(userService.page(query));
}
}
selectPage 返回的 IPage 自带 records(本页数据)和 total(总数)。前端这边注意:res 是请求封装返回的 ApiDataResult(axios 已解包一层 response.data),所以 res.data 才是 IPage,records、total 挂在它下面:
bash
const res = await pageSysUser(params)
list.value = res.data.records
total.value = res.data.total
四、配套:通用 CRUD 基类 + 生成器,让新模块只写差异
分页收口到基类之后,还能再进一步:新增、更新、删除、状态切换这些通用逻辑也一并收进基类,子类只剩两处差异代码------buildWrapper()(查询条件)和 newEntity()(new 一个实体)。
于是每新增一个业务模块, 代码生成 器只要吐出几十行差异代码,分页、CRUD 全都不用重写。这比若依默认生成的那一套 controller + service + impl + mapper 的复制粘贴干净得多,也彻底没有「每个模块各写一遍分页」的重复劳动。
bash
@Override
protected LambdaQueryWrapper<SysUser> buildWrapper(SysUserQuery query) {
return new LambdaQueryWrapper<SysUser>()
// 有值才拼条件,避免把空字符串当查询条件
.like(StringUtils.hasText(query.getName()), SysUser::getName, query.getName())
.orderByDesc(SysUser::getCreateTime);
}
五、两个细节提醒
- 默认值一定给 :
current = 1、size = 10写死在基类里,前端不传也能翻出第一页,接口不会因为缺参数直接报错。 - 前端翻页记得重置页码 :点「查询」时把
current归 1 再请求,否则在第 5 页点搜索,条件变了还停在第 5 页,很容易出现「翻页了但数据没变」的误判。
收尾
若依 + MyBatis-Plus 分页失效,本质不是「哪个插件更好」,而是两个分页机制都在抢同一条 SQL,抢来抢去就错了。解决思路就一条:分页只留一个入口,参数收口、逻辑收口,全项目只写一次。
如果你也在这条路上踩过坑,欢迎评论区聊聊:你是直接弃了 PageHelper,还是想办法让两个插件共存了?