一对多分页的经典坑:先分页后映射问题
"查第 2 页的 10 个用户,每人带上订单"是后台最常见需求,却最容易翻车:直接 LIMIT 分页再一对多映射,页大小、总数全不对。本节讲清为什么错,以及"先分页后映射"的正确姿势。
为什么直接分页会错
错误直觉是 join 子表后 LIMIT:
SELECT u.id, u.name, o.id AS oid, o.amount
FROM user u LEFT JOIN `order` o ON o.user_id = u.id
ORDER BY u.id LIMIT 10, 10;
join 后"一行 = 一个订单",LIMIT 作用在订单行上,后果有三:
- 一页本应是 10 个用户,结果可能只剩 3 个用户,订单还被截断;
- 同一个用户可能跨页重复出现(父行被 LIMIT 切开);
- COUNT(*) 统计的是订单行数,总页数完全失真。
MyBatis 的 collection 按父对象组装行,join 产生的重复父行先被去重,页面与总数自然对不上——根因是"LIMIT 分页发生在一对多映射之前"。
正确姿势一:先分页主表,再批量查子表
拆成两步最通用,也最好优化:
// 第一步:只分页查用户主表(LIMIT 作用在 user 上)
Page<User> page = userMapper.pageUsers(pageNum, pageSize);
// 第二步:收集本页用户 id,一次 IN 查出全部订单
List<Long> ids = page.getRecords().stream().map(User::getId).toList();
List<Order> orders = orderMapper.selectByUserIds(ids);
// 第三步:内存按 userId 分组,挂到对应用户下
Map<Long, List<Order>> map = orders.stream()
.collect(Collectors.groupingBy(Order::getUserId));
page.getRecords().forEach(u ->
u.setOrders(map.getOrDefault(u.getId(), List.of())));
三步共三条 SQL(含 COUNT)解决;订单量大时,IN 列表可再分批查询。
正确姿势二:子查询限定主表
想让数据库一次完成,先算出"本页主表 id"再取数据(MySQL 8.0 写法):
SELECT u.* FROM user u
WHERE u.id IN (
SELECT id FROM user ORDER BY id LIMIT 10, 10 -- 先定本页用户
)
ORDER BY u.id;
随后订单表 WHERE user_id IN (...) 一次查出。注意并非所有数据库都允许子查询内 LIMIT,跨库先验证或以官方文档为准。
分页插件与 MP 的注意
PageHelper、MyBatis-Plus 分页插件只对主查询做 COUNT 与 LIMIT 改写,无法替你解决 join 后的一对多分页。用分页插件时主查询应只查主表,子集合用第二次查询拼装;偷懒用嵌套 select 懒加载会出现 N+1(见懒加载一章),细节以插件官方文档为准。
结论一句话:一对多分页永远"先分页主表,再批量取子表"。让 LIMIT 先作用在父行上,再 IN 查询子表挂载,页数与数据才会同时正确。