1.問題的緣起
考察下面的類結構定義
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映射文件的內容為:
<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中的子對象”時,通常,我們會寫出如下符合要求的代碼:
IList<Category> list = query.List(); //第一級查詢
foreach (Category item in list)
{
foreach (Category child in item.Children) //第二級查詢
{
//...
}
}
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映射如下:
<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映射配置如下:
<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語句類似於:
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一次性的取出對象本身及其子對象,比使用批量加載生成的語句還要少,首一次加載效率高。
雖然,在映射文件中啟用預加載設置,十分簡單,但是考慮到其他方式(如:Get或Load)獲取對象時也會自動裝載子對象,造成不必要的性能損失,另外,在映射文件中設置預加載,其“作用域”有只適用於:通過Get或Load獲取對象、延遲加載(隱式)關聯對象、Criteria查詢和帶有left join fetch的HQL語句,因此通常要求避免將啟用預加載的配置寫在映射文件里(Nhibernate也不推薦寫在映射文件中),而是將其寫在需要用到預加載的代碼中,其他的地方則保持原有邏輯,這樣才不會產生不良影響,預加載在代碼里的寫法有三種:
或者在3.0里面使用的
再或者HQL中使用left join fetch
這里有一個奇特的情況,FetchMode枚舉包含Eager和Join兩個選項,但實際使用中的效果是一樣的,都是輸出Join語句,沒有任何區別,Nhibernate如此設置,我猜想可能的原因是開始時只有Join一個選項,而后覺得不夠貼切,遂增加一個Eager,但考慮到老版本兼容性,沒有刪除Join,所以就成了現在這個樣子。下面的代碼說明了在IQueryOver中如何使用預加載
到此為止,一切都顯得很完美,不過,還沒完,預加載由於其生成的SQL語句包括了Join或子查詢語句,因此它無法保證獲取到集合中元素的唯一性,例如:A包含兩個子元素B和C,那么通過預加載后,第一級查詢取出的列表中會包括兩個A對象,而不是通常我們想象的一個。所以,啟用預加載后獲取到的列表,需要手動的解決唯一性的問題,最簡單的就是把列表裝入ISet里“過濾”一次。
{
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