前言:
NHibernate 性能提升策略方案:在运用抓取策略提高性能时,总的原则就是:尽量在首次查询或每次查询时多加载关联的集合对象,在合适的地方使用抓取策略,既提高性能,又要影响其他应用场景为好。
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映射文件的内容为:
<
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中的子对象”时,通常,我们会写出如下符合要求的代码:
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映射如下:
<
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映射配置如下:
<
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语 句类似于:
--
第一级查询
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也不推荐写在映射文件中),而是将其写在需要用到预加 载的代码中,其他的地方则保持原有逻辑,这样才不会产生不良影响,预加载在代码里的写法有三种:
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抓取,这对大多数的应用而言,都是有效的,如果需要优化这种默认策略,就需要选择适当的抓取策略,本文第二章列出的具体的可用解决方案。