背景
head头表(5000),line行表(15万),导出数据包含头和行,一对多。
以行表为维度导出15万数据。
sql
如下两个sql查询,有如下差异
驱动方式:第一个大表驱动小表,第二个反之
第一个自己比较:根据ID排序,和不排序,explain分析
sql
explain
select
head.code,
head.company_code,
head.company_name,
head.expense_date,
head.data_source_num,
head.data_source,
head.prepared_by,
head.status,
line.asset_number,
line.tag_number,
line.asset_name,
line.asset_category_1,
line.asset_category_2,
line.retired_units,
line.retired_date,
line.retirement_type,
line.retirement_cost,
line.disposal_amount,
line.comment
from
asset_fa_disposal_line line
left join asset_fa_disposal head on line.parent_code = head.code
order by line.id desc
limit 0,1000
无排序
SIMPLE line ALL 141466 100.0
1 SIMPLE head eq_ref uniq_code uniq_code 194 fssc_backend_asset.line.parent_code 1 100.0
有排序
1 SIMPLE line index PRIMARY 8 141466 100.0 Backward index scan
1 SIMPLE head eq_ref uniq_code uniq_code 194 fssc_backend_asset.line.parent_code 1 100.0
-- asset_fa_disposal head
-- left join asset_fa_disposal_line line on head.code = line.parent_code
-- order by line.id desc
explain
select
head.code,
head.company_code,
head.company_name,
head.expense_date,
head.data_source_num,
head.data_source,
head.prepared_by,
head.status,
line.asset_number,
line.tag_number,
line.asset_name,
line.asset_category_1,
line.asset_category_2,
line.retired_units,
line.retired_date,
line.retirement_type,
line.retirement_cost,
line.disposal_amount,
line.comment
from
asset_fa_disposal head
left join asset_fa_disposal_line line on head.code = line.parent_code
order by line.id desc
limit 0,1000
无排序
SIMPLE head ALL 4832 100.0
1 SIMPLE line ref disposal_line_parent_code disposal_line_parent_code 194 fssc_backend_asset.head.code 31 100.0
有排序
1 SIMPLE head ALL 4832 100.0 Using temporary; Using filesort
1 SIMPLE line ref disposal_line_parent_code disposal_line_parent_code 194 fssc_backend_asset.head.code 31 100.0
问题
1,不是小表驱动大表吗,为什么这里大表驱动小表效率更高
2,针对第一个sql,line表驱动时,排序好像比不排序,效率更高,line表不排序type=all,排序反而是index为什么
3,数据库中总共有15万数据,需要导出,每次查询1000,分页查询,大表驱动小表和小表驱动大表,影响最终的分页结果吗,比如导出数量有影响吗
4,采用第一个sql,又想不遗漏head数据(head无line关联数据),需要保证正确导出分页,怎么使用union优化
回答
1
确实,在大多数JOIN优化场景中,我们通常推荐"小表驱动大表"的原则,但您这个案例却出现了相反的情况。让我详细解释为什么这里"大表驱动小表"反而更快:
为什么这个特例中大表驱动小表更快
-
LEFT JOIN的特殊性:
-
您使用的是LEFT JOIN,不是INNER JOIN
-
LEFT JOIN的逻辑是:必须返回左表的所有行,无论右表是否有匹配
-
第一个查询(大表驱动):
sql
复制
下载
FROM asset_fa_disposal_line(line) LEFT JOIN asset_fa_disposal(head)
优化器知道必须返回line表所有141,466行,所以选择全表扫描line,然后对每行快速查找head
-
-
索引效率差异:
-
head.code
有uniq_code索引(唯一索引),查找速度极快(O(1)复杂度) -
line.parent_code
有普通索引,查找速度稍慢 -
大表驱动时:141,466次唯一索引查找
-
小表驱动时:4,832次普通索引查找,每次返回约31行(总共约150,000行)
-
-
数据分布特点:
-
每个head.code平均对应约31个line记录(141466/4832≈29.3)
-
这种"一对多"关系使得从小表出发会导致更多中间结果
-
-
执行成本对比:
-
大表驱动:141,466次高效唯一索引查找
-
小表驱动:4,832次范围查找,每次返回约31行,需要处理更多数据页
-
何时"大表驱动小表"更优
-
LEFT JOIN且右表有极高效索引时
-
右表的查找条件比左表的选择性更好时
-
数据关系是一对多且"多"的一方数据量极大时
-
查询需要返回左表大多数行时(如无WHERE条件)
验证方法
您可以尝试以下实验验证:
sql
复制
下载
-- 强制小表驱动大表
SELECT /*+ JOIN_ORDER(head, line) */ ... FROM head LEFT JOIN line ...
-- 强制大表驱动小表
SELECT /*+ JOIN_ORDER(line, head) */ ... FROM line LEFT JOIN head ...
总结
"小表驱动大表"是通用原则,但在LEFT JOIN、特殊索引结构和特定数据分布下,可能会出现大表驱动更优的情况。优化器根据成本估算选择了更适合当前表结构和数据分布的执行计划。
2
您观察到了一个非常有意思的现象,这涉及到MySQL优化器的工作机制。让我详细解释为什么在第一种查询中(line表驱动),加上ORDER BY line.id DESC
后执行效率反而更高:
核心原因:MySQL的"排序驱动优化"
当查询包含ORDER BY
且使用LIMIT时,MySQL优化器会尝试"优先排序后连接"的策略:
-
无排序时(性能较低):
-
type=ALL
表示全表扫描line表(141,466行) -
对每一行再通过索引查找head表
-
最后才应用LIMIT 1000
-
-
有排序时(性能更高):
-
type=index
且显示"Backward index scan"表示优化器选择:-
直接按id索引的逆序扫描(从最大id开始)
-
只需扫描1000行满足LIMIT的数据
-
然后对这1000行做关联查询
-
-
避免了全表扫描
-
关键技术点
-
Backward index scan(反向索引扫描):
-
InnoDB索引默认是升序排列的
-
当使用
ORDER BY id DESC
时,MySQL 8.0+可以智能地从索引尾部开始反向扫描 -
只需读取满足LIMIT的行数(1000行)即可停止
-
-
LIMIT的优化作用:
sql
复制
下载
ORDER BY line.id DESC LIMIT 1000
-
告诉优化器"我只需要1000条排好序的记录"
-
优化器会优先使用id索引直接定位到符合条件的记录
-
-
索引覆盖优势:
-
虽然您的查询选择了很多列,但排序只依赖line.id
-
id是主键,索引结构本身就包含全部排序信息
-
执行流程对比
无排序流程:
复制
下载
1. 全表扫描line表(141,466行)
2. 对每行查找head表
3. 组合所有结果
4. 应用LIMIT 1000
有排序流程:
复制
下载
1. 从line表的id索引尾部开始反向扫描
2. 扫描1000行后停止(不读全表)
3. 对这1000行查找head表
4. 返回结果
为什么第二种方式(head驱动)没有这种优化
当head表驱动时:
-
排序字段(line.id)不在驱动表
-
MySQL必须先完成全部连接操作
-
生成包含所有字段的临时结果集(约150,000行)
-
然后排序这个巨大临时表
-
最后应用LIMIT
最佳实践建议
-
排序字段应尽量来自驱动表
-
确保排序字段有索引(如您案例中的line.id)
-
结合LIMIT使用ORDER BY可以触发这种优化
-
对于分页查询,这种模式性能最好
这种优化特别适合:
-
大型事实表关联小型维度表
-
需要最新/最旧记录的查询
-
分页展示场景
您的案例完美展示了当表结构设计合理(line.id有主键索引)时,MySQL优化器能够发挥的强大优化能力。
3
分页查询驱动方式对导出结果的影响分析
在您的场景中(15万数据,每次查询1000条分页导出),驱动方式的选择确实会影响最终导出结果,但影响的性质取决于您的具体需求。
两种驱动方式对结果的影响
1. 大表驱动小表(line LEFT JOIN head)
-
结果特点:
-
保证line表的所有记录都会出现在结果中
-
如果某些line记录没有对应的head记录,head相关字段为NULL
-
结果数量 = line表的记录数(15万)
-
2. 小表驱动大表(head LEFT JOIN line)
-
结果特点:
-
保证head表的所有记录都会出现在结果中
-
如果某些head记录没有对应的line记录,line相关字段为NULL
-
结果数量 ≥ head表的记录数(因为有1对多关系)
-
关键差异点
-
结果集基数不同:
-
大表驱动:结果行数=line表行数(15万)
-
小表驱动:结果行数≈head表行数×平均关联数(4832×31≈15万)
-
-
NULL记录出现位置不同:
-
大表驱动:可能出现head字段为NULL的记录
-
小表驱动:可能出现line字段为NULL的记录
-
-
分页边界问题:
-
两种方式的分页切分点可能不同,因为记录排序不同
-
特别是当使用ORDER BY时,排序字段的位置会影响分页边界
-
导出数量是否受影响?
-
如果只是简单导出所有关联数据:两种方式导出的总记录数应该相同(15万),因为您描述的是1对多关系且数据完整
-
如果有NULL记录过滤:导出数量可能会有差异
-
如果使用ORDER BY:分页边界不同可能导致某些记录在不同页中出现
实际建议
-
业务需求决定驱动方式:
-
如果需要确保不遗漏任何line记录 → 使用line驱动
-
如果需要确保不遗漏任何head记录 → 使用head驱动
-
-
导出一致性保障:
sql
复制
下载
-- 推荐使用游标方式而非分页导出,确保数据一致性 DECLARE cur CURSOR FOR SELECT ... FROM line LEFT JOIN head ... ORDER BY line.id; -- 或者使用基于ID的范围查询 SELECT ... FROM line LEFT JOIN head WHERE line.id > last_id ORDER BY line.id LIMIT 1000;
-
性能考虑:
-
如您之前发现的,line驱动+ORDER BY line.id性能更好
-
对于大批量导出,建议使用这种高效方式
-
-
导出验证:
-
可以在导出完成后检查两种方式的记录数是否匹配预期
-
特别检查边界条件记录是否都被包含
-
总结:驱动方式主要影响的是结果集的组成和顺序,在1对多关系且数据完整的情况下,总导出量应该相同,但具体哪些记录出现在哪些分页中可能会有差异。
4
sql
SELECT * FROM (
-- 有关联的记录
SELECT
/* 字段列表 */,
1 as record_type,
line.id as sort_key
FROM asset_fa_disposal head
JOIN asset_fa_disposal_line line ON head.code = line.parent_code
UNION ALL
-- 无关联的记录
SELECT
/* 字段列表 */,
2 as record_type,
head.code as sort_key
FROM asset_fa_disposal head
WHERE NOT EXISTS (
SELECT 1 FROM asset_fa_disposal_line
WHERE parent_code = head.code
)
) combined
ORDER BY record_type, sort_key DESC
LIMIT 1000 OFFSET 0;