Spring Data JPA 高级指南:关联映射、N+1 与性能优化

Spring Data JPA 深度实战:关联映射、N+1 问题根治、批量操作、分页优化、二级缓存、QueryDSL 动态查询与 JPA vs MyBatis 选型。

Spring Data JPA 是 Spring 生态中最主流的 ORM 封装方案,它屏蔽了绝大多数 JDBC 样板代码,让开发者以声明式方式操作数据库。然而,简单 CURD 与生产级应用之间存在巨大的性能鸿沟:一次不小心的 EAGER 加载可能拖垮整个接口,一个缺失的索引会让分页查询变成全表扫描。本文从实体映射、关联设计、N+1 根治、批量写入、分页策略、审计、动态查询、二级缓存到事务传播,逐层递进,给出可直接落地的代码与配置。


1. JPA 实体基础与最佳实践

1.1 最小可用实体示例

一个规范的 JPA 实体应包含:主键策略、不可变字段、合适的列约束与无参构造器。

package com.example.demo.entity;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity
@Table(name = "t_user", indexes = {
    @Index(name = "idx_email", columnList = "email"),
    @Index(name = "idx_status_ct", columnList = "status,created_time")
})
public class User {

    // 主键使用自增 BIGINT,业务不感知主键值
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // 不允许为空,唯一索引在数据库层保证
    @Column(nullable = false, length = 120)
    private String email;

    @Column(nullable = false, length = 64)
    private String username;

    // 状态机字段,使用 tinyint 映射
    @Column(nullable = false)
    private Integer status = 1;

    // 创建时间与更新时间由框架自动维护,见第 6 节
    @Column(name = "created_time", nullable = false, updatable = false)
    private LocalDateTime createdTime;

    @Column(name = "updated_time", nullable = false)
    private LocalDateTime updatedTime;

    // JPA 规范要求无参构造器
    protected User() {}

    public User(String email, String username) {
        this.email = email;
        this.username = username;
    }

    // Getter 省略,Setter 视业务需要开放
}

1.2 主键策略选型

策略适用场景注意事项
IDENTITYMySQL / SQL Server 自增列批量插入时会退化为逐条插入,性能较低
SEQUENCEPostgreSQL / Oracle配合 allocationSize 可减少序列访问次数
TABLE需要数据库无关性时存在单点性能瓶颈,生产环境慎用
UUID分布式系统、数据合并场景索引碎片化严重,建议用有序 UUID(如 UUIDv7)

对于 MySQL 且需批量插入的场景,可显式指定 GenerationType.SEQUENCE 并使用 Hibernate 的 hibernate.id.new_generator_mappings = true,或改用应用层发号器(如 Leaf、TinyID)。


2. 关联映射深度解析

关系型数据库的核心在于表之间的关联,JPA 通过对象引用来表达这种关系,但背后生成的 SQL 却差异巨大。理解 fetch 策略与级联行为是避免性能灾难的第一步。

2.1 @OneToOne:共享主键 vs 外键关联

一对一通常用于拆分宽表或敏感字段隔离。推荐共享主键方式,避免额外索引。

@Entity
@Table(name = "t_user_profile")
public class UserProfile {

    @Id
    @Column(name = "user_id")
    private Long userId;

    @OneToOne(fetch = FetchType.LAZY)
    @MapsId
    @JoinColumn(name = "user_id")
    private User user;

    @Column(length = 500)
    private String bio;

    @Column(name = "avatar_url", length = 500)
    private String avatarUrl;
}

@MapsId 表示使用关联实体的主键作为自己的主键,同时兼任外键。生成的表结构仅包含 user_idbioavatar_url,无需额外唯一索引。

2.2 @OneToMany / @ManyToOne:双向关联与拥有方

一对多与多对一构成双向关联。多的一方(@ManyToOne)始终是拥有方(owning side),它持有外键列。

@Entity
@Table(name = "t_department")
public class Department {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    // 一对多: LAZY 加载,mappedBy 指向子表的字段名
    @OneToMany(mappedBy = "department", fetch = FetchType.LAZY,
               cascade = CascadeType.ALL, orphanRemoval = true)
    private List<Employee> employees = new ArrayList<>();

    // 辅助方法:维护双向关联的一致性
    public void addEmployee(Employee e) {
        employees.add(e);
        e.setDepartment(this);
    }

    public void removeEmployee(Employee e) {
        employees.remove(e);
        e.setDepartment(null);
    }
}
@Entity
@Table(name = "t_employee")
public class Employee {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    // 多对一:拥有方,数据库中生成 department_id 外键
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "department_id", nullable = false)
    private Department department;

    // Getter / Setter 省略
}

关键约定

  • 永远将 fetch = FetchType.LAZY 显式写出,Hibernate 默认对 @ManyToOneEAGER,极易导致 N+1。
  • CascadeType.ALL + orphanRemoval = true 表示对员工的持久化操作会级联到部门,且从部门集合移除的员工会被自动删除。
  • 双向关联必须通过辅助方法维护一致性,否则会出现外键为空或脏数据。

2.3 @ManyToMany:避免联合主键表,使用中间实体

多对多默认生成仅有双外键的联合表,无法扩展。生产环境强烈建议拆分为独立的中间实体。

@Entity
@Table(name = "t_user_role_link")
public class UserRoleLink {

    @EmbeddedId
    private UserRoleId id;

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("userId")
    @JoinColumn(name = "user_id")
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @MapsId("roleId")
    @JoinColumn(name = "role_id")
    private Role role;

    @Column(name = "granted_at", nullable = false)
    private LocalDateTime grantedAt;

    @Column(length = 100)
    private String grantedBy;
}
@Embeddable
public class UserRoleId implements Serializable {

    @Column(name = "user_id")
    private Long userId;

    @Column(name = "role_id")
    private Long roleId;

    // 必须重写 equals 和 hashCode
    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof UserRoleId)) return false;
        UserRoleId that = (UserRoleId) o;
        return Objects.equals(userId, that.userId) &&
               Objects.equals(roleId, that.roleId);
    }

    @Override
    public int hashCode() {
        return Objects.hash(userId, roleId);
    }
}

2.4 LAZY vs EAGER:一张图的代价

FetchType.EAGER 在实体加载时会立即执行额外的 JOIN 或 SELECT 去获取关联对象。当查询主列表时,每一条记录都触发关联加载,就会变成 N+1 的温床。所有关联均设为 LAZY,在查询层按需抓取是铁律。


3. N+1 问题:诊断与根治

3.1 什么是 N+1

假设有 100 个部门,每个部门有若干员工。以下代码会触发多少次查询?

List<Department> departments = departmentRepository.findAll();
for (Department d : departments) {
    // 第一次遍历时,Hibernate 会为每个部门额外发一条 SQL 查员工
    for (Employee e : d.getEmployees()) {
        System.out.println(e.getName());
    }
}

答案是 1 + 100 = 101 次。第一条 findAll() 查部门,随后每个部门的 getEmployees() 都触发一次 SELECT。这就是 N+1。

3.2 方案一:EntityGraph(JPA 标准)

EntityGraph 允许在特定查询中动态指定需要 EAGER 抓取的关联,而不影响全局映射的 LAZY 策略。

// 在实体上定义命名 EntityGraph
@Entity
@Table(name = "t_department")
@NamedEntityGraph(name = "Department.withEmployees",
    attributeNodes = @NamedAttributeNode("employees"))
public class Department { /* ... */ }
// Repository 层使用
public interface DepartmentRepository extends JpaRepository<Department, Long> {

    @Override
    @EntityGraph(value = "Department.withEmployees", type = EntityGraph.EntityGraphType.LOAD)
    List<Department> findAll();
}

执行时 Hibernate 会生成 LEFT OUTER JOIN 一次性把员工数据带出来。注意:若一对多关联数据量大,JOIN 会导致主表数据膨胀(笛卡尔积),此时更适合方案二。

3.3 方案二:@BatchSize(Hibernate 扩展)

@BatchSize 不改变查询结构,而是将 N 条单条查询合并为少量 IN 查询。

@Entity
@Table(name = "t_department")
public class Department {

    @OneToMany(mappedBy = "department", fetch = FetchType.LAZY)
    @BatchSize(size = 30)  // 每 30 个部门批量查一次员工
    private List<Employee> employees = new ArrayList<>();
}

查询 100 个部门时,员工查询次数变为 100 / 30 ≈ 4 次,而非 100 次。size 建议设为 15-50,需根据数据库 IN 子句长度限制调整。

也可全局配置:

spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 30

3.4 方案三:JOIN FETCH(JPQL 显式抓取的终极手段)

public interface DepartmentRepository extends JpaRepository<Department, Long> {

    // JOIN FETCH 直接写在 JPQL 中,最直观
    @Query("SELECT DISTINCT d FROM Department d LEFT JOIN FETCH d.employees")
    List<Department> findAllWithEmployees();

    // 配合分页时不可直接用 JOIN FETCH 在一对多上,否则count查询会出错
    @Query("SELECT d FROM Department d JOIN FETCH d.employees WHERE d.name LIKE %:name%")
    List<Department> findByNameWithEmployees(@Param("name") String name);
}

重要JOIN FETCH 用于 @ManyToOne 或一对一的场景最干净;用于 @OneToMany 时,若配合 Pageable 分页需要拆成两次查询(先查 ID,再查详情),否则 Hibernate 会抛出 HHH000104 警告。

3.5 方案四: projections / DTO 直查

如果仅需要部分字段做列表展示,没必要加载整个实体图。Spring Data JPA 支持接口投影与类投影。

// 接口投影:基于方法名的属性自动映射
public interface DepartmentSummary {
    Long getId();
    String getName();
    // 嵌套投影
    interface EmployeeInfo {
        String getName();
    }
    List<EmployeeInfo> getEmployees();
}

public interface DepartmentRepository extends JpaRepository<Department, Long> {

    @Query("SELECT d FROM Department d WHERE d.status = :status")
    List<DepartmentSummary> findSummariesByStatus(@Param("status") Integer status);
}

对于更复杂的聚合统计,直接用类投影 + 构造器表达式:

@Query("SELECT NEW com.example.demo.dto.DeptEmpCountDto(d.id, d.name, COUNT(e.id)) " +
       "FROM Department d LEFT JOIN d.employees e GROUP BY d.id, d.name")
List<DeptEmpCountDto> findDeptEmpCounts();

4. 批量操作优化

4.1 批量写入:jdbc.batch_size 与重写

Spring Data JPA 的 saveAll() 默认逐条 insert,性能极差。开启批量写入需要三层配合。

配置层(application.yml)

spring:
  jpa:
    properties:
      hibernate:
        jdbc:
          batch_size: 500           # 每 500 条刷盘一次
          batch_versioned_data: true # 批量更新乐观锁字段
        order_inserts: true         # 对插入排序,合并同表操作
        order_updates: true         # 对更新排序
        dialect: org.hibernate.dialect.MySQLDialect
  datasource:
    url: jdbc:mysql://localhost:3306/demo?rewriteBatchedStatements=true&useServerPrepStmts=false

rewriteBatchedStatements=true 是 MySQL 驱动关键参数,否则驱动端不会真正合并 SQL。

实体层:主键策略必须改成 SEQUENCE 或应用层发号,因为 IDENTITY 会在每批插入后立刻回查自增值,破坏批处理语义。

@Entity
@Table(name = "t_order")
public class Order {

    @Id
    // 使用 Hibernate 的 SequenceStyleGenerator,兼容 MySQL 的表模拟序列
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq")
    @GenericGenerator(
        name = "order_seq",
        strategy = "org.hibernate.id.enhanced.SequenceStyleGenerator",
        parameters = {
            @Parameter(name = "sequence_name", value = "seq_order"),
            @Parameter(name = "initial_value", value = "1"),
            @Parameter(name = "increment_size", value = "500"),
            @Parameter(name = "optimizer", value = "pooled-lo")
        }
    )
    private Long id;

    @Column(nullable = false)
    private String orderNo;

    // 省略其他字段
}

应用层:使用 saveAll() 前确保不会执行额外 SELECT,且分块提交。

@Service
public class OrderService {

    @Autowired
    private OrderRepository orderRepository;

    @Transactional
    public void batchInsert(List<Order> orders) {
        // 分批处理防止内存溢出
        Lists.partition(orders, 500).forEach(chunk -> {
            orderRepository.saveAll(chunk);
            // 手动 flush 并清理一级缓存,避免 session 过大
            orderRepository.flush();
        });
    }
}

4.2 批量更新与删除:避免内存加载

批量更新/删除应绕过一级缓存,直接发 SQL,否则几万条数据会把堆内存撑爆。

public interface EmployeeRepository extends JpaRepository<Employee, Long> {

    // 直接执行 UPDATE,不加载实体到内存
    @Modifying
    @Query("UPDATE Employee e SET e.status = :status WHERE e.department.id = :deptId")
    int batchUpdateStatusByDept(@Param("status") Integer status,
                                @Param("deptId") Long deptId);

    // 批量物理删除
    @Modifying
    @Query("DELETE FROM Employee e WHERE e.joinDate < :cutoff")
    int batchDeleteBefore(@Param("cutoff") LocalDate cutoff);
}

注意:@Modifying 查询会清除受影响的实体区域缓存,若与二级缓存结合使用,需额外配置 clearAutomatically = trueflushAutomatically = true


5. 分页与排序:Pageable 的陷阱与优化

5.1 Page vs Slice

// 需要总页数、总条数时用 Page
Page<Order> page = orderRepository.findByStatus(1, PageRequest.of(0, 20, Sort.by("id").descending()));

// 仅需是否有下一页时用 Slice:不执行 count 查询,性能更好
Slice<Order> slice = orderRepository.findByStatus(1, PageRequest.of(0, 20));

5.2 Count 优化:覆盖索引与 count 查询重写

分页接口慢,十有八九卡在 SELECT count(*)。Spring Data JPA 生成的 count 查询会带上所有 JOIN,导致索引失效。

方案 A:自定义 count 查询

@Query(value = "SELECT o FROM Order o JOIN FETCH o.items WHERE o.userId = :userId",
       countQuery = "SELECT count(o) FROM Order o WHERE o.userId = :userId")
Page<Order> findByUserIdWithItems(@Param("userId") Long userId, Pageable pageable);

方案 B:使用覆盖索引 + 原生查询

@Query(value = "SELECT * FROM t_order WHERE user_id = ?1 ORDER BY id DESC LIMIT ?2 OFFSET ?3",
       countQuery = "SELECT COUNT(1) FROM t_order WHERE user_id = ?1",
       nativeQuery = true)
Page<Order> findByUserIdNative(Long userId, int limit, int offset, Pageable pageable);

方案 C:延迟 count(先返回列表,异步或缓存总条数)

对于数据量极大且实时性要求不高的后台列表,可用 Redis 缓存总条数,或前端采用"无限滚动"只关心 Slice

5.3 游标分页:跳过 offset 性能瓶颈

当数据量达到千万级,OFFSET 会越来越慢。使用"上一页最后一条 ID"作为游标:

@Query("SELECT o FROM Order o WHERE o.userId = :userId AND o.id < :lastId ORDER BY o.id DESC")
List<Order> findNextPage(@Param("userId") Long userId,
                         @Param("lastId") Long lastId,
                         Pageable pageable);

客户端保存当前页最后一条记录的 ID,下一页传入 lastId。此方案无 count、无 offset,性能稳定。


6. 审计(Auditing):自动维护创建与更新时间

6.1 启用 JPA Auditing

@Configuration
@EnableJpaAuditing(auditorAwareRef = "auditorAware")
public class JpaConfig {
}

6.2 实体中声明审计字段

@Entity
@Table(name = "t_article")
@EntityListeners(AuditingEntityListener.class)
public class Article {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    @CreatedDate
    @Column(name = "created_at", nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @LastModifiedDate
    @Column(name = "updated_at", nullable = false)
    private LocalDateTime updatedAt;

    @CreatedBy
    @Column(name = "created_by", length = 64, updatable = false)
    private String createdBy;

    @LastModifiedBy
    @Column(name = "updated_by", length = 64)
    private String updatedBy;
}

6.3 提供当前操作人

@Component
public class AuditorAwareImpl implements AuditorAware<String> {

    @Override
    public Optional<String> getCurrentAuditor() {
        // 从 SecurityContext 获取当前登录用户
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth == null || !auth.isAuthenticated()) {
            return Optional.of("system");
        }
        return Optional.of(auth.getName());
    }
}

7. QueryDSL 与 Specification:动态查询的两种范式

7.1 QueryDSL 集成

QueryDSL 通过 APT 生成 Q 类,在编译期保证类型安全。

Maven 依赖(pom.xml)

<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-jpa</artifactId>
    <classifier>jakarta</classifier>
    <version>5.1.0</version>
</dependency>
<dependency>
    <groupId>com.querydsl</groupId>
    <artifactId>querydsl-apt</artifactId>
    <classifier>jakarta</classifier>
    <version>5.1.0</version>
    <scope>provided</scope>
</dependency>

查询示例

@Service
public class OrderQueryService {

    @PersistenceContext
    private EntityManager em;

    public List<Order> search(OrderSearchParam param) {
        JPAQueryFactory qf = new JPAQueryFactory(em);
        QOrder order = QOrder.order;
        QOrderItem item = QOrderItem.orderItem;

        return qf.selectFrom(order)
            .leftJoin(order.items, item).fetchJoin()
            .where(
                param.getUserId() != null ? order.userId.eq(param.getUserId()) : null,
                param.getStatus() != null ? order.status.eq(param.getStatus()) : null,
                param.getStartDate() != null ? order.createdAt.goe(param.getStartDate()) : null,
                param.getEndDate() != null ? order.createdAt.loe(param.getEndDate()) : null,
                param.getKeyword() != null ? order.orderNo.containsIgnoreCase(param.getKeyword()) : null
            )
            .orderBy(order.id.desc())
            .offset(param.getOffset())
            .limit(param.getLimit())
            .fetch();
    }
}

QueryDSL 的 BooleanExpression 可封装为可复用的谓词:

public class OrderPredicates {

    public static BooleanExpression userIdEq(Long userId) {
        return userId != null ? QOrder.order.userId.eq(userId) : null;
    }

    public static BooleanExpression dateRange(LocalDateTime from, LocalDateTime to) {
        if (from != null && to != null) {
            return QOrder.order.createdAt.between(from, to);
        }
        return null;
    }
}

7.2 Specification(JPA Criteria 的封装)

若不引入 QueryDSL,可用 Spring Data 原生的 Specification

public interface OrderRepository extends JpaRepository<Order, Long>, JpaSpecificationExecutor<Order> {
}
@Service
public class OrderSpecService {

    @Autowired
    private OrderRepository orderRepository;

    public Page<Order> search(OrderSearchParam param, Pageable pageable) {
        Specification<Order> spec = (root, query, cb) -> {
            List<Predicate> predicates = new ArrayList<>();

            if (param.getUserId() != null) {
                predicates.add(cb.equal(root.get("userId"), param.getUserId()));
            }
            if (param.getStatus() != null) {
                predicates.add(cb.equal(root.get("status"), param.getStatus()));
            }
            if (param.getKeyword() != null) {
                predicates.add(cb.like(root.get("orderNo"), "%" + param.getKeyword() + "%"));
            }
            if (param.getStartDate() != null && param.getEndDate() != null) {
                predicates.add(cb.between(root.get("createdAt"), param.getStartDate(), param.getEndDate()));
            }

            return cb.and(predicates.toArray(new Predicate[0]));
        };

        return orderRepository.findAll(spec, pageable);
    }
}
维度QueryDSLSpecification
类型安全编译期检查,Q 类自动生成字符串属性名,运行时才能发现错误
代码可读性接近 SQL 的流式 API较冗长,需要 root / cb / query
学习成本需配置 APT 与额外依赖Spring 原生,开箱即用
动态 JOIN简洁直观较繁琐
推荐场景复杂查询居多的中大型项目简单条件检索、不愿引入额外依赖

8. 二级缓存:Ehcache 与 Caffeine

JPA 的一级缓存(Session/EntityManager)生命周期绑定于事务,无法跨请求复用。二级缓存(Second-Level Cache)在 SessionFactory 级别共享,适合读多写少的业务数据。

8.1 开启二级缓存

spring:
  jpa:
    properties:
      hibernate:
        cache:
          use_second_level_cache: true
          use_query_cache: true
          region:
            factory_class: org.hibernate.cache.jcache.internal.JCacheRegionFactory

8.2 Ehcache 配置

<!-- ehcache.xml -->
<config xmlns="http://www.ehcache.org/v3">
    <cache-template name="default">
        <expiry>
            <ttl unit="minutes">10</ttl>
        </expiry>
        <heap unit="entries">1000</heap>
    </cache-template>

    <cache alias="com.example.demo.entity.Department" uses-template="default"/>
    <cache alias="com.example.demo.entity.Role" uses-template="default"/>
</config>

8.3 实体缓存注解

@Entity
@Table(name = "t_department")
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Department { /* ... */ }
@Entity
@Table(name = "t_role")
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE)
public class Role { /* ... */ }
  • READ_ONLY:数据插入后永不变更,性能最高。
  • NONSTRICT_READ_WRITE:允许偶尔不一致,不加锁,适合读远多于写。
  • READ_WRITE:通过软锁保证读写一致性,性能略低。
  • TRANSACTIONAL:需要 JTA 事务管理器,分布式事务场景使用。

8.4 查询缓存

@QueryHints(@QueryHint(name = org.hibernate.annotations.QueryHints.CACHEABLE, value = "true"))
@Query("SELECT d FROM Department d WHERE d.status = :status")
List<Department> findByStatusWithCache(@Param("status") Integer status);

8.5 Caffeine 替代方案(纯应用层缓存)

若不想引入 JCache 复杂性,可在 Service 层直接用 Caffeine:

@Service
public class DepartmentCacheService {

    private final LoadingCache<Long, Department> cache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .expireAfterWrite(Duration.ofMinutes(10))
        .refreshAfterWrite(Duration.ofMinutes(5))
        .build(id -> departmentRepository.findById(id).orElse(null));

    public Department getById(Long id) {
        return cache.get(id);
    }

    @CacheEvict(value = "dept", key = "#department.id")
    @Transactional
    public void update(Department department) {
        departmentRepository.save(department);
    }
}

注意:应用层缓存不会自动处理关联实体的缓存一致性,适合单项字典类数据。业务关联复杂的场景仍建议使用 Hibernate 二级缓存。


9. 事务传播与隔离级别

9.1 传播行为实战选型

@Service
public class OrderApplicationService {

    @Autowired
    private OrderService orderService;
    @Autowired
    private PaymentService paymentService;
    @Autowired
    private InventoryService inventoryService;

    // 外部事务:下单主流程
    @Transactional(rollbackFor = Exception.class)
    public Order createOrder(CreateOrderCmd cmd) {
        Order order = orderService.save(cmd);

        // 扣库存与支付需与主流程同事务,任一失败全回滚
        inventoryService.deduct(order);
        paymentService.charge(order);

        return order;
    }
}
@Service
public class LogService {

    // 日志记录的异常不能影响主业务,故用 REQUIRES_NEW 独立事务
    @Transactional(propagation = Propagation.REQUIRES_NEW,
                   rollbackFor = Exception.class)
    public void saveOperationLog(String content) {
        operationLogRepository.save(new OperationLog(content));
    }
}
@Service
public class AuditService {

    // 审计接口可独立成功,即使主业务回滚,可选 NOT_SUPPORTED 或 REQUIRES_NEW
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void asyncAudit(AuditEvent event) {
        auditRepository.save(event);
    }
}

9.2 只读事务优化查询

@Transactional(readOnly = true)
public List<Order> findUserOrders(Long userId) {
    // readOnly = true 时 Hibernate 不会执行脏检查,一级缓存不维护快照,节省内存与 CPU
    return orderRepository.findByUserId(userId);
}

9.3 自定义隔离级别

@Transactional(isolation = Isolation.READ_COMMITTED, timeout = 5)
public void updateStock(Long productId, int quantity) {
    // 显式指定隔离级别,避免数据库默认隔离级别不可控
}

常用隔离级别:

  • READ_UNCOMMITTED:极少使用,脏读不可接受。
  • READ_COMMITTED(默认):防止脏读,允许不可重复读与幻读。
  • REPEATABLE_READ:MySQL InnoDB 默认,通过 MVCC 防止不可重复读。
  • SERIALIZABLE:串行化,性能最差,仅用于极端严格的对账场景。

10. JPA vs MyBatis:选型参考

两者并非互斥,很多团队采用混合策略:JPA 负责主业务 CRUD 与关联管理,MyBatis 负责复杂报表与遗留系统兼容。

10.1 核心差异

维度Spring Data JPAMyBatis / MyBatis-Plus
开发范式面向对象,声明式面向 SQL,命令式
代码量极少,Repository 接口即可需写 XML / Mapper / SQL
关联查询自动映射,易用但需关注性能手工编写 JOIN,完全可控
动态查询QueryDSL / Criteria / SpecificationXML <if> 标签或 Lambda 封装
缓存二级缓存集成度高需自行集成 Redis 或 Ehcache
分页内置 Pageable,count 自动生成手动拦截或插件支持
团队门槛需理解 Hibernate 原理需熟悉 SQL 与数据库优化
适用场景业务模型清晰、CRUD 为主复杂查询、极端性能优化、遗留项目

10.2 混合架构示例

// JPA 负责实体管理与常规业务
public interface OrderRepository extends JpaRepository<Order, Long> {}

// MyBatis 负责复杂报表
@Mapper
public interface OrderReportMapper {

    @Select("<script>" +
            "SELECT o.id, o.order_no, u.username, SUM(oi.price * oi.qty) as total " +
            "FROM t_order o " +
            "JOIN t_user u ON o.user_id = u.id " +
            "JOIN t_order_item oi ON o.id = oi.order_id " +
            "WHERE o.created_at BETWEEN #{start} AND #{end} " +
            "<if test='status != null'> AND o.status = #{status} </if>" +
            "GROUP BY o.id " +
            "ORDER BY total DESC " +
            "LIMIT #{limit} OFFSET #{offset}" +
            "</script>")
    List<OrderReportDto> findReport(@Param("start") LocalDateTime start,
                                    @Param("end") LocalDateTime end,
                                    @Param("status") Integer status,
                                    @Param("offset") int offset,
                                    @Param("limit") int limit);
}

11. FAQ

Q1:Hibernate 打印的 SQL 里参数都是 ?,如何查看实际绑定的值?

application.yml 中同时开启:

spring:
  jpa:
    show-sql: true
    properties:
      hibernate:
        format_sql: true
logging:
  level:
    org.hibernate.SQL: DEBUG
    org.hibernate.orm.jdbc.bind: TRACE   # Hibernate 6.x
    # org.hibernate.type.descriptor.sql.BasicBinder: TRACE  # Hibernate 5.x

Q2:出现 LazyInitializationException: could not initialize proxy 怎么解决?

此异常意味着在 Session 关闭后访问了未加载的 LAZY 关联。三种根治方案:

  1. 查询时使用 JOIN FETCHEntityGraph 提前加载。
  2. application.yml 中开启 spring.jpa.properties.hibernate.enable_lazy_load_no_trans=true(不推荐,会隐藏性能问题)。
  3. 在 Open EntityManager in View 模式下延长 Session 生命周期(spring.jpa.open-in-view=true),但大项目慎用,可能延长数据库连接持有时间。

Q3:Spring Data JPA 的 deleteAll() 为什么这么慢?

deleteAll() 会先执行 findAll() 加载所有实体到内存,再逐条发 DELETE。应改用:

@Modifying
@Query("DELETE FROM Order o WHERE o.status = 0")
int deleteByStatus();

或直接调用原生的 jdbcTemplate.batchUpdate()

Q4:乐观锁 @Version 如何使用?

@Entity
public class Account {

    @Id
    private Long id;

    private BigDecimal balance;

    @Version
    private Long version;
}

JPA 会在 UPDATE 时自动带上 WHERE version = ?,若版本已被其他事务修改则抛出 OptimisticLockException。Service 层可结合重试框架(如 Spring Retry)处理冲突。

Q5:多租户场景下如何使用 JPA?

Hibernate 支持 MultiTenancyType.DATABASE / SCHEMA / DISCRIMINATOR。以 SCHEMA 隔离为例:

@Configuration
public class MultiTenancyConfig {

    @Bean
    public MultiTenantConnectionProvider multiTenantConnectionProvider() {
        // 根据当前线程持有的租户标识返回对应 DataSource
        return new SchemaBasedMultiTenantConnectionProvider();
    }

    @Bean
    public CurrentTenantIdentifierResolver currentTenantIdentifierResolver() {
        return () -> TenantContextHolder.getTenantId();
    }
}

12. 总结

Spring Data JPA 的便捷性不应以牺牲性能为代价。本文的核心实践可归纳为以下清单:

  1. 所有关联显式设为 LAZY,在查询层按需抓取(EntityGraph / JOIN FETCH / BatchSize)。
  2. 批量写入时关闭 IDENTITY,启用 batch_size + rewriteBatchedStatements + 分块 flush。
  3. 分页列表优先用 Slice,必须 count 时手写轻量 countQuery;大数据量列表考虑游标分页。
  4. 动态查询在简单场景用 Specification,复杂场景引入 QueryDSL。
  5. 读多写少的静态数据开启二级缓存,极端读场景使用 Caffeine 应用层缓存。
  6. 事务根据业务边界选择传播行为,查询方法标记 readOnly = true
  7. 监控慢 SQL 与 N+1,借助 Hibernate Statistics 或 APM 工具持续优化。

生产环境中的 ORM 不是黑盒,每条自动生成的 SQL 都值得审视。掌握原理,JPA 依然是 Java 后台最高效的数据持久化方案之一。


参考链接

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. Spring Cloud 微服务全栈实践
  2. Spring Security 6.x 与 OAuth2/JWT 安全认证实战
  3. Spring Boot 3 深度解析:自动装配、Starter 开发与生产就绪