引言
最近在排查一个财务报表系统的性能问题时,发现一个关键查询执行时间长达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可能导致:
-
无法有效利用索引
-
执行全表扫描
-
增加查询优化器的复杂度
优化方案
方案一:使用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>
经验总结
-
避免SELECT子句中的相关子查询:这是性能杀手
-
预计算聚合结果:使用CTE或临时表减少重复计算
-
合理使用索引:为JOIN条件和WHERE条件创建复合索引
-
理解JOIN类型:明确区分LEFT JOIN和INNER JOIN的应用场景
-
关注数据唯一性:一对多JOIN可能导致数据重复和计算错误
-
应用层配合优化:结合缓存、分页、异步处理等方案
结语
SQL优化是一个系统性的工程,需要从数据库设计、查询编写、索引优化到应用层缓存全方位考虑。通过这次优化,我们不仅解决了眼前的性能问题,更建立了一套完整的SQL性能监控和优化体系。
希望本文的经验能帮助大家在实际工作中更好地处理SQL性能问题。记住:优化永无止境,但每一次优化都应该有明确的目标和可衡量的结果。