传统的JOIN查询容易导致致的数据膨胀和分页问题,对于该问题的解决方案就是:将主表查询与关联表查询分离,通过 Stream API 高效组装数据。
方案对比
方案 A:传统 JOIN 查询
┌─────────────────────────────────────────────────────────────┐ │ SQL: SELECT * FROM orders o │ │ LEFT JOIN order_detail od ON od.order_id = o.id │ │ LIMIT 0, 10 │ ├─────────────────────────────────────────────────────────────┤ │ │ │ 数据库返回: │ │ ┌──────┬──────┬─────────┬──────────┬──────────┬──────────┐ │ │ │ 订单1 │ 详情1 │ (重复) │ (重复) │ (重复) │ (重复) │ │ │ │ 订单1 │ 详情2 │ (重复) │ (重复) │ (重复) │ (重复) │ │ │ │ 订单1 │ 详情3 │ (重复) │ (重复) │ (重复) │ (重复) │ │ │ │ 订单2 │ 详情1 │ (重复) │ (重复) │ (重复) │ (重复) │ │ │ │ ... │ ... │ │ │ │ │ │ │ └──────┴──────┴─────────┴──────────┴──────────┴──────────┘ │ │ │ │ 问题: │ │ ❌ 返回 30 行(3个订单×每个10个详情) │ │ ❌ 但实际只有 3 个独立订单! │ │ ❌ PageHelper 错误地认为有 30 条记录 │ │ ❌ 分页结果不准确! │ │ │ └─────────────────────────────────────────────────────────────┘方案 B:当前分离查询方案
┌─────────────────────────────────────────────────────────────┐ │ Step 1: 查询主表(分页) │ │ │ │ SQL: SELECT * FROM orders LIMIT 0, 10 │ │ │ │ 返回: [订单1, 订单2, 订单3] ← 精确的 3 条!✅ │ │ │ ├─────────────────────────────────────────────────────────────┤ │ Step 2: 批量提取ID │ │ │ │ orderIds = [1, 2, 3] │ │ │ ├─────────────────────────────────────────────────────────────┤ │ Step 3: 批量查询详情 │ │ │ │ SQL: SELECT * FROM order_detail │ │ WHERE order_id IN (1, 2, 3) │ │ │ │ 返回: [详情1-1, 详情1-2, ..., 详情3-10] ← 所有详情 │ │ │ ├─────────────────────────────────────────────────────────────┤ │ Step 4 & 5: 内存中组装 │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ 订单1 │ │ │ │ ├─ orderDetailList: [详情1-1, 详情1-2, ...] │ │ │ │ └─ orderDishes: "宫保鸡丁、麻婆豆腐、..." │ │ │ ├─────────────────────────────────────────────────────┤ │ │ │ 订单2 │ │ │ │ ├─ orderDetailList: [详情2-1, 详情2-2, ...] │ │ │ │ └─ orderDishes: "红烧肉、糖醋里脊、..." │ │ │ ├─────────────────────────────────────────────────────┤ │ │ │ 订单3 │ │ │ │ ├─ orderDetailList: [详情3-1, ...] │ │ │ │ └─ orderDishes: "鱼香肉丝、..." │ │ │ └─────────────────────────────────────────────────────┘ │ │ │ │ ✅ 分页准确! │ │ ✅ 数据完整! │ │ ✅ 性能优秀! │ │ │ └─────────────────────────────────────────────────────────────┘以上是两种方案的思路对比,对于分离查询优化策略,有四大优势体现出来。
四大优势:
优势1:解决分页数据膨胀问题(最关键!)
问题场景:
-- 传统 JOIN 查询 SELECT o.*, od.* FROM orders o LEFT JOIN order_detail od ON od.order_id = o.id WHERE ... LIMIT 0, 10; -- 希望获取前 10 个订单实际情况:
| 订单数 | 平均详情数 | 实际返回行数 | 预期行数 |
|---|---|---|---|
| 10 | 5 | 50 行 | 10 行 |
| 10 | 10 | 100 行 | 10 行 |
| 10 | 20 | 200 行 | 10 行 |
后果:
// PageHelper 会错误地认为: page.getTotal() = 200 // ❌ 错误!(应该是 10) page.getResult().size() = 200 // ❌ 应该是 10 个订单对象 // 前端分页组件会显示错误的总页数! // 用户点击第 2 页时可能看到重复数据或遗漏数据解决方案效果:
// 当前方案:精确的分页 try (Page<OrderVO> page = PageHelper.startPage(1, 10)) { List<OrderVO> orders = orderMapper.queryOrders(dto); // 只查主表 // page.getTotal() = 真实的订单总数(如 156) // orders.size() = 精确的 10 个订单对象 ✅ }优势2:减少网络传输和内存占用
数据量对比:
假设查询 20 个订单,每个订单平均 8 个详情项:
传统 JOIN 方案:
传输数据量: = 20 个订单 × (8 个详情 + 20 个订单字段) = 160 行完整数据 每行包含: - 订单字段: ~500 字节 - 详情字段: ~200 字节 总计: 160 × 700 字节 = 112,000 字节 ≈ 109 KB分离查询方案:
Step 1 - 主表查询: = 20 行订单数据 = 20 × 500 字节 = 10,000 字节 ≈ 9.8 KB Step 2 - 详情查询: = 160 行详情数据 = 160 × 200 字节 = 32,000 字节 ≈ 31 KB 总计传输: 9.8 + 31 = 40.8 KB ≈ 40 KB节省比例:
节省空间 = (109 - 40) / 109 = 63.3% ↓ 网络传输减少 63%! 内存占用减少 63%!优势3:避免数据冗余和重复反序列化
传统 JOIN 的数据冗余示例:
[ { "id": 1, "number": "20240701001", "userId": 5, "status": 3, "amount": 158.00, "orderTime": "2024-07-01 12:00:00", // ... 其他 15 个字段全部重复 ... "detailId": 101, "dishName": "宫保鸡丁", "detailAmount": 38.00 }, { "id": 1, // ⚠️ 重复! "number": "20240701001", // ⚠️ 重复! "userId": 5, // ⚠️ 重复! // ... 所有订单字段都重复 ... "detailId": 102, "dishName": "麻婆豆腐", "detailAmount": 28.00 }, // 同一个订单的 20 个字段 × 8 个详情 = 160 个字段的冗余! ]分离查询的数据结构:
{ "id": 1, "number": "20240701001", "userId": 5, "status": 3, "amount": 158.00, // ... 订单字段只出现一次 ✅ "orderDetailList": [ {"id": 101, "dishName": "宫保鸡丁", "amount": 38.00}, {"id": 102, "dishName": "麻婆豆腐", "amount": 28.00}, // ... 详情数据独立存储 ], "orderDishes": "宫保鸡丁、麻婆豆腐、..." }优势4:灵活的数据组装能力
Stream API 的强大之处:
需求 1:获取完整的详情对象列表
Map<Long, List<OrderDetail>> detailMap = details.stream() .collect(Collectors.groupingBy(OrderDetail::getOrderId)); // 用途:订单详情弹窗展示所有信息 order.setOrderDetailList(detailMap.get(orderId));输出示例:
"orderDetailList": [ { "id": 101, "name": "宫保鸡丁", "dishId": 12, "number": 1, "amount": 38.00, "image": "/images/dish/12.jpg" }, { "id": 102, "name": "麻婆豆腐", "dishId": 15, "number": 2, "amount": 56.00, "image": "/images/dish/15.jpg" } ]需求 2:提取菜品名称用于列表展示
Map<Long, List<String>> orderDishesMap = details.stream() .collect(Collectors.groupingBy( OrderDetail::getOrderId, Collectors.mapping(OrderDetail::getName, Collectors.toList()) )); // 用途:订单列表快速预览 String dishes = String.join("、", orderDishesMap.get(orderId)); order.setOrderDishes(dishes);输出示例:
"orderDishes": "宫保鸡丁、麻婆豆腐×2、米饭×3"需求 3:还可以轻松扩展其他聚合操作
// 统计每个订单的菜品数量 Map<Long, Long> dishCountMap = details.stream() .collect(Collectors.groupingBy( OrderDetail::getOrderId, Collectors.counting() )); // 计算每个订单的总金额(从详情角度验证) Map<Long, BigDecimal> detailAmountMap = details.stream() .collect(Collectors.groupingBy( OrderDetail::getOrderId, Collectors.reducing(BigDecimal.ZERO, OrderDetail::getAmount, BigDecimal::add) )); // 提取所有菜品ID(用于缓存预热) Set<Long> dishIds = details.stream() .map(OrderDetail::getDishId) .collect(Collectors.toSet());这种灵活性在 JOIN 方案中很难实现,因此正如开头所说,将主表查询与关联表查询分离后利用Stream API 即可高效组装数据。