Hibernate的抓取策略及优化

Defining the global fetch plan

Retrieving persistent objects from the database is one of the most interesting parts of working with Hibernate.

The object-retrieval options

Hibernate提供了以下方式,从数据库中获取对象:

  • 如果persistence context是开启的,访问实体对象的get方法来访问关联实体对象,Hibernate会自动从数据库中加载关联实体对象
  • 通过ID主键查询
  • HQL/JPA QL
  • Criteria接口,QBC/QBE
  • SQL查询,包括调用存储过程
HQL & JPA QL

JPA QL是HQL的一个subset,所以JPA QL总是一个有效的HQL。
HQL通常用于object retrieval,不太适合更新、插入、删除,但是如果数据量比较多(mass data operations),可以利用HQL/JPA QL更新、插入、删除,即direct bulk operation,第12章有讲到。

通常利用HQL来查询某实体对象,如下:

Session session = getSessionFactory().getCurrentSession();
Query query = session.createQuery("from User as u where u.firstname = :fname");
query.setString("fname", "John");
List list = query.list();

HQL is powerful,HQL支持以下功能(在14,15章将深入这些功能):

  • 查询条件支持使用关联对象的属性
  • 只查询entity的某些属性,不会加载entity全部属性到persistence context. 这叫report query或是projection
  • 查询结果排序
  • 分页
  • 使用group by, having聚合(aggregation),以及聚合函数,比如:sum, min, max/min.
  • outer join,当在一行要查询多个对象时
  • 可以调用标准或用户定义的SQL function.
  • 子查询(subqueries/nested queries)
Criteria

QBC-query by criteria,通过操作criteria对象来创建query,同HQL相比,可读性较差。

Session session = getSessionFactory().getCurrentSession();
Criteria criteria = session.createCriteria(User.class);
criteria.add(Restrictions.like("firstname", "John"));
List list = criteria.list();

Restrictions类提供了许多静态方法来生成Criterion对象。Criteria是Hibernate API,JPA标准中没有。

Querying by example

QBE-query by example,查询符合指定对象属性值的结果。

Session session = getSessionFactory().getCurrentSession();
Criteria criteria = session.createCriteria(User.class);

User user = new User();
user.setName("Jonh");

criteria.add(Example.create(user));
criteria.add(Restrictions.isNotNull("email"));
List list = criteria.list();

QBE适合在搜索时使用,比如页面有一系列的搜索条件,用户可以随意指定某些条件来查询,可以将这些条件自动封装成一个对象,再利用QBE来查询,省去自己组装查询语句。

第15章将具体讨论QBC,QBE

The lazy default fetch plan

延迟加载,Hibernate对所有的实体和集合默认使用延迟加载策略(原文: Hibernate defaults to a lazy fetching strategy for all entities and collections.)。

User user = (User) session.load(User.class, 1L);

查询User对象,通过load()方法查询指定ID的对象,此时persistence context中,有一个user对象,并且是persistent state。

其实这个user对象是Hibernate创建的一个proxy(代理),这个代理对象的主键值是1,执行load()方法后,不会执行SQL去查询指定对象。

Understanding proxies

当Hibernate返回entity instance时,会检查是否能返回一代理,从而避免去查询数据库。当代理对象第一次被访问时,Hibernate就会去数据库查询真正的对象。

User user = (User) session.load(User.class, 1L);
user.getId();
user.getName(); // Initialize the proxy

代码执行到第三行时才去查询数据库。

当你只是需要一个对象来创建引用关系时,代理非常有用。如:

Item item = (Item) session.load(Item.class, new Long(123));
User user = (User) session.load(User.class, new Long(1234));

Bid newBid = new Bid("99.99");
newBid.setItem(item);
newBid.setBidder(user);

session.save(newBid);

BID表中有两个外键列,分别指向ITEM表和USER表,所以只需要ITEM和USER的主键值,而代理对象中包含有主键值,所以不需要执行SELECT查询数据库。

如是是调用get()方法,则总是试图去数据库中查询;如果当前persistence context和second-level cache中不存在指定对象就查询数据库,如果数据库中没有返回null

Hibernate返回的代理对象,其类型是实体类的子类,如果要获得真正的实体类型:

User user = (User) session.load(User.class, new Long(1234));
// 返回真正的实体类Class
Class userClass = HibernateProxyHelper.getClassWithoutInitializingProxy(user);

JPA中对应的方法:

// find()相当于Hibernate的get()
Item item = entityManager.find(Item.class, new Long(123));

// getReference()相当于Hibernate的load()
Item itemRef = entityManager.getReference(Item.class, new Long(1234));

当获取一个Item实例,不管是通过get()方法,还是load()方法然后再访问非ID的属性强迫初始化。此时实例的状态如下图所示:


可以看到所有的关联对象(association)属性都是代理(proxy),所有的集合属性也没有真正初始化,用术语collection wrapper表示。默认情况下,只有value-typed属性和component会被立即初始化。
当访问代理对象的非ID属性时,代理被真正初始化;当迭代集合或调用集合的方法,如size(), contains()时,集合被真正初始化。

针对数据比较多的集合,Hibernate进一步提供了Extra lazy,即使在调用集合方法size(), contains(), isEmpty()时,也不会去真正查询集合中的对象。
如果集合是Map或List,containsKey(), get()方法会直接查询数据库。

@org.hibernate.annotations.LazyCollection(org.hibernate.annotations.LazyCollectionOption.EXTRA)
private Set<Bid> bids = new HashSet<Bid>();

Disabling proxy generation

不使用代理,JPA规范中没有代理(至少JPA 1.0是这样),但如果使用Hibernate做为JPA实现,默认是启用代理的,可以为某个实体类设置关闭代理:

@Entity
@Table(name = "USERS")
@org.hibernate.annotations.Proxy(lazy = false)
public class User implements Serializable, Comparable {
    // ...
}

如果这样全局禁用User类的代理,则load()方法在加载User对象时就不会再返回代理;同时查询关联User的其他类对象时,也不会生成User代理对象,而是直接查询数据库。

// lazy = false,代理被禁用,不再生成代理对象,直接查询数据库
User user = (User) session.load(User.class, new Long(123));
User user = em.getReference(User.class, new Long(123));

// Item对象关联User对象,默认为Item中User属性生成代理
// 但当User类禁用代理后,不会再为User属性生成代理,而要查询数据库初始化User对象
Item item = (Item) session.get(Item.class, new Long(123));
Item item = em.find(Item.class, new Long(123));

这种全局禁用代理,太粗粒度(too coarse-grained),通过只为特定的关联实体(associated entity)或集合禁用代理,达到细粒度控制(fine-grained)。

Eager loading of associations and collections

Hibernate默认对关联实体和集合使用代理,这就导致如果确实需要某个关联实体或集合,还要再查询一次数据库来加载这些数据。
还有,当Item对象从persitent state变成detached state后,如果你还想访问Item的关联对象seller,那在加载Item对象时可以对seller不使用代理,从而在detached state还能访问真正的seller对象而不是一个代理。

为关联实体和集合禁用延迟加载:

@Entity
@Table(name = "ITEM")
public class Item implements Serializable, Comparable, Auditable {

    @ManyToOne(fetch = FetchType.EAGER)
    private User seller;

    @ManyToOne(fetch = FetchType.LAZY)
    private User approvedBy;

    @OneToMany(fetch = FetchType.EAGER)
    private List<Bid> bids = new ArrayList<Bid>();

}
Item item = (Item) session.get(Item.class, new Long(123));

此时访问通过get()获取或者强致初始化一个代理Item对象时,seller关联对象和bids集合都被加载到persistence context中,而approveBy关联对象仍然是代理对象。如果此时关闭persistence context,item变成detached state,此时可以访问seller和bids,但是如果访问approvedBy关联对象,就会抛出LazyInitializationException,因为你没有在persistence context关闭之前初始化approvedBy。

JPA同Hibernate的fetch plan不同,尽管Hibernate中所有的关联实体都默认是延迟加载的,但@ManyToOne@OneToOne关联映射默认是FetchType.EAGER,这是JPA 1.0规范要求的,因为有些JPA实现根本没有lazy loading,所以建议为to-one关联映射都添加上FetchType.LAZY,只有需要禁用延迟加载时再设置为FetchType.EAGER

@Entity
@Table(name = "ITEM")
public class Item implements Serializable, Comparable, Auditable {

    @ManyToOne(fetch = FetchType.EAGER)
    private User seller;

    @ManyToOne(fetch = FetchType.LAZY)
    private User approvedBy;
}

Lazy loading with interception(可忽略,不重要)

延迟加载除了可以使用代理,还可以使用interception来实现。使用代理,实体类需要满足两个条件:package or public的无参构造器,不能有final的方法和类。因为Hibernate的代理是通过创建实体类的子类来实现的。

代理方式的两个小问题:

  1. 不能使用instanceof和类型转换(typecast),因为代理是在运行时创建的一个实体类子类。(第7章-Polymorphic many-to-one associations有变通方法来解决此问题)
  2. 代理只延迟加载entity associations and collections,不能用于value-typed properties or components。 不过通常不需要在这个级别做延迟加载。

interception可以解决这两个问题,但是这些问题不重要,所以不在此讨论该功能了,如果需要请Google。


Selecting a fetch strategy

fetching strategy,为了减少查询SQL的数量以及精简SQL,从而让查询变的更加高效,我们需要为collection or association选择合适的抓取策略。

Hibernate默认采用延迟加载(这里假定在使用JPA时,所有的to-one关联都设置了FetchType.LAZY),所以当加载某个实体对象时,只会加载实体类对应表中的一行数据,所有关联的表都不会查询。当访问proxied association or uninitialized collection时,Hibernate会立即执行一条SELECT语句来查询所需要对象(可以称为 fetch on demand)。

Prefetching data in batches

fetch on demand的不好之处,举个例子:

List allItems = session.createQuery("from Item").list();
processSeller( (Item)allItems.get(0) ); // 此方法需要item的seller关联对象
processSeller( (Item)allItems.get(1) );
processSeller( (Item)allItems.get(2) );

如果是默认的fetch on demand,就会导致大量的SELECT查询:

select items...
select u.* from USERS u where u.USER_ID = ?
select u.* from USERS u where u.USER_ID = ?
select u.* from USERS u where u.USER_ID = ?

为了避免因为fetch on demand而可能导致的大量SELECT查询,就需要更加有效的fetching strategy,首先就是batch fetching or prefetch in batch

批量获取,上面例子中,当有一个seller association代理对象需要被初始化时,可以批量查询USERS表,初始化当前persistence context中所有未初始化的seller association,但是一条SELECT语句最多可查询几个seller association是需要你来设定的。

@Entity
@Table(name = "USERS")
@org.hibernate.annotations.BatchSize(size = 10)
public class User { ... }
@Entity
public class Item {
    @OneToMany
    @org.hibernate.annotations.BatchSize(size = 10)
    private Set<Bid> bids = new HashSet<Bid>();
}

如上,现在设置User的@BatchSize为10,代表一条SELECT语句最多查询10个User对象。
之前代码生成的查询就变成:

select items...
select u.* from USERS u where u.USER_ID in (?, ?, ?)

原来的三条SELECT,现在由一条SELECT完成了。

@BatchSize设置为10,如果第一次查询的Item对象大于10个,当访问第一个未初始化的seller association时,Hibernate会执行一条SELECT,查询10个User对象来初始化10个seller association,如果又访问到一个未初始化的seller association,Hibernate会再执行一个SELECT,再查询10个User对象,直到persistence context中没有未初始化的seller association或都程序不再访问未初始化的seller association。

batch fetchingblind-guess optimization,因为你不知道真正需要加载多少个未初始化的association or collection。虽然这种方法可以减少SELECT查询,但是也可能造成:加载了本就不需要加载的数据到内存中

Prefetching collections with subselects

为集合设置subselect fetching抓取策略,subselect只支持collection(至少在Hiberante 3.x是这样的)。

@OneToMany
@org.hibernate.annotations.Fetch(
org.hibernate.annotations.FetchMode.SUBSELECT
)
private Set<Bid> bids = new HashSet<Bid>();
List allItems = session.createQuery("from Item").list();
processBids( (Item)allItems.get(0) ); //需要用到bids集合
processBids( (Item)allItems.get(1) );
processBids( (Item)allItems.get(2) );
select i.* from ITEM i
select b.* from BID b where b.ITEM_ID in (select i.ITEM_ID from ITEM i)

首先会查出所有Item对象,然后当访问一个未初始化的collection时,Hibernate就会执行第二条SQL,为所有的Item对象初始化所有的bids collection。注意子查询语句基本就是第一个查询语句,Hibernate会重用第一个查询(简单修改,只查询ID)。所以subselect fetching只对查询Item访问Item对象的bids属性是在一个persistence context中时才会有效。

Also note that the original query that is rerun as a subselect is only remembered by Hibernate for a particular Session. If you detach an Item instance without initializing the collection of bids, and then reattach it and start iterating through the collection, no prefetching of other collections occurs.

Eager fetching with joins

Hibernate默认是延迟加载--fetch on demand,认为你可能不需要association or collection,但如果你确实需要association or collection呢,就需要eager fetching,通过join来关联多张表,同时返回多张表的数据,从而在一条SQL中返回所有需要的数据。

<class name="Item" table="ITEM">
    <many-to-one name="seller"
                class="User"
                column="SELLER_ID"
                update="false"
                fetch="join"/>
</class>
@Entity
public class Item {
    // fetch = FetchType.EAGER等价与XML映射中的fetch="join"
    @ManyToOne(fetch = FetchType.EAGER)
    private User seller;
}

此时Hibernate在加载Item时,就会通过JOIN在一条SQL中把seller对象也加载出来。至于是outer join还是inner join这取决于你是否启用了<many-to-one not-null="true"/>,如果确保实体对象肯定有指定的关联对象,则使用inner join,否则默认是outer join。

Item item = (Item) session.get(Item.class, new Long(123));
select i.*, u.*
from ITEM i
    left outer join USERS u on i.SELLER_ID = u.USER_ID
where i.ITEM_ID = ?

如果设置lazy="false",也会禁用延迟加载,但不是通过JOIN在一条SQL中查询关联对象,而是查询完实体对象后,紧接着再发送一条SELECT查询关联实体对象。

<class name="Item" table="ITEM">
    <many-to-one name="seller"
                class="User"
                column="SELLER_ID"
                update="false"
                lazy="false"/>
</class>

注解的方式如下:

@Entity
public class Item {
    @ManyToOne(fetch = FetchType.EAGER)
    @org.hibernate.annotations.Fetch(
        org.hibernate.annotations.FetchMode.SELECT
    )
    private User seller;
}

为collection设置eager join fetching strategy.

<class name="Item" table="ITEM">
    <set name="bids" inverse="true" fetch="join">
            <key column="ITEM_ID"/>
            <one-to-many class="Bid"/>
    </set>
</class>

等价的注释:

@Entity
public class Item {
    @ManyToOne(fetch = FetchType.EAGER)
    private User seller;
    
    @OneToMany(fetch = FetchType.EAGER)
    private Set<Bid> bids = new HashSet<Bid>();
}

现在执行createCriteria(Item.class).list(),为执行以下SQL:

select i.*, b.* from ITEM i left outer join BID b on i.ITEM_ID = b.ITEM_ID

最后需要知道参数hibernate.max_fetch_depth,他控制最多可连接的关联实体表个数,默认是没有限制的,该值通常控制在1-5。

Finally, we have to introduce a global Hibernate configuration setting that you can use to control the maximum number of joined entity associations (not collections)注意只针对关联实体,不针对集合.
The number of tables joined in this case depends on the global hibernate.max_fetch_depth configuration property.

Optimizing fetching for secondary tables

当查询继承实体对象时,SQL的处理可能更加复杂。如果继承映射选择的是table-per-hierarchy,则查询在一条SQL中就可以完成。
CreditCardBankAccountBillingDetails的两个子类。

List result = session.createQuery("from BillingDetails").list();
Outer joins for a table-per-subclass hierarchy

如果继承映射选择的是table-per-subclass,所有的子类都通过OUTER JOIN在一条SQL中查询出来。

SELECT b1.BILLING_DETAILS_ID,
       b1.OWNER,
       b1.USER_ID,
       b2.NUMBER,
       b2.EXP_MONTH,
       b2.EXP_YEAR,
       b3.ACCOUNT,
       b3.BANKNAME,
       b3.SWIFT,
       CASE
         WHEN b2.CREDIT_CARD_ID IS NOT NULL THEN 1
         WHEN b3.BANK_ACCOUNT_ID IS NOT NULL THEN 2
         WHEN b1.BILLING_DETAILS_ID IS NOT NULL THEN 0
       END AS clazz
  FROM BILLING_DETAILS b1
  LEFT OUTER JOIN CREDIT_CARD b2 ON b1.BILLING_DETAILS_ID = b2.CREDIT_CARD_ID
  LEFT OUTER JOIN BANK_ACCOUNT b3 ON b1.BILLING_DETAILS_ID = b3.BANK_ACCOUNT_ID
Switching to additional selects

此节忽略,基本用不到,某些数据库可能会限制连接表的数量,此时可以从JOIN切换到additional SELECT,即做完第一次查询后,再立即发送一条SELECT语句来查询。

Optimization guidelines

Hibernate默认抓取策略是fetch on demand,这就可能导致执行非常多的SELECT语句。
如果你将抓取策略设置为eager fetch with join,虽然只需要一条SELECT语句,但是可能会产生另外一个问题笛卡儿积 - Cartesian product,导致加载过多数据到内存以及persistence context中(尤其是eager join collection时)。

你需要找到一个平衡点,设置合适的fetch strategy;你需要知道哪些fetch plan and strategy应该设置为全局的,哪些策略应用与特定的查询(HQL or Criteria)。

The n+1 selects problem

假如你现在使用Hibernatel默认的fetch on demand,当执行以下代码:

List<Item> allItems = session.createQuery("from Item").list();
// List<Item> allItems = session.createCriteria(Item.class).list();

Map<Item, Bid> highestBids = new HashMap<Item, Bid>();
for (Item item : allItems) {
    Bid highestBid = null;
    for (Bid bid : item.getBids() ) { // Initialize the collection
        if (highestBid == null)
            highestBid = bid;
        if (bid.getAmount() > highestBid.getAmount())
            highestBid = bid;
    }
    highestBids.put(item, highestBid);
}

首先查询所有的Item对象,不管是HQL还是Criteria的方式,Hibernate会执行一条SELECT语句,查询出所有的Item对象,这里假如是N个。
然后循环处理Item list,获取每个Item的bids collection,由于使用的fetch on demand策略,所以bids collection需要被初始化,所以Hibernate会再执行一条SELECT来初始化bids collection;这样就总共执行了N + 1条SELECT查询,这就是所谓有N + 1问题。

解决方法:

  1. 使用之前讲到的prefetch in batch,如果batch size设置为10,那很可能会执行N/10 + 1条SELECT。
  2. 使用之前讲到的prefetch with subselect,这样就只会执行两条SELECT。
  3. 前面两种prefetch还是有一定的延迟效果的;现在彻底放弃lazy loading,使用eager fetching with join,这样就只会执行一条SELECT。但是这种方式,在一条SELECT中OUTER JOIN collection时,很可能造成严重的笛卡尔积问题,所是不适合做为global mapping。

实际上,做为全局的映射(global mapping),应该选择prefetch in batch来避免N+1问题;如果在某些case下,你确实需要关联的collection而又不想在global mapping中设置eager fetching with join,可以使用如下方法:

// 在HQL中直接使用left join fetch,直接查询出所有有的关联bids collection
List<Item> allItems = session.createQuery("from Item i left join fetch i.bids").list();

// Criteria方式,效果同上
List<Item> allItems = session.createCriteria(Item.class).setFetchMode("bids", FetchMode.JOIN).list();

这两种方式没有使用全局策略,针对特定case利用HQL/Criteria(可以称为dynamic fetching strategy)来避免N+1问题。

eager fetching with join对于<many-to-one>或<one-to-one>这种association,是种比较好的避免N+1问题的策略,因为这种to-one的关联不会造成笛卡尔积问题。

The Cartesian product problem

当对collection(即一对多)使用eager fetching with join策略时,就会产生笛卡尔积问题,所以要避免在@OneToMany时使用eager fetching with join

如下映射,Item中存在两个集合,并且都设置为eager fetching with join.

<class name="Item">
    <set name="bids" inverse="true" fetch="join">
        <key column="ITEM_ID"/>
        <one-to-many class="Bid"/>
    </set>

    <set name="images" fetch="join">
        <key column="ITEM_ID"/>
        <composite-element class="Image">
    </set>
</class>

执行SQL如下:

select item.*, bid.*, image.*
    from ITEM item
        left outer join BID bid on item.ITEM_ID = bid.ITEM_ID
        left outer join ITEM_IMAGE image on item.ITEM_ID = image.ITEM_ID

此时就会产生严重的笛卡尔积问题,导致查询结果集中存在大量冗余数据。


Forcing proxy and collection initialization

除了利用全局的抓取策略和dynamic fetching strategy (利用HQL/Criteria),还有一种方式可以强制初始化proxy or collection wrapper.

Session session = sessionFactory.openSession();
Transaction tx = session.beginTransaction();

Item item = (Item) session.get(Item.class, new Long(1234));
Hibernate.initialize(item.getSeller()); // 强制初始化seller proxy

tx.commit();
session.close();
processDetached( item.getSeller() );

Hibernate.initialize()可以强制初始化proxy or collection wrapper,但是针对collection,集合中的每一个引用也只是初始化为一个proxy,不会真正初始化每个集合中的引用。

Explicit initialization with this static helper method is rarely necessary; you should always prefer a dynamic fetch with HQL or Criteria.

Optimization step by step

这节没啥东西,开发时,开启下面两个参数

hibernate.format_sql
hibernate.use_sql_comments

此文是对《Java Persistence with Hibernate》第13章fetch部分的归纳。原文中有些XML mapping没有列出,重点关注annotation的用法。

推薦閱讀更多精彩內容