1. 为什么需要自定义分页查询
在传统的MyBatisPlus分页查询中,我们通常使用Page对象配合selectPage方法实现单表分页查询。这种标准用法对于简单的业务场景已经足够,但当遇到多表关联查询时,就会暴露出明显的局限性。
我去年在开发一个电商后台系统时就遇到了这个问题。商品列表需要展示商品基本信息、分类名称、品牌名称等多个关联表的数据。如果按照常规做法,先分页查询商品表,再循环查询关联表,性能会非常糟糕。N+1查询问题导致页面加载时间超过5秒,完全无法接受。
MyBatisPlus默认的分页实现原理是在SQL层面添加LIMIT和OFFSET(或对应数据库方言)。这种机制对于单表查询很高效,但遇到多表JOIN时就会产生问题:
- 分页是在JOIN之后进行的,如果关联表数据量大,即使主表只有少量数据,也会生成巨大的临时结果集
- 分页偏移量(OFFSET)越大,性能下降越明显
- 某些复杂查询可能无法直接使用MyBatisPlus提供的分页接口
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 多表联查分页的三种实现方案
2.1 方案对比与选型
在解决多表联查分页问题时,我们主要有三种技术路线可选:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 内存分页 | 实现简单,代码改动少 | 数据量大时内存消耗高 | 小数据量(<1000条) |
| 子查询分页 | 数据库层面优化,性能较好 | SQL复杂度高 | 中等数据量 |
| 游标分页 | 性能最优,无偏移量问题 | 实现复杂,不支持随机跳页 | 大数据量,连续翻页 |
经过性能测试,在10万级数据量下,三种方案的查询耗时对比如下:
- 内存分页:1200ms
- 子查询分页:350ms
- 游标分页:80ms
2.2 核心实现思路
无论采用哪种方案,自定义分页的核心实现步骤都是:
- 创建自定义Page对象继承MyBatisPlus的Page
- 编写包含JOIN的SQL语句
- 实现分页逻辑(内存/数据库层面)
- 处理结果集映射
这里以最常用的子查询分页方案为例,展示具体实现过程。
3. 完整实现步骤
3.1 环境准备
首先确保项目中已正确引入MyBatisPlus依赖(以Spring Boot为例):
xml复制<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
3.2 自定义Page类
创建自定义分页类,继承MyBatisPlus的Page:
java复制public class JoinQueryPage<T> extends Page<T> {
// 添加多表查询需要的额外参数
private String categoryName;
private String brandName;
// getters & setters
}
3.3 Mapper接口定义
在Mapper接口中定义自定义分页查询方法:
java复制@Mapper
public interface ProductMapper extends BaseMapper<Product> {
List<ProductWithDetail> selectProductWithDetailPage(JoinQueryPage<ProductWithDetail> page);
// 使用@Select注解定义SQL
@Select("SELECT p.*, c.name as category_name, b.name as brand_name " +
"FROM product p " +
"LEFT JOIN category c ON p.category_id = c.id " +
"LEFT JOIN brand b ON p.brand_id = b.id " +
"WHERE p.id IN (SELECT id FROM product WHERE #{ew.sqlSegment} LIMIT #{page.current}, #{page.size})")
List<ProductWithDetail> selectProductWithDetailPage(@Param("page") JoinQueryPage<ProductWithDetail> page,
@Param("ew") Wrapper<Product> wrapper);
}
3.4 Service层实现
java复制@Service
@RequiredArgsConstructor
public class ProductServiceImpl implements ProductService {
private final ProductMapper productMapper;
@Override
public JoinQueryPage<ProductWithDetail> getProductPage(JoinQueryPage<ProductWithDetail> page) {
// 构建查询条件
LambdaQueryWrapper<Product> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Product::getStatus, 1);
// 执行查询
List<ProductWithDetail> records = productMapper.selectProductWithDetailPage(page, wrapper);
page.setRecords(records);
// 设置总记录数
int total = productMapper.selectCount(wrapper);
page.setTotal(total);
return page;
}
}
3.5 Controller层调用
java复制@RestController
@RequestMapping("/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@GetMapping
public Result<JoinQueryPage<ProductWithDetail>> listProducts(
@RequestParam(defaultValue = "1") int current,
@RequestParam(defaultValue = "10") int size) {
JoinQueryPage<ProductWithDetail> page = new JoinQueryPage<>(current, size);
return Result.success(productService.getProductPage(page));
}
}
4. 性能优化与注意事项
4.1 SQL优化技巧
-
索引优化:确保JOIN字段和WHERE条件字段都有合适索引
sql复制ALTER TABLE product ADD INDEX idx_category (category_id); ALTER TABLE product ADD INDEX idx_status (status); -
**避免SELECT ***:只查询需要的字段
sql复制-- 不推荐 SELECT p.*, c.*, b.* FROM ... -- 推荐 SELECT p.id, p.name, c.name as category_name, b.name as brand_name FROM ... -
分页子查询优化:使用覆盖索引
sql复制SELECT p.* FROM product p JOIN (SELECT id FROM product WHERE status = 1 LIMIT 10000, 10) tmp ON p.id = tmp.id
4.2 常见问题排查
问题1:分页结果不正确
- 检查子查询和主查询的条件是否一致
- 确认totalCount查询条件与分页查询一致
问题2:性能突然下降
- 检查是否出现了全表扫描(EXPLAIN分析SQL)
- 大数据量时考虑使用游标分页代替传统分页
问题3:MyBatis映射失败
- 确保结果集字段名与实体类属性名匹配
- 复杂映射可以使用
@ResultMap注解
5. 高级应用场景
5.1 动态表名分页
在SAAS系统中,经常需要根据租户动态切换表名。我们可以通过自定义SQL解析器实现:
java复制public class DynamicTableNameParser implements ISqlParser {
@Override
public void processSelect(Select select, Object o) {
// 替换SQL中的表名
select.getFrom().setTable(new Table("tenant_" + TenantContext.getCurrentTenant()));
}
}
// 配置注入
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new DynamicTableNameParser());
return interceptor;
}
5.2 多数据源分页
在多数据源环境下,需要特别注意分页方言的配置:
yaml复制spring:
datasource:
db1:
url: jdbc:mysql://localhost:3306/db1
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
default-scripting-language: org.apache.ibatis.scripting.xmltags.XMLLanguageDriver
variables:
db1: db1
db2:
url: jdbc:postgresql://localhost:5432/db2
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
variables:
db2: db2
然后在分页拦截器中指定方言:
java复制@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
PaginationInnerInterceptor paginationInnerInterceptor = new PaginationInnerInterceptor();
paginationInnerInterceptor.setDbType(DbType.POSTGRE_SQL); // 设置方言
interceptor.addInnerInterceptor(paginationInnerInterceptor);
return interceptor;
}
5.3 结果集自定义处理
有时我们需要对分页结果进行后处理:
java复制public JoinQueryPage<ProductWithDetail> getProductPage(JoinQueryPage<ProductWithDetail> page) {
// ...查询逻辑
// 结果集后处理
page.getRecords().forEach(product -> {
// 添加计算字段
product.setDiscountPrice(calculateDiscount(product.getPrice()));
// 转换日期格式
product.setCreateTimeStr(DateUtil.format(product.getCreateTime(), "yyyy-MM-dd"));
});
return page;
}
6. 替代方案与未来演进
虽然本文主要介绍MyBatisPlus方案,但在某些场景下,其他技术可能更合适:
-
JPA的Specification:适合简单关联查询
java复制public Page<Product> findAll(Specification<Product> spec, Pageable pageable) { return productRepository.findAll(spec, pageable); } -
QueryDSL:类型安全的复杂查询
java复制JPAQuery<Product> query = new JPAQuery<>(entityManager); List<Product> products = query.from(qProduct) .leftJoin(qCategory).on(qProduct.categoryId.eq(qCategory.id)) .where(qProduct.status.eq(1)) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .fetch(); -
MyBatis动态SQL:最灵活但维护成本高
xml复制<select id="selectProductPage" resultMap="productDetailMap"> SELECT p.*, c.name as category_name FROM product p LEFT JOIN category c ON p.category_id = c.id <where> <if test="status != null"> AND p.status = #{status} </if> <if test="categoryId != null"> AND p.category_id = #{categoryId} </if> </where> LIMIT #{offset}, #{pageSize} </select>
随着Spring Data项目的发展,未来可能会有更优雅的多表分页解决方案。但目前在生产环境中,经过优化的MyBatisPlus自定义分页仍然是大多数Java项目的稳妥选择。
