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;
}

查看 ExternalExchangeRate 代码 →

执行时:

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()
        );
    }
}

查看 DebugController 代码 →

优化前的测量

GET /api/external-exchange-rates/latest?date=2024-11-24&currencyFromId=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(@ManyToOneJOIN 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);
}
Back to Blog

相关文章

阅读更多 »