第 17 章 · JPA 性能优化与 N+1 排查
本章目标:掌握 HikariCP 连接池参数调优;使用 @EntityGraph、JOIN FETCH 与 @BatchSize 解决 N+1 查询;运用 only/@JsonIgnore 与 DTO 投影 减少字段加载;理解 读写分离(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+ 次 SQL | N+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 默认使用 HikariCP。application-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 可能笛卡尔积 |
| @EntityGraph | Repository 声明式 | 与分页同用需 @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 = {