PAYATHON 2026

JPA/Hibernate:回填时删除并重建子实体为何出现重复主键(id=1)?

排障赵师傅

结论

应先检查 PostgreSQL 用于生成主键的 sequence:它可能落后于表中已有的 id,也可能是 id 列关联了错误的 sequence。

在 GenerationType.IDENTITY 配合 PostgreSQL BIGSERIAL 的常见配置下,Hibernate 插入记录时,由数据库生成主键,再读取生成的值。数据库不会在每次插入前查询 MAX(id),删除子实体也不会让 sequence 自动与现有数据对齐。

后续运行依次在 2、3、4…… 上发生冲突,符合 sequence 持续递增的行为:即使插入失败或事务回滚,已经消耗的 sequence 值也不会退回。仅凭这些现象,还不能认定 Hibernate 的主键生成有缺陷。

先检查并修复数据库的主键生成状态,再整理 backfill 中删除和重建实体的逻辑。flush()、clear() 和解除双向关联都无法修复 sequence 的当前位置。

为什么表中已有较大的 id,sequence 仍然从 1 开始?

BIGSERIAL 由 bigint 列、sequence 和调用 nextval(...) 的列默认值组成。例如,默认值可能是:

nextval('public.commit_file_categories_id_seq'::regclass)

表中的数据和 sequence 的计数状态分别维护。以下情况都可能导致两者不一致:

  • 导入数据时显式指定了 id,却没有同步调整 sequence。
  • 通过 INSERT 或 COPY 写入了已有主键值。
  • 重建或重置了 sequence,或者只恢复了表数据。
  • 修改列默认值后,指向了新建或错误的 sequence。
  • 应用与排查工具连接了不同的数据库或 schema。

题目中的信息还不足以确定具体原因,需要逐项核对。

旧子实体的 id=26976 只是其中一条记录的主键。sequence 为整张表生成编号,与当前处理的 commit、被删除子实体的编号都没有直接关系。

先确认实际使用的表、默认值和 sequence

1. 确认数据库连接与 schema

尽量使用与应用相同的连接配置执行:

SELECT current_database(), current_schema();
SHOW search_path;

以下示例使用 public.commit_file_categories。如果实际 schema 不同,请替换为对应的 schema。

查看主键列定义:

SELECT
    table_schema,
    table_name,
    column_name,
    data_type,
    column_default,
    is_identity,
    identity_generation
FROM information_schema.columns
WHERE table_schema = 'public'
  AND table_name = 'commit_file_categories'
  AND column_name = 'id';

对于 BIGSERIAL,通常能在 column_default 中看到 nextval(...)。SQL 标准 identity 列的元数据与 BIGSERIAL 不同,不能仅凭默认值为空就判断主键生成配置损坏。

2. 找到关联的 sequence

SELECT pg_get_serial_sequence(
    'public.commit_file_categories',
    'id'
) AS sequence_name;

核对返回的 sequence 是否与列默认值一致。pg_get_serial_sequence() 查找的是与列关联的 sequence;如果有人手动改过默认值,两者可能不一致。

如果返回 NULL,继续检查实际 DDL、列默认值和 sequence 的归属关系,不要猜一个名称就执行修复。

3. 比较表中最大值与 sequence 状态

SELECT MAX(id) AS max_id
FROM public.commit_file_categories;

假设前面确认的 sequence 名称为 public.commit_file_categories_id_seq:

SELECT last_value, is_called
FROM public.commit_file_categories_id_seq;

当步长为 INCREMENT BY 1、没有并发调用且没有缓存干扰时:

  • is_called = false:下一次 nextval() 返回当前 last_value。
  • is_called = true:下一次返回 last_value + 1。

因此,last_value = 1 且 is_called = false 时,下一次生成 1 符合 sequence 的正常行为,只是这个值已经存在于表中。

不要反复调用 nextval() 来查看当前位置,因为每次调用都会消耗编号。使用较大的 CACHE 时,也不能把 last_value 直接当作最近插入记录的主键。

4. 核对 Hibernate 真正执行的 INSERT

检查 SQL 日志,确认:

  • INSERT 是否省略了 id 列,或使用数据库默认值。
  • 是否有代码显式传入非空的 id。
  • 实际插入的是哪个 schema 下的表。

如果 id 由数据库生成,就继续检查列默认值和 sequence。如果 INSERT 显式绑定了 id=1,还需要检查实际生效的实体映射、数据复制代码,以及是否配置了其他生成器。若数据库触发器会改写 id,也要一并检查。

将落后的 sequence 调整到已有数据之后

以下修复适用于已确认 sequence 落后、步长为 1,且列与 sequence 关联正确的情况。

先暂停相关写入,包括 backfill、其他应用实例,以及直接调用该 sequence 的任务,然后执行:

BEGIN;

LOCK TABLE public.commit_file_categories
    IN ACCESS EXCLUSIVE MODE;

SELECT setval(
    pg_get_serial_sequence(
        'public.commit_file_categories',
        'id'
    )::regclass,
    MAX(id),
    true
)
FROM public.commit_file_categories
HAVING MAX(id) IS NOT NULL;

COMMIT;

假设执行时整张表的 MAX(id) 为 26976,那么:

setval(sequence_name, 26976, true)

会使下一次 nextval() 返回 26977。第三个参数 true 表示指定值已经使用过;如果传入 false,下一次会返回指定值本身。

HAVING 会让空表跳过调整。空表可以保留 sequence 已有的计数,无需将它重置为 1。

执行时还要注意:

  • 只调整已经确认落后的 sequence。正常的 sequence 可能因回滚或删除而高于 MAX(id),无需调小。
  • 表锁可以阻止该表的并发写入,但无法阻止独立执行的 nextval(),所以仍需暂停相关调用方。
  • 如果其他数据库会话缓存了旧的 sequence 值,恢复写入前应重建相关连接。
  • setval() 的状态变更不会随事务回滚恢复,不能依赖 ROLLBACK 撤销。
  • CommitTechnology 对应的表也要单独检查,不能只修复 categories 表。

如果列默认值指向了错误的 sequence,应先修复默认值和归属关系,再对齐计数。调整另一条 sequence 不会改变 INSERT 实际使用的取值来源。

用 orphanRemoval 简化子实体替换

当前映射中的 cascade = CascadeType.ALL 包含删除级联,但从集合中移除子实体并不会自动删除它。父实体删除时的级联删除,与移除集合元素的行为不同。

如果这些 enrichment 子实体完全属于一个 commit,并且移出集合后就应删除,可以启用 orphanRemoval = true。在现有 Commit 中调整集合映射,并补充维护关联的方法:

@OneToMany(
    mappedBy = "commit",
    cascade = CascadeType.ALL,
    orphanRemoval = true,
    fetch = FetchType.LAZY
)
private List<CommitFileCategory> fileCategories = new ArrayList<>();

@OneToMany(
    mappedBy = "commit",
    cascade = CascadeType.ALL,
    orphanRemoval = true,
    fetch = FetchType.LAZY
)
private List<CommitTechnology> fileTechnologies = new ArrayList<>();

public void clearEnrichment() {
    for (CommitFileCategory category : fileCategories) {
        category.setCommit(null);
    }
    fileCategories.clear();

    for (CommitTechnology technology : fileTechnologies) {
        technology.setCommit(null);
    }
    fileTechnologies.clear();
}

public void addFileCategory(CommitFileCategory category) {
    fileCategories.add(category);
    category.setCommit(this);
}

public void addFileTechnology(CommitTechnology technology) {
    fileTechnologies.add(technology);
    technology.setCommit(this);
}

mappedBy = "commit" 表明子实体的 commit 属性维护外键。添加新对象时,需要同时更新集合和子实体引用。

修改 Hibernate 已管理的集合时,应保留原集合实例,通过 clear()、add() 等方法修改内容,避免直接替换整个集合。

backfill 可以写成:

@Transactional
public void backfillCommit(Long commitId) {
    Commit commit = commitRepository.findById(commitId)
        .orElseThrow();

    commit.clearEnrichment();

    // 先将旧子实体的删除发送到数据库。
    entityManager.flush();

    // 此方法应创建 id == null 的新子实体,
    // 并通过 addFileCategory / addFileTechnology 建立关联。
    commitEnrichmentService.enrichCommits(
        Collections.singletonList(commit)
    );

    // 提前执行数据库操作,让异常在这里暴露。
    entityManager.flush();
}

这个示例要求 enrichCommits() 在当前事务和持久化上下文中处理同一个 Commit,不会自行 clear()、分离实体或切换到独立事务。

findById() 返回的 commit 在当前事务中处于 managed 状态,通常无需再调用 commitRepository.save(commit)。Hibernate 会在 flush 时同步变更,并按级联配置处理新增子实体。

第一次 flush() 保证旧数据先删除,适合替换数据时还存在业务唯一约束的场景。它不会提交事务;后续 enrichment 失败时,正常的事务回滚仍会撤销这些删除。

如果不适合使用 orphanRemoval,也可以保留原来显式调用 deleteAll(toDelete) 的方案。两种方案都需要单独修复 sequence。

backfill 运行时还应注意什么?

entityManager.clear() 会分离当前持久化上下文中的实体。此后继续操作原来的 commit,就不能再依赖 managed 实体的自动同步。这里通常只需在适当位置调用 flush()。

新子实体应使用全新实例,并保持 id=null。从旧对象复制数据时,不要连同主键一起复制。也不要在应用侧用 MAX(id) + 1 分配主键,否则并发写入会产生竞争。

避免多个 backfill worker 同时处理同一个 commit。可以通过任务领取机制,或在同一事务中对父记录加悲观锁,让这些任务串行执行。修复 sequence 可以解决主键冲突,但无法自动防止重复 enrichment。

修复后,先用一个 commit 验证完整事务:确认旧子记录已删除、新记录关联正确、生成的主键没有冲突,并确认失败时删除操作能够回滚。如果后续导入脚本仍会显式写入 id,导入流程也需要同步维护 sequence,否则相同问题还会出现。

备注:内容仅供参考。