
本文详解 Hibernate 中自引用(self-referencing)@ManyToMany 关系的正确建模与数据获取方式,重点解决因错误使用 @JoinTable 导致的 SQL 语法异常、字段投影失败及 N+1 查询问题。
本文详解 hibernate 中自引用(self-referencing)`@manytomany` 关系的正确建模与数据获取方式,重点解决因错误使用 `@jointable` 导致的 sql 语法异常、字段投影失败及 n+1 查询问题。
在构建树形或网状结构(如药品分组体系)时,常需用同一张表(如 medicaments_group)通过关联表(如 medicaments_group_join)表达“父子分组”关系。此时若直接在实体中为同一关联关系定义两个方向相反的 @ManyToMany 并各自配置 @JoinTable(如原代码中 childrens 和 childrenOf 均显式声明完整 @JoinTable),Hibernate 会在 JPQL/HQL 或 QueryDSL 查询中生成非法 SQL —— 典型表现为 SELECT ... , . as col_2_0_, ... 这类缺失字段名的语法错误(PostgreSQL 报错 syntax error at or near "."),根源在于 Hibernate 无法为双向自引用集合生成正确的别名与投影路径。
✅ 正确建模:单向映射 + mappedBy 维护反向关系
应将其中一个集合设为 拥有方(owning side),另一个设为 被维护方(inverse side),通过 mappedBy 建立逻辑关联,避免重复定义 @JoinTable:
@Entity
@Table(name = "medicaments_group")
@Getter
@Setter
public class MedicamentGroup extends GenericDictionary {
@Id
private Long id;
@Column(name = "group_main")
private boolean groupMain;
@Column(name = "short_name")
private String shortName;
// 拥有方:定义关联表和外键列
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "medicaments_group_join",
joinColumns = @JoinColumn(name = "medicament_group_id"), // 当前分组ID → 父ID
inverseJoinColumns = @JoinColumn(name = "medicament_join_id") // 关联分组ID → 子ID
)
private List<MedicamentGroup> children = new ArrayList<>();
// 被维护方:通过 mappedBy 关联到拥有方,不定义 @JoinTable
@ManyToMany(mappedBy = "children", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private List<MedicamentGroup> parents = new ArrayList<>(); // 更语义化的命名:parents 而非 childrenOf
}⚠️ 注意事项:
- mappedBy = "children" 中的 "children" 必须严格匹配拥有方字段名(非数据库列名);
- 移除原 childrenOf 字段上冗余的 @JoinTable,否则 Hibernate 会尝试为同一张关联表生成两套映射,引发元数据冲突;
- 推荐使用 parents 替代 childrenOf,语义更清晰,且避免歧义。
✅ 安全查询:避免 QueryDSL 投影集合(关键修复)
原 QueryDSL 代码试图在 select() 中直接投影 children 集合(.select(id, shortName, childrens)),但 Hibernate 不支持在 JPQL/QueryDSL 的 SELECT 子句中直接选取集合属性——这会导致生成无效 SQL(如 ., medicament2_.id...)。正确做法是:
- 分两步加载:先查主实体,再通过 @Fetch(FetchMode.JOIN) 或 JOIN FETCH 显式关联子集;
- 或使用 Entity Graph(推荐,类型安全且可复用):
@Repository
public class MedicamentGroupRepositoryImpl implements MedicamentGroupRepository {
@PersistenceContext
private EntityManager entityManager;
@Override
public List<MedicamentGroup> getGroupsAndItsChildren() {
// 定义实体图:确保 children 集合被 JOIN FETCH 加载
EntityGraph<MedicamentGroup> graph = entityManager.createEntityGraph(MedicamentGroup.class);
graph.addAttributeNodes("children");
return entityManager.createQuery(
"SELECT mg FROM MedicamentGroup mg WHERE mg.groupMain = false",
MedicamentGroup.class)
.setHint("org.hibernate.fetchGraph", graph)
.getResultList();
}
}或使用 JPQL 显式 JOIN FETCH(更直观):
@Override
public List<MedicamentGroup> getGroupsAndItsChildren() {
return entityManager.createQuery(
"SELECT DISTINCT mg FROM MedicamentGroup mg " +
"LEFT JOIN FETCH mg.children ch " +
"WHERE mg.groupMain = false",
MedicamentGroup.class)
.getResultList();
}✅ 关键点:
- LEFT JOIN FETCH 确保 children 在单次查询中被初始化,避免懒加载 N+1 问题;
- DISTINCT 消除因 JOIN 导致的重复根实体(Hibernate 自动处理,但显式声明更稳妥);
- 绝不在 SELECT 子句中写 mg.children —— 只能用于 FETCH。
? 补充说明:为何“删库重建”有时有效?
原答案建议“删除数据库后重建”,其本质是清除 Hibernate 自动生成的过时 DDL 元数据缓存。当实体映射从错误的双 @JoinTable 改为正确的 mappedBy 后,旧数据库可能残留不兼容的外键约束或视图定义,导致 Hibernate 初始化失败。但此法属临时规避,根本解法始终是修正映射逻辑。
✅ 最终验证要点
- ✅ 启动应用时检查 Hibernate 日志,确认生成的 CREATE TABLE medicaments_group_join 仅有一份,且含两个外键指向 medicaments_group.id;
- ✅ 执行查询后,调用 group.getChildren().size() 不触发额外 SQL(证明 FETCH 生效);
- ✅ 数据库中 medicaments_group_join 表结构符合预期:medicament_group_id(父)与 medicament_join_id(子)均为 BIGINT 外键。
通过以上三步(修正映射、禁用集合投影、启用 JOIN FETCH),即可稳定获取带子节点的药品分组列表,彻底规避语法错误与性能陷阱。

















