Skip to content

26经典的N+1SQL问题如何正确解决?(下)

你好,上一讲我们介绍了什么是 N+1 的 SQL 问题,以及减少 N 对应 SQL 条数的机制有哪些,相信你对此已经有了大概的了解。那么这一讲我们接着说这个经典的 SQL 问题,看看还有没有其他的解决方式。

Hibernate 中 @Fetch 数据的策略

Hibernate 提供了一个 @Fetch 注解,用来改变获取数据的策略。我们来研究一下这一注解的语法,代码如下所示。

java
// fetch注解只能用在方法和字段上面
@Target({ElementType.METHOD, ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Fetch {
   //注解里面,只有一个属性获取数据的模式
   FetchMode value();
}
//其中FetchMode的值有如下几种:
public enum FetchMode {
   //默认模式,就是会有N+1 sql的问题;
   SELECT,
   //通过join的模式,用一个sql把主体数据和关联关系数据一口气查出来
   JOIN,
   //通过子查询的模式,查询关联关系的数据
   SUBSELECT
}

需要注意的是,不要把这个注解和 JPA 协议里面的 FetchType.EAGER、FetchType.LAZY 搞混了,JPA 协议的关联关系中的 FetchTyp 解决的是取关联关系数据时机的问题,也就是说 EAGER 代表的是立即获得关联关系的数据,LAZY 是需要的时候再获得关联关系的数据。

这和 Hibernate 的 FetchMode 是两回事,FetchMode 解决的是获得数据策略的问题,也就是说,获得关联关系数据的策略有三种模式:SELECT(默认)、JOIN、SUBSELECT。下面我通过例子来分别介绍一下这三种模式有什么区别,分别起到什么作用。

FetchMode.SELECT

我们直接更改一下 UserInfo 实体,将 @Fetch(value = FetchMode.SELECT) 作为获取数据的策略,使用 FetchType.EAGER 作为获取数据的时机,代码如下所示。

java
@Entity
@Data
@SuperBuilder
@AllArgsConstructor
@NoArgsConstructor
@Table
@ToString(exclude = "addressList")
public class UserInfo extends BaseEntity {
   private String name;
   private String telephone;
   @OneToMany(mappedBy = "userInfo",cascade = CascadeType.PERSIST,fetch = FetchType.EAGER)
   @Fetch(value = FetchMode.SELECT)
   private List<Address> addressList;
}

然后还是执行 userInfoRepository.findAll(); 这个方法,看一下打印的 SQL 有哪些。

sql
org.hibernate.SQL                        :
select userinfo0_.id                    as id1_1_,
       userinfo0_.create_time           as create_t2_1_,
       userinfo0_.create_user_id        as create_u3_1_,
       userinfo0_.last_modified_time    as last_mod4_1_,
       userinfo0_.last_modified_user_id as last_mod5_1_,
       userinfo0_.version               as version6_1_,
       userinfo0_.ages                  as ages7_1_,
       userinfo0_.email_address         as email_ad8_1_,
       userinfo0_.last_name             as last_nam9_1_,
       userinfo0_.name                  as name10_1_,
       userinfo0_.telephone             as telepho11_1_
from user_info userinfo0_ 
org.hibernate.SQL                        :
select addresslis0_.user_info_id          as user_inf8_0_0_,
       addresslis0_.id                    as id1_0_0_,
       addresslis0_.id                    as id1_0_1_,
       addresslis0_.create_time           as create_t2_0_1_,
       addresslis0_.create_user_id        as create_u3_0_1_,
       addresslis0_.last_modified_time    as last_mod4_0_1_,
       addresslis0_.last_modified_user_id as last_mod5_0_1_,
       addresslis0_.version               as version6_0_1_,
       addresslis0_.city                  as city7_0_1_,
       addresslis0_.user_info_id          as user_inf8_0_1_
from address addresslis0_
where addresslis0_.user_info_id = ? 
org.hibernate.SQL                        :
select addresslis0_.user_info_id          as user_inf8_0_0_,
       addresslis0_.id                    as id1_0_0_,
       addresslis0_.id                    as id1_0_1_,
       addresslis0_.create_time           as create_t2_0_1_,
       addresslis0_.create_user_id        as create_u3_0_1_,
       addresslis0_.last_modified_time    as last_mod4_0_1_,
       addresslis0_.last_modified_user_id as last_mod5_0_1_,
       addresslis0_.version               as version6_0_1_,
       addresslis0_.city                  as city7_0_1_,
       addresslis0_.user_info_id          as user_inf8_0_1_
from address addresslis0_
where addresslis0_.user_info_id = ? 
org.hibernate.SQL                        :
select addresslis0_.user_info_id          as user_inf8_0_0_,
       addresslis0_.id                    as id1_0_0_,
       addresslis0_.id                    as id1_0_1_,
       addresslis0_.create_time           as create_t2_0_1_,
       addresslis0_.create_user_id        as create_u3_0_1_,
       addresslis0_.last_modified_time    as last_mod4_0_1_,
       addresslis0_.last_modified_user_id as last_mod5_0_1_,
       addresslis0_.version               as version6_0_1_,
       addresslis0_.city                  as city7_0_1_,
       addresslis0_.user_info_id          as user_inf8_0_1_
from address addresslis0_
where addresslis0_.user_info_id = ?

从上述 SQL 中可以看出,这依然是 N+1 的 SQL 问题,FetchMode.Select 是默认策略,加与不加是同样的效果,代表获取关系的时候新开一个 SQL 进行查询。

FetchMode.JOIN

FetchMode.JOIN 的意思是主表信息和关联关系通过一个 SQL JOIN 的方式查出来,我们看一下例子。

首先,将 UserInfo 里面的 FetchMode 改成 JOIN 模式,关键代码如下。

java
public class UserInfo extends BaseEntity {
   private String name;
   private String telephone;
   @OneToMany(mappedBy = "userInfo",cascade = CascadeType.PERSIST,fetch = FetchType.EAGER)
   @Fetch(value = FetchMode.JOIN) //唯一变化的地方采用JOIN模式
   private List<Address> addressList;
}

然后,调用一下 userInfoRepository.findAll(); 这个方法,发现依然是这三条 SQL,如下图所示。

这是因为 FetchMode.JOIN 只支持通过 ID 或者联合唯一键获取数据才有效,这正是 JOIN 策略模式的局限性所在。

那么我们再调用一下 userInfoRepository.findById(id),看看控制台的 SQL 执行情况,代码如下。

java
select userinfo0_.id                      as id1_1_0_,
       userinfo0_.create_time             as create_t2_1_0_,
       userinfo0_.create_user_id          as create_u3_1_0_,
       userinfo0_.last_modified_time      as last_mod4_1_0_,
       userinfo0_.last_modified_user_id   as last_mod5_1_0_,
       userinfo0_.version                 as version6_1_0_,
       userinfo0_.ages                    as ages7_1_0_,
       userinfo0_.email_address           as email_ad8_1_0_,
       userinfo0_.last_name               as last_nam9_1_0_,
       userinfo0_.name                    as name10_1_0_,
       userinfo0_.telephone               as telepho11_1_0_,
       addresslis1_.user_info_id          as user_inf8_0_1_,
       addresslis1_.id                    as id1_0_1_,
       addresslis1_.id                    as id1_0_2_,
       addresslis1_.create_time           as create_t2_0_2_,
       addresslis1_.create_user_id        as create_u3_0_2_,
       addresslis1_.last_modified_time    as last_mod4_0_2_,
       addresslis1_.last_modified_user_id as last_mod5_0_2_,
       addresslis1_.version               as version6_0_2_,
       addresslis1_.city                  as city7_0_2_,
       addresslis1_.user_info_id          as user_inf8_0_2_
from user_info userinfo0_
         left outer join address addresslis1_ on userinfo0_.id = addresslis1_.user_info_id
where userinfo0_.id = ?

这时我们会发现,当查询 UserInfo 的时候,它会通过 left outer join 把 Address 的信息也查询出来,虽然 SQL 上会有冗余信息,但是你会发现我们之前的 N+1 的 SQL 直接变成 1 条 SQL 了。

此时我们修改 UserInfo 里面的 @OneToMany,这个 @Fetch(value = FetchMode.JOIN) 同样适用于 @ManyToOne;然后再改一下 Address 实例,用 @Fetch(value = FetchMode.JOIN) 把 Adress 里面的 UserInfo 关联关系改成 JOIN 模式;接着我们用 LAZY 获取数据的时机,会发现其对获取数据的策略没有任何影响。

这里我只是给你演示获取数据时机的不同情况,关键代码如下。

java
@Entity
@Table
@Data
@SuperBuilder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "userInfo")
public class Address extends BaseEntity {
   private String city;
   @ManyToOne(cascade = CascadeType.PERSIST,fetch = FetchType.LAZY)
   @JsonBackReference
   @Fetch(value = FetchMode.JOIN)
   private UserInfo userInfo;
}

同样的道理,JOIN 对列表性的查询是没有效果的,我们调用一下 addressRepository.findById(id),产生的 SQL 如下所示。

sql
org.hibernate.SQL                        : 
select address0_.id                     as id1_0_0_,
       address0_.create_time            as create_t2_0_0_,
       address0_.create_user_id         as create_u3_0_0_,
       address0_.last_modified_time     as last_mod4_0_0_,
       address0_.last_modified_user_id  as last_mod5_0_0_,
       address0_.version                as version6_0_0_,
       address0_.city                   as city7_0_0_,
       address0_.user_info_id           as user_inf8_0_0_,
       userinfo1_.id                    as id1_1_1_,
       userinfo1_.create_time           as create_t2_1_1_,
       userinfo1_.create_user_id        as create_u3_1_1_,
       userinfo1_.last_modified_time    as last_mod4_1_1_,
       userinfo1_.last_modified_user_id as last_mod5_1_1_,
       userinfo1_.version               as version6_1_1_,
       userinfo1_.ages                  as ages7_1_1_,
       userinfo1_.email_address         as email_ad8_1_1_,
       userinfo1_.last_name             as last_nam9_1_1_,
       userinfo1_.name                  as name10_1_1_,
       userinfo1_.telephone             as telepho11_1_1_
from address address0_
         left outer join user_info userinfo1_ on address0_.user_info_id = userinfo1_.id
where address0_.id = ?

我们发现此时只会产生一个 SQL,即通过 from address left outer join user_info 一次性把所有信息都查出来,然后 Hibernate 再根据查询出来的结果组合到不同的实体里面。

也就是说 FetchMode.JOIN 对于关联关系的查询 LAZY 是不起作用的,因为 JOIN 的模式是通过一条 SQL 查出来所有信息,所以 FetchMode.JOIN 会忽略 FetchType。

那么我们再来看第三种模式。

FetchMode.SUBSELECT

这种模式很简单,就是将关联关系通过子查询的形式查询出来,我们还是结合例子来理解一下。

首先,将 UserInfo 里面的关联关系改成 @Fetch(value = FetchMode.SUBSELECT),关键代码如下。

java
public class UserInfo extends BaseEntity {
   @OneToMany(mappedBy = "userInfo",cascade = CascadeType.PERSIST,fetch = FetchType.LAZY) //我们这里测试一下LAZY情况
   @Fetch(value = FetchMode.SUBSELECT) //唯一变化之处
   private List<Address> addressList;
}

接着,像上面的做法一样,执行一下 userInfoRepository.findAll();方法,看一下控制台的 SQL 情况,如下所示。

java
org.hibernate.SQL                        :
select userinfo0_.id                    as id1_1_,
       userinfo0_.create_time           as create_t2_1_,
       userinfo0_.create_user_id        as create_u3_1_,
       userinfo0_.last_modified_time    as last_mod4_1_,
       userinfo0_.last_modified_user_id as last_mod5_1_,
       userinfo0_.version               as version6_1_,
       userinfo0_.ages                  as ages7_1_,
       userinfo0_.email_address         as email_ad8_1_,
       userinfo0_.last_name             as last_nam9_1_,
       userinfo0_.name                  as name10_1_,
       userinfo0_.telephone             as telepho11_1_
from user_info userinfo0_ 
org.hibernate.SQL                        :
select addresslis0_.user_info_id          as user_inf8_0_1_,
       addresslis0_.id                    as id1_0_1_,
       addresslis0_.id                    as id1_0_0_,
       addresslis0_.create_time           as create_t2_0_0_,
       addresslis0_.create_user_id        as create_u3_0_0_,
       addresslis0_.last_modified_time    as last_mod4_0_0_,
       addresslis0_.last_modified_user_id as last_mod5_0_0_,
       addresslis0_.version               as version6_0_0_,
       addresslis0_.city                  as city7_0_0_,
       addresslis0_.user_info_id          as user_inf8_0_0_
from address addresslis0_
where addresslis0_.user_info_id in (select userinfo0_.id from user_info userinfo0_)

这个时候会发现,查询 Address 信息是直接通过 addresslis0_.user_info_id in (select userinfo0_.id from user_info userinfo0_) 子查询的方式进行的,也就是说 N+1 SQL 变成了 1+1 的 SQL,这有点类似我们配置 @BatchSize 的效果。

FetchMode.SUBSELECT 支持 ID 查询和各种条件查询,唯一的缺点是只能配置在 @OneToMany 和 @ManyToMany 的关联关系上,不能配置在 @ManyToOne 和 @OneToOne 的关联关系上,所以我们在 Address 里面关联 UserInfo 的时候就没有办法做实验了。

总之,@Fetch 的不同模型,都有各自的优缺点:FetchMode.SELECT 默认,和不配置的效果一样;FetchMode.JOIN 只支持类似 findById(id) 的方法,只能根据 ID 查询才有效果;FetchMode.SUBSELECT 虽然不限使用方式,但是只支持 **ToMany 的关联关系。

所以你在使用 @Fetch 的时候需要注意一下它的局限性,我个人是比较推荐 @BatchSize 的方式。

那么除了上面的处理方式,我们也可以采用之前写 Mybatis 的思路来查询关联关系,下面来看一下该如何转变思路。

转化解决问题的思路

这时需要我们在思想上进行转变,利用 JPA 的优势,摒弃它的缺陷。想想我们没有用 JPA 的时候是怎么做的?难道一定要用实体之间的关联关系吗?如果用的是 Mybatis,你在给前端返回关联关系数据的时候一般怎么写呢?

答案肯定是写成 1+1 SQL 的形式,也就是一条主 SQL、一条查关联关系的 SQL。我们还用 UserInfo 和 Address 实体来演示,代码如下。

java
@Entity
@Data
@SuperBuilder
@AllArgsConstructor
@NoArgsConstructor
@Table
public class UserInfo extends BaseEntity {
   private String name;
   private String telephone;
   @Transient //在UserInfo实体中,我们不利用JPA来关联实体的关联关系了,而是把它设置成@Transisent,只维护java对象的关系,不维护DB之间的关联关系
   private List<Address> addressList;
}
@Entity
@Table
@Data
@SuperBuilder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "userInfo")
public class Address extends BaseEntity {
   private String city;
   private String userId;
   @Transient //同样Address里面也可以不维护UserInfo的关联关系
   private UserInfo userInfo;
}

当我们查询所有 UserInfo 信息的时候,又想把每个 UserInfo 的 Address 信息都带上,应该怎么做呢?请看如下代码。

java
/**
 * 自己实现一套 Batch fetch的逻辑
 */
@Transactional
public List<UserInfo> getAllUserWithAddress() {
   //先查出来所有的UserInfo信息
   List<UserInfo> userInfos = userInfoRepository.findAll();
   //再查出来上面userInfos里面的所有userId列表,再查询出来上面的查询结果所对应的所有Address信息
   List<Address> addresses = addressRepository.findByUserIdIn(userInfos.stream().map(userInfo -> userInfo.getId()).collect(Collectors.toList()));
   //我们自己再写一个转化逻辑,把各自user info的address信息放置到响应的UserInfo实例里面;
   Map<Long,List<Address>> addressMaps = addresses
         .stream()
         .collect(Collectors.groupingBy(Address::getUserId));//里面Map结构方便获取
   return userInfos.stream().map(userInfo -> {
       userInfo.setAddressList(addressMaps.get(userInfo.getId()));
       return userInfo;
   }).collect(Collectors.toList());
}

你会发现,这要比原来的方式稍微复杂一点,但是如果我们做框架的话,上面有些逻辑可以抽到一个 Util 类里面去。

不过需要注意的是,实际工作中我们肯定不是 findAll(),而是会根据一些业务逻辑查询一个 UserInfo 的 List 信息,然后再根据查询出来的 userInfo 的 ID 列表去二次查询 Address 信息,这样最多只需要 2 个 SQL 就可完成实际业务逻辑。

那么反向思考,我们通过 Address 对象查询 UserInfo 也是一样的道理,可以先查询出 List<Address>,再查询出 List<Address>里面包含的所有 UserInfoId 列表,然后再去查询 UserInfo 信息,通过 Map 组装到 Address 里面。

Tips:实体里面如果关联关系有非常多的请求,想维护关联关系是一件非常难的事情。我们可以利用 Mybatis 的思想、JPA 的快捷查询语法,来组装想要的任何关联关系的对象。这样的代码虽然比起原生的 JPA 语法较复杂,但是比起 Mybatis 还是要简单很多,理解起来也更容易,问题反倒会更少一点。

上面我们介绍完了 Hibernate 中的做法,其实 JPA 协议也提供了另外一种解题思路:利用 @EntityGraph 注解来解决,我们详细看一下。

@EntityGraph 使用详解

众所周知,实体与实体之间的关联关系错综复杂,就像一个大网图一样,网状分布交叉引用。而 JPA 协议在 2.1 版本之后企图用 Entity Graph 的方式,描绘出一个实体与实体之间的关联关系。

普通做法为,通过 @ManyToOne/@OneToMany/@ManyToMany/@OneToOne 这些关联关系注解表示它们之间的关系时,只能配置 EAGER 或者 LAZY,没办法根据不同的配置、不同的关联关系加载时机。

而 JPA 协议企图通过 @NamedEntityGraph 注解来描述实体之间的关联关系,当被 @EntityGraph 使用的时候进行 EAGER 加载,以减少 N+1 的 SQL,我们来看一下具体用法。

@NamedEntityGraph 和 @EntityGraph 用法

还是直接通过一个例子来说明,请看下面的代码。

java
//可以被@NamedEntityGraphs注解重复使用,只能配置在类上面,用来声明不同的EntityGraph;
@Repeatable(NamedEntityGraphs.class)
@Target({TYPE})
@Retention(RUNTIME)
public @interface NamedEntityGraph {
    //指定一个名字
    String name() default "";
    //哪些关联关系属性可以被EntityGraph包含进去,默认一个没有。可以配置多个
    NamedAttributeNode[] attributeNodes() default {};

    //是否所有的关联关系属性自动包含在内,默认false;
    boolean includeAllAttributes() default false;

    //配置subgraphs,子实体图(可以理解为关联关系实体图,即如果算层级,可以配置第二层级),可以被NamedAttributeNode引用
    NamedSubgraph[] subgraphs() default {};
    //配置subclassSubgraphs的namedSubgraph有哪些。即如果算层级,可以配置第三层级
    NamedSubgraph[] subclassSubgraphs() default {};
}

上述代码中,可以看到 @NamedEntityGraphs 能够配置多个 @NamedEntityGraph。我们接着往下看。

java
//只能使用在实体类上面
@Target({TYPE})
@Retention(RUNTIME)
public @interface NamedEntityGraphs{
    NamedEntityGraph[] value();//可以同时指定多个NamedEntityGraph
}

上面这段代码中,NamedSubgraph 用来指定关联关系的策略,也就关联关系有两层。

我们再看一下 @NamedEntityGraph 里面的 NamedAttributeNode 属性有哪些值,代码如下。

java
// 用来进行属性节点的描述
@Target({})
@Retention(RUNTIME)
public @interface NamedAttributeNode {
    //要包含的关联关系的属性的名字,必填
    String value();
    //如果我们在@NamedEntityGraph里面配置了子关联关系,这个是配置subgraph的名字
    String subgraph() default "";
   //当关联关系是被Map结构引用的时候,我们可以指定key的方式,一般很少用
    String keySubgraph() default "";
}

上面就是对 @NamedAttributeNode 的介绍,我们再看一下 @EntityGraph 里面的 @NamedSubgraph 的结构,代码如下。

java
@Target({})
@Retention(RUNTIME)
public @interface NamedSubgraph {
    //指定一个名字
    String name();
    //子关联关系的类的class
    Class type() default void.class;
    //二层关联关系的要包含的关联关系的属性的名字
    NamedAttributeNode[] attributeNodes();
}

其中,@NamedEntityGraph 的注解都是配置在实体本身上面的,而 @EntityGraph 是用在 ***Repository 接口里的方法中的。

接着我们再来了解一下 @EntityGraph 注解的语法,如下所示。

java
@Retention(RetentionPolicy.RUNTIME)
@Target({ ElementType.METHOD, ElementType.ANNOTATION_TYPE })
//EntityGraph 作用在Repository的接口里面的方法上面
public @interface EntityGraph {
   //指@EntityGraph注解引用的@NamedEntityGraph里面定义的name,如果是空EntityGraph就不会起作用,如果为空相当于没有配置;
   String value() default "";
   //EntityGraph的类型,默认是EntityGraphType.FETCH类型,我们接着往下看EntityGraphType一共有几个值
   EntityGraphType type() default EntityGraphType.FETCH;
    //可以指定attributePaths用来覆盖@NamedEntityGraph里面的attributeNodes的配置,默认配置是空,以@NamedEntityGraph里面的为准;
   String[] attributePaths() default {};
   //JPA 2.1支持的EntityGraphType对应的枚举值
   public enum EntityGraphType {
      //LOAD模式,当被指定了这种模式、被@EntityGraph管理的attributes的时候,原来的FetchType的类型直接忽略变成Eager模式,而不被@EntityGraph管理的attributes还是保持默认的FetchType
      LOAD("javax.persistence.loadgraph"),
      //FETCH模式,当被指定了这种模式、被@EntityGraph管理的attributes的时候,原来的FetchType的类型直接忽略变成Eager模式,而不被@EntityGraph管理的attributes将会变成Lazy模式,和LOAD的区别就是对不被@NamedEntityGraph配置的关联关系的属性的FetchType不一样;
      FETCH("javax.persistence.fetchgraph");
      private final String key;
      private EntityGraphType(String value) {
         this.key = value;
      }
      public String getKey() {
         return key;
      }
   }
}

现在你知道这个注解的基本用法了,下面我们通过实例来具体操作一下。

@EntityGraph 使用实例

我们通过改造 Address 和 UserInfo 实体,来分别测试一下 @NamedEntityGraph 和 @EntityGraph 的用法。

第一步:在实体里面配置 @EntityGraph,关键代码如下。

java
@Entity
@Table
@Data
@SuperBuilder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "userInfo")
//这里我们直接使用@NamedEntityGraph,因为只需要配置一个@NamedEntityGraph,我们指定一个名字getAllUserInfo,指定被这个名字的实体试图关联的关联关系属性是userInfo
@NamedEntityGraph(name = "getAllUserInfo",attributeNodes = @NamedAttributeNode(value = "userInfo"))
public class Address extends BaseEntity {
   private String city;
   @JsonBackReference //防止JSON死循环
   @ManyToOne(cascade = CascadeType.PERSIST,fetch = FetchType.LAZY)//采用默认的lazy模式
   private UserInfo userInfo;
}
@Entity
@Data
@SuperBuilder
@AllArgsConstructor
@NoArgsConstructor
@Table
@ToString(exclude = "addressList")
//UserInfo对应的关联关系,我们利用@NamedEntityGraphs配置了两个,一个是针对Address的关联关系,一个是name叫rooms的实体图包含了rooms属性;我们在UserInfo里面增加了两个关联关系;
@NamedEntityGraphs(value = {@NamedEntityGraph(name = "addressGraph",attributeNodes = @NamedAttributeNode(value = "addressList")),@NamedEntityGraph(name = "rooms",attributeNodes = @NamedAttributeNode(value = "rooms"))})
public class UserInfo extends BaseEntity {
   private String name;
   private String telephone;
   private Integer ages;
   //默认LAZY模式
   @OneToMany(mappedBy = "userInfo",cascade = CascadeType.PERSIST,fetch = FetchType.LAZY)
   private List<Address> addressList;
   //默认EAGER模式
   @OneToMany(cascade = CascadeType.PERSIST,fetch = FetchType.EAGER)
   private List<Room> rooms;
}

**第二步:在我们需要的 *Repository 的方法上面直接使用 @EntityGraph,关键代码如下。

java
//因为要用findAll()做测试,所以可以覆盖JpaRepository里面的findAll()方法,加上@EntityGraph注解
public interface UserInfoRepository extends JpaRepository<UserInfo, Long>{
   @Override
   //我们指定EntityGraph引用的是,在UserInfo实例里面配置的name=addressGraph的NamedEntityGraph;
   // 这里采用的是LOAD的类型,也就是说被addressGraph配置的实体图属性address采用的fetch会变成 FetchType.EAGER模式,而没有被addressGraph实体图配置关联关系属性room还是采用默认的EAGER模式
@EntityGraph(value = "addressGraph",type = EntityGraph.EntityGraphType.LOAD)
   List<UserInfo> findAll();
}}

同样的道理,其对于 AddressRepository 也是适用的,代码如下。

java
public interface AddressRepository extends JpaRepository<Address, Long>{
@Override //可以覆盖原始方法,添加上不同的@EntityGraph策略
//使用@EntityGraph查询所有Address的时候,指定name = "getAllUserInfo"的@NamedEntityGraph,采用默认的EntityGraphType.FETCH,如果Address里面有多个关联关系的时候,只有在name = "getAllUserInfo"的实体图配置的userInfo属性上采用Eager模式,其他关联关系属性没有指定,默认采用LAZY模式;
@EntityGraph(value = "getAllUserInfo")
List<Address> findAll();
}

第三步:看一下上面的两个方法执行的 SQL

当我们再次执行 userInfoRepository.findAll(); 这个方法的时候会发现,被配置 EntityGraph 的 Address 和 user_info 通过 left join 一条 SQL 就把所有的信息都查出来了,SQL 如下所示。

java
org.hibernate.SQL                        :
select userinfo0_.id                      as id1_2_0_,
       addresslis1_.id                    as id1_0_1_,
       userinfo0_.create_time             as create_t2_2_0_,
       userinfo0_.create_user_id          as create_u3_2_0_,
       userinfo0_.last_modified_time      as last_mod4_2_0_,
       userinfo0_.last_modified_user_id   as last_mod5_2_0_,
       userinfo0_.version                 as version6_2_0_,
       userinfo0_.ages                    as ages7_2_0_,
       userinfo0_.email_address           as email_ad8_2_0_,
       userinfo0_.last_name               as last_nam9_2_0_,
       userinfo0_.name                    as name10_2_0_,
       userinfo0_.telephone               as telepho11_2_0_,
       addresslis1_.create_time           as create_t2_0_1_,
       addresslis1_.create_user_id        as create_u3_0_1_,
       addresslis1_.last_modified_time    as last_mod4_0_1_,
       addresslis1_.last_modified_user_id as last_mod5_0_1_,
       addresslis1_.version               as version6_0_1_,
       addresslis1_.city                  as city7_0_1_,
       addresslis1_.user_info_id          as user_inf8_0_1_,
       addresslis1_.user_info_id          as user_inf8_0_0__,
       addresslis1_.id                    as id1_0_0__
from user_info userinfo0_
    left outer join address addresslis1_ on userinfo0_.id = addresslis1_.user_info_id

而我们没有配置 rooms 这个关联关系的属性时,rooms 的查询还是会触发 N+1 的 SQL。

从中可以看到 @EntityGraph 的效果有点类似 Hibernate 里面提供的 FetchModel.JOIN 的模式,但不同的是 @EntityGraph 可以搭配任何的查询情况,只需要我们在查询方法上直接加 @EntityGraph 注解即可。

这种方法还有个优势就是 @EntityGraph 和 @NamedEntityGraph 是 JPA 协议规定的,这样可以对 Hibernate 无感。

那么我们再看一下 @ManyToOne 的模式是否同样奏效,访问 addressRepository.findAll() 这个方法看一下 SQL,如下所示。

java
org.hibernate.SQL                        :
select address0_.id                     as id1_0_0_,
       userinfo1_.id                    as id1_2_1_,
       address0_.create_time            as create_t2_0_0_,
       address0_.create_user_id         as create_u3_0_0_,
       address0_.last_modified_time     as last_mod4_0_0_,
       address0_.last_modified_user_id  as last_mod5_0_0_,
       address0_.version                as version6_0_0_,
       address0_.city                   as city7_0_0_,
       address0_.user_info_id           as user_inf8_0_0_,
       userinfo1_.create_time           as create_t2_2_1_,
       userinfo1_.create_user_id        as create_u3_2_1_,
       userinfo1_.last_modified_time    as last_mod4_2_1_,
       userinfo1_.last_modified_user_id as last_mod5_2_1_,
       userinfo1_.version               as version6_2_1_,
       userinfo1_.ages                  as ages7_2_1_,
       userinfo1_.email_address         as email_ad8_2_1_,
       userinfo1_.last_name             as last_nam9_2_1_,
       userinfo1_.name                  as name10_2_1_,
       userinfo1_.telephone             as telepho11_2_1_
from address address0_
         left outer join user_info userinfo1_ on address0_.user_info_id = userinfo1_.id

可以看到 address left join 的模式中,一个 SQL 把所有的 address 和 user_info 都查询出来了。

综上所述,@EntityGraph 可以用在任何 ***Repository 的查询方法上,针对不同的场景配置不同的关联关系策略,就可以减少 N+1 的 SQL,成为一条 SQL。

总结

通过这两讲的介绍,你可以知道关联关系在 Hibernate 的 JPA 中的优点就是使用方便、效率高;而缺点就是需要了解很多知识,才能知道最佳实践是什么。

关于这四种处理 N+1 SQL 的方法,你在使用的时候可以根据实际情况自由选择,不局限于某一种解决方式。

在我介绍的内容中,有一些方法不是 JPA 协议的标准,而是 Hibernate 的语法,所以你在用的时候要看一下注解或者配置的源码注释,看看是否有变化,再根据实际情况自由调整。不过思路上的转化可以不需要关心版本的变化。

好了,本讲到这里就结束了。全网最全的 N+1 SQL 处理方案,如果你觉得有用,就动动手指分享吧。下一讲我们来聊聊 SPEL 表达式的相关内容,再见。

点击下方链接查看源码(不定时更新)
https://github.com/zhangzhenhuajack/spring-boot-guide/tree/master/spring-data/spring-data-jpa