PAYATHON 2026

Hibernate 7 中如何为复合主键子实体配置 @OrderColumn?

数据小陈

结论

复合主键本身不妨碍使用 @OrderColumn。需要分别检查关联映射、主键映射和排序列的管理方式。

报错 SQL 的同一个 INSERT 列表中,vacation_trip_id 出现了两次,应先修正重复列映射。这不是两个子实体主键相同导致的重复键异常。

排序时,@OrderColumn 根据 List 位置维护索引;@OrderBy 根据实体属性对读取结果排序,不会自动生成序号。同一个集合不要同时使用这两个注解。Jakarta Persistence 3.2 规范

为什么升级后 tripStopOrder 都是 0?

Hibernate 7 的迁移指南说明,对于带 mappedBy 的 @OneToMany,如果排序列同时映射为子实体字段,Hibernate 不再像以前那样通过额外的 UPDATE 按 List 位置更新该列。只有排序列没有映射为实体字段时,才会保留这类更新行为。Hibernate 7.0 迁移指南

原映射中的这段代码仍将排序列声明为持久化属性:

@Column(insertable = false, updatable = false)
private int tripStopOrder;

insertable = false, updatable = false 只限制该属性参与写入,并没有取消它的持久化映射,也不会让 Hibernate 自动将 List 索引赋给 Java 字段。新对象的 int 字段初始值为 0,不能只凭内存中的值判断数据库排序列是否正确。

因此,需要先确定排序号是由 Hibernate 的集合索引机制管理,还是由应用代码作为实体属性管理。

先修正关联和复合主键

父集合通过 @JoinColumn 声明外键映射,子实体又通过 @Id @ManyToOne 映射同一列。从当前代码和生成的 SQL 看,应优先排查这处映射重叠。具体为何生成重复列,还需要结合实际 Hibernate 小版本和最小复现确认。

保留双向关联时,可以让子实体的 vacationTrip 管理外键,父集合通过下面的配置引用它:

@OneToMany(
    mappedBy = "vacationTrip",
    cascade = CascadeType.ALL,
    orphanRemoval = true
)

这里必须是 mappedBy = "vacationTrip"。mappedBy 指向子实体的关联属性名,而非数据库列名;所贴实体中没有名为 vacationTripId 的关联属性。

按照 Jakarta Persistence 的派生标识规则,TripStopId.vacationTrip 应使用父实体主键的类型 UUID,而不是 VacationTrip。主键类还需要无参构造以及正确的 equals()、hashCode()。Jakarta Persistence:复合主键与派生标识

下面是修正后的子实体映射骨架,省略了 imports 和实体的普通访问器。@Data 沿用问题中的 Lombok 用法,主键类额外显式声明无参构造。

@Entity(name = "tripStop")
@Table(name = "trip_stop")
@IdClass(TripStop.TripStopId.class)
public class TripStop {

    @Data
    public static class TripStopId implements java.io.Serializable {
        private UUID vacationTrip;
        private String location;
        private LocalDateTime arrivalDateTime;
        private LocalDateTime departureDateTime;

        public TripStopId() {
        }
    }

    @Id
    @ManyToOne(optional = false)
    @JoinColumn(name = "vacation_trip_id", nullable = false)
    private VacationTrip vacationTrip;

    @Id
    @Column(name = "location", length = 100, nullable = false)
    private String location;

    @Id
    @Column(name = "arrival_date_time", nullable = false)
    private LocalDateTime arrivalDateTime;

    @Id
    @Column(name = "departure_date_time", nullable = false)
    private LocalDateTime departureDateTime;
}

这里移除了原来主键映射上的只读标记,使子实体能正常提供插入所需的主键和外键值。是否添加排序属性,取决于下面选择的方案。

方案一:让 Hibernate 根据 List 位置维护排序列

如果希望新增、删除和移动 List 元素后,由 Hibernate 更新排序号,可以采用迁移指南中仍保留的行为:排序列只作为集合索引存在,不再映射为 TripStop 的持久化字段。

父实体的相关映射改为:

@Entity(name = "vacationTrip")
@Table(name = "vacation_trip")
public class VacationTrip {

    @Id
    private UUID id;

    @Column(length = 100)
    private String destination;

    @OneToMany(
        mappedBy = "vacationTrip",
        cascade = CascadeType.ALL,
        orphanRemoval = true
    )
    @OrderColumn(name = "trip_stop_order")
    private List<TripStop> tripStops = new ArrayList<>();

    public void addTripStop(TripStop stop) {
        stop.setVacationTrip(this);
        tripStops.add(stop);
    }
}

使用这个方案时需要:

  • 删除父集合上的 @JoinColumn 和 @OrderBy。
  • 删除子实体中映射到 trip_stop_order 的持久化属性;仅设置为只读仍然属于字段映射。
  • 添加子实体时,同时设置它的 vacationTrip,并在保存前填好全部主键值。
  • 保持 List 的实际顺序,由集合索引表示持久化顺序。

这对应 Hibernate 7.0 迁移指南描述的兼容行为。不过,Hibernate 7 的入门指南仍建议在 mappedBy 一侧使用 @OrderBy,因此无法保证调整后相关警告会消失,也不能将其视为跨实现的通用保证。应在实际使用的小版本上验证插入和重排 SQL。Hibernate 7 关联集合排序说明

如果业务只需要显示“第几个停靠点”,可以根据父实体的 List 位置计算,无需将数据库索引重复映射为子实体属性。

方案二:保留 tripStopOrder,由应用维护序号

如果业务需要直接读取或查询 TripStop.tripStopOrder,可以把它作为普通可写字段,父集合使用 @OrderBy。

父集合:

@OneToMany(
    mappedBy = "vacationTrip",
    cascade = CascadeType.ALL,
    orphanRemoval = true
)
@OrderBy("tripStopOrder ASC")
private List<TripStop> tripStops = new ArrayList<>();

子实体增加:

@Column(name = "trip_stop_order")
private int tripStopOrder;

在父实体中集中处理添加和移动,并在每次修改后重新编号。下面的 to 表示移动完成后的目标索引:

public void addTripStop(TripStop stop) {
    stop.setVacationTrip(this);
    tripStops.add(stop);
    renumberTripStops();
}

public void moveTripStop(int from, int to) {
    int size = tripStops.size();
    if (from < 0 || from >= size || to < 0 || to >= size) {
        throw new IndexOutOfBoundsException();
    }

    TripStop stop = tripStops.remove(from);
    tripStops.add(to, stop);
    renumberTripStops();
}

public void removeTripStop(int index) {
    tripStops.remove(index);
    renumberTripStops();
}

private void renumberTripStops() {
    for (int i = 0; i < tripStops.size(); i++) {
        tripStops.get(i).setTripStopOrder(i);
    }
}

这些操作应在事务内针对受管实体执行。此时 @OrderBy 只负责加载时排序,编号由上述方法完成。还要避免其他调用方绕过这些方法直接修改集合。

如何确认修改有效?

使用实际项目的 Hibernate 版本验证以下行为,不要只检查注解或代码格式:

  1. 保存包含两个不同主键子实体的父实体,确认 INSERT 中每个列名只出现一次。
  2. 执行 flush() 后,直接查询 trip_stop_order,确认值符合所选方案。
  3. 清空持久化上下文后重新加载,检查 List 顺序。
  4. 交换两个元素、删除中间元素,再次刷新并重新加载,检查顺序和编号。

location、两个时间字段以及 vacationTrip 都参与主键。实体持久化后,不应直接修改这些标识属性;调整停靠时间或把停靠点移到另一个行程,可能需要删除旧实体并创建新实体。如果这些操作很常见,可以评估独立主键加业务唯一约束的设计,但这并非解决当前排序问题的必要条件。

目前没有提供 Hibernate 7 的具体小版本或可运行复现,无法据此断定重复列异常属于某个已知缺陷,也不能声称上述映射已在该项目中测试通过。

备注:内容仅供参考。