PAYATHON 2026

如何并行初始化多个 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 索引设计,现有信息不足以直接作出替换建议。

备注:内容仅供参考。