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_STREETBillingAddress.city对应BILLING_CITY
当同一种嵌入类型在实体中出现多次时,这种写法很常见。例如,一个实体同时包含账单地址和收货地址,如果不覆盖列名,两组属性可能会映射到相同的默认列名。
原代码的写法是:
@Id
@AttributeOverrides({
@AttributeOverride(name = "street", column = @Column(name = "STREET")),
@AttributeOverride(name = "city", column = @Column(name = "CITY"))
})
private String street;
这里有两个问题:
street的类型是String,并不是包含street和city属性的嵌入对象,所以没有可供覆盖的嵌入属性。@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,可能有以下原因:
@AttributeOverrides的标注位置不正确,Hibernate 没有把它应用到预期的嵌入属性上。- 数据库表已经存在。
hibernate.hbm2ddl.auto=update通常不会安全地把旧列重命名为新列,因为它无法判断这是一次重命名,还是新增了一个字段。 - 数据库或命名策略转换了标识符的大小写。
- 实际生效的映射来自 getter、XML 配置或其他访问方式,并非当前字段。
- 当前查看的是旧 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 的映射方式。
备注:内容仅供参考。
