Hibernate 多对多关系新增实体导致另一实体意外更新
结论
这是正常行为。
Payment.refunds 是关联关系的维护端,因为它的 @ManyToMany 没有声明 mappedBy。向这个集合添加元素时,Hibernate 会认为 Payment 的持久化状态发生了变化。启用 @Version 后,Hibernate 需要递增 Payment.version,因此会执行:
update Payment
set version = ?
where paymentId = ?
and version = ?
这条语句通常只更新版本号,并不代表 Payment 的其他业务字段发生了变化。
如果不希望关联集合的变化导致版本号递增,可以使用 Hibernate 专有的 @OptimisticLock(excluded = true) 排除该集合。但这会削弱并发修改关联关系时的冲突检测,使用前需要确认它符合业务要求。
为什么修改集合会更新 Payment
当前映射中,关联表由 Payment.refunds 维护:
@ManyToMany(
cascade = CascadeType.ALL,
fetch = FetchType.LAZY
)
private List<DRefund> refunds = new ArrayList<>();
DRefund.payments 声明了 mappedBy,因此是反向端:
@ManyToMany(mappedBy = "refunds")
private List<Payment> payments = new ArrayList<>();
执行下面的操作时:
payment.getRefunds().add(refund);
Hibernate 会认为 Payment 所维护的关联发生了变化。事务提交或执行 flush() 时,它需要完成三项操作:
- 插入新的
DRefund; - 增加
Payment的版本号,并校验旧版本; - 向中间表插入关联记录。
对应的 SQL 类似:
insert into Refund (...);
update Payment set version = ? where paymentId = ? and version = ?;
insert into Payment_Refund (...);
移除 @Version 后,Hibernate 不再需要递增版本号。如果 Payment 主表没有其他字段需要更新,这条 UPDATE 也就不会执行。
方案一:保留默认行为
如果关联关系属于 Payment 聚合状态的一部分,可以保留当前行为。这样,当两个事务同时修改同一个 Payment 的退款关联时,版本号可以用于检测并发冲突。
映射可以整理为:
@Entity
public class Payment {
@Id
@GeneratedValue
private Long paymentId;
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "Payment_Refund",
joinColumns = @JoinColumn(name = "payment_id"),
inverseJoinColumns = @JoinColumn(name = "refund_id")
)
private List<DRefund> refunds = new ArrayList<>();
@Version
private long version;
public void addRefund(DRefund refund) {
refunds.add(refund);
refund.getPayments().add(this);
}
}
@Entity
public class DRefund {
@Id
@GeneratedValue
private Long refundId;
@ManyToMany(mappedBy = "refunds", fetch = FetchType.LAZY)
private List<Payment> payments = new ArrayList<>();
@Version
private long version;
public List<Payment> getPayments() {
return payments;
}
}
保存操作应在同一个事务中完成:
DRefund refund = new DRefund();
entityManager.persist(refund);
Payment payment = entityManager.find(Payment.class, paymentId);
payment.addRefund(refund);
此时 payment 已经是 managed entity,不需要再次调用 save() 或 persist()。事务提交时,Hibernate 会通过脏检查同步这些变更。
方案二:将集合排除在乐观锁之外
如果业务允许多个事务分别修改关联表,并且不希望这些修改增加 Payment.version,可以使用 Hibernate 的 @OptimisticLock:
import org.hibernate.annotations.OptimisticLock;
@Entity
public class Payment {
@Id
@GeneratedValue
private Long paymentId;
@ManyToMany(fetch = FetchType.LAZY)
@JoinTable(
name = "Payment_Refund",
joinColumns = @JoinColumn(name = "payment_id"),
inverseJoinColumns = @JoinColumn(name = "refund_id")
)
@OptimisticLock(excluded = true)
private List<DRefund> refunds = new ArrayList<>();
@Version
private long version;
}
这样,集合发生变化时,通常不会再触发仅用于递增 Payment.version 的 UPDATE。
@OptimisticLock 是 Hibernate 扩展,不属于标准 JPA 注解。如果应用以后需要切换到其他 JPA provider,这部分映射可能需要调整。具体的注解包名和支持情况,也应以项目实际使用的 Hibernate 版本为准。
方案三:把关联关系映射为独立实体
如果 Payment 和 DRefund 之间的关联本身具有业务含义,例如需要记录退款金额、创建时间、状态或操作人,可以不再使用直接的 @ManyToMany,而是把中间表映射为独立实体:
@Entity
public class PaymentRefund {
@EmbeddedId
private PaymentRefundId id;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("paymentId")
private Payment payment;
@ManyToOne(fetch = FetchType.LAZY)
@MapsId("refundId")
private DRefund refund;
@Version
private long version;
}
这样,关联的新增和修改由 PaymentRefund 自己进行版本控制,不必将每次关联变化都反映到 Payment.version 上。独立实体也更适合承载关联级别的属性和约束。
注意事项
不要依赖 @DynamicUpdate 解决这个问题
@DynamicUpdate 只能减少普通字段更新时 SQL 中包含的列,不能阻止乐观锁要求的版本号递增。这里的 UPDATE 本来就是为了更新 version,因此加上 @DynamicUpdate 通常没有作用。
谨慎使用 CascadeType.ALL
CascadeType.ALL 包含 REMOVE。在多对多关系中,删除一端实体时,如果把删除操作级联到另一端,可能误删仍被其他实体引用的数据。
如果这里只需要保存新建的退款对象,可以根据实际生命周期缩小级联范围:
@ManyToMany(
cascade = {CascadeType.PERSIST, CascadeType.MERGE},
fetch = FetchType.LAZY
)
private List<DRefund> refunds = new ArrayList<>();
也可以显式保存 DRefund,不配置任何级联。
双向关系两端应保持一致
数据库关联由 owning side,也就是 Payment.refunds 决定,但内存中的双向关系仍然需要同步维护:
public void addRefund(DRefund refund) {
refunds.add(refund);
refund.getPayments().add(this);
}
否则,在当前事务中从反向端读取关系时,可能得到与预期不一致的对象状态。
总的来说,这条 UPDATE Payment 是 @Version 与关联维护端集合变化共同产生的预期结果。只有在明确接受关联修改不参与 Payment 乐观锁检测时,才适合使用 @OptimisticLock(excluded = true) 排除该集合。
备注:内容仅供参考。
