一、学习目标
- 掌握 MySQL 中常见的多表关联查询方式。
- 理解
INNER JOIN、LEFT JOIN、子查询、聚合查询的使用场景。 - 能够用 MyBatis Plus 和 XML Mapper 编写复杂查询。
- 理解索引的基本原理,知道什么时候该建索引,什么时候索引会失效。
- 学会使用
EXPLAIN分析 SQL 执行计划。 - 掌握慢 SQL 的常见原因与优化思路。
- 能把第40天的订单模块扩展成更接近真实项目的数据查询能力。
二、为什么第41天要学习复杂查询
第40天已经完成了数据库基础、表设计、MyBatis Plus 基础 CRUD 和事务入门。
但真实项目中很少只有简单的 selectById 或 selectList,更多场景是:
- 查询用户的订单列表,同时带上用户昵称。
- 查询订单详情,同时返回订单明细。
- 按状态、时间、金额范围组合筛选订单。
- 统计每天订单数量和销售额。
- 查询销量最高的商品。
- 后台管理系统做复杂分页查询。
- 接口变慢后,需要定位是哪条 SQL 慢。
所以第41天的重点是:从"会查数据"升级到"会写可维护、可优化的查询"。
三、多表关联查询基础
3.1 示例表结构回顾
用户表:
sql
CREATE TABLE users (
id BIGINT NOT NULL AUTO_INCREMENT,
email VARCHAR(128) NOT NULL,
nickname VARCHAR(64) NOT NULL,
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_users_email (email)
);
订单表:
sql
CREATE TABLE orders (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
status VARCHAR(32) NOT NULL,
total_amount DECIMAL(12,2) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_orders_user_id (user_id),
KEY idx_orders_status_created (status, created_at)
);
订单明细表:
sql
CREATE TABLE order_items (
id BIGINT NOT NULL AUTO_INCREMENT,
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INT NOT NULL,
unit_price DECIMAL(12,2) NOT NULL,
PRIMARY KEY (id),
KEY idx_order_items_order_id (order_id)
);
四、JOIN 查询
4.1 INNER JOIN
INNER JOIN 只返回两张表都能匹配上的数据。
查询订单及其用户信息:
sql
SELECT
o.id AS order_id,
o.status,
o.total_amount,
o.created_at,
u.id AS user_id,
u.nickname,
u.email
FROM orders o
INNER JOIN users u ON o.user_id = u.id
WHERE o.status = 'PAID'
ORDER BY o.created_at DESC;
适用场景:
- 订单必须有用户。
- 只关心有关联关系成立的数据。
- 查询结果不需要保留孤立数据。
4.2 LEFT JOIN
LEFT JOIN 会保留左表数据,即使右表没有匹配记录。
查询所有用户及其订单数量:
sql
SELECT
u.id,
u.nickname,
COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.nickname;
适用场景:
- 要显示所有用户,即使用户没有订单。
- 后台统计列表常用。
- 主表数据不能因为子表为空而丢失。
4.3 JOIN 常见错误
错误一:忘记 ON 条件,导致笛卡尔积。
sql
SELECT *
FROM orders o
JOIN users u;
这会让每一条订单和每一个用户组合,数据量会暴涨。
错误二:在 LEFT JOIN 后把右表条件写进 WHERE,导致效果变成 INNER JOIN。
sql
SELECT
u.id,
u.nickname,
o.id AS order_id
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE o.status = 'PAID';
如果想保留没有订单的用户,应写成:
sql
SELECT
u.id,
u.nickname,
o.id AS order_id
FROM users u
LEFT JOIN orders o
ON u.id = o.user_id
AND o.status = 'PAID';
五、聚合查询
5.1 COUNT 统计
统计每种订单状态的数量:
sql
SELECT
status,
COUNT(*) AS count
FROM orders
GROUP BY status;
5.2 SUM 求和
统计已支付订单总金额:
sql
SELECT
SUM(total_amount) AS paid_amount
FROM orders
WHERE status = 'PAID';
5.3 按日期统计
统计每天订单数量和订单金额:
sql
SELECT
DATE(created_at) AS order_date,
COUNT(*) AS order_count,
SUM(total_amount) AS total_amount
FROM orders
WHERE created_at >= '2026-06-01'
GROUP BY DATE(created_at)
ORDER BY order_date ASC;
注意:GROUP BY DATE(created_at) 可读性好,但可能影响索引使用。数据量大时可以考虑增加单独的日期字段,例如 order_date。
5.4 HAVING 过滤聚合结果
查询订单数大于 5 的用户:
sql
SELECT
user_id,
COUNT(*) AS order_count
FROM orders
GROUP BY user_id
HAVING COUNT(*) > 5;
WHERE 是分组前过滤,HAVING 是分组后过滤。
六、子查询
6.1 IN 子查询
查询有订单的用户:
sql
SELECT *
FROM users
WHERE id IN (
SELECT DISTINCT user_id
FROM orders
);
6.2 EXISTS 子查询
sql
SELECT *
FROM users u
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id = u.id
);
通常大数据量场景下,EXISTS 更适合判断是否存在,具体性能要看执行计划。
6.3 子查询与 JOIN 的选择
优先考虑 JOIN:
- 查询结果需要两张表字段。
- 需要排序、分页、过滤组合。
- SQL 可读性更强。
考虑子查询:
- 只判断是否存在。
- 查询逻辑独立。
- 聚合后再筛选主表。
七、分页查询优化
7.1 普通分页
sql
SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 100;
问题:OFFSET 越大,数据库需要跳过的数据越多,深分页会越来越慢。
7.2 深分页问题
sql
SELECT *
FROM orders
ORDER BY id DESC
LIMIT 20 OFFSET 100000;
数据库可能需要扫描前 100000 条,再返回 20 条。
7.3 游标分页
sql
SELECT *
FROM orders
WHERE id < 100000
ORDER BY id DESC
LIMIT 20;
接口参数可以从:
text
page=0&size=20
升级为:
text
cursor=100000&size=20
适合场景:
- 信息流。
- 消息列表。
- 日志列表。
- 订单流水列表。
- 不要求精确跳到第 N 页的场景。
7.4 延迟关联优化
深分页时可以先查主键,再关联完整数据:
sql
SELECT o.*
FROM orders o
INNER JOIN (
SELECT id
FROM orders
ORDER BY id DESC
LIMIT 20 OFFSET 100000
) t ON o.id = t.id;
八、索引基础
8.1 索引是什么
索引可以理解为数据库的目录。
没有索引时:
sql
SELECT * FROM orders WHERE user_id = 100;
数据库可能要从第一行扫到最后一行。
有索引时:
sql
KEY idx_orders_user_id (user_id)
数据库可以通过索引快速定位 user_id = 100 的订单。
8.2 常见索引类型
| 类型 | 说明 | 示例 |
|---|---|---|
| 主键索引 | 唯一且非空 | PRIMARY KEY (id) |
| 唯一索引 | 值不能重复 | UNIQUE KEY uk_email (email) |
| 普通索引 | 加速查询 | KEY idx_user_id (user_id) |
| 联合索引 | 多列组成一个索引 | KEY idx_status_created (status, created_at) |
8.3 什么时候建索引
适合建索引的字段:
- 经常出现在
WHERE条件中。 - 经常用于
JOIN ON。 - 经常用于
ORDER BY。 - 区分度较高的字段。
- 唯一性约束字段。
例如:
sql
CREATE INDEX idx_orders_user_status
ON orders(user_id, status);
适合查询:
sql
SELECT *
FROM orders
WHERE user_id = 1
AND status = 'PAID';
8.4 不适合建索引的字段
不太适合建索引:
- 数据量很小的表。
- 频繁更新但很少查询的字段。
- 区分度很低的字段,例如性别、布尔值。
- 大文本字段,如
TEXT。
九、联合索引与最左前缀原则
9.1 联合索引示例
sql
CREATE INDEX idx_orders_user_status_created
ON orders(user_id, status, created_at);
这个索引可以用于:
sql
WHERE user_id = 1
sql
WHERE user_id = 1 AND status = 'PAID'
sql
WHERE user_id = 1 AND status = 'PAID' ORDER BY created_at DESC
不适合直接用于:
sql
WHERE status = 'PAID'
因为跳过了最左边的 user_id。
9.2 最左前缀原则
联合索引 (a, b, c) 可以命中:
aa, ba, b, c
一般不能很好命中:
bcb, c
9.3 范围查询后的字段
sql
CREATE INDEX idx_orders_user_created_status
ON orders(user_id, created_at, status);
查询:
sql
SELECT *
FROM orders
WHERE user_id = 1
AND created_at >= '2026-06-01'
AND status = 'PAID';
created_at 是范围条件,后面的 status 可能不能充分利用索引。
所以联合索引字段顺序要根据查询场景设计。
十、索引失效常见场景
10.1 对索引列使用函数
sql
SELECT *
FROM orders
WHERE DATE(created_at) = '2026-06-10';
优化:
sql
SELECT *
FROM orders
WHERE created_at >= '2026-06-10 00:00:00'
AND created_at < '2026-06-11 00:00:00';
10.2 左模糊查询
sql
SELECT *
FROM users
WHERE email LIKE '%example.com';
普通 BTree 索引难以利用。
可以利用索引:
sql
SELECT *
FROM users
WHERE email LIKE 'admin%';
10.3 隐式类型转换
字段是字符串:
sql
email VARCHAR(128)
错误写法:
sql
SELECT *
FROM users
WHERE email = 123;
应该写成:
sql
SELECT *
FROM users
WHERE email = '123';
10.4 OR 条件使用不当
sql
SELECT *
FROM orders
WHERE user_id = 1
OR status = 'PAID';
如果两个字段索引设计不合理,可能导致全表扫描。
可以根据场景改成 UNION ALL:
sql
SELECT *
FROM orders
WHERE user_id = 1
UNION ALL
SELECT *
FROM orders
WHERE status = 'PAID';
注意:UNION ALL 不去重,如果可能重复,需要额外处理。
十一、EXPLAIN 执行计划
11.1 基本用法
sql
EXPLAIN
SELECT *
FROM orders
WHERE user_id = 1
ORDER BY created_at DESC;
11.2 重点字段
| 字段 | 含义 |
|---|---|
| id | 查询执行顺序标识 |
| select_type | 查询类型 |
| table | 当前访问的表 |
| type | 访问类型,越接近 const 越好 |
| possible_keys | 可能使用的索引 |
| key | 实际使用的索引 |
| rows | 预估扫描行数 |
| Extra | 额外信息 |
11.3 type 常见级别
从好到差大致为:
text
system > const > eq_ref > ref > range > index > ALL
重点关注:
ALL:全表扫描,数据量大时危险。index:扫整个索引,也可能很慢。range:范围扫描,通常可以接受。ref:普通索引等值查询,较好。const:主键或唯一索引命中,非常好。
11.4 Extra 常见信息
| Extra | 说明 |
|---|---|
| Using index | 使用覆盖索引 |
| Using where | 使用 WHERE 过滤 |
| Using temporary | 使用临时表 |
| Using filesort | 额外排序 |
| Using index condition | 使用索引条件下推 |
看到 Using temporary 和 Using filesort 不一定必然有问题,但在大数据量查询中要重点关注。
十二、覆盖索引
12.1 什么是覆盖索引
查询需要的字段都在索引中,不需要回表。
索引:
sql
CREATE INDEX idx_orders_user_status_created
ON orders(user_id, status, created_at);
查询:
sql
SELECT user_id, status, created_at
FROM orders
WHERE user_id = 1
AND status = 'PAID';
这可能形成覆盖索引。
12.2 避免 SELECT *
sql
SELECT *
FROM orders
WHERE user_id = 1;
如果只需要部分字段,应写成:
sql
SELECT id, status, total_amount, created_at
FROM orders
WHERE user_id = 1;
好处:
- 减少网络传输。
- 有机会使用覆盖索引。
- 避免表结构变化影响接口。
十三、MyBatis Plus 复杂查询
13.1 LambdaQueryWrapper 动态条件
java
public List<OrderEntity> searchOrders(Long userId, String status, LocalDateTime start, LocalDateTime end) {
LambdaQueryWrapper<OrderEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(userId != null, OrderEntity::getUserId, userId)
.eq(StringUtils.hasText(status), OrderEntity::getStatus, status)
.ge(start != null, OrderEntity::getCreatedAt, start)
.lt(end != null, OrderEntity::getCreatedAt, end)
.orderByDesc(OrderEntity::getCreatedAt);
return orderMapper.selectList(wrapper);
}
优点:
- 字段引用类型安全。
- 动态条件写法清晰。
- 适合单表查询。
13.2 分页查询
java
public Page<OrderEntity> pageOrders(int page, int size, String status) {
LambdaQueryWrapper<OrderEntity> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(StringUtils.hasText(status), OrderEntity::getStatus, status)
.orderByDesc(OrderEntity::getCreatedAt);
return orderMapper.selectPage(new Page<>(page, size), wrapper);
}
注意:MyBatis Plus 的页码从 1 开始。如果接口使用从 0 开始的页码,需要转换。
java
int current = page + 1;
十四、XML Mapper 复杂查询
当查询涉及多表关联、聚合统计、复杂动态条件时,XML 比 Wrapper 更清晰。
14.1 DTO 定义
java
public class OrderAdminPageDTO {
private Long orderId;
private Long userId;
private String nickname;
private String status;
private BigDecimal totalAmount;
private LocalDateTime createdAt;
// getter setter
}
14.2 Mapper 接口
java
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
import java.util.List;
@Mapper
public interface OrderReportMapper {
List<OrderAdminPageDTO> selectAdminPage(
@Param("userId") Long userId,
@Param("status") String status,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime,
@Param("offset") int offset,
@Param("size") int size
);
long countAdminPage(
@Param("userId") Long userId,
@Param("status") String status,
@Param("startTime") LocalDateTime startTime,
@Param("endTime") LocalDateTime endTime
);
}
14.3 XML 查询
xml
<mapper namespace="com.company.app.mapper.OrderReportMapper">
<select id="selectAdminPage" resultType="com.company.app.dto.OrderAdminPageDTO">
SELECT
o.id AS orderId,
o.user_id AS userId,
u.nickname AS nickname,
o.status AS status,
o.total_amount AS totalAmount,
o.created_at AS createdAt
FROM orders o
INNER JOIN users u ON o.user_id = u.id
<where>
<if test="userId != null">
AND o.user_id = #{userId}
</if>
<if test="status != null and status != ''">
AND o.status = #{status}
</if>
<if test="startTime != null">
AND o.created_at >= #{startTime}
</if>
<if test="endTime != null">
AND o.created_at < #{endTime}
</if>
</where>
ORDER BY o.created_at DESC
LIMIT #{size} OFFSET #{offset}
</select>
<select id="countAdminPage" resultType="long">
SELECT COUNT(*)
FROM orders o
INNER JOIN users u ON o.user_id = u.id
<where>
<if test="userId != null">
AND o.user_id = #{userId}
</if>
<if test="status != null and status != ''">
AND o.status = #{status}
</if>
<if test="startTime != null">
AND o.created_at >= #{startTime}
</if>
<if test="endTime != null">
AND o.created_at < #{endTime}
</if>
</where>
</select>
</mapper>
14.4 Service 封装分页
java
@Service
public class OrderAdminQueryService {
private final OrderReportMapper orderReportMapper;
public OrderAdminQueryService(OrderReportMapper orderReportMapper) {
this.orderReportMapper = orderReportMapper;
}
public PageResult<OrderAdminPageDTO> search(OrderSearchRequest req) {
int safeSize = Math.min(Math.max(req.getSize(), 1), 100);
int safePage = Math.max(req.getPage(), 0);
int offset = safePage * safeSize;
List<OrderAdminPageDTO> list = orderReportMapper.selectAdminPage(
req.getUserId(),
req.getStatus(),
req.getStartTime(),
req.getEndTime(),
offset,
safeSize
);
long total = orderReportMapper.countAdminPage(
req.getUserId(),
req.getStatus(),
req.getStartTime(),
req.getEndTime()
);
return PageResult.of(list, total, safePage, safeSize);
}
}
十五、统计接口示例
15.1 每日订单统计 DTO
java
public class DailyOrderStatsDTO {
private LocalDate orderDate;
private Long orderCount;
private BigDecimal totalAmount;
// getter setter
}
15.2 Mapper
java
List<DailyOrderStatsDTO> selectDailyStats(
@Param("startDate") LocalDate startDate,
@Param("endDate") LocalDate endDate
);
15.3 XML
xml
<select id="selectDailyStats" resultType="com.company.app.dto.DailyOrderStatsDTO">
SELECT
DATE(o.created_at) AS orderDate,
COUNT(*) AS orderCount,
SUM(o.total_amount) AS totalAmount
FROM orders o
WHERE o.created_at >= #{startDate}
AND o.created_at < DATE_ADD(#{endDate}, INTERVAL 1 DAY)
GROUP BY DATE(o.created_at)
ORDER BY orderDate ASC
</select>
优化建议:
如果订单量很大,建议新增字段:
sql
order_date DATE NOT NULL
然后查询:
sql
WHERE order_date >= '2026-06-01'
AND order_date <= '2026-06-30'
GROUP BY order_date
并建立索引:
sql
CREATE INDEX idx_orders_order_date
ON orders(order_date);
十六、慢 SQL 常见原因
16.1 没有索引
sql
SELECT *
FROM orders
WHERE user_id = 100;
如果 user_id 没有索引,大表会全表扫描。
16.2 索引设计不匹配
索引:
sql
KEY idx_status_created (status, created_at)
查询:
sql
SELECT *
FROM orders
WHERE created_at >= '2026-06-01';
这个查询没有使用联合索引的最左列 status,效果可能不好。
16.3 返回数据过多
sql
SELECT *
FROM orders
WHERE status = 'PAID';
如果 PAID 占全表 80%,即使有索引也可能不快。
16.4 排序字段无索引
sql
SELECT *
FROM orders
WHERE user_id = 1
ORDER BY created_at DESC;
建议索引:
sql
CREATE INDEX idx_orders_user_created
ON orders(user_id, created_at);
16.5 深分页
sql
LIMIT 20 OFFSET 500000
考虑游标分页或延迟关联。
16.6 大事务占用锁
长时间事务可能阻塞其他更新操作。
十七、慢 SQL 优化流程
17.1 第一步:定位 SQL
来源:
- 应用日志。
- MySQL 慢查询日志。
- APM 监控。
- 接口耗时日志。
- 数据库监控平台。
17.2 第二步:查看执行计划
sql
EXPLAIN SELECT ...
重点看:
type是否为ALL。key是否为空。rows是否过大。Extra是否出现Using temporary、Using filesort。
17.3 第三步:判断是否需要索引
常见策略:
- 等值过滤字段放前面。
- 范围字段放后面。
- 排序字段尽量纳入联合索引。
- 避免建太多重复索引。
17.4 第四步:改 SQL
常见改法:
- 减少
SELECT *。 - 避免函数包裹索引列。
- 大分页改游标分页。
- 拆分复杂 SQL。
- 预聚合统计数据。
- 用批量查询替代循环查询。
17.5 第五步:回归测试
优化后要确认:
- 结果没有变化。
- 执行计划更合理。
- 接口耗时下降。
- 写入性能没有明显下降。
十八、接口层查询设计
18.1 查询参数设计
后台订单查询接口:
http
GET /api/v1/admin/orders?page=0&size=20&status=PAID&userId=1&startTime=2026-06-01T00:00:00&endTime=2026-07-01T00:00:00
对应 Request:
java
public class OrderSearchRequest {
private int page = 0;
private int size = 20;
private Long userId;
private String status;
private LocalDateTime startTime;
private LocalDateTime endTime;
// getter setter
}
18.2 Controller
java
@RestController
@RequestMapping("/api/v1/admin/orders")
public class OrderAdminController {
private final OrderAdminQueryService orderAdminQueryService;
public OrderAdminController(OrderAdminQueryService orderAdminQueryService) {
this.orderAdminQueryService = orderAdminQueryService;
}
@GetMapping
public PageResult<OrderAdminPageDTO> search(OrderSearchRequest request) {
return orderAdminQueryService.search(request);
}
}
18.3 参数校验
java
public class OrderSearchRequest {
@Min(0)
private int page = 0;
@Min(1)
@Max(100)
private int size = 20;
private Long userId;
private String status;
private LocalDateTime startTime;
private LocalDateTime endTime;
// getter setter
}
十九、避免 N+1 查询
19.1 反例
java
List<OrderEntity> orders = orderMapper.selectList(wrapper);
for (OrderEntity order : orders) {
UserEntity user = userMapper.selectById(order.getUserId());
order.setUserName(user.getNickname());
}
如果有 100 条订单,会查询 1 次订单加 100 次用户。
19.2 批量查询优化
java
List<Long> userIds = orders.stream()
.map(OrderEntity::getUserId)
.distinct()
.toList();
List<UserEntity> users = userMapper.selectBatchIds(userIds);
Map<Long, UserEntity> userMap = users.stream()
.collect(Collectors.toMap(UserEntity::getId, Function.identity()));
for (OrderEntity order : orders) {
UserEntity user = userMap.get(order.getUserId());
if (user != null) {
order.setUserName(user.getNickname());
}
}
19.3 直接 JOIN 查询
如果接口本来就需要用户信息,可以直接使用 JOIN 查询返回 DTO。
二十、数据库与业务边界
20.1 不要把所有业务逻辑塞进 SQL
适合放 SQL 的逻辑:
- 过滤。
- 排序。
- 分页。
- 聚合。
- 简单计算。
适合放 Java 的逻辑:
- 权限判断。
- 状态流转。
- 复杂业务规则。
- 调用外部服务。
- 领域对象组装。
20.2 查询 DTO 不等于实体 Entity
实体 OrderEntity 对应数据库表。
接口返回 OrderAdminPageDTO 对应前端需要的数据。
不要为了接口方便随意改实体结构。