如何并行初始化多个 EntityManagerFactory?
结论
原代码已经将 EntityManagerFactoryBuilderImpl(...).build() 提交给多个工作线程执行。代码中没有逐个等待工厂创建完成的操作,因此不能仅凭初始化日志依次出现,就认定所有初始化都在串行执行。
可以先记录每个任务的开始时间、结束时间和线程名,确认任务是否重叠执行,再通过线程转储查找等待位置。目前缺少运行日志、线程栈以及数据库和连接池配置,无法确定具体瓶颈,也不能断言 Hibernate 存在统一的全局初始化锁。
原代码中哪些操作会等待?
for 循环依次提交任务,但 build() 位于 lambda 内部,由线程池执行。newFixedThreadPool(n) 可以让多个任务同时执行,最多使用指定数量的工作线程。参见 Executors 文档。
几种写法的等待时机不同:
- 在提交循环内调用
submit(...).get(),会等当前任务完成后才提交下一个任务,使任务串行执行。 - 先提交全部任务,再逐个调用
Future.get(),调用线程会依次收集结果,已提交的任务不会因此变成串行执行。 - 原代码的
try-with-resources在退出时会等待线程池结束,但不会让块内提交的任务逐个执行。
ExecutorService 从 Java 19 开始支持 AutoCloseable。如果实际使用 Java 8、11 或 17,原代码中的 try-with-resources 无法直接编译,需要显式调用 shutdown()。参见 ExecutorService.close() 文档。
先保留 Future,并记录任务执行时间
下面的方法演示如何提交任务并记录诊断日志。调用前,应按原代码为各个 PersistenceUnitInfo 设置好配置。初始化期间不要继续修改这些配置,也不要让多个任务复用同一个可变的 PersistenceUnitInfo 实例。
import org.hibernate.jpa.boot.internal.EntityManagerFactoryBuilderImpl;
import org.hibernate.jpa.boot.internal.PersistenceUnitInfoDescriptor;
import javax.persistence.EntityManagerFactory;
import javax.persistence.spi.PersistenceUnitInfo;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public final class FactoryBootstrap {
public static List<Future<EntityManagerFactory>> submitFactories(
List<PersistenceUnitInfo> units, int parallelism) {
if (parallelism < 1) {
throw new IllegalArgumentException("parallelism must be positive");
}
List<Future<EntityManagerFactory>> futures = new ArrayList<>();
if (units.isEmpty()) {
return futures;
}
ExecutorService executor = Executors.newFixedThreadPool(
Math.min(parallelism, units.size()));
try {
for (PersistenceUnitInfo unit : units) {
futures.add(executor.submit(() -> {
String name = unit.getPersistenceUnitName();
String thread = Thread.currentThread().getName();
long started = System.nanoTime();
System.out.printf(
"START unit=%s thread=%s time=%s%n",
name, thread, java.time.Instant.now());
try {
EntityManagerFactory factory =
new EntityManagerFactoryBuilderImpl(
new PersistenceUnitInfoDescriptor(unit),
new HashMap<>()
).build();
System.out.printf("SUCCESS unit=%s%n", name);
return factory;
} finally {
long elapsedMs =
(System.nanoTime() - started) / 1_000_000;
System.out.printf(
"END unit=%s thread=%s elapsedMs=%d%n",
name, thread, elapsedMs);
}
}));
}
return futures;
} finally {
executor.shutdown();
}
}
}
shutdown() 停止接收新任务,已经提交的任务仍会继续执行。调用端应在全部任务提交后,通过 Future.get() 获取工厂,并处理 InterruptedException 和 ExecutionException。任务中的初始化异常会通过 Future 暴露;如果丢弃 submit() 的返回值,就会失去这个明确的异常检查入口。参见 ExecutorService 文档。
日志中的 SUCCESS 表示创建成功,END 只表示任务退出,异常退出时也会打印 END。
生产代码还需要管理工厂生命周期:保存成功创建的实例,在应用关闭时调用 close();如果整体启动失败或被中断,也要清理已经创建成功的工厂。
如何判断瓶颈在哪里?
先观察 START 和 END 是否重叠:
- 多个不同线程的
START连续出现,而第一个任务尚未END,说明这些初始化任务已经开始并发执行。 START重叠,但总耗时没有明显下降,需要检查任务内部的资源竞争、等待或计算开销。- 每次都要等前一个任务
END才出现下一个START,需要核对实际运行代码是否与示例一致,以及任务提交前的配置读取是否耗时。
启动缓慢时,可以连续获取几次线程转储,例如使用 jcmd <pid> Thread.print,查看工作线程停在哪里:
- 多个线程等待同一个 monitor:沿线程栈查找锁的持有者及对应代码。
- 等待获取 JDBC 连接:检查共享连接池的容量和数据库连接限制。
- 停在数据库元数据查询或 DDL:检查数据库端的执行情况和锁等待。
- 停在 Elasticsearch HTTP 请求:检查请求耗时、集群状态及索引初始化操作。
这些都是排查方向,不能仅凭当前代码认定其中某一项就是原因。
当前配置中应重点检查什么?
hibernate.hbm2ddl.auto=update 会在启动期间执行数据库 schema 更新相关工作。如果多个持久化单元操作同一组表,需要检查并发 DDL 是否发生等待。可以在隔离的测试环境中,等 schema 准备好后,对比不执行自动更新时的耗时。不要直接取消必要的 schema 管理来换取启动速度。
Hibernate Search 也可以单独测量。在测试环境中临时设置 hibernate.search.enabled=false,对比初始化行为,判断 Search 启动是否占据主要耗时。这个设置会禁用 Search 集成,只适合诊断或明确不需要 Search 的持久化单元。配置含义见 Hibernate Search 6.1 文档。
如果线程栈显示正在等待 Elasticsearch,还应检查多个工厂是否指向相同索引,以及启动阶段的 schema 操作是否发生竞争。原代码只给出了相同的 Elasticsearch 地址,尚不能证明它们使用相同索引。
并发数应该设置为多少?
不建议直接让约 150 个工厂同时初始化。线程数增加后,数据库连接、CPU、内存和 Elasticsearch 都可能成为限制。
可以先用较小的并发数,例如 2 或 4,比较总启动耗时、单个任务耗时和资源占用,再逐步调整。这些数字只是测量起点,没有适用于所有部署环境的最优值,也不能保证耗时按线程数等比例缩短。
如果这 150 个工厂主要来自租户隔离需求,还可以评估多租户方案是否适用。能否减少工厂数量取决于实体映射、数据库隔离方式和 Search 索引设计,现有信息不足以直接作出替换建议。
备注:内容仅供参考。
