SQL性能优化:子查询优化

引言

最近在排查一个财务报表系统的性能问题时,发现一个关键查询执行时间长达60秒,严重影响了用户体验。经过一系列优化后,查询时间降至6秒,性能提升了10倍。本文记录这次优化过程的技术细节和思考,希望对遇到类似问题的开发者有所帮助。

问题背景

系统有一个基金收益统计报表,需要展示每个基金的各项财务指标。原始SQL查询如下(简化版):

SELECT df.fund_id AS fundId, df.fund_name AS fundName,(SELECT accounting_method FROM dma_fund_accounting_method_checkbox WHERE fund_name = df.fund_name AND data_type = '清算维度') AS dimension, GREATEST(ROUND((1-MAX(sr.tcl_share_rate))*COALESCE(SUM(excess_income_amt), 0)/10000.0, 2),0)+ ROUND(COALESCE(SUM(payment_fund_rent_amt), 0)/10000.0, 2) AS totalFundIncomeAmt,-- 20+个类似的复杂计算字段... FROM dma_fund_cesy_fund_dim_qd df LEFT JOIN dma_s_tcl_share_rate sr ON df.fund_id = sr.fund_id AND sr.fund_id NOT IN ('12','14')-- 其他JOIN和WHERE条件GROUP BY df.fund_id, df.fund_name

性能问题分析

1. 子查询的N+1问题

这是最严重的性能问题。在SELECT子句中的相关子查询:

(SELECT accounting_method FROM dma_fund_accounting_method_checkbox

WHERE fund_name = df.fund_name AND data_type = '清算维度') AS dimension

问题分析:

  • 对于主查询返回的每一行,都会执行一次这个子查询

  • 如果主查询返回1000行,子查询执行1000次

  • 每次执行都需要解析SQL、优化、执行,开销巨大

优化方案:

改为LEFT JOIN,一次获取所有需要的数据:

LEFT JOIN dma_fund_accounting_method_checkbox amc

ON amc.fund_name = df.fund_name

AND amc.data_type = '清算维度'

2. 聚合函数的重复计算

原始查询中多次重复计算相同的聚合:

-- 计算了5次 COALESCE(SUM(excess_income_amt), 0)-- 计算了2次COALESCE(SUM(payment_fund_rent_amt), 0)

每次计算都需要扫描数据并执行聚合操作,大量浪费CPU和内存资源。

3. JOIN条件中的NOT IN

sql

LEFT JOIN dma_s_tcl_share_rate sr ON df.fund_id = sr.fund_id

AND sr.fund_id NOT IN ('12','14')

在JOIN条件中使用NOT IN可能导致:

  1. 无法有效利用索引

  2. 执行全表扫描

  3. 增加查询优化器的复杂度

优化方案

方案一:使用CTE预计算(推荐)

sql

WITH fund_aggregates AS (-- 预计算所有聚合结果 SELECT fund_id, fund_name,SUM(excess_income_amt) AS total_excess_income_amt,SUM(payment_fund_rent_amt) AS total_payment_fund_rent_amt,-- 其他聚合字段... MAX(accounting_type) AS accounting_typeFROM dma_fund_cesy_fund_dim_qdWHERE -- 查询条件 GROUP BY fund_id, fund_name ), filtered_share_rate AS (-- 预先过滤共享率数据 SELECT DISTINCT fund_id, tcl_share_rateFROM dma_s_tcl_share_rate WHERE fund_id NOT IN ('12','14'))SELECT fa.fund_id AS fundId, fa.fund_name AS fundName, amc.accounting_method AS dimension,-- 使用预计算结果进行计算 GREATEST(ROUND((1-COALESCE(sr.tcl_share_rate,0)) * COALESCE(fa.total_excess_income_amt,0)/10000.0, 2), 0)+ ROUND(COALESCE(fa.total_payment_fund_rent_amt,0)/10000.0, 2) AS totalFundIncomeAmt,*-- 其他字段...*FROM fund_aggregates fa LEFT JOIN dma_fund_accounting_method_checkbox amc ON amc.fund_name = fa.fund_name AND amc.data_type = '清算维度'LEFT JOIN filtered_share_rate sr ON fa.fund_id = sr.fund_id;

方案二:使用临时表(适合复杂场景)

sql

-- 创建临时表存储聚合结果 CREATE TEMPORARY TABLE tmp_fund_aggregates ASSELECT fund_id, fund_name,-- 聚合计算... FROM dma_fund_cesy_fund_dim_qd GROUP BY fund_id, fund_name;-- 然后基于临时表进行查询 SELECT fa.fund_id AS fundId, fa.fund_name AS fundName,-- ... FROM tmp_fund_aggregates fa -- ... 其他JOIN-- 清理临时表DROP TEMPORARY TABLE tmp_fund_aggregates;

索引优化

为关键查询字段创建合适的索引:

-- 为子查询表创建 覆盖索引 CREATE INDEX idx_checkbox_fund_data ON dma_fund_accounting_method_checkbox(fund_name, data_type, accounting_method);-- 为JOIN条件创建索引 CREATE INDEX idx_share_rate_fund ON dma_s_tcl_share_rate(fund_id, tcl_share_rate);-- 为主表创建 复合索引CREATE INDEX idx_fund_dim_main ON dma_fund_cesy_fund_dim_qd(fund_id, fund_name, accounting_type);

LEFT JOIN vs INNER JOIN的陷阱

在优化过程中,发现一个容易混淆的点:

FROM fund_aggregates fa

LEFT JOIN dma_fund_accounting_method_checkbox amc

ON amc.fund_name = fa.fund_name

AND amc.data_type = '清算维度'

重要发现:即使加上AND amc.data_type = '清算维度'条件,这仍然是LEFT JOIN,不是INNER JOIN。

两者的区别:

|-------|-----------|------------|
| 特性 | LEFT JOIN | INNER JOIN |
| 匹配原则 | 返回左表所有行 | 只返回匹配行 |
| 右表无匹配 | 右表字段为NULL | 左表行被过滤 |
| 结果集大小 | 可能大于左表 | 小于等于左表 |

一对多JOIN的陷阱:

如果右表存在重复记录,LEFT JOIN会产生多行结果:

-- 左表:1行(基金A)-- 右表:2行(基金A有2个会计方法)-- 结果:2行!这可能导致聚合计算错误翻倍

解决方案:

-- 使用DISTINCT或 子查询 确保唯一性LEFT JOIN (SELECT DISTINCT fund_name, accounting_methodFROM dma_fund_accounting_method_checkboxWHERE data_type = '清算维度') amc ON amc.fund_name = fa.fund_name

代码层面的优化

在Java应用层,我们可以进一步优化:

1. 使用MyBatis的缓存机制

xml

<!-- 启用 二级缓存 --> <cache eviction="LRU" flushInterval="60000" size="512" readOnly="true"/><!-- 对查询结果缓存 -->

<select id="getFundReport" resultMap="fundReportMap" useCache="true"><!-- SQL 查询 --></select>

2. 分页查询优化

java

// 使用 游标 分页代替传统分页public List<FundReport> getFundReportByCursor(Long lastId, int pageSize) {return sqlSession.selectList("FundMapper.getFundReportByCursor", Map.of("lastId", lastId, "pageSize", pageSize));}

xml

<select id="getFundReportByCursor" resultMap="fundReportMap">

SELECT * FROM (

-- 优化后的查询

) t

WHERE t.fundId > #{lastId}

ORDER BY t.fundId

LIMIT #{pageSize}

</select>

经验总结

  1. 避免SELECT子句中的相关子查询:这是性能杀手

  2. 预计算聚合结果:使用CTE或临时表减少重复计算

  3. 合理使用索引:为JOIN条件和WHERE条件创建复合索引

  4. 理解JOIN类型:明确区分LEFT JOIN和INNER JOIN的应用场景

  5. 关注数据唯一性:一对多JOIN可能导致数据重复和计算错误

  6. 应用层配合优化:结合缓存、分页、异步处理等方案

结语

SQL优化是一个系统性的工程,需要从数据库设计、查询编写、索引优化到应用层缓存全方位考虑。通过这次优化,我们不仅解决了眼前的性能问题,更建立了一套完整的SQL性能监控和优化体系。

希望本文的经验能帮助大家在实际工作中更好地处理SQL性能问题。记住:优化永无止境,但每一次优化都应该有明确的目标和可衡量的结果。

相关推荐
皮皮林5514 小时前
Java性能调优黑科技!1行代码实现毫秒级耗时追踪,效率飙升300%!
java
冰_河4 小时前
QPS从300到3100:我靠一行代码让接口性能暴涨10倍,系统性能原地起飞!!
java·后端·性能优化
桦说编程7 小时前
从 ForkJoinPool 的 Compensate 看并发框架的线程补偿思想
java·后端·源码阅读
躺平大鹅9 小时前
Java面向对象入门(类与对象,新手秒懂)
java
初次攀爬者10 小时前
RocketMQ在Spring Boot上的基础使用
java·spring boot·rocketmq
花花无缺10 小时前
搞懂@Autowired 与@Resuorce
java·spring boot·后端
倔强的石头_10 小时前
kingbase备份与恢复实战(二)—— sys_dump库级逻辑备份与恢复(Windows详细步骤)
数据库
Derek_Smart11 小时前
从一次 OOM 事故说起:打造生产级的 JVM 健康检查组件
java·jvm·spring boot
NE_STOP12 小时前
MyBatis-mybatis入门与增删改查
java