【Spring JPA总结】JPA 问题汇总:EAGER fetch,LazyInitializationException,N+1

参考:


尽管JPA提供了CRUD的抽象操作,使得操作数据库变得十分的方便,但同时又存在另外一些效率等问题需要引起注意。

【本文内容】
JPA问题汇总

问题一:fetch类型=EAGER导致的问题

在JPA中一个entity中想要加载它的相关的entity list时,有两种fetch类型:EAGERLAZY。比如班级和学生是一对多关系,在班级这个entity中,配置了学生(关系为一对方),那么加载学生这个list的时候,就用到了fetch类型。

  • EAGER类型:和父entity一起获取子entity list(一般用到了join语句)。这也导致JPA可能返回不必要的数据,从而影响效率。
  • LAZY类型:按需获取子entity list,并不会和父entity一起返回。LAZY类型有可能会抛出LazyInitializationException异常。

【例子】
数据原型,查看:https://www.jianshu.com/p/1c279b221527
书店里有很多书,所以书店和书之间,是一对多关系:

@Entity
@Table(name = "book_store")
public class BookStore {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    private String name;

    @OneToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL)
    @JoinColumn(name = "book_store_id")
    private Set<Book> books = new HashSet<>();
}
public interface BookStoreRepository extends JpaRepository<BookStore, Integer> {
    List<BookStore> findByNameContaining(String name);
}

方法findByNameContaining(),按name模糊查询,如果fetch类型为EAGER,那么在返回数据的时候,会同时查询book表。sql如下:
第一次会按name like查询book_store表:

select
bookstore0_.id as id1_2_,
bookstore0_.name as name2_2_
from
book_store bookstore0_
where
bookstore0_.name like ? escape ?

如果fetch是LAZY的话,就不需要以下的查询了。
EAGER的话,会再次按book_store_id进行查询,逐次返回各个id下的book list数据,如果上面的按name模糊查询返回3个bookStore(比如id=1, 2, 3),那么下面的sql语句会执行三次,传入的id分别为1, 2, 3:

select
books0_.book_store_id as book_sto3_1_0_,
books0_.id as id1_1_0_,
books0_.id as id1_1_1_,
books0_.name as name2_1_1_
from
book books0_
where
books0_.book_store_id=?

【总结】fetch类型=EAGER时,会查询不必要的数据,也会导致N+1的问题。

【解决方式 1-1】想要解决上述问题,可以使用fetch类型=LAZY

另外,在JPA注解的x对一关联(如@ManyToOne, @OneToOne)中fetch默认类型都是EAGER,如果想用LAZY,需要显示指定出来,如@ManyToOne(fetch = FetchType.LAZY)

问题二:fetch类型LAZY导致LazyInitializationException异常

@Entity
@Table(name = "book")
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private int id;

    private String name;

    @ManyToOne(fetch = FetchType.LAZY, optional = false)
    @JoinColumn(name = "book_store_id")
    private BookStore bookStore;
}

如果fetch类型为LAZY,如果在transactional context之外(即没有事务),那么就会报:LazyInitializationException异常。

原因是会先查多方的数据(from book),但是再获取一方(bookStore)的时候session已经关闭了,因此报错。

我们尝试按book名字模糊查询,并转化成BookWithBookStoreView类:

    @Test
    public void findByNameContainingTest() {
        List<BookWithBookStoreView> bookList = findByNameContaining("book");
        System.out.println(bookList);
    }

    private List<BookWithBookStoreView> findByNameContaining(String name) {
        List<Book> bookList = bookRepository.findByNameContaining(name);
        return bookList.stream()
                .map(book -> new BookWithBookStoreView(book.getId(), book.getName(), book.getBookStore()))
                .collect(Collectors.toList());
    }

抛错:org.hibernate.LazyInitializationException: could not initialize proxy [com.entity.BookStore#1] - no Session

想要避免LazyInitializationException异常,可以尝试:

  • 使用fetch类型为EAGER,但这又回到了问题一,即导致N+1问题
  • 使用@Transactional来标记上述的测试方法queryTest(),使得这个方法在事务的上下文中执行,这样就不会导致session被关闭了。但同样的,这会致致N+1的问题。
  • 修改Hibernate的初始化参数,使得避免产生上述异常。但同样的,这个解决方式会带来另外一些问题,如它会执行额外的SQL等

【解决思路】尝试从repository方面入手来解决JPA在执行完1个SQL后,再获取相关联的数据时不要抛LazyInitializationException异常。

【解决方式 2-1】查询结果自定义DTO对象

Spring Data can help retrieve partial view of a JPA @Entity with interface-based or class-based projection (DTO classes)

public interface BookDTO {
    Integer getId();
    String getName();
    BookStoreDTO getBookStore();
}
public interface BookStoreDTO {
    Integer getId();
    String getName();
}

在repository层,用BookDTO代替原来的Book返回:

public interface BookRepository extends JpaRepository<Book, Integer> {
    List<BookDTO> findByNameContaining(String name);
}

测试:

    @Test
    public void findByNameContainingTest() {
        List<BookWithBookStoreView> bookList = findByNameContaining("book");
        System.out.println(bookList);
    }

    private List<BookWithBookStoreView> findByNameContaining(String name) {
        List<BookDTO> bookList = bookRepository.findByNameContaining(name);
        return bookList.stream()
                .map(book -> new BookWithBookStoreView(book.getId(), book.getName(),
                        new BookStore(book.getBookStore().getId(), book.getBookStore().getName())))
                .collect(Collectors.toList());
    }

sql,可以看到避免了N+1的问题,在where name like的时候,同时也用inner join获取了bookStore的数据:

select
book0_.id as col_0_0_,
book0_.name as col_1_0_,
book0_.book_store_id as col_2_0_,
bookstore1_.id as id1_2_,
bookstore1_.name as name2_2_
from
book book0_
inner join book_store bookstore1_ on book0_.book_store_id=bookstore1_.id
where
book0_.name like ? escape ?

【解决方式 2-2】使用@EntityGraph

使用@EntityGraph注解,标注在repository的方法上,用来声明这是一个查询配置的属性的query。
声明我们需要查询bookStore:

public interface BookRepository extends JpaRepository<Book, Integer> {
    @EntityGraph(attributePaths = "bookStore")
    List<Book> findByNameContaining(String name);
}

这时候我们用第#2章一开始的test去执行,发现不会再报LazyInitializationException异常。
sql和解决方式-1 一样,在where name like的基础上,会再用left outer join book_store表,这样就极好的避免了N+1的问题,同时也避免了LazyInitializationException异常:

select
book0_.id as id1_1_0_,
bookstore1_.id as id1_2_1_,
book0_.book_store_id as book_sto3_1_0_,
book0_.name as name2_1_0_,
bookstore1_.name as name2_2_1_
from
book book0_
left outer join book_store bookstore1_ on book0_.book_store_id=bookstore1_.id
where
book0_.name like ? escape ?

【解决方式 2-3】使用JPQL JOIN FETCH

JPQL(Java Persistence Query Language)支持以JOIN的方式在一个query中关联相关的数据并返回。

public interface BookRepository extends JpaRepository<Book, Integer> {
    @Query(value="FROM Book b LEFT JOIN FETCH b.bookStore where b.name like %:name%")
    List<Book> findByNameContaining(String name);
}

sql语句如下,可以看到也是用了left outer join来获取数据,同时也避免了N+1的问题:

select
book0_.id as id1_1_0_,
bookstore1_.id as id1_2_1_,
book0_.book_store_id as book_sto3_1_0_,
book0_.name as name2_1_0_,
bookstore1_.name as name2_2_1_
from
book book0_
left outer join book_store bookstore1_ on book0_.book_store_id=bookstore1_.id
where
book0_.name like ?

问题三:执行N+1次的问题:查询方面

当JPA想要获取实体内的子entity list的数据时,不得不执行多余的N次SQL,往往发生在以下情形中:

  • 当fetch=EAGER时,当获到取bookStore的数据后,会再逐个按bookStoreId获取下面的book list数据。(在#1中有详细介绍)。
  • 当fetch=LAZY时,上述#2一开始的测试代码,如果加上@Transactional,同样会有N+1的问题。它会先查询book表,where name like ?,查询出book list后,再按book.book_store_id的值,逐个本询book_store表。
  • 不仅仅是查询,delete的时候也会有这个问题(留到下章讲)。
【解决方式 3-1】使用@EntityGraph

在上述第#2章的【解决方式 2-2】有介绍。

【解决方式 3-2】使用JPQL JOIN FETCH

在上述第#2章的【解决方式 2-3】有介绍。

问题四:执行N+1次的问题:删除方面

假设bookStore id = 1下有两本书:
image.png

我们在BookRepository中希望按bookStoreId进行删除:

public interface BookRepository extends JpaRepository<Book, Integer> {
    @Transactional
    void deleteByBookStoreId(int bookStoreId);
}

测试:

    @Test
    public void deleteByBookStoreIdTest() {
        bookRepository.deleteByBookStoreId(1);
    }

相应的sql,首先是查询出bookStoreId下所有的book list:

select
book0_.id as id1_1_,
book0_.book_store_id as book_sto3_1_,
book0_.name as name2_1_
from
book book0_
inner join
book_store bookstore1_
on book0_.book_store_id=bookstore1_.id
where
bookstore1_.id=?

然后进行逐个删除,因为数据库中相应的数据有2条(id = 1, 2),所以这里执行了两次:

delete from book where id=?
delete from book where id=?

即,在删除的时候,我们发现JPA会逐一删除,这样会导致N+1的问题。

【解决方式 4-1】: 定义DELETE语句

在repository层,我们自己定义DELETE语句来按bookStoreId进行删除:

public interface BookRepository extends JpaRepository<Book, Integer> {
    @Modifying
    @Transactional
    @Query("DELETE FROM Book b WHERE b.bookStore.id = :bookStoreId")
    void deleteInBulkByBookStoreId(int bookStoreId);
}

这样在执行的时候,可以有效的避免按book.id进行逐个删除,sql如下(只有一个):

delete from book where book_store_id=?

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 162,825评论 4 377
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,887评论 2 308
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 112,425评论 0 255
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,801评论 0 224
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 53,252评论 3 299
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 41,089评论 1 226
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,216评论 2 322
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 31,005评论 0 215
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,747评论 1 250
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,883评论 2 255
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,354评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,694评论 3 265
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,406评论 3 246
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,222评论 0 9
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,996评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,242评论 2 287
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 36,017评论 2 281

推荐阅读更多精彩内容