NHibernate N+1問題實例分析和優化

1.問題的緣起

考察下面的類結構定義html

public class Category
    {
        string _id;
        Category _parent;
        IList<Category> _children = new List<Category>();

        public virtual string Id
        {
            get
            {
                return _id;
            }
        }

        public virtual Category Parent
        {
            get
            {
                return _parent;
            }
        }

        public virtual IList<Category> Children
        {
            get
            {
                return _children;
            }
        }

        public virtual string Title
        {
            get;
            set;
        }

        public virtual string ImageUrl
        {
            get;
            set;
        }

        public virtual int DisplayOrder
        {
            get;
            set;
        }
    }

  

其Nhibernate映射文件的內容爲:數據庫

 

<hibernate-mapping xmlns="urn:nhibernate-mapping-2.2">
  <class name="Category">
    <id name="Id" access="nosetter.camelcase-underscore" length="32">
      <generator class="uuid.string"/>
    </id>
    <property name="Title" not-null="true" length="50"/>
    <property name="ImageUrl" length="128"/>
    <property name="DisplayOrder" not-null="true"/>
    <many-to-one name="Parent" class="Category" column="ParentId" access="nosetter.camelcase-underscore"/>
    <bag name="Children" access="nosetter.camelcase-underscore" cascade ="all-delete-orphan" inverse="true" order-by="DisplayOrder ASC">
      <key column="ParentId"/>
      <one-to-many class="Category"/>
    </bag>
  </class>
</hibernate-mapping>

程序中要求「篩選出全部Category,再依次遍歷其下的Children中的子對象」時,一般,咱們會寫出以下符合要求的代碼app

var query = from o in CurrentSession.QueryOver<Category>() select o;
IList<Category> list = query.List(); //第一級查詢
foreach (Category item in list)
{
  foreach (Category child in item.Children) //第二級查詢
  {
    //...
  }
}
 
這段代碼運行正常,輸出的SQL語句相似於:
 
--第一級查詢
Select * From [Category]
--第二級查詢(每次參數都不一樣)
Select * From [Category] Where [ParentId]=@p0
Select * From [Category] Where [ParentId]=@p0
.......
Select * From [Category] Where [ParentId]=@p0

 

從輸出的SQL能夠看出,上面的代碼隱藏着嚴重的性能問題。假設第一級查詢返回20個Category對象保存到list列表中,而每一個Category中又包含10個子對象,那麼兩個foreach循環執行下來,共須要向數據庫發送20*10=200條Select查詢語句。對於list列表中的每一個Category來講,從數據庫中取出其自己須要執行一條Select語句(即第一級查詢),查詢其下的子元素須要執行10條Select語句,也就是說從取出Category到遍歷完其全部子對象,須要執行N+1條Select語句,N是子對象的個數,這就是所謂的「N+1」問題,它最大的弊端顯而易見,在於向數據庫發送了過多的查詢語句,形成沒必要要的開銷,而一般狀況下這是能夠優化的。性能

 

2.解決方案

 

2.1 批量加載

因爲「N+1」問題是發送了過多的Select語句,首先就會想到,能不能把這些語句合併在一次數據庫查詢中,爲了解決這個問題,Nhibernate在集合映射中,提供了「批量加載」策略,即:batch-size,經改造後的bag映射以下:fetch

 

<bag name="Children" access="nosetter.camelcase-underscore" cascade ="all-delete-orphan" inverse="true" order-by="DisplayOrder ASC" batch-size="20">
  <key column="ParentId"/>
  <one-to-many class="Category"/>
</bag>

 

batch-size代表Category.Children列表裝載時,每次讀取20個子對象,而不是一個一個加載。所以,N+1就演變成N/20+1。對於上文的兩個foreach過程,輸出的SQL語句相似於:優化

--第一級查詢
Select * From [Category]
--第二級查詢
Select * From [Category] Where [ParentId] In (@p0,@p1....@p19)
對比未採用「批量加載」策略的SQL輸出,顯然新的解決方案可以極大的減小向數據庫發送查詢語句。
若是須要將多有對象加載過程都設置爲批量,能夠在Nhibernate配置文件中添加default_batch_fetch_size屬性,而不須要修改每一個類的映射文件。
 

2.2 預加載

解決「N+1」問題的另外一種方法是使用預加載(Eager Fetching),一樣,Nhibernate在集合映射中也提供了對它的支持,即:outer-join或fetch。改造後的bag映射配置以下:ui

 

<bag name="Children" access="nosetter.camelcase-underscore" cascade ="all-delete-orphan" inverse="true" order-by="DisplayOrder ASC" outer-join="true">
 <key column="ParentId"/>
 <one-to-many class="Category"/>
</bag>

 

outer-join=「true」等效於fetch="join",而fetch還有「select」和「subselect」兩個選項(默認爲「select」選項),他們指的都是用何種SQL語句加載子集合。當outer-join=「true」或fetch="join"時,輸出的SQL語句相似於:spa

--第一級查詢
Select t0.Id,t0.ParentId,t0.Title...t1.Id,t1.ParentId,t1.Title... From [Category] t0 Left Join [Category] t1 On t1.ParentId=t0.Id

預加載在第一級查詢,就經過Join一次性的取出對象自己及其子對象,比使用批量加載生成的語句還要少,首一次加載效率高。hibernate

雖然,在映射文件中啓用預加載設置,十分簡單,可是考慮到其餘方式(如:Get或Load)獲取對象時也會自動裝載子對象,形成沒必要要的性能損失,另外,在映射文件中設置預加載,其「做用域」有隻適用於:經過Get或Load獲取對象、延遲加載(隱式)關聯對象、Criteria查詢和帶有left join fetch的HQL語句,所以一般要求避免將啓用預加載的配置寫在映射文件裏(Nhibernate也不推薦寫在映射文件中),而是將其寫在須要用到預加載的代碼中,其餘的地方則保持原有邏輯,這樣纔不會產生不良影響,預加載在代碼裏的寫法有三種:代理

 

ICriteria.SetFetchMode(string associationPath, FetchMode mode);

 

或者在3.0裏面使用的

 

IQueryOver<TRoot, TSubType>.Fetch(Expression<Func<TRoot, object>> path);

 

再或者HQL中使用left join fetch

 

from Category a left join fetch Category b

 

這裏有一個奇特的狀況,FetchMode枚舉包含Eager和Join兩個選項,但實際使用中的效果是同樣的,都是輸出Join語句,沒有任何區別,Nhibernate如此設置,我猜測可能的緣由是開始時只有Join一個選項,然後以爲不夠貼切,遂增長一個Eager,但考慮到老版本兼容性,沒有刪除Join,因此就成了如今這個樣子。下面的代碼說明了在IQueryOver中如何使用預加載

 

q = q.Fetch(o => o.Children).Eager;

 

到此爲止,一切都顯得很完美,不過,還沒完,預加載因爲其生成的SQL語句包括了Join或子查詢語句,所以它沒法保證獲取到集合中元素的惟一性,例如:A包含兩個子元素B和C,那麼經過預加載後,第一級查詢取出的列表中會包括兩個A對象,而不是一般咱們想象的一個。因此,啓用預加載後獲取到的列表,須要手動的解決惟一性的問題,最簡單的就是把列表裝入ISet裏「過濾」一次。

protected IList<T> ToUniqueList(IEnumerable<T> collection)
{
ISet<T> set = new HashSet<T>(collection);
return set.ToList();
}

 

2.3 混合加載

上面,咱們只假設了Category包含子對象只有一層嵌套的狀況,然而,若是子對象還有子對象,無限層嵌套時,批量加載和預加載會出現什麼狀況呢,首先,只採用批量加載的狀況下,除第一層外,如下每層嵌套都會採用批量加載的方式,可見第一層加載的效率相對較低,其次,只採用預加載的狀況下,第一次使用Join加載,獲取到第一層和第二層對象,而第二層往下,每層對象的加載過程又還原到簡單的Select上,與本文開頭所講的情形是一摸同樣的,所以,多層次加載效率較低。那麼把它們結合起來,既在映射文件中設置batch-size,又在代碼中開啓FetchMode.Eager,會不會綜合兩種的優點克服不足呢?通過實踐,答案是確定的。同時使用批量加載和預加載的狀況下,首次查詢時,SQL中出現了Join語句,即預加載起做用,獲取到第一層和第二層對象,然後每層的查詢,SQL中出現了In語句,也就是批量加載又發揮了做用,我把這種綜合運用兩種加載方式,結合了各自優勢的新方式稱爲「混合加載」,這是在Nhibernate官方文檔裏沒有的。

 

3.抓取策略

以上咱們談到的內容,統稱爲抓取策略(Fetching Strategy)。Nhibernate中,定義了一下幾種抓取策略:

  • 鏈接抓取(Join fetching):經過 在SELECT語句使用OUTER JOIN(外鏈接)來得到對象的關聯實例或者關聯集合。
  • 查詢抓取(Select fetching):另外發送一條 SELECT 語句抓取當前對象的關聯實體或集合(lazy="true"時,這是默認選項)。
  • 子查詢抓取(Subselect fetching):另外發送一條SELECT 語句抓取在前面查詢到(或者抓取到)的全部實體對象的關聯集合。(lazy="true"時)
  • 批量抓取(Batch fetching): 對查詢抓取的優化方案, 經過指定一個主鍵或外鍵列表,使用單條SELECT語句獲取一批對象實例或集合。

另外,Nhibernate抓取策略會區分下列各類狀況:

  • Immediate fetching,當即抓取:當宿主被加載時,關聯、集合或屬性被當即抓取。
  • Lazy collection fetching,延遲集合抓取:直到應用程序對集合進行了一次操做時,集合才被抓取。(對集合而言這是默認行爲。)
  • "Extra-lazy" collection fetching,"Extra-lazy"集合抓取:對集合類中的每一個元素而言,都是直到須要時纔去訪問數據庫。除非絕對必要,Hibernate不會試圖去把整個集合都抓取到內存裏來(適用於很是大的集合)。
  • Proxy fetching,代理抓取:對返回單值的關聯而言,當其某個方法被調用,而非對其關鍵字進行get操做時才抓取。
  • "No-proxy" fetching,非代理抓取:對返回單值的關聯而言,當實例變量被訪問的時候進行抓取。
  • Lazy attribute fetching,屬性延遲加載:對屬性或返回單值的關聯而言,當其實例變量被訪問的時候進行抓取。須要編譯期字節碼強化,所以這一方法不多是必要的。

默認狀況下,NHibernate對集合使用延遲select抓取,這對大多數的應用而言,都是有效的,若是須要優化這種默認策略,就須要選擇適當的抓取策略,本文第二章列出的具體的可用解決方案。

 

4.整體原則

上文講述了Nhibernate的抓取策略和具體解決方案,歸結起來,在運用抓取策略提升性能時,總的原則就是:儘可能在首次查詢或每次查詢時多加載關聯的集合對象,在合適的地方使用抓取策略,既提升性能,又要影響其餘應用場景爲好。

 

謝謝觀賞!

 

參考文獻:

1.《NHibernate Reference Documentation 3.0》:http://nhforge.org/doc/nh/en/index.html

2.Pierre Henri Kuaté, Tobin Harris, Christian Bauer, and Gavin King.Nhibernate in Action February, 2009

相關文章
相關標籤/搜索