优化 NHibernate 中的性能:第二部分:一系列增强






4.86/5 (22投票s)
本文是两部分系列文章的第二部分,重点介绍优化NHibernate ORM层的效率。
快速链接
引言
本文是两部分系列文章的第二部分,重点介绍优化NHibernate ORM层的效率。在第一部分中,我深入讨论了检测和隔离数据层交互中性能缺陷的各种技术。此时,我假设您对瓶颈有清晰的定义,并且对要实现的目标指标有所了解。以下各节定义了可以优化NHibernate应用程序的区域。每个区域都有一系列性能调整,以及您需要注意的潜在收益和副作用。请记住,如果它没有坏,就不要修补它!
背景
我的一个客户托管了一个应用程序,其数据复杂性和业务逻辑都是单体式的(120多个表,400多个类)。NHibernate使我们能够大幅简化代码库和维护成本,但密集的数据和业务逻辑对实时速度有要求,这要求我们的数据层实现最佳性能。以下信息是从数天对NHibernate一些鲜为人知的功能进行实验以及绕过其一些令人沮丧的特性中整理出来的。撰写本文时,NHibernate的当前版本(稳定版)是1.0.3.0。一个有吸引力且相对稳定的测试版(1.2)也可用。像任何流行的框架一样,速度和稳定性都会随着时间的推移而提高,其中一些性能调整仅适用于特定版本。
实验环境
拥有一个现有且熟悉的 codebase 可以帮助开展工作。DBA通常使用Northwind作为他们的环境,感谢James Avery 和他的开发博客的工作,我找到了一个正在运行的NHibernate/Northwind项目,我们可以用它来玩。这是用VS2005编写的,NHibernate版本是1.01-contrib。这个项目提供了一些很好的想法,关于可以添加到实体类基类的属性和方法,所以绝对值得一看。我将经常使用它来演示示例。
第一部分:Schema 定义
NHibernate 的效率只取决于它生成的 SQL。考虑以下两个完全相同的 SQL 语句:
select * from publishers
select pub_id, pub_name, city, state, country from pubs.dbo.publishers
你知道吗,后面的语句执行时间明显更快。为什么?这两个语句在执行前都需要验证和编译查询计划,但第一个查询需要额外的工作来解析特定的数据库对象“publishers”以及“*”涉及哪些列。数据库需要找出*的含义,“publishers”是什么类型的数据库对象,以及它在哪里。这个故事的寓意是:完全限定名称意味着数据库服务器需要更少的解释开销。通过查询详细程度获得的性能增益差异很大,但这始终是一个很好的实践。NHibernate 允许您通过为每个会话定义 schema 来利用这一概念。
以以下示例为例。在您的 .config 文件的 NHibernate 部分中使用此行。
<add key="hibernate.default_schema" value="Northwind.dbo"/>
这实际上将转换为以下 SQL 代码(在 Northwind 项目中)用于选择类别
execsp_executesql N'SELECT .... FROM Northwind.dbo.Categories category0_'
对于那些有幸拥有多个 schema 或数据库的人来说,Billy McCafferty 通过使用多个配置文件对这个问题进行了一些阐述。
第 2 部分:什么是 sp_execsql?
NHibernate 最棒的一点是,其基本形式是与数据库无关的。您的 NHibernate 应用程序生成的 SQL 取决于您使用的方言。如果您使用的是 NHibernate 的 SQL200X 方言,您会经常看到使用 sp_execsql
语句。它们由会话调用生成,例如 session.GetBy(..)
和 session.CreateCriteria(...)
。sp_execsql
是一个内置存储过程,它将 SQL 命令作为参数并执行它们。它限制为 nvarchar
(4000) 的输入,它支持 Unicode,并且非常适合动态生成 SQL 的接口,例如 NHibernate。每次调用 sp_execsql
都作为一个 SQL 批处理操作,这意味着语句以串行方式执行,没有任何缓冲。
当我们直接生成 SQL 即可时,为什么如此依赖这个存储过程?sp_execsql
不仅执行给定的命令参数,它还需要一些时间来编译和缓存查询计划,以便后续的类似调用能够得到优化。如果 sp_execsql
被多次调用以执行类似的查询,则可以利用缓存的编译查询计划的优势。当您有一组类似的查询连续执行时,最终结果是由于构建和重用缓存的查询计划而“感知到”的“势头”增加。遵循这种行为,sp_execsql
的缺点在于罕见或多样的查询,在这种情况下,构建和缓存很少重用的执行计划实际上是浪费时间。
考虑以下示例
使用 Northwind 项目,我转到 ManageCategories.aspx 页面。我点击了几个不同的类别来查看它们的属性。每次点击都是对数据库的回调,以检索有关特定类别的数据。
以下是 NHibernate(和我的“点击”)生成的 SQL 跟踪
第一个“黄色”调用是我首次点击查看给定类别。sp_execsql
花时间编译并缓存查询计划,因此“CPU 成本”为 16,50 次读取,执行持续时间为 33 毫秒。在这种情况下,编译和存储此计划是值得的,因为我随后的点击生成了类似的 SQL 代码,并且查询计划得到了重用,效率更高(参见红色行上的相对成本?)。
这留下了两个明显的问题
- 两个给定查询必须“相似”到何种程度才能重用查询计划?
- 给定查询计划在缓存中失效之前的生命周期是多久?
唉,这些问题的答案在很大程度上取决于具体情况。当 SQL Server 认为现有查询计划的执行时间比构建和运行新查询计划更快时,就会重用查询计划。例如,如果您使用 SQL Query Analyzer 查看任意两个查询,它们是否具有相似的执行计划?给定查询计划的缓存生命周期是为了 SQL Server 的利益;它是一个“黑盒”方案,不应被依赖或预测。如果您需要无条件缓存某些内容,请考虑使用 NHibernate 的缓存功能,或使用 DBCC PINTABLE
函数以加快访问速度(稍后将详细介绍)。如果您有大量多样且运行缓慢的查询,请考虑将它们分解为参数化的用户定义函数、索引视图或存储过程。也许,您可以通过某种方式将这些相似的查询组合在一起,从而看到与上述相同的行为。
第三部分:事务与 NHibernate
事务在企业应用程序中至关重要。它们直接影响您的数据完整性和可伸缩性。过快的速度可能危及完整性,过多的完整性约束会降低速度阈值。在使用 NHibernate 时,您应该了解所使用的隔离级别以及事务的平均占用空间和时间成本。
隔离级别定义
如果您正在构建企业应用程序,则需要一定的预期并发负载阈值。给定事务的隔离级别定义了其在涉及并发情况下的行为。定义事务的隔离级别将对您的企业应用程序的可伸缩性产生巨大的影响。NHibernate 允许您定义每个事务的隔离级别,或者更方便地,为应用程序定义一个默认(但可覆盖)的隔离级别。隔离级别的权衡是速度和安全性之间的折衷,NHibernate 允许您从 System.Data
内置隔离级别中选择,这些级别应该适用于您可能需要的任何情况,按从最安全到最快的级别排序:
Serializable
- 数据库对象(即整个表)上的锁将一直保持到事务结束。多个可序列化操作可以回滚,同时保持完整性。RepeatableRead
- 对行的“读取锁”和“写入锁”将一直保持到事务结束。风险包括在事务期间遗漏从外部源新插入的行。ReadCommitted
- “读取锁”会迅速释放,但对行的“写入锁”在事务结束之前不会释放。这是 SQL Server 中的默认隔离级别。风险包括由于事务中途来自外部源的更新而导致不可重复读取。ReadUncommitted
- 对行的“读取锁”和“写入锁”在事务结束之前释放。风险包括“幻象更新”,指事务期间读取不一致。Chaos
- 只有最高优先级的写入才使用锁。除了事务的通用 ACID 优势之外,我还没有找到这种隔离级别的用途。
要为您的应用程序定义默认隔离级别,请在您的 .config 文件中添加类似以下内容
<add key="hibernate.connection.isolation" value="ReadCommitted" />
要为给定事务定义自定义隔离级别,请在事务声明中添加 System.Data.IsolationLevel
参数
ITransaction transaction =
session.BeginTransaction(System.Data.IsolationLevel.ReadCommitted);
为什么我应该在 NHibernate 中定义我的隔离级别?这种概念不是特定于数据库的吗?是的,它是特定于数据库的,但某些代码,例如
ITransaction transaction=
session.BeginTransaction(System.Data.IsolationLevel.ReadCommitted);
session.SaveOrUpdate(entity);
transaction.Commit();
执行后实际上会向您的 SQL Server 发送以下 SQL 代码
SET TRANSACTION ISOLATION LEVEL READ COMMITTED; BEGIN TRANSACTION
exec sp_executesql N'UPDATE Northwind.dbo.Categories SET CategoryName = @p0,
Picture = @p1, Description = @p2 WHERE CategoryID = @p3', N'@p0 nvarchar(4000),...
COMMIT TRANSACTION
事务粒度及其对可伸缩性的影响
软件工程师和 DBA 之间常见的误解之一是事务及其对可伸缩性的影响。想象一下以下场景:
工程师 Joe 设计了一个电子商务应用程序来销售音乐会门票。当客户点击“结账”时,会启动一个事务,该事务“预留”所订购的门票库存,并在客户完成确认和输入付款信息后提交事务。Joe 认为这很完美,因为它确保客户在结账期间,其库存无法被其他买家访问。
几周后,Joe 将会寻找一份新工作。为什么?这种事务用法在许多层面上都是极其错误的。
- 如果我买下下一场 Devo 演唱会的所有门票,然后在结账和付款之前重定向到我的 MySpace 页面怎么办?根据隔离级别,在我的事务提交或超时之前,其他任何人都无法访问这些项目,并且在 Joe 的应用程序收到我结账的响应之前,其他任何人都无法购买门票。Devo 粉丝会不高兴,你也不想看到不高兴的 Devo 粉丝,不是吗?我的观点是,事务绝不应等待用户输入而保持打开状态。
- 这一点更为微妙,但同样重要:根据所使用的隔离级别,一旦事务中访问了给定行,该行通常会在事务生命周期的其余时间被“锁定”。这意味着其他可能需要访问此行才能完成其任务的操作将被挂起,直到事务完成。如果这是一段经常访问的数据,您会看到操作排队(并等待)(并超时)(并死锁),因为它们试图访问这块热门数据。更糟糕的是,SQL Server 有一个“贪婪”的锁定方案,它很少只锁定一行而不锁定相邻行的半径(页级锁定),以预测连续的更新和插入。
使用事务时要谨慎。这个特定的用例是否需要 ACID 属性?如果您的给定用例是只读的,您确定需要使用事务吗?您的事务从初始化到提交之间花费了多少时间?超过一秒了吗?将您的事务想象成试图并入单车道高速公路的汽车;小型汽车比 18 轮卡车更能提高高速公路效率!您能否将事务分解成多个较小的事务?如果不能,您是否有机会以更快的方式(例如存储过程或 SQL 委托的 CLR)来实施此逻辑?事务是确保数据完整性(通过 ACID)的绝佳方式,并且多个事务可以相互嵌套,但粒度高的事务会迅速锁定资源并损害您的可伸缩性。这里的范式是,完全缺乏事务完整性可能会损害您的数据完整性。
第四部分:需要考虑的实践
.NET 设计最佳实践
我不会假装是软件工程权威;Billy McCafferty 已经撰写了一篇关于该主题的文章,内容涵盖了 NHibernate 层的最佳安排,并提供了对泛型和测试框架的适应。这是正确使用 NHibernate 的起点。以下是一种通过使用 HQL 生成类来保持查询高效和灵活的方法。
搜索条件对象
ORM 应用程序面临的最大问题是数据库和应用程序之间过多的 I/O。通常情况下,会从数据库加载一个对象集合,而实际上只需要集合中的一个对象。为了避免这种趋势,您可以在 DAO 层中编写一个自定义方法,其中包含您特定需求的 HQL。但这有一个问题:每次您需要在新的上下文中加载一个特定对象时,您都需要编写一个新的自定义方法,并管理更多的 HQL。有时,为了避免应用程序中 HQL 的堆积,最好将一个类指定为负责为多个上下文生成 HQL。我称之为“SearchCriteria
”类。这个类将拥有可用于指定搜索条件的自定义属性,以及一个自动生成 HQL 以检索特定实例的方法。好处是什么?给定实体的所有 HQL 都存储在一个地方,并且条件对象充当松散耦合的数据层和业务层之间的连接。
为了举例说明,我们将再次参考Northwind 代码库。我们可以通过采用以下类模型来简化给定持久化对象的搜索过程
由名为 ISearchCriteria
的新文件中的以下代码示例表示
namespace Northwind.Domain
{
public interface ISearchCriteria
{
string HQL
{
get ;
}
}
}
现在,Category
类的每个属性都可能成为条件类要处理的参数。为了简化此示例,我们将创建我们的类别特定搜索条件类,名为 CategorySearchCriteria
,具有以下两个属性
using System;
using System.Collections.Generic;
using System.Text;
namespace Northwind.Domain
{
class CategorySearchCriteria:ISearchCriteria
{
/// <summary>
/// Generally, it is a better idea to store all
/// of your default values in a more central location,
/// but we will be storing them here for the sake of simplicity
/// </summary>
#region defaults
private static readonly int defaultID = -1;
private static readonly bool defaultExcludeCategoriesWithProducts = false;
#endregion
#region members
private int id = defaultID;
private bool excludeCategoriesWithProducts =
defaultExcludeCategoriesWithProducts;
#endregion
#region properties
/// <summary>
/// Search for a specific Category ID
/// </summary>
public int Id
{
get { return id; }
set { id = value; }
}
/// <summary>
/// Use this to search only within the realm
/// of categories without any products
/// </summary>
public bool ExcludeCategoriesWithProducts
{
get { return excludeCategoriesWithProducts; }
set { excludeCategoriesWithProducts = value; }
}
#endregion
#region HQL
public string HQL
{
get
{
StringBuilder builder = new
StringBuilder("select cat from Category cat where (1=1) ");
if (id != defaultID)
builder.Append(" and cat.ID = '" + id + "'");
if (excludeCategoriesWithProducts !=
defaultExcludeCategoriesWithProducts)
builder.Append(" and cat.Id not in " +
"(select p.Category.Id from Product p) ");
return builder.ToString();
}
}
#endregion
}
}
上述代码的有趣之处
- "
where (1=1)
" 是WHERE
条件的初始子句。这在生成 SQL 风格的代码时是一个常用技巧。如果没有设置其他搜索条件参数,此对象将生成返回所有类别的 HQL。 - 每次我们有一个新的条件属性时,我们都会创建一个新的默认值,并为
WHERE
子句添加一个新的过滤器。 - 由于此类别生成 HQL,因此可以使用
SetFirstResult()
/SetMaxResults()
方法轻松实现本地支持的功能,例如分页。
第五部分:缓存还是不缓存
如果本文有一个反复出现的主题,那就是关于避免 NHibernate 可能引入的数据库和 .NET 层之间过多的 I/O。有时,与其关注优化数据库交互,不如探索通过使用缓存来绕过数据库交互的可能性。简而言之,缓存是将频繁访问的数据副本存储在易于快速访问的存储位置的做法。这个存储位置可能不可靠,并且重要的是要知道存储和真实数据源何时不同步。它在许多方面都是一把双刃剑,因此对您的缓存方案有很好的理解至关重要。
- 您可以使用 DBCC PINTABLE 方法在 SQL Server 中手动缓存数据库表。
- 您可以使用 NHibernate 的缓存功能(有两种实现方式,Bamboo Prevalence 和更常见的 ASP.NET 缓存)在 NHibernate 中缓存一组持久化对象。
- 您可以在 Castle 项目的 ActiveRecord 框架中采用 二级缓存功能。
- 您可以编写自己的对象池设计,例如单例类,以自定义您的缓存行为。
缓存会帮助您
- 快速交付频繁访问但很少插入或更改的数据。
- 减少数据库交互量,从而提高速度和可伸缩性。
缓存不会帮助您
- 预测您的应用程序。在集群环境中放置不当的缓存机制可能会带来麻烦,因为集群中的每个服务器都可能拥有自己的缓存版本。
探索缓存数据的可能性时,请提出以下问题:
- 我希望缓存的数据范围是什么?
- 我希望在程序启动时立即缓存数据,还是在数据首次加载时缓存?
- 给定缓存对象在失效之前的生命周期是多久?
- 当缓存对象失效时,需要发生什么事件链?
- 当缓存中的对象更新时,需要发生什么事件链?
- 缓存数据被外部力量更改和失效的可能性有多大?
第六部分:存储过程
预编译 SQL 命令的使用是软件工程社区中一个经常被误解的概念。有些人将其视为银弹,有些人则完全厌恶它们。在我解释如何使用存储过程之前,我将尝试澄清何时它是有效的途径:
存储过程将帮助您
- 如果你能编写一个将使用一致且可重用查询计划的存储过程/UDF。
- 当您需要高度控制数据库(包括锁定、嵌套事务和临时表)时。
- 当您处理 NHibernate 映射无法访问的数据时。
- 您希望利用业务逻辑增强功能,例如 CLR 或 SQL Server 2005 中的 作业调度代理。
- 当您有一个具有少量参数但导致大量更新或插入的单一方法时。
存储过程不会帮助您
- 更快地加载大量数据。大量的 I/O 受限于连接介质。
- 如果存储过程的行为方式差异很大(这会导致同一存储过程在运行时多次重新编译)。
- 提高代码维护开销。通过使用数据库逻辑,您在某种意义上违反了应用程序设计的 NHibernate “纯洁性”。换句话说,您已将部分业务智能分散到数据层中,并且这两个层之间的接口需要同步和维护。此外,您必须跟踪哪些是数据库的责任,哪些不是。思考一下单元测试和调试所涉及的复杂性。
- 关于分页。即使在 1.2 版本中,存储过程查询也无法使用
SetFirstResult()
/SetMaxResults()
进行分页。除非您自己编写自定义 SQL 进行分页,否则您将始终从调用中获得整个结果集。
通常,需要频繁使用(但开销大)的查询。考虑使用索引视图或用户定义函数(UDF)进行只读的特定查询优化。主要挑战是将存储过程的返回值映射到 NHibernate 的视图中,以便 NHibernate 可以以正常方式管理持久化对象。
为了举例,我将使用 Northwind 中的“十大最昂贵产品”存储过程,并向您展示如何在 NHibernate 的两个版本中调用它。
在 1.0.X 中调用存储过程
NHibernate 1.0 不直接支持存储过程调用。可以通过巧妙地使用 session.CreateSQLQuery(..)
方法进行变通。挑战在于 NHibernate 会期望数据库返回一组特定的列别名,并且您的存储过程必须精确匹配这些列别名。如果缺少列,或者返回的列名别名与 NHibernate 的预期不符,则会抛出 SQLException
。我们如何知道正确的列别名是什么?列别名约定似乎因您使用的特定 NHibernate 版本而异,因此如果不确定,请捕获一些 NHibernate 生成的概要查询作为示例。幸运的是,我们有上面列出的产品查询,因此我们可以更改现有的存储过程
create procedure
"Ten Most Expensive Products" AS
--this is the procedure that comes with Northwind
SET ROWCOUNT 10
SELECT Products.ProductName AS
TenMostExpensiveProducts,
Products.UnitPrice
FROM
Products
ORDER BY Products.UnitPrice
DESC
GO
变成 NHibernate 1.0.X 更易接受的形式
CREATE procedure [Ten Most Expensive Products]
AS
--this is the modified procedure for NHibernate 1.0
SET ROWCOUNT 10
SELECT
product0_.ProductID as ProductID0_,
product0_.UnitsOnOrder as UnitsOnO6_0_,
product0_.ProductName as ProductN2_0_,
product0_.ReorderLevel as ReorderL7_0_,
product0_.Discontinued as Disconti8_0_,
product0_.QuantityPerUnit as Quantity3_0_,
product0_.UnitPrice as UnitPrice0_,
product0_.SupplierID as SupplierID0_,
product0_.UnitsInStock as UnitsInS5_0_,
product0_.CategoryID as CategoryID0_
FROM
Northwind.dbo.Products product0_
ORDER BY
product0_.UnitPrice DESC
GO
最后,使用 CreateSQLQuery
方法调用它
IList products = session.CreateSQLQuery("exec [Ten Most Expensive Products]",
"irrelevant parameter", typeof(Product)).List();
正如预期的那样,这返回了十大最昂贵的产品,排序正确,并具有 NHibernate 的完整持久化功能。请注意,返回别名参数未使用。在 NHibernate 1.2 中,此参数已弃用。缺点是:
- 找到 NHibernate 期望的正确返回列别名的明显挑战。
- 每当您更改给定类的属性集时,您可能需要重新访问与该类相关的存储过程以更新别名。
在我的经验中,我经常发现使用 System.Data
库而不是 NHibernate 1.0 来调用特定的数据库函数更有价值,但我想向您展示它是一种可能性。
在 1.2.X 中调用存储过程
由于广受欢迎的需求,1.2 版最大的新功能之一是支持访问数据库对象。这超越了存储过程的范畴!对原生 SQL 的支持使您能够对数据库进行更高程度的控制。同样,挑战在于将存储过程的返回值映射到 NHibernate 的持久化对象视图中。这次,我们通过在关系映射文件中定义数据库调用来对映射拥有更大的权力,类似于 IBatis 风格。在 Product.hbm.xml 文件中,我们精确声明如何调用过程、过程将返回什么以及如何映射返回值
<!--the query name is a reference used by the .NET code-->
<sql-query name="sp_TenMostExpensiveProducts" callable="true">
<return class="Product">
<!--the name refers to the mapped property, and the column
is the returned value from the database call-->
<return-property name="ProductID" column="ProductID"/>
<return-property name="UnitsOnOrder" column="UnitsOnOrder"/>
<return-property name="ProductName" column="ProductName"/>
<return-property name="ReorderLevel" column="ReorderLevel"/>
<return-property name="Discontinued" column="Discontinued"/>
<return-property name="QuantityPerUnit" column="QuantityPerUnit"/>
<return-property name="UnitPrice" column="UnitPrice"/>
<return-property name="SupplierID" column="SupplierID"/>
<return-property name="UnitsInStock" column="UnitsInStock"/>
<return-property name="CategoryID" column="CategoryID"/>
</return>
<!-- write any native SQL needed here, as NHibernate
will send this to the database verbatim -->
exec [Ten Most Expensive Products]
</sql-query>
使用 <return-property>
,您可以明确告诉 NHibernate 使用哪些列别名,而不是让 NHibernate 注入自己的列别名(请参阅 1.0.X 的上述示例)。
相应的存储过程变得更具可读性
CREATE procedure [Ten Most Expensive Products]
AS
SET ROWCOUNT 10
SELECT
ProductID,
UnitsOnOrder,
ProductName,
ReorderLevel,
Discontinued,
QuantityPerUnit,
UnitPrice,
SupplierID,
UnitsInStock,
CategoryID
FROM
Northwind.dbo.Products
ORDER BY
UnitPrice DESC
GO
.NET 端的调用结果是
IList products = session.GetNamedQuery("sp_TenMostExpensiveProducts") .List();
这只是 NHibernate 1.2 SQL 功能的冰山一角。有关原生 SQL 支持及其潜力的更多信息,请访问 NHibernate 文档。
结论
在第一部分中,我讨论了查找和隔离与数据相关的性能和可伸缩性问题的方法。在本文中,我介绍了解决这些问题的各种方法。NHibernate 是一个不断发展的框架,其灵活性和在开发人员中的受欢迎程度都在不断提高,并且有许多充分的理由!下次有人驳斥 ORM 框架的性能潜力时,也许您可以将他们指向本文作为概念验证!
历史
- 2007年4月7日 - 初次发布。
- 2007年4月7日 - 修订 - 第6节中的多对一缺陷修正。
- 2007年4月8日 - 修订 - 我暂时删除了第6节中的多对一缺陷。我将在不久的将来添加一个关于获取 schema 及其对性能影响的章节。