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_id、bio、avatar_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 默认对 @ManyToOne 是 EAGER,极易导致 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 = true 和 flushAutomatically = 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 FETCH 或 EntityGraph 提前加载。
  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 Boot 入门》18.3 打包与运行
  2. 《Spring Boot 入门》18.2 实现
  3. 《Spring Boot 入门》18.1 需求与设计