系统和数据交互的时候,通常会有三层:底层数据库通过 SQL 语言对实际数据进行增删改查,中间层的 ORM 会封装底层的 SQL,上层应用程序会调用 ORM 来执行具体的应用逻辑。由于中间出现了一个翻译角色,因此在实际运行时,上下层的沟通有可能出现偏差。顶层简单的用例,到了底部的 SQL 执行层有可能变得复杂且冗余。一旦数据量变大,性能问题就会凸显出来,而由于中间层的不透明,排错的成本将会非常高。
在具体的工作中,我就遇到过一个实际的问题,Java 应用使用 Hibernate 调用 SQL 操作数据库中的数据。如果没找到实体就新建,如果找到了就更新。这个最常见不过操作,却埋藏着性能隐患。
首先,我们有一个通用方法,用于处理 transaction:
public class TransactionWrapper {
public <T> boolean executeInTransaction(Function<EntityManager, Optional<T>> action) {
EntityManager em = getEntityManager();
EntityTransaction tx = em.getTransaction();
try {
tx.begin();
Optional<T> result = action.apply(em);
if (result.isPresent()) {
em.merge(result.get()); // 【性能风险】
}
tx.commit();
return result.isPresent();
} catch (Exception e) {
if (tx != null && tx.isActive()) {
tx.rollback();
}
throw new RuntimeException("DB Error", e);
} finally {
if (em != null) {
em.close(); // 确保释放连接
}
}
}
}
针对这个通用逻辑,我们有个具体用例:
public class DataService{
public boolean updateRecord(String id, String data) {
return txWrapper.executeInTransaction(em -> {
// 从数据库查找实体
RecordEntity entity = em.find(RecordEntity.class, id);
// 找不到,创建
if (entity == null) {
entity = new RecordEntity();
entity.setId(id);
}
// 更新entity
entity.setData(data);
return Optional.of(entity);
});
}
}
执行代码时候会出现两个主要的问题:
- Hibernate 状态管理滥用,导致出现多余的 SELECT 查询
- Check-then-Act 逻辑导致的并发竞态条件
系统并非处于高并发的环境,因此我们先说最隐避的状态问题,并提出解决方案,然后再根据新的方案,用一种优雅的方式解决第二个问题。
上述代码展现了一种和数据库交互非常常用的逻辑,『先查 find,如果为空 == null,则新建』,其中涉及到了实体的两种情况:找到和没找到。
实体状态管理的问题
如果 em.find 找到了已经存在的实体,那么该实体已处于 Managed(托管)状态。由于 JPA 中 (Hibernate 是其一种实现) 有个名为 Dirty Checking 的机制,当我们修改了这个实体属性后,JPA 会在 transaction commit 的时候自动执行 UPDATE,此时方法 executeInTransaction() 中的 em.merge 就是纯粹的画蛇添足。
如果 em.find 没有找到实体,之后的逻辑会新建一个,然后设置主键 ID。此时对 Hibernate 而言,不知道这个对象是新的,还是处于 Detached(游离)状态。所以当 em.merge 的时候,它会再次发出一条 SELECT 语句,以确认该主键是否存在。结果就是在实际执行的时候,数据库收到的指令其实是:SELECT -> SELECT -> INSERT。
Managed 与 Detached 状态
在 JPA 中,实体的生命周期有多种状态。Managed 状态意味着,它正被 EntityManager 所管理和追踪,是组织中的一员。这个对象在数据库中有对应的记录,任何变化都在 JPA 的监控之下。比如当我们调用 em.find() 或从 em.persist() 返回出来的新对象,都会进入到该状态。
与之相对,Detached 状态是指这个对象曾经被 EntityManager 管理过,也在数据库中有记录,但是脱离了 EntityManager 的管理上下文,脱离了组织。它拥有数据库的数据,却是个普通的 Java 对象,但 JPA 不追踪它,也不会同步它的任何修改。比如 transaction 或 EntityManager 结束了,亦或是我们主动 em.detach(entity),都会让它进入这个状态。
不论对象是什么状态,当向 em.merge() 传入时,Hibernate 必须决定是 INSERT 还是 UPDATE。它会根据主键 ID 判断:
- 空的:Hibernate 果断执行 INSERT
- 不空(当前情况):Hibernate 不知道它是新的(或说 Transient 状态),还是包含真实数据的 Detached 对象。
为了绝对安全并且符合事实,Hibernate 只好再次使用 SELECT 查找这个 ID,然后决定执行什么操作。
Dirty Checking 脏检查
当代码执行到了最后的 tx.commit() 时,Hibernate 会对数据进行 Dirty Checking,意思是,只要是 Managed 状态的对象,你改了它的属性(即脏了),不需要显式调用保存方法,事务提交时数据库就会自动更新,执行 UPDATE SQL。正因为有这个自动化的机制兜底,我们在代码中对 Managed 对象显式调用 em.merge() 就成了一种多余的操作,徒增内部校验开销。
第一次优化
显然,我们当前的通用方法还不够『通用』,为此我们可以这样修改,让 wrapper 专注于事务的边界控制和资源释放:
public class TransactionWrapper {
public <T> boolean executeInTransaction(Function<EntityManager, Optional<T>> action) {
EntityManager em = getEntityManager();
EntityTransaction tx = em.getTransaction();
try {
tx.begin();
// 执行业务逻辑。状态管理(persist 或依赖脏检查)已在 action 内部完成
Optional<T> result = action.apply(em);
// 【优化点】去掉了 if(result.isPresent()) { em.merge(result.get()); }
// 提交事务时,JPA 会自动根据对象状态执行 INSERT 或 UPDATE
tx.commit();
return result.isPresent();
} catch (Exception e) {
if (tx != null && tx.isActive()) {
tx.rollback();
}
throw new RuntimeException("DB Error", e);
} finally {
if (em != null) {
em.close(); // 确保释放连接
}
}
}
}
业务 lambda 可以灵活的管理状态:
public class DataService {
public boolean updateRecord(String id, String data) {
return txWrapper.executeInTransaction(em -> {
// 从数据库查找实体,此时如果查到,entity 处于 Managed(托管)状态
RecordEntity entity = em.find(RecordEntity.class, id);
if (entity == null) {
// 1. 新增分支:创建对象并使用 persist() 将其转为托管状态
entity = new RecordEntity();
entity.setId(id);
entity.setData(data);
// 避免后续 merge 带来的额外 SELECT 查询
em.persist(entity);
} else {
// 2. 更新分支:由于 entity 已经是托管状态,直接修改属性即可
// 不需要调用任何 save/update 方法,事务 commit 时会触发脏检查自动 UPDATE
entity.setData(data);
}
return Optional.of(entity);
});
}
}
修改以后,em.persist() 会明确告知 Hibernate 是一个新对象,从而跳过 merge 必要的检查。对于已经存在的记录,直接去掉了 merge 操作,让它自动生成 UPDATE SQL,减少了内部开销。同时,他们两个模块的职责更加明确且纯粹。
第二次优化
当并发不高的时候,该方案是可行的,但如果并发变高,并当两个线程同时为一个不存在的 ID 保存 data,则会出现下述问题:
- A 执行 find,返回 null
- B 执行 find,返回 null
- A 执行
em.persist(entry)并提交事务,成功 INSERT - B 执行
em.persist(entry)并尝试提交事务, - 主键冲突,B 的事务被迫回滚
纯 Java 代码层的逻辑,是无法优雅并高效的防御并发冲突的,为了解决这个问题,我们可以使用原生 UPSERT(即 UPDATE + INSERT)的解决方案:
public class DataService {
public boolean updateRecord(String id, String data) {
return txWrapper.executeInTransaction(em -> {
// 1. 定义原生 UPSERT SQL
String nativeSql = "INSERT INTO record_table (id, data_value) " +
"VALUES (:id, :data) " +
"ON CONFLICT (id) DO UPDATE " +
"SET data_value = EXCLUDED.data_value";
// 2. 直接执行原生 SQL
int updatedCount = em.createNativeQuery(nativeSql)
.setParameter("id", id)
.setParameter("data", data)
.executeUpdate();
// 3. 适配返回值
// 因为 txWrapper 是通过 result.isPresent() 来返回 true/false 的,
// 并且我们不再需要 JPA 返回受管实体,所以这里随便包裹一个对象返回即可表示“执行成功”。
return Optional.of(new RecordEntity());
});
}
}
相较于之前的方法,它避免了将 SELECT 和 UPDATE 分开执行的操作,也彻底绕过了 JPA 缓存带来的额外开销。作为一个原子指令,它直接向数据库底层,下达了『尝试插入新记录,如遇主键冲突,则在引擎内部转换为更新』的操作,完美匹配了我们『有则更新,无则新建』的业务意图,从根本上杜绝了并发报错。
至此,我们通过一个实际的例子,了解了由于 JPA 的机制做引发的潜在的性能问题,并通过重构代码,以及改变修改策略,优雅的解决了这个问题。