JOIN FETCH 如何将数据库负载降低了94%:真实案例研究
发布: (2025年12月16日 GMT+8 04:47)
6 min read
原文: Dev.to
Source: Dev.to
介绍
N+1 问题是 Spring Boot 应用中导致数据库负载高的最常见原因之一。本文将通过一个真实的金融系统项目,系统性地展示如何解决该问题。
什么是 N+1 问题?
N+1 问题发生在 ORM 为加载关联实体而生成额外的 SELECT 查询时。
问题示例
@Entity
public class ExternalExchangeRate {
@ManyToOne(fetch = FetchType.LAZY)
private Currency currencyFrom;
@ManyToOne(fetch = FetchType.LAZY)
private Currency currencyTo;
}
执行时:
List rates = repository.findByExchangeDateAndCurrencyFromId(date, currencyFromId);
for (ExternalExchangeRate rate : rates) {
System.out.println(rate.getCurrencyFrom().getCode()); // N 次查询!
System.out.println(rate.getCurrencyTo().getCode()); // 再 N 次查询!
}
结果: 1 + N*2 次查询。
例如,一天有 15 条货币汇率:
- 1 条 SELECT 查询汇率
- 15 条 SELECT 查询
currencyFrom - 15 条 SELECT 查询
currencyTo
总计:31 条查询! 😱
检测 N+1 问题
开发模式下的配置
# application-dev.properties
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.generate_statistics=true
spring.jpa.properties.hibernate.use_sql_comments=true
logging.level.org.hibernate.SQL=DEBUG
查看 application-dev.properties →
用于监控的调试端点
@RestController
@RequestMapping("/api/debug")
public class DebugController {
@Autowired
private EntityManagerFactory emf;
@GetMapping("/hibernate-stats")
public Map getHibernateStats() {
Statistics stats = emf.unwrap(SessionFactory.class)
.getStatistics();
return Map.of(
"queriesExecuted", stats.getQueryExecutionCount(),
"prepareStatementCount", stats.getPrepareStatementCount(),
"entitiesLoaded", stats.getEntityLoadCount(),
"entitiesFetched", stats.getEntityFetchCount()
);
}
}
优化前的测量
GET /api/external-exchange-rates/latest?date=2024-11-24¤cyFromId=2
Result:
- Time: 48ms
- prepareStatementCount: 33 ← 实际执行的 SQL 语句数!
- queriesExecuted: 2 ← HQL/JPQL 查询类型数
- entitiesLoaded: 131
- entitiesFetched: 31 ← 额外的懒加载!
指标说明
queriesExecuted= 不同的 HQL/JPQL 查询数量prepareStatementCount= 实际的 JDBC 语句数(33)entitiesFetched= 懒加载的实体数(31 个Currency实体)
SQL 日志摘录:
-- 1 query for exchange rates
SELECT * FROM external_exchange_rates
WHERE exchange_date = '2024-11-24'
AND currency_from_id = 2;
-- 31 additional queries for currencies
SELECT * FROM currencies WHERE id = 1;
SELECT * FROM currencies WHERE id = 3;
SELECT * FROM currencies WHERE id = 4;
-- ... 28 more queries
JOIN FETCH 完美适用的场景
扁平数据结构(@ManyToOne)
JOIN FETCH 适用于实体只引用另一张表中 单个 实体的扁平结构。
@Entity
public class ExternalExchangeRate {
@Id
private Long id;
// 每条汇率只引用一个 FROM 货币
@ManyToOne(fetch = FetchType.LAZY)
private Currency currencyFrom;
// 每条汇率只引用一个 TO 货币
@ManyToOne(fetch = FetchType.LAZY)
private Currency currencyTo;
private BigDecimal rate;
}
为何能完美工作
- 1 ExternalExchangeRate → 1 Currency(from)
- 1 ExternalExchangeRate → 1 Currency(to)
- 没有多重关系(
@OneToMany) - 分页正常 ✅
- COUNT 查询准确 ✅
JOIN FETCH 不适用的场景
表分部与集合(@OneToMany)
当实体引用 集合 数据(例如单据明细行)时,JOIN FETCH 并非最佳选择。
@Entity
public class Invoice {
@Id
private Long id;
@ManyToOne
private Customer customer;
// ⚠️ 问题:集合的明细项!
@OneToMany(mappedBy = "invoice")
private List items; // 可能是 1、10、100+ 项!
}
分页问题
错误写法
@Query("SELECT DISTINCT i FROM Invoice i " +
"LEFT JOIN FETCH i.items") // ⚠️ 问题!
Page<Invoice> findAll(Pageable pageable);
失败原因
- 笛卡尔积: 1 Invoice 有 10 条 items → 产生 10 行。
- 分页失效: 页面大小 20 可能只返回 2 条发票。
- COUNT 不准确: 统计的是 join 后的行数,而不是发票数。
Hibernate 警告
HHH000104: firstResult/maxResults specified with collection fetch; applying in memory!
Hibernate 会 加载全部数据,在内存中再进行分页,性能优势荡然无存。
集合的替代方案
@EntityGraph– 让 Hibernate 高效地抓取关联。- 两次独立查询 – 完全掌控每一次抓取。
- DTO 投影 – 只抓取所需字段。
- 批量抓取 – 减少集合查询的次数。
注:我将在后续文章中用真实的单据 + 明细示例详细讲解 @EntityGraph。
对比表
| 功能 | JOIN FETCH(@ManyToOne) | JOIN FETCH(@OneToMany) |
|---|---|---|
| 分页 | ✅ 完全正常 | ❌ 会出错 |
| COUNT 准确性 | ✅ 正确 | ❌ 统计的是行数 |
| 查询次数 | ✅ 1–2 条 | ⚠️ 全部在内存中处理 |
| 可预测性 | ✅ 稳定 | ❌ 受数据量影响 |
| 推荐使用 | ✅ 适用 | ❌ 应避免 |
方案:对扁平结构使用 JOIN FETCH
步骤 1:创建优化后的 Repository 方法
@Repository
public interface ExternalExchangeRateRepository
extends JpaRepository<ExternalExchangeRate, Long> {
/**
* 根据日期和 currencyFrom 查找最新汇率,并加载关联的货币。
* 使用 JOIN FETCH 防止 N+1 查询。
*/
@Query("SELECT e FROM ExternalExchangeRate e " +
"LEFT JOIN FETCH e.currencyFrom " +
"LEFT JOIN FETCH e.currencyTo " +
"WHERE e.exchangeDate = :date " +
"AND e.currencyFrom.id = :currencyFromId")
List<ExternalExchangeRate> findLatestRatesByCurrencyFromWithCurrencies(
@Param("date") LocalDate date,
@Param("currencyFromId") Long currencyFromId);
/**
* 在单次查询中加载所有汇率及其关联的货币。
*/
@Query(value = "SELECT DISTINCT e FROM ExternalExchangeRate e " +
"LEFT JOIN FETCH e.currencyFrom " +
"LEFT JOIN FETCH e.currencyTo " +
"ORDER BY e.exchangeDate DESC, e.id DESC",
countQuery = "SELECT COUNT(e) FROM ExternalExchangeRate e")
Page<ExternalExchangeRate> findAllWithCurrencies(Pageable pageable);
}