如果你最近在学 Spring Boot十有八九会撞见一个让人绕晕的组合JPA 和 Hibernate 总是成对出现教程里一会儿说spring-boot-starter-data-jpa一会儿说Hibernate 自动建表偶尔又冒出个 Spring Data JPA。很多人一开始以为这是三个互相竞争的框架纠结到底该学哪个。这篇就是我基于一个本地练习工程总结出来的完整经验专门把JPA Hibernate 双实现这件事讲透标准 JPA API 怎么用Hibernate 原生 API 怎么用两者为什么能在同一个持久化上下文里共存以及同时使用时最容易翻车的坑。适合刚入门 Spring Boot 的同学也适合已经会写 Repository 但一直觉得底层是黑盒的开发者。1. JPA 与 Hibernate 的真实关系规范、实现与 Spring Data 的三层套娃1.1 三层架构接口规范、落地实现、更高层的封装先往后倒退半步看问题。在 JPA 出现之前Java 操作关系型数据库最常见的方式就是 JDBC。JDBC 的问题很明显代码量巨大SQL 散落在各处连接管理、资源释放、结果集映射全靠手写。后来出现了各种 ORM 框架EJB 时代的实体 Bean 又太重Hibernate 就是那个年代靠轻量级 ORM杀出来的代表性开源框架。2006 年 JPAJava Persistence API1.0 作为 Java 官方的持久化规范发布Hibernate 从那时起成为 JPA 的一种实现。这里有句关键的话JPA 是规范Hibernate 是规范的一份实现。规范本身只定义接口、注解、查询语言和整体行为模型具体干活的是实现。JPA 定义的东西都放在jakarta.persistence包老版本叫javax.persistence里比如EntityManager、Entity、Id、JPQLHibernate 则把这些接口一一落地同时保留了自己大量扩展能力放在org.hibernate包下。Spring Data JPA 又是在 JPA 之上加了一层仓库封装。你只要写一个继承了JpaRepository的接口它就在启动时帮你生成实现类把常见的增删改查、分页排序自动搞定。到了 Spring Boot 这里又用自动配置把前面的所有东西串起来。你可以这样类比JPA 是插座标准Hibernate 是插座面板Spring Data JPA 是墙上那排开关Spring Boot 是帮你把电线排好的电工。学双实现本质上就是搞清楚插座标准和面板各自能干什么别天天只看着开关。1.2 双实现具体指哪两条线关于JPA Hibernate 双实现网上大致有两种理解。第一种理解是同一个持久化需求既可以用 JPA 标准 API 完成也可以用 Hibernate 原生 API 完成。比如查询全部用户用EntityManager.createQuery()是 JPA 路线用Session.createQuery()是 Hibernate 原生路线。两者底层操作同一个数据库、共享同一份持久化上下文这就是最常见的双实现。第二种理解是在一个项目里同时配置两个 JPA Provider比如 Hibernate 和 EclipseLink跑同一套实体去对比行为差异。这种玩法在正式项目里很罕见两个 Provider 共用同一个 EntityManagerFactory 时会相互冲突除非你刻意做隔离数据源否则我完全不建议。绝大多数人说的Spring Boot 之 JPA Hibernate 双实现指的都是第一种。我下面所有内容都基于第一种理解展开一个 Spring Boot 工程默认用 Hibernate 作为 JPA 实现既走 JPA 标准 API也走 Hibernate 原生 API两条线共享一个底座。1.3 为什么学习时要两条线一起碰日常 CRUD 开发Spring Data JPA 的 Repository 就够用了但这只能让你停留在会用的层面。你迟早会碰到几个绕不开的场景需要生成计算列比如一个订单总额不是存在表里的而是由子查询计算出来的这时Formula是 Hibernate 独有的注解JPA 标准没有。需要批量灌数十万条数据逐条save()性能惨不忍睹Hibernate 的StatelessSession就是专门干这个的。需要调教查询性能比如设置抓取策略、二级缓存、批量化加载这些配置项大多挂在 Hibernate 自己的属性体系下。面试或者排查问题别人问JPA 和 Hibernate 的区别你如果只能说一个是规范一个是实现那是背概念能结合项目讲明白 EntityManager 和 Session 的关系才算真懂。关键是理解一个核心概念不管走哪条 API 线背后只有一个 SessionFactory、一个持久化上下文、一个事务边界。搞懂这个概念双实现就不会打架。2. 一个 Spring Boot 项目里把两条 API 线同时拉起来的配置细节2.1 依赖怎么引入其实一个 starter 就够了先看pom.xml。我用的是 Spring Boot 3.2.xJava 17 起步。你只差一个依赖parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version3.2.5/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-jpa/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency /dependenciesspring-boot-starter-data-jpa是一个聚合 Starter它把三层都帮你带齐了spring-data-jpa、Hibernate 核心、JPA 规范 API、以及事务相关依赖。如果你想亲眼确认依赖树可以执行mvn dependency:tree -Dincludesorg.hibernate,org.springframework.data输出里会看到类似org.hibernate.orm:hibernate-core和org.springframework.data:spring-data-jpa的条目。这里要特别注意Spring Boot 3.x 用的是jakarta.persistence命名空间如果你以前看的教程全是 2.x 的javax.persistence代码里 import 的包要跟着换。这是一个非常经典的迁移坑IDE 里报红一大片多半就是这个问题。2.2 application.yml 关键配置逐项拆解接着是配置文件我先把完整版贴出来再逐项解释spring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your-password driver-class-name: com.mysql.cj.jdbc.Driver jpa: hibernate: ddl-auto: update show-sql: true open-in-view: false properties: hibernate: format_sql: true default_batch_fetch_size: 100先说spring.jpa.hibernate.ddl-auto这个配置决定 Hibernate 启动时怎么处理表结构none什么都不做。validate只校验实体映射和表结构是否匹配不修改表。生产环境推荐。update按实体映射自动增量更新表结构。开发环境方便但生产环境慎用因为你不知道它哪天会给你改出什么意料之外的列。create每次启动先删表再建表数据会丢千万别在生产用。create-drop启动建表关闭时删表测试环境常用。show-sql: true会把 Hibernate 实际执行的 SQL 打到控制台开发阶段强烈建议开配合format_sql: true让 SQL 换行缩进不然一长串 SQL 根本没法看。注意show-sql是 Spring Boot 提供的属性它最终会映射到 Hibernate 的hibernate.show_sql如果你想在spring.jpa.properties.hibernate下面配置也能达到相同效果但正常情况二选一即可。open-in-view: false这个配置值得单独说。Spring Boot 默认open-in-view是 true启动时它会打印一条警告大意是open-in-view 默认开启视图渲染阶段可能执行数据库查询。简单说它会把 Session 的生命周期延长到请求渲染结束让你在 Controller 层拿到延迟加载的属性不至于报错。代价是连接占用时间变长、业务边界变模糊。我习惯直接关掉强制自己在 Service 层把该查的数据查完后面第六部分会专门讲它惹出来的麻烦。default_batch_fetch_size: 100是 Hibernate 的属性它和BatchSize注解配合用来解决集合懒加载时一次一个查询的 N1 问题后面性能章节详细说。还有一个容易踩的点命名策略。Spring Boot 默认把实体字段的驼峰命名映射成数据库下划线命名比如createTime映射成create_time。如果你之前手动设置过spring.jpa.hibernate.naming.physical-strategy换成另一种物理命名策略后所有隐式列名都会变很快就会出现Unknown column之类的报错。我的建议是保持 Boot 默认如果表结构是历史遗留的不规则命名用Table(name ...)和Column(name ...)显式指定即可。2.3 启动验证亲眼确认底层 Provider 是谁配置写完之后先别急着写业务做一个简单验证。随手建一个最朴素的实体Entity Table(name t_user) public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; private String username; private Integer age; // getter / setter }再写一个启动类用CommandLineRunner打印 EntityManager 底层委托对象SpringBootApplication public class DemoApplication implements CommandLineRunner { PersistenceContext private EntityManager entityManager; public static void main(String[] args) { SpringApplication.run(DemoApplication.class, args); } Override public void run(String... args) { Object delegate entityManager.getDelegate(); System.out.println(实际 JPA Provider: delegate.getClass().getName()); } }EntityManager.getDelegate()是 JPA 规范专门留给实现方暴露原生对象的入口。如果是 Hibernate你会在控制台看到类似org.hibernate.internal.SessionImpl的输出这就证明了你嘴里说的 JPA 操作底层实际上就是 Hibernate 的 Session。这个认知就是整个双实现学习的第一步。3. 实体映射实战标准注解守下限Hibernate 扩展注解提上限3.1 JPA 标准注解先保证换个实现也能跑实体映射里我习惯把注解分成两类。第一类是 JPA 标准注解它们决定了实体和表结构的基本对应关系是下限。最常用的一套就是Entity标记这是一个 ORM 实体Table指定表名Id标记主键GeneratedValue指定主键生成策略Column用来精确描述列名、长度、是否可空等属性。一个典型的用户实体会长这样Entity Table(name t_user) public class User { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name username, nullable false, unique true, length 50) private String username; Column(name age) private Integer age; Enumerated(EnumType.STRING) private UserStatus status; }重点说一下GeneratedValue的IDENTITY策略。IDENTITY就是数据库自增列MySQL 的auto_increment属于这一类它的特点是插入后数据库才生成主键Hibernate 必须立刻执行 INSERT 才能拿到 ID。如果你用的是 PostgreSQL 这类支持序列的数据库用GenerationType.SEQUENCE会让 Hibernate 预先从序列取值再执行 INSERT这对批量插入有直接影响后面性能章节会展开。Enumerated(EnumType.STRING)表示枚举在数据库里存字符串名而不是数字下标。数字下标的问题在于一旦你调整枚举顺序历史数据就全乱了。能用 STRING 尽量用 STRING。别在这些地方贪省事。3.2 Hibernate 扩展注解专门接 JPA 标准兜不住的活第二类注解是 Hibernate 自己的扩展它们解决了 JPA 标准没定义或定义得不够好用的场景。这里必须要明白用了这些注解你的项目就有一部分绑定在 Hibernate 上了换 Provider 时这些行为会失效。我的判断标准是基础映射用标准注解行为增强用扩展注解。第一个常用的是CreationTimestamp和UpdateTimestamp它们让 Hibernate 在插入、更新时自动填充时间字段省去你写PrePersist、PreUpdate回调代码Entity Table(name t_order) public class Order { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name order_no, nullable false, length 32) private String orderNo; CreationTimestamp Column(name create_time, updatable false) private LocalDateTime createTime; }第二个是Formula用来做计算列。比如订单总额并不存在t_order表里而是根据子表汇总算出来的你可以这样写Formula((select sum(oi.amount) from order_item oi where oi.order_id id)) private BigDecimal totalAmount;这个字段在数据库里没有对应列Hibernate 查询时会自动带上这段子查询。要记住它是只读的新增、修改实体时必须忽略它也不要用它做 JPA 查询里的排序或过滤条件会踩到各种版本兼容问题。它适合的是列表展示场景帮你省一段手动 DTO 映射代码。第三个是DynamicUpdate。默认情况下Hibernate 更新实体时会把所有列都放进 UPDATE 语句哪怕只有一个字段变了。加上DynamicUpdate后它只更新真正发生变化的列。但这不是银弹它会让 Hibernate 在更新前比较实体前后状态有一定的额外开销而且和实体监听器组合时容易出怪问题。我的建议是先不加等真发现 UPDATE 语句刷了太多大字段再考虑。用得不好反而增加维护难度。第四个是BatchSize它解决的是懒加载时的批量抓取问题。给实体或者集合属性加上BatchSize(size 50)后Hibernate 加载第一批懒代理时会顺便多查 49 个同类对象的代理避免一个对象一次查询。这个在性能优化部分再详细展开。3.3 实战混合示例一个订单实体的完整姿势为了展示两类注解怎么混在同一个实体里我写一个相对完整的订单实体Entity Table(name t_order) DynamicUpdate BatchSize(size 50) public class Order { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name order_no, nullable false, unique true, length 32) private String orderNo; Column(name customer_name, nullable false, length 50) private String customerName; CreationTimestamp Column(name create_time, updatable false) private LocalDateTime createTime; Formula((select sum(oi.amount) from order_item oi where oi.order_id id)) private BigDecimal totalAmount; Version private Integer version; }Version是乐观锁的标配它不是一个 Hibernate 专属注解而是 JPA 标准里的。更新时会自动带where version ?条件并发修改时抛出乐观锁异常避免脏写。字段名用t_order而不是order是因为order是 SQL 里的保留字直接建表或写原生 SQL 时容易踩坑加个前缀是最省事的规避办法。这一段的核心经验是不要追求所有注解都统一出身的洁癖。标准注解保证可移植底线扩展注解提供实际生产力它们本来就是姐妹关系不是敌人。4. 路线一Spring Data JPA 仓储与 EntityManager 的标准 API 用法4.1 Repository 的派生查询与 Query 到底什么时候用第一条路由是大部分业务代码的主战场Spring Data JPA 的 Repository。你定义一个接口public interface UserRepository extends JpaRepositoryUser, Long { OptionalUser findByUsername(String username); ListUser findByAgeGreaterThan(Integer age); boolean existsByUsername(String username); }Spring Data JPA 启动时替你生成实现类方法名里的findBy、And、Or、GreaterThan、Between、Containing、OrderBy就是一套小型的查询 DSL。简单查询用它非常爽但记住一个原则方法名的表达能力是有限的一旦带上多条件、多表关联就别硬拼方法名那会让方法名长得像英语长句可读性反而崩了。复杂查询用Query。比如查某个状态下最近创建的用户还要分页Query(select u from User u where u.status :status order by u.createTime desc) PageUser findRecentByStatus(Param(status) UserStatus status, Pageable pageable);注意 JPQL 里写的是实体类名User和属性名createTime不是表名t_user和列名create_time。这是新人最容易迷糊的地方JPQL 是面向对象的查询语言由 Hibernate 负责翻译成数据库方言的 SQL。有时候不得不写原生 SQL比如用数据库特有的语法或者优化器对这种 SQL 识别得更好Query(value select * from t_user where age :age, nativeQuery true) ListUser findByNativeAge(Param(age) Integer age);原生查询返回的User对象依然会被 Hibernate 映射成实体但要注意它不经过实体生命周期里的部分回调使用时别对回调行为做假设。我的习惯是组合拳单表简单条件用派生查询多表或聚合统计用 JPQL极端 SQL 优化需求才上原生 SQL。4.2 EntityManager当你需要手动控制实体状态的时候Repository 封装得太舒服很多人就会忽略底层 JPA 的实体状态模型。EntityManager是 JPA 标准的核心接口实体在它眼中有四种状态瞬态transient、托管managed、游离detached、删除removed。这四个状态听起来抽象其实行为非常具体persist(entity)把瞬态实体变成托管状态相当于 INSERT。find(clazz, id)按主键查命中一级缓存就不查数据库。getReference(clazz, id)返回一个懒加载代理不立刻查库真正访问字段时才发 SQL。merge(entity)把游离实体合并回持久化上下文相当于有主键就 UPDATE没主键就 INSERT。remove(entity)标记删除事务提交时发 DELETE。flush()把持久化上下文里的变更立即同步到数据库不等事务提交。clear()清空持久化上下文所有托管实体变成游离态。detach(entity)把单个实体变成游离态。contains(entity)判断实体是否处于托管状态。在 Service 里直接注入EntityManagerService public class UserService { PersistenceContext private EntityManager entityManager; Transactional public User createUser(String username) { User user new User(); user.setUsername(username); entityManager.persist(user); return user; } }注意PersistenceContext和Autowired的区别。PersistenceContext是 JPA 规范里专门用来注入容器管理的 EntityManager 的它保证你拿到的 EntityManager 绑定当前事务用Autowired也能注入但语义上不如前者正规。4.3 事务与脏检查JPA 的自动更新不是魔法很多人奇怪为什么改了托管实体的字段没调任何更新方法数据库里就变了。这是 JPA 的脏检查机制事务提交时Hibernate 会比对持久化上下文里的实体快照发现有字段变化自动生成 UPDATE。也就是说更新动作是 flush 时发生的不是你的代码显式调用发生的。这个机制让人省心也让人困惑。有几点实际经验值得记下来事务方法里先查实体再改字段最后正常返回就会自动 UPDATE。不要画蛇添足再调一次save()。如果方法没有Transactional每次调用 Repository 方法都会开启一个短事务返回后实体立刻游离你改字段也不会触发 UPDATE而且还会踩延迟加载的坑。查询方法可以加上Transactional(readOnly true)。它一方面告诉事务管理器不开启写入锁另一方面给 Hibernate 一个优化提示让它跳过不必要的脏检查。查询密集的 Service 我基本都加。事务边界要放在合理的层上。我习惯放在 Service 层方法而不是 Controller 层更不会让事务跨越 Web 请求。这一条路线用好80% 的 CRUD 业务就稳了。5. 路线二Hibernate 原生 Session 与高级特性如何无缝介入5.1 在 Spring Boot 里拿到 Session 的三种方式路线二的关键是拿到 Hibernate 的原生Session。有三种常见做法。第一种最推荐直接从EntityManager上 unwrap。PersistenceContext private EntityManager entityManager; Session session entityManager.unwrap(Session.class);unwrap是 JPA 标准预留的扩展口子它返回的 Session 和你当前事务里的 EntityManager 是同一个会话的两种视角。你在 EntityManager 上 persist 的东西从 Session 里也能看到反向也一样共享一级缓存和数据库连接。要双实现协同作业这是最优雅的入口。第二种注入SessionFactory并调用getCurrentSession()。Autowired private SessionFactory sessionFactory; Session currentSession sessionFactory.getCurrentSession();getCurrentSession()返回绑定当前事务的 Session前提是上下文中确实存在事务。在 Spring Boot 默认的JpaTransactionManager体系下也可以工作但 unwrap 方式更不容易踩到事务管理器的边界问题。第三种手动openSession()。Session session sessionFactory.openSession(); try { // ... } finally { session.close(); }这条路是在一个已有事务里又开一个新 Session、新连接两边互不感知。我基本只在批处理任务里手动开StatelessSession时用日常业务代码里搞这种额外会话等于给自己埋雷。记住一个经验在 Spring 管理的事务里不要为了用 Hibernate API 而 openSession直接 unwrap 才是双实现的正确落地方式。5.2 用 StatelessSession 做批量插入的高效姿势日常 CRUD 用有状态的 Session 没问题但一次性插入几万条数据时有状态 Session 的问题是每个插入的实体都会进入一级缓存内存越堆越高而且每次 flush 都要做脏检查。这时候 Hibernate 提供了无状态的StatelessSession。SessionFactory sessionFactory entityManager.getEntityManagerFactory().unwrap(SessionFactory.class); try (StatelessSession statelessSession sessionFactory.openStatelessSession()) { org.hibernate.Transaction tx statelessSession.beginTransaction(); for (int i 0; i 10000; i) { Item item new Item(); item.setName(item- i); statelessSession.insert(item); if (i % 500 0) { statelessSession.flush(); } } tx.commit(); }StatelessSession没有一级缓存、没有脏检查、不维护实体状态插入完的实体立刻变成游离态内存开销小得多。代价是它也不会触发实体的生命周期回调你要自己管理事务的 begin/commit。这种 API 适合数据迁移、批量导入不适合日常业务。再次强调别在有状态事务里乱用无状态会话它们不是一个持久化上下文。5.3 Hibernate 6 里查询 API 的变化别去翻老教程的 createCriteria如果你搜 Hibernate 原生 API 教程很容易看到老代码里有session.createCriteria(User.class)这种写法。这个东西在 Hibernate 5.2 里就标记废弃Hibernate 6.0 直接删掉了。按老教程抄代码你会直接编译失败。现在 Hibernate 6 推荐的是 JPA 的 Criteria API或者 HQL 查询。JPA Criteria 的典型写法CriteriaBuilder cb entityManager.getCriteriaBuilder(); CriteriaQueryUser query cb.createQuery(User.class); RootUser root query.from(User.class); query.select(root).where(cb.greaterThan(root.get(age), 18)); ListUser users entityManager.createQuery(query).getResultList();Criteria API 的优势在于类型安全和动态拼接。查询条件在运行时才能确定的场景比如多条件搜索框用 Criteria 比拼 JPQL 字符串舒服得多不会因为少一个空格报错。但它的代码确实啰嗦简单的固定查询就别用它了。Hibernate 原生的 HQL 和 JPA 的 JPQL 也值得一提。HQL 是 Hibernate 自己的查询语言JPQL 是 JPA 标准查询语言它俩语法几乎一致。在标准 API 里用 JPQL在原生 Session 上用 HQL这是双实现里最自然的组合。Hibernate 6 中还引入了类型化的createSelectionQuery等方法但日常写业务时Session.createQuery已经完全够用不必刻意追求新 API。5.4 第二级缓存独立于会话的跨请求缓存Hibernate 的一级缓存绑定 Session生命周期很短。二级缓存则是 SessionFactory 级别的跨请求共享缓存专门用来缓存热点实体和集合。开启它需要三步。首先引入 JCache 和 Ehcache 依赖其次在配置里指定缓存工厂spring: jpa: properties: hibernate: cache: use_second_level_cache: true region.factory_class: jcache然后在实体上标注Entity Table(name t_user) Cache(usage CacheConcurrencyStrategy.READ_WRITE) public class User { // ... }二级缓存对读多写少的数据收益很明显比如字典类数据但要警惕一致性风险。READ_WRITE策略靠数据库事务保证一致性但我发现很多人配置成功后代码里却用原生 SQL 查询绕过了实体缓存导致缓存怎么都命中不上。排查这类问题时先确认查询是否走实体映射原生 SQL 默认不查二级缓存。二级缓存是双实现里另一个典型的Hibernate 比 JPA 标准走得更远的能力JPA 规范只是模棱两可地提供了Cache接口和Cacheable注解具体怎么用还是得看实现方。6. 两条线一起使时最容易翻车的坑与我的调优心得6.1 N1 查询双实现场景里更容易犯的低级错误先看一个经典场景查询 100 个用户然后遍历他们各自的一个懒加载集合字段。你以为只发了 1 条 SQL实际上发的是 1 条用户查询 100 条订单查询这就是 N1。这个问题的根子在查询策略和走 JPA 路线还是 Hibernate 路线没关系。你在 Repository 里查到实体再把实体丢给一个用Session写的方法去访问懒关联一样会触发 N1。解决手段按场景选方案适用场景写法JOIN FETCH固定抓取路径Query(select u from User u join fetch u.orders where u.status :status)EntityGraph动态指定抓取属性EntityGraph(attributePaths orders)BatchSize懒加载集合数量大且分散实体上加BatchSize(size 100)二级缓存热点数据反复读取实体上加Cache(usage READ_WRITE)如果用Query直接拼join fetch要注意分页时可能产生内存分页的问题因为join fetch关联集合后SQL 结果集的条数和实体条数不一致数据库分页会失灵。遇到复杂分页加抓取的场景我更倾向于拆成两次查询先查出当前页的主实体 ID再用集合抓取查询按 ID 批量加载关联数据。这是我在一个实际项目里踩出来的经验。6.2 LazyInitializationException为什么我关了 OSIV 还是报错把open-in-view设置成 false 后很多同学会发现一个熟悉报错突然变多org.hibernate.LazyInitializationException: could not initialize proxy [com.example.User#1] - no Session这个异常的意思是你想访问一个懒加载代理的字段但 Session 已经关了。我把open-in-view关掉就是在主动逼自己在事务内把该查的数据查完。如果你的代码是这样GetMapping(/user/{id}) public UserDto getUser(PathVariable Long id) { User user userService.findById(id); // 事务已结束user 变成游离态 user.getOrders().size(); // LazyInitializationException! return convert(user); }问题非常明确真正需要的数据没在 Service 层加载好。而不是把 OSIV 打开就能解决。OSIV 只是把 Session 延长到视图渲染本质是把问题往后推还占着数据库连接。我在实际项目里的做法是在事务内通过EntityGraph或JOIN FETCH把需要的关联一次性加载或者干脆在事务内转成 DTO 再抛出去。把模型层的东西带到 Controller 层是典型的架构坏味道。6.3 一级缓存与批量操作一个容易把内存搞炸的细节有状态 Session 的一级缓存是双刃剑。我在做数据迁移时犯过一个错用Repository.saveAll()一次插入十几万条记录结果内存飞涨后面越来越慢。原因很简单每个实体都留在持久化上下文里被一级缓存引用着。解决办法是分批 flush 并 clearfor (int i 0; i records.size(); i) { entityManager.persist(records.get(i)); if (i % 200 0) { entityManager.flush(); entityManager.clear(); } }flush把 SQL 发出去clear清空持久化上下文释放内存。注意清空后之前所有托管实体都变成游离态千万别再访问它们。这就是一级缓存最常见的生产事故。同样的逻辑也适用于大量merge批量更新时定期清缓存避免上下文越拖越臃肿。还有一个和主键策略强相关的点如果你用GenerationType.IDENTITYHibernate 的 JDBC 批量插入优化会失效因为 IDENTITY 必须立即执行 INSERT 才能拿到主键无法等批量提交时一起执行。想批量插入快在支持序列的数据库上改用GenerationType.SEQUENCE可以搭配列数较多的jdbc.batch_size配置。MySQL 场景下这方面先天吃亏我一般直接走StatelessSession或者JdbcTemplate来做超大批量写入。6.4 命名策略、保留字与 ddl-auto 的生产环境纪律实体命名这里有两个高频坑。第一个是 SQL 保留字比如把表命名为order、user在某些数据库方言里也会有问题order尤其危险。我统一给业务表加t_前缀不是空穴来风。第二个是命名策略。前面提过 Spring Boot 默认把createTime映射成create_time但如果你图省事去查 Hibernate 原生文档发现在原生属性里设置的是另一套命名策略两边对不上启动后列名不一致立刻报Unknown column。我见过一个项目因此栽了排查了半天才发现是某个同事把物理命名策略改成了 Hibernate 标准实现。改动命名策略前先确认你清楚所有实体的列名都会跟着变。ddl-auto的生产环境纪律我强调多少遍都不为过不要在生产环境开create那是删库跑路的节奏update也只有在业务初期、团队能接受表结构自动变化时才建议开。正经项目到后期应该用专业的迁移工具管理表结构变更Hibernate 的ddl-auto只适合开发期和测试期。另外Hibernate 6 以后会自动探测数据库方言不需要手动指定spring.jpa.properties.hibernate.dialect。手动指定不仅多余而且一旦数据库换版本或切换厂商你写死的东西就成了拦路虎。我见过有人的配置里写了老版本的 MySQL 方言升级数据库后出现诡异的 SQL 兼容问题删掉配置就好。少配置让框架自己在可靠范围内做决定是一种成熟的态度。6.5 双 API 协作的正确边界最后把双实现的协作边界彻底交代清楚。两条 API 线如果各自安分守己在一个事务里协同是没有问题的。比如先用 Repository 查到实体再把实体交给 Session 处理这是共享同一个持久化上下文安全。但如果你为了用 Session 而刻意开一个新的openSession()麻烦就来了新 Session 有自己的一级缓存和数据库连接你在 EntityManager 里改的东西它看不到它查出来的实体和原来的实体甚至不是同一个 Java 实例后续相等性判断、状态管理全会变得诡异。同时开两个连接还可能把连接池资源吃紧。所以正确的姿势永远是PersistenceContext private EntityManager entityManager; public void mixedOperation() { Session session entityManager.unwrap(Session.class); // Repository 或 EntityManager 操作 User user entityManager.find(User.class, 1L); user.setUsername(新名字); // Session 操作 session.flush(); // 此时实体状态与数据库已经同步 }适当混用的地方还有事务提交时机、批量处理、以及个别 Hibernate 独有 API。比如用EntityManager.find查完再交给StatelessSession做大数据量迁移这种两条线联手的做法在真实项目里非常实用前提是你对持久化上下文有清晰认知。我个人做了这个练习工程后最大的体会是不要把 JPA 和 Hibernate 当作两个需要选边站的框架它们是同一层底座在标准与能力之间来回切换的两种操作方式。日常业务用 JPA 标准 API保住代码的可迁移性和整洁度批量任务、计算列、二级缓存这类高价值场景放心用 Hibernate 原生 API。遇到报错先想持久化上下文现在是什么状态比死记 API 差异管用得多。最后分享一个个技巧当你怀疑一个实体是不是托管状态时直接调entityManager.contains(entity)看一眼返回 true 说明它还受 Hibernate 管理返回 false 就要警惕游离态带来的各种连锁问题。这个小检查帮你省掉大量深夜排查时间。
阅读完成 · 觉得有帮助?