PAYATHON 2026

Hibernate 中如何使用 composite key 和 @AttributeOverrides

支付老李

结论

当前的映射方式有误。@AttributeOverrides 标注在了 String street 上,但它用于覆盖嵌入对象或继承属性的列映射,不能描述两个普通的字符串字段。

如果要把 BillingAddress 作为 PurchaseOrder 的复合主键,可以采用下面的方式:

  • 用 @Embeddable 标注 BillingAddress。
  • 用 @EmbeddedId 标注 PurchaseOrder.billingAddress。
  • 不要在 PurchaseOrder 中重复声明 street 和 city。
  • 如需覆盖列名,把 @AttributeOverrides 标注在 billingAddress 字段上。

不过,从业务建模的角度看,地址通常不适合作为订单主键。同一地址可能对应多张订单,地址本身也可能需要修正。更常见的做法是给订单设置独立的生成型主键,再把地址作为普通的 @Embedded 值对象。

@AttributeOverrides 的作用

@AttributeOverride 用于覆盖可嵌入对象中某个属性原有的列映射。

例如,BillingAddress 包含两个属性:

@Embeddable
public class BillingAddress implements Serializable {

    private String street;
    private String city;

    // constructors, getters and setters
}

把它嵌入另一个实体时,可以指定这些属性最终映射到哪些列:

@Embedded
@AttributeOverrides({
    @AttributeOverride(
        name = "street",
        column = @Column(name = "BILLING_STREET")
    ),
    @AttributeOverride(
        name = "city",
        column = @Column(name = "BILLING_CITY")
    )
})
private BillingAddress billingAddress;

这段映射表示:

  • BillingAddress.street 对应 BILLING_STREET
  • BillingAddress.city 对应 BILLING_CITY

当同一种嵌入类型在实体中出现多次时,这种写法很常见。例如,一个实体同时包含账单地址和收货地址,如果不覆盖列名,两组属性可能会映射到相同的默认列名。

原代码的写法是:

@Id
@AttributeOverrides({
    @AttributeOverride(name = "street", column = @Column(name = "STREET")),
    @AttributeOverride(name = "city", column = @Column(name = "CITY"))
})
private String street;

这里有两个问题:

  1. street 的类型是 String,并不是包含 street 和 city 属性的嵌入对象,所以没有可供覆盖的嵌入属性。
  2. @Id 只标记了 street,city 并未成为主键的一部分。

普通字段应直接使用 @Column:

@Id
@Column(name = "STREET")
private String street;

使用 @EmbeddedId 映射复合主键

如果确实需要用 street 和 city 组成复合主键,可以按下面的方式定义。

复合主键类

import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;

import java.io.Serializable;
import java.util.Objects;

@Embeddable
public class BillingAddress implements Serializable {

    @Column(name = "STREET")
    private String street;

    @Column(name = "CITY")
    private String city;

    protected BillingAddress() {
    }

    public BillingAddress(String street, String city) {
        this.street = street;
        this.city = city;
    }

    public String getStreet() {
        return street;
    }

    public void setStreet(String street) {
        this.street = street;
    }

    public String getCity() {
        return city;
    }

    public void setCity(String city) {
        this.city = city;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (!(o instanceof BillingAddress)) {
            return false;
        }
        BillingAddress that = (BillingAddress) o;
        return Objects.equals(street, that.street)
                && Objects.equals(city, that.city);
    }

    @Override
    public int hashCode() {
        return Objects.hash(street, city);
    }
}

实体类

import jakarta.persistence.EmbeddedId;
import jakarta.persistence.Entity;

@Entity
public class PurchaseOrder {

    @EmbeddedId
    private BillingAddress billingAddress;

    private String itemName;

    protected PurchaseOrder() {
    }

    public PurchaseOrder(BillingAddress billingAddress) {
        this.billingAddress = billingAddress;
    }

    public BillingAddress getBillingAddress() {
        return billingAddress;
    }

    public void setBillingAddress(BillingAddress billingAddress) {
        this.billingAddress = billingAddress;
    }

    public String getItemName() {
        return itemName;
    }

    public void setItemName(String itemName) {
        this.itemName = itemName;
    }
}

此时不应再在 PurchaseOrder 中保留以下两个字段:

private String street;
private String city;

这两个属性已经包含在 billingAddress 中。再次声明会造成重复列映射。虽然可以把重复字段显式设置为只读映射,但这里没有必要这样做。

如果想由 PurchaseOrder 决定具体的列名,也可以把覆盖注解放在 @EmbeddedId 字段上:

@EmbeddedId
@AttributeOverrides({
    @AttributeOverride(
        name = "street",
        column = @Column(name = "ORDER_STREET")
    ),
    @AttributeOverride(
        name = "city",
        column = @Column(name = "ORDER_CITY")
    )
})
private BillingAddress billingAddress;

这样,BillingAddress.street 和 BillingAddress.city 会分别映射到 ORDER_STREET 和 ORDER_CITY。

保存实体

使用 @EmbeddedId 后,问题中的保存方式是合理的:

PurchaseOrder purchaseOrder = new PurchaseOrder();
purchaseOrder.setItemName("name");

BillingAddress billingAddress = new BillingAddress();
billingAddress.setCity("c1");
billingAddress.setStreet("s1");

purchaseOrder.setBillingAddress(billingAddress);

session.persist(purchaseOrder);

保存前需要确认:

  • billingAddress 不是 null。
  • 主键中的 street 和 city 都有有效值。
  • 当前操作处于事务中。
  • 数据库中不存在相同的 street、city 组合,否则会违反主键唯一性约束。

实体持久化后,不应再修改复合主键。Hibernate 会使用完整的 BillingAddress 识别该实体。如果修改其中的主键字段,持久化上下文中的实体可能无法再与数据库记录正确对应。

如果继续使用 @IdClass

@IdClass 也能实现相同的映射,但实体必须分别保留所有主键字段,并给每个字段标注 @Id:

@Entity
@IdClass(BillingAddress.class)
public class PurchaseOrder {

    @Id
    @Column(name = "STREET")
    private String street;

    @Id
    @Column(name = "CITY")
    private String city;

    private String itemName;

    protected PurchaseOrder() {
    }

    // getters and setters
}

BillingAddress 中的属性名称和类型必须与实体中的主键属性一致:

public class BillingAddress implements Serializable {

    private String street;
    private String city;

    public BillingAddress() {
    }

    // equals, hashCode, getters and setters
}

采用 @IdClass 时:

  • 实体中需要保留 street 和 city。
  • BillingAddress 主要用作主键类型。
  • 不要把 @AttributeOverrides 放在 street 字段上。
  • 查询时仍使用 BillingAddress 作为主键,例如 session.find(PurchaseOrder.class, id)。

如果希望直接在实体中操作一个完整的 BillingAddress 对象,使用 @EmbeddedId 通常更自然。

为什么修改列名后仍然看到 STREET

即使把注解中的列名改成 STREET1,数据库里仍然显示 STREET,可能有以下原因:

  1. @AttributeOverrides 的标注位置不正确,Hibernate 没有把它应用到预期的嵌入属性上。
  2. 数据库表已经存在。hibernate.hbm2ddl.auto=update 通常不会安全地把旧列重命名为新列,因为它无法判断这是一次重命名,还是新增了一个字段。
  3. 数据库或命名策略转换了标识符的大小写。
  4. 实际生效的映射来自 getter、XML 配置或其他访问方式,并非当前字段。
  5. 当前查看的是旧 schema、旧数据库实例,或者缓存后的数据库结构。

验证映射时,可以查看 Hibernate 输出的建表 SQL,也可以在允许安全删除数据的测试数据库中重新创建表。不要为了验证列名而直接删除生产环境中的表。

是否应该避免复合主键

“新系统一律不要使用复合主键”这个说法过于绝对,不过,生成型单列主键通常确实更容易使用和维护。

复合主键会带来一些额外成本:

  • 外键需要包含多列。
  • 关联映射和查询参数更复杂。
  • 主键类必须正确实现 equals() 和 hashCode()。
  • 主键字段应保持不可变。
  • 缓存键、日志和接口参数通常更长。
  • 一旦自然键的业务规则发生变化,修改主键会很麻烦。

以下场景仍然适合使用复合主键:

  • 需要映射无法修改表结构的遗留数据库。
  • 多对多关系表的主键天然由两侧外键组成。
  • 明细表由”单据 ID + 行号”唯一标识。
  • 多租户数据由”租户 ID + 业务 ID”共同标识。
  • 某个业务标识天然稳定且不可变,数据库也需要直接把它作为主键。
  • 外部标准明确规定多个字段共同组成唯一身份。

业务字段具有唯一性,并不意味着它们必须成为主键。许多新系统会使用独立的 id 作为主键,再通过唯一约束保护业务字段的组合:

@Entity
@Table(
    name = "PURCHASE_ORDER",
    uniqueConstraints = @UniqueConstraint(
        name = "UK_ORDER_ADDRESS",
        columnNames = {"STREET", "CITY"}
    )
)
public class PurchaseOrder {

    @Id
    @GeneratedValue
    private Long id;

    @Embedded
    @AttributeOverrides({
        @AttributeOverride(
            name = "street",
            column = @Column(name = "STREET", nullable = false)
        ),
        @AttributeOverride(
            name = "city",
            column = @Column(name = "CITY", nullable = false)
        )
    })
    private BillingAddress billingAddress;

    private String itemName;
}

不过,“地址唯一”通常不该被解释为每个地址只能有一张订单。更合理的模型一般是使用生成型订单 ID,把 BillingAddress 作为普通的 @Embedded 值对象,并且不为地址组合设置唯一约束。是否需要唯一约束,应该取决于实际业务规则,而不是 ORM 的映射方式。

备注:内容仅供参考。