JPA复合主键关联异常:Token表插入时user_id为空报错
你遇到的问题核心是复合主键的映射方式错误——直接把关联的User实体标记为@Id并不是JPA处理复合主键的正确姿势,这会导致JPA无法正确解析主键字段与关联实体的映射关系,最终插入时出现user_id为空、token_id默认值为0的情况,触发数据库非空约束。
问题根源
你的token表是复合主键(token_id, user_id),并且与user表是一对多关联。当你在Token实体中把user字段标记为@Id时,JPA无法将User实体直接映射到数据库的user_id主键字段——JPA要求主键必须是基本数据类型、包装类,或者实现Serializable的复合主键类,而不是关联实体。这种错误的映射会导致:
- JPA无法从
User实体中提取user_id值填充到数据库字段,导致user_id为空; token_id因为没有配置正确的生成策略,默认初始化为0,若数据库对token_id有非空/自增约束,也会触发错误。
正确解决方案
针对复合主键+关联实体的场景,JPA提供了两种标准解决方案:@EmbeddedId(嵌入式主键)和**@IdClass**(主键类),下面分别给出具体实现:
方案1:使用@EmbeddedId(推荐,更简洁)
这种方式通过一个嵌入式类来封装复合主键的所有字段,同时通过@MapsId关联到User实体。
步骤1:创建嵌入式主键类TokenId
这个类必须实现Serializable,并重写equals和hashCode方法:
import java.io.Serializable; import java.util.Objects; import jakarta.persistence.Embeddable; @Embeddable public class TokenId implements Serializable { // 对应token表的token_id字段 private Long tokenId; // 对应token表的user_id字段 private Long userId; // 必须提供无参构造器(JPA要求) public TokenId() {} // 方便创建实例的有参构造器 public TokenId(Long tokenId, Long userId) { this.tokenId = tokenId; this.userId = userId; } // Getter和Setter方法 public Long getTokenId() { return tokenId; } public void setTokenId(Long tokenId) { this.tokenId = tokenId; } public Long getUserId() { return userId; } public void setUserId(Long userId) { this.userId = userId; } // 重写equals和hashCode,用于JPA判断实体唯一性 @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; TokenId tokenId1 = (TokenId) o; return Objects.equals(tokenId, tokenId1.tokenId) && Objects.equals(userId, tokenId1.userId); } @Override public int hashCode() { return Objects.hash(tokenId, userId); } }
步骤2:修改Token实体类
使用@EmbeddedId标记主键,并用@MapsId关联User实体,让JPA自动将User的user_id映射到TokenId的userId字段:
import jakarta.persistence.*; @Entity @Table(name = "token") public class Token { @EmbeddedId private TokenId id; // 关联User实体,@MapsId("userId")表示将User的主键映射到TokenId的userId字段 @ManyToOne(fetch = FetchType.LAZY) @MapsId("userId") @JoinColumn(name = "user_id") private User user; // 其他业务字段,比如token值、过期时间等 @Column(name = "token_value") private String tokenValue; @Column(name = "expire_time") private Long expireTime; // 构造器:传入User和业务字段,自动初始化主键的userId部分 public Token(User user, String tokenValue, Long expireTime) { this.id = new TokenId(null, user.getUserId()); // tokenId如果是自增,这里传null this.user = user; this.tokenValue = tokenValue; this.expireTime = expireTime; } // 无参构造器(JPA要求) public Token() {} // Getter和Setter方法 // ... }
配置token_id自增(如果需要)
如果你的token_id是数据库自增字段,需要在TokenId的tokenId字段上添加@GeneratedValue和@Column注解:
@Embeddable public class TokenId implements Serializable { @GeneratedValue(strategy = GenerationType.IDENTITY) // MySQL自增用IDENTITY @Column(name = "token_id") private Long tokenId; // 其他代码... }
方案2:使用@IdClass(拆分主键字段)
这种方式将复合主键的每个字段单独标记为@Id,同时通过主键类来封装这些字段。
步骤1:创建主键类TokenId
同样需要实现Serializable并重写equals和hashCode:
import java.io.Serializable; import java.util.Objects; public class TokenId implements Serializable { private Long tokenId; private Long userId; public TokenId() {} public TokenId(Long tokenId, Long userId) { this.tokenId = tokenId; this.userId = userId; } // Getter和Setter方法 // ... @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; TokenId tokenId1 = (TokenId) o; return Objects.equals(tokenId, tokenId1.tokenId) && Objects.equals(userId, tokenId1.userId); } @Override public int hashCode() { return Objects.hash(tokenId, userId); } }
步骤2:修改Token实体类
用@IdClass指定主键类,分别标记tokenId和userId为@Id,并关联User实体:
import jakarta.persistence.*; @Entity @Table(name = "token") @IdClass(TokenId.class) public class Token { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) // 自增配置 @Column(name = "token_id") private Long tokenId; @Id @Column(name = "user_id") private Long userId; // 关联User实体,注意insertable=false, updatable=false,避免JPA重复操作userId字段 @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", insertable = false, updatable = false) private User user; // 其他业务字段 @Column(name = "token_value") private String tokenValue; // 构造器:传入User和业务字段 public Token(User user, String tokenValue) { this.userId = user.getUserId(); this.user = user; this.tokenValue = tokenValue; } public Token() {} // Getter和Setter方法 // ... }
关键注意事项
- 复合主键类必须实现Serializable:JPA要求主键类可序列化,用于缓存和持久化操作。
- 重写equals和hashCode:确保JPA能正确判断实体的唯一性,避免缓存或查询错误。
- 关联实体的映射配置:使用
@MapsId(EmbeddedId方案)或insertable=false, updatable=false(IdClass方案),避免JPA对主键字段进行重复操作。 - 自增字段的生成策略:根据数据库类型选择合适的生成策略,比如MySQL用
GenerationType.IDENTITY,Oracle用GenerationType.SEQUENCE。
验证效果
修改完成后,插入Token数据时,JPA会正确从User实体中提取user_id填充到复合主键,同时token_id会根据配置的生成策略自动生成,不会再出现user_id为空或token_id为0的情况,也就不会触发SQL 1048非空约束错误了。
内容的提问来源于stack exchange,提问作者Jamie Stivala

