本节目标:把「脏数据什么时候变成 SQL」这件事拆到
FlushMode与ActionQueue的层面,并解释readOnly、saveAndFlush、IDENTITY三者与 flush 时机的联动。
适用版本:Spring Boot 4.1.x(Java 21)
6.2 事务与 Flush 时机
6.1 说持久化上下文是「写缓冲」:persist / merge / remove 不立刻发 SQL,只把变化记进上下文。本节回答那个自然接下去的问题——这些挂起的改动,到底在哪个时刻被翻译成 SQL 发出去? 这个时机决定了你能不能在同一个事务里「先写后查」,也决定了批量插入为什么有时一点也没批起来。
6.2.1 flush 不是 commit
先破除一个最常见的误解:flush 和 commit 是两件事。
| 动作 | 做了什么 | 能否回滚 |
|---|---|---|
| flush | 把上下文里的挂起改动翻译成 INSERT/UPDATE/DELETE 发到数据库 | 能,事务还没提交 |
| commit | 让这些改动在数据库里永久生效,并释放锁 | 不能 |
flush 只是「把 SQL 发出去」,这些 SQL 仍处在当前事务内,回滚依然有效。EntityManager#flush() 的唯一契约是「确保后续的读取能看到我这次写的内容」,它不结束事务。
理解这一点,就能解释为什么 flush 之后紧接着抛异常、事务回滚,库里并不会留下脏数据——SQL 发了,但事务回滚把它们撤了。
6.2.2 flush 的三个触发点
Hibernate 不会随便 flush。默认(FlushMode.AUTO)下只有三个时机:
- 事务提交前。
JpaTransactionManager#doCommit调用底层EntityTransaction#commit(),Hibernate 在提交前必须先 flush,否则改动丢失。 - 执行查询前。 当你要跑一条 JPQL/Criteria 查询,而这条查询涉及的表上还有未 flush 的挂起改动时,Hibernate 会先 flush 再查,保证查询能看到自己刚写的行。
- 显式调用。
em.flush(),或 Spring Data JPA 的saveAndFlush。
第 2 条是「同一事务里先写后查能查到」的机制来源,但它有一个精确的边界:只有当事务里真的有待写的实体、且查询涉及的表与待写表有交集时才会 flush。Hibernate 用 ActionQueue#areTablesToBeUpdated(Set) 来判断这次查询是否「命中」了待写表。所以下面两种情况不会触发 flush:
- 你只查了
Member,而待写的是Book; - 你根本没有挂起的写操作。
这个判断是性能优化:否则每次查询都要先把所有挂起改动刷一遍,读多写少的事务会被拖垮。但也带来一个反直觉现象——在一个事务里对 Book 做了 merge,随后用原生 SQL 查 Book,可能查不到刚 merge 的值,因为原生查询不走这条「查前 flush」的判定路径。
6.2.3 FlushMode 的取值与 AUTO 的真实语义
Hibernate 的 flush 策略由 org.hibernate.FlushMode 枚举控制,它有四个取值:
| 取值 | 语义 |
|---|---|
MANUAL | 绝不自动 flush,只能显式 flush() |
COMMIT | 只在事务提交前 flush,查询前不 flush |
AUTO | 提交前 flush,且查询命中待写表时也 flush |
ALWAYS | 每次查询前都 flush(无论是否命中待写表) |
注意 FlushMode 上有一个 lessThan(FlushMode) 方法,它的顺序是 MANUAL < COMMIT < AUTO < ALWAYS。这个比较方法正是 Spring 与 Hibernate 用来判断「当前模式是否已经足够」的依据。
JPA 层只暴露两个值:jakarta.persistence.FlushModeType.COMMIT 与 AUTO。Hibernate 用 FlushMode#fromJpaFlushMode / toJpaFlushMode 在两个枚举间映射。JPA 的 AUTO 对应 Hibernate 的 AUTO,JPA 的 COMMIT 对应 Hibernate 的 COMMIT——也就是说,Hibernate 私有的 MANUAL 与 ALWAYS 只能通过 Session#setHibernateFlushMode(FlushMode) 设置,走 JPA API 设不到。
AUTO 这个名字容易误导:它不是「随时自动 flush」,而是「提交前 + 查询命中待写表时」两个条件。很多「为什么我 find 出来的还是旧值」的问题,根因就是查询没命中待写表、AUTO 没有触发。
6.2.4 @Transactional(readOnly = true) 到底改了什么
这是面试高频题,也是真正常见的误用。readOnly = true 并不真的把连接设成数据库级只读(大多数数据库也不支持对单条连接强制只读)。它在 Hibernate 层做的是两件事,都能从源码里读出来。
第一件:把 FlushMode 压到 MANUAL。 在 org.springframework.orm.jpa.vendor.HibernateJpaDialect#prepareFlushMode(Session, boolean readOnly) 里:
protected FlushMode prepareFlushMode(Session session, boolean readOnly) {
FlushMode flushMode = session.getHibernateFlushMode();
if (readOnly) {
if (!flushMode.equals(FlushMode.MANUAL)) {
session.setHibernateFlushMode(FlushMode.MANUAL);
return flushMode; // 返回旧值,事务结束后恢复
}
}
else {
if (flushMode.lessThan(FlushMode.COMMIT)) {
session.setHibernateFlushMode(FlushMode.AUTO);
return flushMode;
}
}
return null;
}
readOnly 事务里 flush 被压成 MANUAL,意味着脏检查生成的 SQL 一条都不会自动发。这带来两个效果:省掉了无谓的快照对比开销;以及一个副作用——如果你在 readOnly 事务里真的改了受管实体,改动会被静默丢弃,不报错、不落库。这是排查「明明 set 了却没保存」时最该先怀疑的一条。
第二件:把实体默认设为只读。 同一个 beginTransaction 方法里,对本地资源事务会调用 session.setDefaultReadOnly(true)。这会让 Hibernate 加载实体时跳过「保存加载快照」这一步(因为反正不会脏检查),进一步省内存。
所以 readOnly = true 的准确表述是:它是一次「优化 + 自我约束」,不是一次强制保护。它适合纯查询方法;它不能阻止你在方法里写数据库,只是让写悄无声息地失效。真正要防写,应该靠代码审查或数据库账号权限。
prepareFlushMode 会返回被替换掉的旧 FlushMode,cleanupTransaction 在事务结束时恢复它——这就是为什么你手动设过 setHibernateFlushMode 后,一个 readOnly 事务不会永久污染当前 Session 的模式。
6.2.5 saveAndFlush 与 flush 的差别
Spring Data JPA 的 JpaRepository 提供了 saveAndFlush,它和直接调用 em.flush() 的差别在返回值与调用栈:
| 写法 | 做了什么 | 拿到什么 |
|---|---|---|
repository.save(b) | em.persist 或 em.merge,只挂起 | 受管实体 |
repository.saveAndFlush(b) | 先 save,再 em.flush() | 受管实体,且 SQL 已发出 |
em.flush() | 把当前所有挂起改动刷出去 | 无返回值 |
saveAndFlush 的典型用途是:需要立刻拿到数据库生成的值(自增主键、@Generated 字段、触发器写入的列),或者需要在同一事务里用原生 SQL 依赖这条已写入的行。注意它刷的是整个上下文的挂起改动,不只是你传进去的那个实体——在一个大事务里频繁 saveAndFlush,等于把批量写拆成了一条条即时写,hibernate.jdbc.batch_size 就再也批不起来了。
em.flush() 本身在事务里只是「发 SQL」,不影响后续继续挂起新改动;你可以多次 flush。但在 readOnly 事务里调用 em.flush() 要小心:MANUAL 模式下显式 flush 仍会执行,所以 readOnly 并不能阻止一次显式 flush。
6.2.6 主键生成策略与 flush 的联动
为什么 @GeneratedValue(strategy = GenerationType.IDENTITY) 会让插入「立即发生」?答案在 Hibernate 的动作队列里。
Hibernate 在 flush 时并不是逐条发 SQL,而是把所有动作排进 org.hibernate.engine.spi.ActionQueue,按固定顺序批量执行。这个顺序大致是:
OrphanRemovalAction // 孤儿删除
EntityInsertAction // 普通插入
EntityUpdateAction // 更新
CollectionRemoveAction // 集合元素删除
CollectionRecreateAction // 集合重建
CollectionUpdateAction // 集合更新
EntityDeleteAction // 删除
顺序固定是为了满足外键约束:先插父行再插子行,先删子行再删父行。hibernate.order_inserts / hibernate.order_updates 会把同类动作按表名排序,让相同表的语句聚在一起,配合 hibernate.jdbc.batch_size 才能被 JDBC 驱动真正批量发送。
现在看 IDENTITY。数据库自增主键意味着主键值要等 INSERT 执行完、由数据库返回。但 Hibernate 在插入前需要知道主键,才能用它维护一级缓存、给关联对象赋值。于是 IDENTITY 走了一条特殊路径:
org.hibernate.id.IdentityGenerator实现的是org.hibernate.id.PostInsertIdentifierGenerator(插入后取主键),而不是普通的插入前生成器;- 这类实体在
persist时就立刻执行INSERT,动作类型是EntityIdentityInsertAction,而不是排队的EntityInsertAction。
ActionQueue 里 addAction 对这两个类型是分开重载的,就是这条特殊路径的直接证据。
后果很实际:
| 生成策略 | 插入时机 | 能否被 batch_size 批量 |
|---|---|---|
IDENTITY | persist 即刻 | 不能,Hibernate 无法延迟到 flush 再批 |
SEQUENCE | flush 时 | 能,主键提前从序列取 |
TABLE | flush 时 | 能,同上 |
UUID(应用侧生成) | flush 时 | 能 |
所以「批量插入很慢」的一个高频根因就是用了 IDENTITY。要批起来,得改用 SEQUENCE(MySQL 上可配 @GenericGenerator 走 pooled 序列)或应用侧 UUID。Hibernate 6/7 的官方文档也明确写着:IDENTITY 会禁用 JDBC 批量插入。
6.2.7 与批量写入的配合
把 flush 时机和 6.2.6 的结论合起来看,才能理解批量写什么时候有效。Hibernate 的批量写依赖三个配置项(均为 Hibernate 原生属性,通过 spring.jpa.properties.hibernate.* 传入):
spring:
jpa:
properties:
hibernate:
jdbc:
batch_size: 50 # 每 50 条语句一批发给驱动
order_inserts: true # 同类 INSERT 按表名排序
order_updates: true # 同类 UPDATE 按表名排序
hibernate.jdbc.batch_size 对应 org.hibernate.cfg.BatchSettings.STATEMENT_BATCH_SIZE,order_inserts / order_updates 分别是 ORDER_INSERTS / ORDER_UPDATES。三者要一起用:只有攒够了同类型的语句、并且它们的目标表相邻,驱动才会把多条合成一次网络往返。
而 IDENTITY 主键会从根上破坏这一点——因为每条 INSERT 都必须立即执行并回读主键,语句根本没机会攒批。这就是「明明配了 batch_size 却完全没批量」的最常见原因。要批量插入,主键必须用能在插入前就确定值或从序列提前取值的策略。
还有一个容易忽略的细节:如果在一个循环里对每个实体调用 saveAndFlush,等于每轮都强制 flush 一次,批也就散了。正确写法是循环里只 save(挂起),循环结束后让事务提交时统一 flush。
6.2.8 亲手观察 flush 时机
三段实验,都能在本机 4.1.1 上跑通。
实验一:验证「查前 flush」只在命中待写表时触发。
@Transactional
public void probeAutoFlush(Long bookId) {
Book book = em.find(Book.class, bookId);
book.setTitle("新标题"); // 挂起 UPDATE,未发 SQL
em.createQuery("select m from Member m", Member.class).getResultList();
// 查询只涉及 Member,与 Book 的待写无关 → 不 flush,UPDATE 还没发
em.createQuery("select b from Book b", Book.class).getResultList();
// 查询命中 Book 的待写表 → 先 flush,UPDATE 此刻发出
}
把 org.hibernate.SQL 开到 debug,就能看到 update 出现在第二条查询之前,而不是第一条之前。
实验二:验证 readOnly 会吞掉写操作。
@Transactional(readOnly = true)
public void silentWrite(Long bookId) {
Book book = em.find(Book.class, bookId);
book.setTitle("这个改动会丢");
// 事务提交时 FlushMode 是 MANUAL,脏检查不发 SQL
}
跑完去库里看,title 没变——不报错,也不落库。这正是 6.2.4 说的「静默失效」。
实验三:验证 IDENTITY 立即插入。
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// ...
}
@Transactional
public void probeIdentity() {
Book b = new Book("深入理解 JVM");
em.persist(b); // 此刻就发 INSERT,而不是等 flush
System.out.println(b.getId()); // 已经有值,因为 INSERT 已执行
}
把 @GeneratedValue 换成 SEQUENCE,persist 时就不会有 INSERT,getId() 也能拿到值(从序列取的),直到 flush 才真正写库。
6.2.9 排障清单
| 现象 | 大概率原因 | 对策 |
|---|---|---|
| 同一事务里写完查不到 | 查询没命中待写表,AUTO 未 flush | 显式 em.flush() 后再查 |
| readOnly 方法里改动没落库 | readOnly 把 FlushMode 压成 MANUAL | 去掉 readOnly,或换方法 |
| 批量插入毫无批量效果 | 用了 IDENTITY 主键 | 改 SEQUENCE/UUID + 配 batch_size |
频繁 saveAndFlush 后批量失效 | 每次都即时 flush,攒不起来 | 循环里改用 save,循环后统一 flush |
| 提交时莫名报约束冲突 | ActionQueue 顺序与你的预期不符 | 显式 flush 分段,或调整关联级联 |
最后一行值得展开:ActionQueue 的固定顺序有时会让「先删后插」的操作在同一个 flush 里表现为「先插后删」,撞上唯一约束。这种时候显式地在两段之间 em.flush(),能把动作切到两个批次里,顺序就符合预期了。
小结
- flush 把挂起改动翻译成 SQL 但不提交;commit 才让改动永久生效。
FlushMode.AUTO(默认)的三个触发点:提交前、查询命中待写表时、显式flush();查询是否命中由ActionQueue#areTablesToBeUpdated判断。FlushMode有MANUAL < COMMIT < AUTO < ALWAYS四级,lessThan用于比较;JPA 只暴露COMMIT与AUTO。@Transactional(readOnly = true)在 Hibernate 层把 FlushMode 压成MANUAL并setDefaultReadOnly(true)——是优化与自我约束,不是强制保护,写入会被静默丢弃。saveAndFlush=save+ 全上下文flush,用于立刻拿到数据库生成值,但会破坏批量写。IDENTITY主键走PostInsertIdentifierGenerator,在persist时立即INSERT(EntityIdentityInsertAction),因此无法被hibernate.jdbc.batch_size批量。
flush 解决的是「什么时候发 SQL」,但发 SQL 之前还得先有一个数据库连接。下一节看连接从池里取出与归还的时机,以及延迟加载为什么会踩空。
阅读导航:上一节:6.1 EntityManager 与持久化上下文 · 下一节:6.3 连接获取与延迟加载 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。