You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Hibernate:orphanRemoval+DtoMapping+@Audited场景下的异常解决问询

问题:Hibernate单向OneToMany映射+@Audited导致NonUniqueObjectException,同时要避免orphanRemoval误删子实体

场景概述

我有带@Audited注解的父、子实体,采用单向OneToMany映射;同时存在与实体字段完全一致(包括id)的DTO类,以及负责DTO与实体转换的Mapper。当DTO的id字段已设置时,Mapper的map方法会通过id从数据库加载实体而非实例化新对象,子实体的映射逻辑同理。

核心问题

映射子列表时,Mapper会先清空父实体的子列表,此时orphanRemoval=true会触发数据库中子实体的删除,之后又需要重新创建并持久化子实体。为避免这个问题,我在映射前调用session.detach(parent)阻止数据库变更,后续通过session.saveOrUpdate(parent)重新附加,却触发了NonUniqueObjectException。

Mapper实现

@Stateless
public class Mapper{
    @PersistenceContext(unitName = "core")
    private EntityManager em;
    public void map(ParentDto parentDto, Parent parent) {
        Session session = em.unwrap(Session.class);
        if(em.contains(parent)) {
            session.detach(parent); //detach so that orphanRemoval will not delete childs from DB
        }
        ...
        parent.getChilds().clear();
        for (ChildDto childDto : parentDto.getChilds()) {
            parent.getChilds().add(mapToEntity(childDto));
        }
        session.saveOrUpdate(parent); //(re-)attach, so that changed fields or new childs get written to DB
    }
    public Parent mapToEntity(ParentDto parentDto) {
        Parent parentEntity= null;
        if (parentDto.id != null) {
            parentEntity= loadParentEntityFromDb(parentDto.id);
        }
        if (parentEntity == null) {
            parentEntity= new Parent();
        }
        map(parentDto, parentEntity);
        return parentEntity;
    }
    public Child mapToEntity(ChildDto childDto) {
        Child childEntity= null;
        if (childDto.id != null) {
            childEntity= loadChildEntityFromDb(childDto.id);
        }
        if (childEntity == null) {
            childEntity= new Child();
        }
        map(childDto, childEntity);
        return childEntity;
    }
}

父实体类

@Entity
@Audited
public class Parent implements Serializable {
    private static final long serialVersionUID = 1L;
    @Id
    @GeneratedValue
    @Getter
    private Long id;
    ...
    @Getter
    @OneToMany(fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true)
    @JoinTable(name = "PARENT_TO_CHILD", joinColumns = @JoinColumn(name = "PARENT_ID", referencedColumnName = "ID"), inverseJoinColumns = @JoinColumn(name = "CHILD_ID", referencedColumnName = "ID"))
    private final List<Child> childs = new ArrayList<>();
}

子实体类

@Entity
@Audited
public class Child implements Serializable {
    private static final long serialVersionUID = 1L;
    @Id
    @GeneratedValue
    @Getter
    private Long id;
    ...
}

DTO类

public class ParentDto implements Serializable {
    private static final long serialVersionUID = 1L;
    @Getter @Setter
    private Long id;
    ...
    @Getter
    private final List<ChildDto> childs = new ArrayList<>();
}

public class ChildDto implements Serializable {
    private static final long serialVersionUID = 1L;
    @Getter @Setter
    private Long id;
    ...
}

触发的异常

12:35:17,422 WARN [com.arjuna.ats.arjuna] (default task-5) ARJUNA012125: TwoPhaseCoordinator.beforeCompletion - failed for SynchronizationImple< 0:ffff0a00100f:4b2caaeb:5e4cf8b3:58b38, org.wildfly.transaction.client.AbstractTransaction$AssociatingSynchronization@1149eaa7 >: org.hibernate.NonUniqueObjectException: A different object with the same identifier value was already associated with the session : [PARENT_TO_CHILDS_AUD#{REV=Revision [id=98781, timestamp=1582112117394, username=RUser], Parent_id=885752, childs_id=885754}]

已尝试的方案

使用em.detach(parent)后调用em.merge(parent)可正常工作,但merge会返回持久化的父实体副本,传入Mapper的父实体仍处于detached状态。由于无法修改map方法签名public void map(ParentDto parentDto, Parent parent),因此尝试使用session.saveOrUpdate(parent)以操作传入对象并重新附加。

我的疑问

有没有办法让@Audit注解与session.saveOrUpdate()兼容?或者在不清空子列表的前提下,防止Hibernate在映射期间删除子实体?我使用的是Hibernate 5.3.6.Final版本。


解决方案思路

针对你的问题,我整理了几个可行的方向,你可以根据业务场景选择:

方案1:用merge+同步状态到传入对象

虽然不能修改map方法的签名,但可以在方法内部调用merge后,把返回的持久化实体的状态复制回传入的parent对象,既利用merge的兼容性,又保证传入对象最终符合预期状态。

修改map方法的最后一步:

// 替换原来的session.saveOrUpdate(parent);
Parent mergedParent = (Parent) session.merge(parent);
// 复制基本字段到传入的parent
BeanUtils.copyProperties(mergedParent, parent, "childs"); // 排除子列表单独处理
// 手动同步子列表(因为子列表是final的)
parent.getChilds().clear();
parent.getChilds().addAll(mergedParent.getChilds());

这种方式避开了saveOrUpdate与@Audited的冲突,同时保持方法签名不变。注意处理集合类型时,手动复制比依赖工具类更稳妥。

方案2:不清空子列表,手动增量更新子实体

既然orphanRemoval=true会在清空列表时误删子实体,那我们可以跳过清空操作,通过对比DTO和现有实体的子列表,做增量更新:

  1. 找出DTO存在但实体没有的子实体:新增到列表
  2. 找出实体存在但DTO没有的子实体:从列表移除(此时orphanRemoval会删除这些子实体,符合业务逻辑)
  3. 找出双方都存在的子实体:调用map方法更新字段

具体实现示例:

// 替换原来的parent.getChilds().clear();和循环添加逻辑
Map<Long, Child> existingChildMap = parent.getChilds().stream()
    .collect(Collectors.toMap(Child::getId, child -> child));

for (ChildDto childDto : parentDto.getChilds()) {
    if (childDto.getId() != null && existingChildMap.containsKey(childDto.getId())) {
        // 更新已有子实体
        map(childDto, existingChildMap.get(childDto.getId()));
        existingChildMap.remove(childDto.getId());
    } else {
        // 添加新子实体
        parent.getChilds().add(mapToEntity(childDto));
    }
}

// 移除DTO中不存在的子实体(orphanRemoval会自动删除这些记录)
existingChildMap.values().forEach(child -> parent.getChilds().remove(child));

这个方案从根源上避免了清空列表触发的误删,也不需要detach和重新附加,自然不会出现NonUniqueObjectException,适合子实体数量不多的场景。

方案3:临时关闭orphanRemoval(应急用)

如果不想修改太多代码,可以在detach前临时关闭orphanRemoval行为,但这个方法需要反射修改注解属性,不够优雅,只适合临时应急:

if(em.contains(parent)) {
    // 反射获取子列表字段的OneToMany注解
    Field childsField = Parent.class.getDeclaredField("childs");
    OneToMany annotation = childsField.getAnnotation(OneToMany.class);
    // 借助第三方库(如javassist)修改注解的orphanRemoval值为false
    // 之后detach parent
    session.detach(parent);
    // 映射完成后再把orphanRemoval改回true
}

不推荐这个方案,因为反射操作会破坏封装,且不同Hibernate版本可能存在兼容性问题。

为什么saveOrUpdate会和@Audited冲突?

出现NonUniqueObjectException是因为@Audited会生成审计表,Hibernate处理审计数据时,已将对应的审计实体实例加载到session中。当你detach原parent后,用saveOrUpdate重新附加时,Hibernate发现session中已存在相同id的审计关联实体,就会抛出这个异常。而merge是合并状态,不会直接附加传入对象,因此不会触发冲突。


内容的提问来源于stack exchange,提问作者Paul

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.06 17:37:53