You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

EntityManager多对多关联子实体在连接表中的持久化问题

多对多关联下的实体持久化问题处理

问题背景

我有一个Book实体,它与Author和Genre均为ManyToMany关系,配有对应的连接表。Book类定义如下:

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@ToString
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "book_seq")
    @GenericGenerator(name = "book_seq",
            strategy = "com.example.bookappjpa.dao.StringPrefixedSequenceIdGenerator",
            parameters = {
                    @org.hibernate.annotations.Parameter(name = StringPrefixedSequenceIdGenerator.INCREMENT_PARAM, value = "1"),
                    @org.hibernate.annotations.Parameter(name = StringPrefixedSequenceIdGenerator.VALUE_PREFIX_PARAMETER, value = "B"),
                    @org.hibernate.annotations.Parameter(name = StringPrefixedSequenceIdGenerator.NUMBER_FORMAT_PARAMETER, value = "%08d"),
            })
    private String id;

    @NaturalId
    private String title;

    @Column(name = "rank_no")
    private int rank;

    private int listId;

    private Double rating;

    private int pages;

    private LocalDate publishedOn;

    @ManyToMany()
    @JoinTable(name = "BOOK_AUTHOR", joinColumns = @JoinColumn(name = "BOOK_ID"), inverseJoinColumns = @JoinColumn(name = "AUTHOR_ID")
    )
    private Set<Author> authors = new HashSet<>();

    @ManyToMany
    @JoinTable(name = "BOOK_GENRE", joinColumns = @JoinColumn(name = "BOOK_ID"), inverseJoinColumns = @JoinColumn(name = "GENRE_ID"))
    private Set<Genre> genres = new HashSet<>();

    public void setTitle(String title) {
        this.title = title;
    }

    public void setRankNo(int rankNo) {
        this.rank = rankNo;
    }

    public void setListId(int listId) {
        this.listId = listId;
    }

    public void setRating(Double rating) {
        this.rating = rating;
    }

    public void setPages(int pages) {
        this.pages = pages;
    }

    public void setPublishedOn(LocalDate publishedOn) {
        this.publishedOn = publishedOn;
    }

    public boolean addAuthor(Author author) {
        return this.authors.add(author);
    }

    public boolean addGenre(Genre genre) {
        return this.genres.add(genre);
    }

}

当前持久化实现(待完善)

下面是我写的持久化方法,逻辑是通过自然ID查询实体是否存在,仅当不存在时才持久化,但代码存在缺陷:

@Transactional
public void addBook(BookDTO book) {
    Session session = entityManager.unwrap(Session.class);
    Book b = session.bySimpleNaturalId(Book.class).load(book.getTitle());

    if (null == b) {
        Book bookToDB = new Book();
        bookToDB.setTitle(book.getTitle());
        bookToDB.setPages(book.getPages());
        bookToDB.setRating(book.getRating());
        bookToDB.setRankNo(book.getRank());
        bookToDB.setPublishedOn(book.getPublishedOn());

        book.getAuthors().forEach(a -> {
            Author authorToDB = new Author(a.getName(), a.getAge(), a.getNationality());
            if(null == session.bySimpleNaturalId(Author.class).load(a.getName())) {
                authorToDB.addBook(bookToDB);
                entityManager.persist(authorToDB);
            } 
        });

        book.getGenres().forEach(g -> {
            if(null == session.bySimpleNaturalId(Genre.class).load(g.getName())) {
                Genre genreToDB = new Genre(g.getName());
                genreToDB.addBook(bookToDB);
                entityManager.persist(genreToDB);
            }
        });

        entityManager.persist(bookToDB);

    } else {
        String message = "Book with title " + book.getTitle() + " already exists!";
        log.info(message);
        throw new RuntimeException(message);
    }

    session.close();
}

现存问题

当前方案的问题很明显:当某本书籍与特定Author和Genre持久化后,后续新增另一本包含相同Author/Genre的书籍时,子实体的持久化会被跳过,导致连接表中没有生成Book与已有Author/Genre的关联记录。

我现在纠结两个点:

  • 保留存在性检查:新Book无法关联已有Author/Genre,连接表无数据
  • 去掉检查:Author/Genre表中会生成大量名称相同但主键不同的重复实体,完全不符合最佳实践

想知道生产级代码中有没有更成熟高效的方案处理这类场景。

示例数据

下面是两个包含同一作者的Book对象(第二个Book不会写入连接表BOOK_AUTHOR,因为作者已存在,当前代码不会处理关联):

{
    "rank": 8,
    "title": "Clap When You Land",
    "authors": [
      {
        "name": "Elizabeth Acevedo",
        "age": 0,
        "nationality": null
      }
    ],
    "rating": 0,
    "genres": [
      {
        "name": "Young Adult"
      },
      {
        "name": "Poetry"
      },
      {
        "name": "Contemporary"
      },
      {
        "name": "Fiction"
      },
      {
        "name": "Audiobook"
      },
      {
        "name": "LGBT"
      },
      {
        "name": "Realistic Fiction"
      }
    ],
    "pages": 432,
    "publishedOn": "2020-05-05",
    "listId": 1
  }


{
    "rank": 18,
    "title": "The Poet X",
    "authors": [
      {
        "name": "Elizabeth Acevedo",
        "age": 0,
        "nationality": null
      }
    ],
    "rating": 0,
    "genres": [
      {
        "name": "Poetry"
      },
      {
        "name": "Young Adult"
      },
      {
        "name": "Contemporary"
      },
      {
        "name": "Fiction"
      },
      {
        "name": "Audiobook"
      },
      {
        "name": "Realistic Fiction"
      },
      {
        "name": "Romance"
      }
    ],
    "pages": 368,
    "publishedOn": "2018-03-06",
    "listId": 1
  }

生产级解决方案

核心思路

不管Author/Genre是否存在,先通过自然ID查询获取实体:存在则直接复用,不存在则创建新实体;然后将获取到的实体与新Book建立关联,最后持久化Book(关联关系会自动同步到连接表)。

优化后的持久化代码

@Transactional
public void addBook(BookDTO book) {
    Session session = entityManager.unwrap(Session.class);
    // 检查Book是否已存在,存在则抛出异常
    Book existingBook = session.bySimpleNaturalId(Book.class).load(book.getTitle());
    if (existingBook != null) {
        String message = "Book with title " + book.getTitle() + " already exists!";
        log.info(message);
        throw new RuntimeException(message);
    }

    // 初始化要持久化的Book对象
    Book bookToDB = new Book();
    bookToDB.setTitle(book.getTitle());
    bookToDB.setPages(book.getPages());
    bookToDB.setRating(book.getRating());
    bookToDB.setRankNo(book.getRank());
    bookToDB.setPublishedOn(book.getPublishedOn());
    bookToDB.setListId(book.getListId()); // 补充设置listId,原代码漏了

    // 处理Author关联:复用已有或创建新实体,再关联到Book
    book.getAuthors().forEach(authorDTO -> {
        // 通过自然ID查询已有Author
        Author existingAuthor = session.bySimpleNaturalId(Author.class).load(authorDTO.getName());
        Author authorToUse;
        
        if (existingAuthor == null) {
            // 不存在则创建并持久化
            authorToUse = new Author(authorDTO.getName(), authorDTO.getAge(), authorDTO.getNationality());
            entityManager.persist(authorToUse);
        } else {
            // 存在则直接复用
            authorToUse = existingAuthor;
        }
        
        // 建立Book和Author的双向关联
        bookToDB.addAuthor(authorToUse);
        authorToUse.addBook(bookToDB);
    });

    // 处理Genre关联:逻辑同Author
    book.getGenres().forEach(genreDTO -> {
        Genre existingGenre = session.bySimpleNaturalId(Genre.class).load(genreDTO.getName());
        Genre genreToUse;
        
        if (existingGenre == null) {
            genreToUse = new Genre(genreDTO.getName());
            entityManager.persist(genreToUse);
        } else {
            genreToUse = existingGenre;
        }
        
        bookToDB.addGenre(genreToUse);
        genreToUse.addBook(bookToDB);
    });

    // 持久化Book,关联关系会自动同步到连接表
    entityManager.persist(bookToDB);
}

关键优化点

  1. 复用优先,避免重复:通过自然ID查询确保同名称的Author/Genre只会存在一条记录
  2. 强制关联所有实体:不管是新创建的还是复用的Author/Genre,都要和当前Book建立关联,同时维护双向关联的另一方(保证ORM能正确同步连接表)
  3. 事务原子性:整个操作在一个事务内完成,确保实体持久化和关联关系的一致性
  4. 补全遗漏字段:原代码漏了设置listId,优化后补充完整

额外生产级建议

  • 数据库层面加唯一约束:在Author和Genre的name字段上添加唯一约束,即使代码出现疏漏,数据库也会抛出异常,避免重复数据
    @Entity
    public class Author {
        @NaturalId
        @Column(unique = true, nullable = false)
        private String name;
        // 其他字段、方法...
    }
    
    @Entity
    public class Genre {
        @NaturalId
        @Column(unique = true, nullable = false)
        private String name;
        // 其他字段、方法...
    }
    
  • 批量查询优化:如果一次导入大量书籍,可以先收集所有Author/Genre的名称,批量查询数据库中已存在的记录,减少单次查询的次数,提升性能
  • 避免手动关闭Session:Spring管理的Session会在事务结束后自动关闭,原代码中的session.close()可能导致异常,建议移除

内容的提问来源于stack exchange,提问作者Soumik Banerjee

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.17 06:57:12