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 主键策略选型
| 策略 | 适用场景 | 注意事项 |
|---|---|---|
IDENTITY | MySQL / SQL Server 自增列 | 批量插入时会退化为逐条插入,性能较低 |
SEQUENCE | PostgreSQL / 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);
}
}
| 维度 | QueryDSL | Specification |
|---|---|---|
| 类型安全 | 编译期检查,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 JPA | MyBatis / MyBatis-Plus |
|---|---|---|
| 开发范式 | 面向对象,声明式 | 面向 SQL,命令式 |
| 代码量 | 极少,Repository 接口即可 | 需写 XML / Mapper / SQL |
| 关联查询 | 自动映射,易用但需关注性能 | 手工编写 JOIN,完全可控 |
| 动态查询 | QueryDSL / Criteria / Specification | XML <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 关联。三种根治方案:
- 查询时使用
JOIN FETCH或EntityGraph提前加载。 - 在
application.yml中开启spring.jpa.properties.hibernate.enable_lazy_load_no_trans=true(不推荐,会隐藏性能问题)。 - 在 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 的便捷性不应以牺牲性能为代价。本文的核心实践可归纳为以下清单:
- 所有关联显式设为
LAZY,在查询层按需抓取(EntityGraph / JOIN FETCH / BatchSize)。 - 批量写入时关闭
IDENTITY,启用batch_size+rewriteBatchedStatements+ 分块 flush。 - 分页列表优先用
Slice,必须 count 时手写轻量 countQuery;大数据量列表考虑游标分页。 - 动态查询在简单场景用 Specification,复杂场景引入 QueryDSL。
- 读多写少的静态数据开启二级缓存,极端读场景使用 Caffeine 应用层缓存。
- 事务根据业务边界选择传播行为,查询方法标记
readOnly = true。 - 监控慢 SQL 与 N+1,借助 Hibernate Statistics 或 APM 工具持续优化。
生产环境中的 ORM 不是黑盒,每条自动生成的 SQL 都值得审视。掌握原理,JPA 依然是 Java 后台最高效的数据持久化方案之一。
参考链接
- Spring Data JPA 官方文档
- Hibernate 用户指南
- QueryDSL 参考手册
- Baeldung: JPA EntityGraph
- Vlad Mihalcea: Hibernate 性能调优博客系列
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。