下载工作台
Spring Boot Web 开发

JPA 性能优化与 N+1 排查

试读上半部分 · 解锁后可读全文

第 17 章 · JPA 性能优化与 N+1 排查

本章目标:掌握 HikariCP 连接池参数调优;使用 @EntityGraphJOIN FETCH@BatchSize 解决 N+1 查询;运用 only/@JsonIgnoreDTO 投影 减少字段加载;理解 读写分离db-primary.example.com / db-replica.example.com)与 AbstractRoutingDataSource 概念;借助 Hibernate 统计p6spy 或测试断言定位慢查询;建立 N+1 回归测试基线。

学时建议:6~7 小时(含 2 小时性能实验)

前置:本模块 ch05 JPA 实体与 Repository;ch09 日志与 traceId;ch16 RBAC 列表查询可作为优化对象;django-web ch17 可横向对照。


17.1 性能问题从哪来

shop-spring-demo 商品与订单量上涨后的典型症状:

现象可能原因
首请求慢、后续变快连接池冷启动
凌晨后 API 报错 Connection is not available连接泄漏或 maxLifetime 不当
商品列表 10 条打 11+ 次 SQLN+1 懒加载 category
报表接口超时缺索引、全表扫描
写后立刻读不到主从复制延迟(读写分离场景)
 Spring Boot (Tomcat)              PostgreSQL / MySQL
┌──────────────────┐             ┌────────────────────┐
│ JPA / Hibernate  │── HikariCP ►│ db-primary.example │ 写
└──────────────────┘             └─────────┬──────────┘
      │ 只读查询(选修)                      │ 流复制
      └──────────────────────────────────►┌────────────────────┐
                                          │ db-replica.example │ 读
                                          └────────────────────┘
数据库主机均为虚构域名;本地 H2 可学 ORM 优化,连接池与读写分离在 staging 用 MySQL/PostgreSQL 验证。

17.2 HikariCP 连接池

Spring Boot 默认使用 HikariCPapplication-prod.yml

spring:
  datasource:
    url: jdbc:postgresql://db-primary.example.com:5432/shop_spring
    username: ${DB_USER:shop_spring}
    password: ${DB_PASSWORD}
    driver-class-name: org.postgresql.Driver
    hikari:
      pool-name: ShopSpringHikari
      maximum-pool-size: 20        # 约等于 (核心数 * 2) + 有效磁盘数,按压测调整
      minimum-idle: 5
      connection-timeout: 30000    # 毫秒,获取连接超时
      idle-timeout: 600000         # 10 分钟
      max-lifetime: 1800000        # 30 分钟,应小于 DB wait_timeout
      connection-test-query: SELECT 1
参数含义常见误区
maximum-pool-size最大连接数越大越好 → 打满 DB
max-lifetime连接最大存活超过 DB 断连导致偶发错误
connection-timeout等待连接时间过短在高并发下误报

连接泄漏排查:开启 leak-detection-threshold: 60000(开发环境),日志出现 Connection leak detection 时检查未关闭的 EntityManager 或长事务。

// 错误:在 @Transactional 外持有懒加载集合遍历
@GetMapping("/products")
public List<Product> list() {
    return productRepository.findAll(); // 返回后 Session 关闭,懒加载报错或额外查询
}

正确:在 Service 层事务内完成加载,或使用 DTO 投影(ch13)。


17.3 N+1 问题演示与修复

17.3.1 问题复现

@Entity
public class Product {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "category_id")
    private Category category;
}
@GetMapping("/api/v1/products")
public List<ProductDto> list() {
    return productRepository.findAll().stream()
        .map(p -> new ProductDto(p.getId(), p.getName(), p.getCategory().getName()))
        .toList();
}

开启 SQL 日志:

logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE
spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true

10 条商品 → 1 + 10 = 11 次查询(经典 N+1)。

17.3.2 方案一:JOIN FETCH(JPQL)

public interface ProductRepository extends JpaRepository<Product, Long> {

    @Query("SELECT p FROM Product p JOIN FETCH p.category WHERE p.published = true")
    List<Product> findPublishedWithCategory();
}

17.3.3 方案二:@EntityGraph

@EntityGraph(attributePaths = {"category"})
List<Product> findByPublishedTrue();

或在接口方法上:

@Override
@EntityGraph(attributePaths = {"category", "tags"})
Optional<Product> findById(Long id);

17.3.4 方案三:@BatchSize(集合懒加载)

一对多 Product.tags 懒加载时:

@Entity
public class Product {
    @OneToMany(mappedBy = "product", fetch = FetchType.LAZY)
    @BatchSize(size = 20)
    private List<Tag> tags = new ArrayList<>();
}

Hibernate 将 IN (...) 批量加载,减少 round-trip。

方案适用注意
JOIN FETCH单关联、列表页多集合 FETCH 可能笛卡尔积
@EntityGraphRepository 声明式与分页同用需 @EntityGraph + count 查询分离
@BatchSize集合懒加载仍可能多次 IN,不如一次 JOIN
DTO 投影只读列表最优性能,见 ch13

17.4 复杂查询与索引

17.4.1 Specification / QueryDSL(选修)

动态筛选商品(名称、分类、价格区间):

public static Specification<Product> nameContains(String q) {
    return (root, query, cb) ->
        q == null ? cb.conjunction() : cb.like(root.get("name"), "%" + q + "%");
}

productRepository.findAll(
    Specification.where(nameContains(keyword))
        .and((root, q, cb) -> cb.isTrue(root.get("published"))),
    PageRequest.of(page, size, Sort.by("createdAt").descending())
);

17.4.2 索引与 EXPLAIN

@Entity
@Table(indexes = {

以下内容需解锁后阅读

试读已结束。解锁本章 ¥5.00,或开通年度会员畅读全部教程。
年度会员 ¥199.00/年; 小紫 AI 工作台有效会员 ¥99.00/年

正文仅在服务端鉴权后下发,未付费无法获取下半部分内容。