Java学习第41天 - 复杂查询、多表关联、索引优化与慢 SQL 调优

23 阅读13分钟

一、学习目标

  • 掌握 MySQL 中常见的多表关联查询方式。
  • 理解 INNER JOINLEFT JOIN、子查询、聚合查询的使用场景。
  • 能够用 MyBatis Plus 和 XML Mapper 编写复杂查询。
  • 理解索引的基本原理,知道什么时候该建索引,什么时候索引会失效。
  • 学会使用 EXPLAIN 分析 SQL 执行计划。
  • 掌握慢 SQL 的常见原因与优化思路。
  • 能把第40天的订单模块扩展成更接近真实项目的数据查询能力。

二、为什么第41天要学习复杂查询

第40天已经完成了数据库基础、表设计、MyBatis Plus 基础 CRUD 和事务入门。

但真实项目中很少只有简单的 selectByIdselectList,更多场景是:

  • 查询用户的订单列表,同时带上用户昵称。
  • 查询订单详情,同时返回订单明细。
  • 按状态、时间、金额范围组合筛选订单。
  • 统计每天订单数量和销售额。
  • 查询销量最高的商品。
  • 后台管理系统做复杂分页查询。
  • 接口变慢后,需要定位是哪条 SQL 慢。

所以第41天的重点是:从“会查数据”升级到“会写可维护、可优化的查询”。


三、多表关联查询基础

3.1 示例表结构回顾

用户表:

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)
);

订单表:

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)
);

订单明细表:

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 只返回两张表都能匹配上的数据。

查询订单及其用户信息:

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 会保留左表数据,即使右表没有匹配记录。

查询所有用户及其订单数量:

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 条件,导致笛卡尔积。

SELECT *
FROM orders o
JOIN users u;

这会让每一条订单和每一个用户组合,数据量会暴涨。

错误二:在 LEFT JOIN 后把右表条件写进 WHERE,导致效果变成 INNER JOIN

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';

如果想保留没有订单的用户,应写成:

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 统计

统计每种订单状态的数量:

SELECT
    status,
    COUNT(*) AS count
FROM orders
GROUP BY status;

5.2 SUM 求和

统计已支付订单总金额:

SELECT
    SUM(total_amount) AS paid_amount
FROM orders
WHERE status = 'PAID';

5.3 按日期统计

统计每天订单数量和订单金额:

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 的用户:

SELECT
    user_id,
    COUNT(*) AS order_count
FROM orders
GROUP BY user_id
HAVING COUNT(*) > 5;

WHERE 是分组前过滤,HAVING 是分组后过滤。


六、子查询

6.1 IN 子查询

查询有订单的用户:

SELECT *
FROM users
WHERE id IN (
    SELECT DISTINCT user_id
    FROM orders
);

6.2 EXISTS 子查询

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 普通分页

SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 100;

问题:OFFSET 越大,数据库需要跳过的数据越多,深分页会越来越慢。

7.2 深分页问题

SELECT *
FROM orders
ORDER BY id DESC
LIMIT 20 OFFSET 100000;

数据库可能需要扫描前 100000 条,再返回 20 条。

7.3 游标分页

SELECT *
FROM orders
WHERE id < 100000
ORDER BY id DESC
LIMIT 20;

接口参数可以从:

page=0&size=20

升级为:

cursor=100000&size=20

适合场景:

  • 信息流。
  • 消息列表。
  • 日志列表。
  • 订单流水列表。
  • 不要求精确跳到第 N 页的场景。

7.4 延迟关联优化

深分页时可以先查主键,再关联完整数据:

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 索引是什么

索引可以理解为数据库的目录。

没有索引时:

SELECT * FROM orders WHERE user_id = 100;

数据库可能要从第一行扫到最后一行。

有索引时:

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
  • 区分度较高的字段。
  • 唯一性约束字段。

例如:

CREATE INDEX idx_orders_user_status
ON orders(user_id, status);

适合查询:

SELECT *
FROM orders
WHERE user_id = 1
  AND status = 'PAID';

8.4 不适合建索引的字段

不太适合建索引:

  • 数据量很小的表。
  • 频繁更新但很少查询的字段。
  • 区分度很低的字段,例如性别、布尔值。
  • 大文本字段,如 TEXT

九、联合索引与最左前缀原则

9.1 联合索引示例

CREATE INDEX idx_orders_user_status_created
ON orders(user_id, status, created_at);

这个索引可以用于:

WHERE user_id = 1
WHERE user_id = 1 AND status = 'PAID'
WHERE user_id = 1 AND status = 'PAID' ORDER BY created_at DESC

不适合直接用于:

WHERE status = 'PAID'

因为跳过了最左边的 user_id

9.2 最左前缀原则

联合索引 (a, b, c) 可以命中:

  • a
  • a, b
  • a, b, c

一般不能很好命中:

  • b
  • c
  • b, c

9.3 范围查询后的字段

CREATE INDEX idx_orders_user_created_status
ON orders(user_id, created_at, status);

查询:

SELECT *
FROM orders
WHERE user_id = 1
  AND created_at >= '2026-06-01'
  AND status = 'PAID';

created_at 是范围条件,后面的 status 可能不能充分利用索引。

所以联合索引字段顺序要根据查询场景设计。


十、索引失效常见场景

10.1 对索引列使用函数

SELECT *
FROM orders
WHERE DATE(created_at) = '2026-06-10';

优化:

SELECT *
FROM orders
WHERE created_at >= '2026-06-10 00:00:00'
  AND created_at < '2026-06-11 00:00:00';

10.2 左模糊查询

SELECT *
FROM users
WHERE email LIKE '%example.com';

普通 BTree 索引难以利用。

可以利用索引:

SELECT *
FROM users
WHERE email LIKE 'admin%';

10.3 隐式类型转换

字段是字符串:

email VARCHAR(128)

错误写法:

SELECT *
FROM users
WHERE email = 123;

应该写成:

SELECT *
FROM users
WHERE email = '123';

10.4 OR 条件使用不当

SELECT *
FROM orders
WHERE user_id = 1
   OR status = 'PAID';

如果两个字段索引设计不合理,可能导致全表扫描。

可以根据场景改成 UNION ALL

SELECT *
FROM orders
WHERE user_id = 1
UNION ALL
SELECT *
FROM orders
WHERE status = 'PAID';

注意:UNION ALL 不去重,如果可能重复,需要额外处理。


十一、EXPLAIN 执行计划

11.1 基本用法

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 常见级别

从好到差大致为:

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 temporaryUsing filesort 不一定必然有问题,但在大数据量查询中要重点关注。


十二、覆盖索引

12.1 什么是覆盖索引

查询需要的字段都在索引中,不需要回表。

索引:

CREATE INDEX idx_orders_user_status_created
ON orders(user_id, status, created_at);

查询:

SELECT user_id, status, created_at
FROM orders
WHERE user_id = 1
  AND status = 'PAID';

这可能形成覆盖索引。

12.2 避免 SELECT *

SELECT *
FROM orders
WHERE user_id = 1;

如果只需要部分字段,应写成:

SELECT id, status, total_amount, created_at
FROM orders
WHERE user_id = 1;

好处:

  • 减少网络传输。
  • 有机会使用覆盖索引。
  • 避免表结构变化影响接口。

十三、MyBatis Plus 复杂查询

13.1 LambdaQueryWrapper 动态条件

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 分页查询

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 开始的页码,需要转换。

int current = page + 1;

十四、XML Mapper 复杂查询

当查询涉及多表关联、聚合统计、复杂动态条件时,XML 比 Wrapper 更清晰。

14.1 DTO 定义

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 接口

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 查询

<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 &gt;= #{startTime}
            </if>
            <if test="endTime != null">
                AND o.created_at &lt; #{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 &gt;= #{startTime}
            </if>
            <if test="endTime != null">
                AND o.created_at &lt; #{endTime}
            </if>
        </where>
    </select>

</mapper>

14.4 Service 封装分页

@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

public class DailyOrderStatsDTO {
    private LocalDate orderDate;
    private Long orderCount;
    private BigDecimal totalAmount;

    // getter setter
}

15.2 Mapper

List<DailyOrderStatsDTO> selectDailyStats(
        @Param("startDate") LocalDate startDate,
        @Param("endDate") LocalDate endDate
);

15.3 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 &gt;= #{startDate}
      AND o.created_at &lt; DATE_ADD(#{endDate}, INTERVAL 1 DAY)
    GROUP BY DATE(o.created_at)
    ORDER BY orderDate ASC
</select>

优化建议:

如果订单量很大,建议新增字段:

order_date DATE NOT NULL

然后查询:

WHERE order_date >= '2026-06-01'
  AND order_date <= '2026-06-30'
GROUP BY order_date

并建立索引:

CREATE INDEX idx_orders_order_date
ON orders(order_date);

十六、慢 SQL 常见原因

16.1 没有索引

SELECT *
FROM orders
WHERE user_id = 100;

如果 user_id 没有索引,大表会全表扫描。

16.2 索引设计不匹配

索引:

KEY idx_status_created (status, created_at)

查询:

SELECT *
FROM orders
WHERE created_at >= '2026-06-01';

这个查询没有使用联合索引的最左列 status,效果可能不好。

16.3 返回数据过多

SELECT *
FROM orders
WHERE status = 'PAID';

如果 PAID 占全表 80%,即使有索引也可能不快。

16.4 排序字段无索引

SELECT *
FROM orders
WHERE user_id = 1
ORDER BY created_at DESC;

建议索引:

CREATE INDEX idx_orders_user_created
ON orders(user_id, created_at);

16.5 深分页

LIMIT 20 OFFSET 500000

考虑游标分页或延迟关联。

16.6 大事务占用锁

长时间事务可能阻塞其他更新操作。


十七、慢 SQL 优化流程

17.1 第一步:定位 SQL

来源:

  • 应用日志。
  • MySQL 慢查询日志。
  • APM 监控。
  • 接口耗时日志。
  • 数据库监控平台。

17.2 第二步:查看执行计划

EXPLAIN SELECT ...

重点看:

  • type 是否为 ALL
  • key 是否为空。
  • rows 是否过大。
  • Extra 是否出现 Using temporaryUsing filesort

17.3 第三步:判断是否需要索引

常见策略:

  • 等值过滤字段放前面。
  • 范围字段放后面。
  • 排序字段尽量纳入联合索引。
  • 避免建太多重复索引。

17.4 第四步:改 SQL

常见改法:

  • 减少 SELECT *
  • 避免函数包裹索引列。
  • 大分页改游标分页。
  • 拆分复杂 SQL。
  • 预聚合统计数据。
  • 用批量查询替代循环查询。

17.5 第五步:回归测试

优化后要确认:

  • 结果没有变化。
  • 执行计划更合理。
  • 接口耗时下降。
  • 写入性能没有明显下降。

十八、接口层查询设计

18.1 查询参数设计

后台订单查询接口:

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:

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

@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 参数校验

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 反例

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 批量查询优化

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 对应前端需要的数据。

不要为了接口方便随意改实体结构。