Mysql中慢 SQL 优化实战:减少不必要的 JOIN 关联
一、问题背景
线上接口查询收货单的出库物流信息时出现慢 SQL。该 SQL 通过三表 JOIN(收货单主表 → 物流记录表 → 物流节点明细表)查询物流轨迹数据,其中收货单主表数据量超过 1200 万行。
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、优化流程
1. 定位慢 SQL
通过慢查询日志或 APM 监控系统,获取到慢 SQL 的完整文本、执行时间和调用链路(trace-id)。
2. 分析 SQL 结构
原始 SQL 的执行结构:
receiving_goods_record (主表, 1200w+) INNER JOIN store_receiving_logistics_record (物流记录表) LEFT JOIN store_receiving_logistics_record_item (物流节点明细表)3. 识别冗余 JOIN
sql 中直接可以从表中获取数据,并且主表中的 record_code 字段是有索引的。分析发现 SELECT 中从主表取的字段(record_code、order_code)在物流记录表中同样存在且数据一致,关联主表纯属冗余。
4. 数据一致性验证
通过 SQL 查询验证两表字段的数据一致性(不一致记录数 = 0),确认去掉 JOIN 不会导致数据差异。
5. 修改并测试
修改 Mapper XML,去掉冗余 JOIN,本地启动服务验证接口正常返回。
三、核心技术点
3.1 EXPLAIN 执行计划
EXPLAINSELECT...FROMtable_a aINNERJOINtable_b bONa.id=b.a_idWHEREa.code='xxx';关键指标:
| 字段 | 含义 | 关注点 |
|---|---|---|
| type | 访问类型 | ALL(全表扫描) → ref(索引) → const(常量) |
| key | 实际使用的索引 | NULL 表示未走索引 |
| rows | 预估扫描行数 | 数值越小越好 |
| Extra | 额外信息 | Using filesort / Using temporary 需关注 |
3.2 索引命中原则
-- 单列索引:WHERE 条件精确匹配时命中CREATEINDEXidx_record_codeONlogistics_record(record_code);SELECT*FROMlogistics_recordWHERErecord_code='xxx';-- 命中-- 联合索引:遵循最左前缀原则CREATEINDEXidx_member_codeONlogistics_record(member_id,order_code);SELECT*FROMlogistics_recordWHEREmember_id=1;-- 命中SELECT*FROMlogistics_recordWHEREmember_id=1ANDorder_code='x';-- 命中SELECT*FROMlogistics_recordWHEREorder_code='x';-- 不命中3.3 JOIN 的代价
每增加一个 JOIN,MySQL 需要:
- 根据驱动表的结果集逐行去被驱动表查找匹配行
- 如果被驱动表没有合适索引,会产生全表扫描
- 结果集膨胀 → 内存消耗增大 → 排序代价增大
四、优化原理
4.1 减少 JOIN 表数量
这是本次优化的核心原理。当你发现:
SELECT 中从 A 表取的字段,在 B 表中也有相同的数据(冗余存储/数据同步字段)
就可以去掉对 A 表的关联,直接从 B 表取字段。
优化前(三表):
SELECTa.name,b.detail,c.itemFROMbig_table a-- 1000w 行INNERJOINmedium_table bONa.id=b.a_idLEFTJOINsmall_table cONb.id=c.b_idWHEREa.code='xxx';优化后(两表):
SELECTb.name,b.detail,c.item-- name 字段从 b 表直接取FROMmedium_table bLEFTJOINsmall_table cONb.id=c.b_idWHEREb.code='xxx';-- b 表的 code 有索引4.2 驱动表选择
MySQL 的 JOIN 执行顺序由优化器决定,但通常:
- 小表驱动大表性能更优
- WHERE 条件能快速过滤的表适合作为驱动表
五、常见的慢 SQL 优化方案
方案一:消除冗余 JOIN(本次使用)
适用场景:JOIN 的表只是为了取几个字段,而这些字段在其他已关联的表中也有。
-- 优化前:三表 JOIN 只为取 order 表的 order_noSELECTo.order_no,d.product_name,d.qtyFROMorders oINNERJOINorder_details dONo.id=d.order_idWHEREo.id=12345;-- 优化后:order_details 表本身冗余存储了 order_noSELECTd.order_no,d.product_name,d.qtyFROMorder_details dWHEREd.order_id=12345;方案二:添加合适索引
适用场景:WHERE 条件或 JOIN 条件的字段没有索引。
-- 慢查询:order_code 无索引,全表扫描SELECT*FROMshipment_detailWHEREmember_id=1179109ANDorder_code='ER.240418.000673';-- 添加联合索引CREATEINDEXidx_member_orderONshipment_detail(member_id,order_code);方案三:改写子查询为 JOIN
适用场景:IN 子查询在大数据量下性能差。
-- 优化前:子查询SELECT*FROMordersWHEREcustomer_idIN(SELECTidFROMcustomersWHERElevel='VIP');-- 优化后:改写为 JOINSELECTo.*FROMorders oINNERJOINcustomers cONo.customer_id=c.idWHEREc.level='VIP';方案四:分页优化(延迟关联)
适用场景:深分页 LIMIT offset, size 越往后越慢。
-- 优化前:LIMIT 100000, 10 需要扫描 100010 行SELECT*FROMordersORDERBYidDESCLIMIT100000,10;-- 优化后:先定位 ID 区间,再取数据SELECT*FROMordersWHEREid>=(SELECTidFROMordersORDERBYidDESCLIMIT100000,1)ORDERBYidDESCLIMIT10;方案五:大表数据归档
适用场景:因为某表数据越来越多,导致执行 sql 越来越慢,因为 sql 暂时无法优化,所以先按照备份数据的方式处理,把指定之间之前的数据全部备份到备份表中,一年一张表。
-- 按年归档:将历史数据迁移到归档表INSERTINTOxxx_2025SELECT*FROMxxxWHEREaccount_time<'2026-01';DELETEFROMxxxWHEREaccount_time<'2026-01';-- 或使用 pt-archiver 等工具无锁归档方案六:为大表新增索引(Online DDL)
适用场景:因为只使用创建时间查询数据,所以需要增加无锁变更,把 create_time 增加索引,表数据共 5000w。
-- MySQL 5.6+ 支持 Online DDL,不阻塞 DMLALTERTABLEstore_inbound_masterADDINDEXidx_create_time(create_time),ALGORITHM=INPLACE,LOCK=NONE;-- 或使用 gh-ost / pt-online-schema-change 进行无锁变更六、优化效果评估
| 维度 | 优化前 | 优化后 |
|---|---|---|
| JOIN 表数量 | 3 表 | 2 表 |
| 驱动表数据量 | 1200w+ (receiving_goods_record) | 470w (store_receiving_logistics_record) |
| 索引使用 | orm.record_code 走唯一索引后再 JOIN | sdlr.record_code 直接走索引 |
| 网络/内存开销 | 需传输主表行数据 | 无冗余数据传输 |
七、总结检查清单
在遇到慢 SQL 时,按以下顺序排查:
- 是否有不必要的 JOIN?— 分析 SELECT 字段来源,确认是否可以从已有表获取
- WHERE/JOIN 条件是否走索引?— 用 EXPLAIN 确认,必要时添加索引
- 是否有子查询可改写?— IN 子查询改为 JOIN
- 结果集是否过大?— 增加过滤条件、分页优化
- 表数据量是否可以缩减?— 历史数据归档、分表