有些开发者因为曾有性能上的不快经验而拒绝采用对象关系映射(ORM)技术。和任何形式的抽象一样,使用ORM框架要以一些额外开销作为代价,但事实上,使用经过恰当调优的ORM和手写原生的数据访问代码在性能上还是有得一拼的。更为重要的是,使用好的ORM框架更容易调优和优化性能,手写原生数据访问代码在性能调优上则会困难得多。
本文中的示例建立在Mindscape的LightSpeed ORM之上,我们将结合示例讨论常见的问题及其解决方案。
N+1问题
让我们来看看web应用程序中的过期订单列表,这有助于我们理解所要讨论的问题。假设我们不仅要查看订单,同时还要查看每个订单的客户信息,如果没做深入分析的话,我们也许会写出这样的代码:
var overdues = unitOfWork.Orders.Where(o => o.DueDate < today); foreach (var o in overdues) // 1 { var customer = o.Customer; // 2 DisplayOverdueOrderInfo(o.Reference, customer.Name); }
这段代码隐藏着所谓的N+1问题。获取订单列表(代码中注释1处)需要一次数据库查询操作,接着代码会获取列表中每个订单对应的客户信息,而每次获取都得进行一次数据库查询!所以,如果总共有100个过期订单,代码就得执行101次数据库查询——第1次用于加载过期订单集合,后续100次用于加载每个订单的客户。一般来说,有N个订单就得执行N+1次数据库查询——这就是N+1问题名称的由来。
显然,这是相当慢和低效的。我们可以通过预先加载(eager loading)技术解决这种性能问题。如果我们能将所有关联客户的加载作为订单查询操作的一部分,在同一次数据库访问中进行,那么后面对客户信息的访问就只是访问对象属性而已——不需要查询数据库,也就没有N+1问题。
使用LightSpeed,我们可以通过将EagerLoading设置为True(或者,在手写业务实体中应用EagerLoadAttribute)来预先加载关联的数据。当LightSpeed查询设置了预先加载关联的实体时,除了“主体”实体本身,它还将产生相应的SQL语句用以查询关联的实体。
(点击图片可查看大图)
在上面的例子中,如果我们在Order.Customer关联上应用预先加载,那么当我们查询Order实体时,LightSpeed将会产生用于加载Order实体和Customer实体的SQL语句,并将所有语句同批处理。这样,只要稍作改动,就可以将101次数据库访问减少为1次。
ORM和原生数据访问代码之间的映射
总的来说,这表明为什么ORM框架具有性能调优上的优势。假设过期订单页面使用了手写的SQL,并手动将数据库访问层中的数据复制到实体对象中,则当N+1问题出现时,你不仅要更新SQL语句,还要更新你的映射代码以处理多种结果集和管理对象关系。对于我们的简单例子,这些工作量并不算很多,但如果页面要从很多数据库表中读取数据就不是这样了。那要比修改一个选项和应用一个attribute麻烦得多!
延迟加载(Lazy Loading)
上述的订单页面存在另外一个潜在问题。让我们假设Customer实体有一个包含大图片的Photo属性(如果你对销售部门做个需求调查,就会发现这是合情合理的)。过期订单页面不需要访问Customer.Photo属性,但相片和Customer对象的其他属性值一样,都会被加载。如果相片很大,这将消耗很多内存,并且需要很长时间才能从数据库提取出所有相片数据——而最后这些时间都浪费了。
解决上述问题的方法是让Photo属性延迟加载——具体说就是只当属性被访问时才加载数据,而不是在加载Customer实体时就加载。因为过期订单页面不访问Photo属性,也就不会加载不需要的图片;而其他确实需要相片的页面,比如客户简介页面,仍然能直接访问Photo属性。
并没有一个简单的标识可以用来设置启用延迟加载机制的属性。但是你可以将属性标识为named aggregate的一部分(通过在属性的Aggregate设置中输入称),这样的属性默认是延迟加载的。下一节中我们会详细讨论这项技术。
(点击图片可查看大图)
如果我们设置Photo属性的Aggregate为“WithPhoto”,则在过期订单页面中客户相片就不会被加载,这样我们避免了内存浪费,减少了数据加载量,提高了页面呈现速度。
Named Aggregates (Includes)
Named Aggregates (Includes)
上面解决N+1问题及重量级属性问题的方案使我们的过期订单页面现在已经变得敏捷多了。但上述解决方案可能会对网站的其他页面产生负面影响。让我们来考虑订单详细页面的情况:因为Order.Customer现在已是预先加载的,导致订单详细页面会被它不需要的Customer实体对象所拖累。这样看来,似乎不管我们是否预先加载Customer,总有些页面会性能不高!
理想的情况是:Order.Customer关联在过期订单列表页面应该预先加载,而在订单详细页面应该延迟加载。我们可以通过让Customer属性成为named aggregate的一部分来达到此目的。
Named aggregate能识别出页面需要对象的哪些部分。一个named aggregate由条件预先加载的对象关联和属性所组成——如果查询要求预先加载则预先加载,否则就延迟加载。(named aggregate是LightSpeed所用的术语,有些ORM框架提供的同类特性名名叫includes)。
为了使Order.Customer成为named aggregate的一部分,我们将Eager Loading设置回False值,这使得订单详细页面能高效运转。接着,为了使过期订单列表页面也能高效运转,我们添加“WithCustomer”到Order.Customer的Aggregate箱中。
(点击图片可查看大图)
现在让我们来修改过期订单列表页面,指定WithCustomer aggregate到订单LINQ查询上。实现方式很简单,只要在LINQ查询语句上添加WithAggregate方法调用就可以了:
var overdues = unitOfWork.Orders .Where(o => o.DueDate < today) .WithAggregate("WithCustomer");
对于实体的非对象关联属性,此方式同样适用。回想之前为了使Customer.Photo属性延迟加载,我们已经让它成为“WithPhoto”aggregate的一部分,但在需要相片的客户简介页面上这是不高效的。不过,只要在客户简介页面的Customer查询上添加WithAggregate("WithPhoto")方法调用,就会再次高效起来。
named aggregate能让你对性能掌控自如,同时你不必操心一条简单字符串设置背后预先加载的复杂细节。你可以根据实际需要,在重量级或者高访问量的页面上稍微调整aggregate以巨大提高性能。
批量化
让我们来关注订单输入页面的情况。订单不仅包含诸如参考号之类的订单级别属性,同时还包括订单明细集合。当用户提交订单输入页面的数据时,应用程序需要创建Order实体和若干个OrderLine实体,然后将所有实体上的数据插入到数据库。
潜在的问题和上述N+1问题类似(只是数据流向不同):如果有100条订单明细,就得执行101次数据库插入操作。我们当然不想访问数据库101次!
LightSpeed采用批量化处理解决此问题。其过程是这样的:不同于通常将INSERT(或UPDATE或DELETE)作为单独的命令执行,LightSpeed将十条命令分为一组,然后批量执行。所以总的来说,对于大数据量的更新操作,LightSpeed的数据库访问次数仅为通常傻瓜式方法的十分之一。
令人惊喜的是我们不必为启用批量更新做任何努力。LightSpeed默认将CUD操作批量化,所以我们毫不费力就让订单输入页面具有了快速持久数据的特性。
一级缓存
现在让我们来看看和用户及其权限相关的页面会有什么样的性能问题。假设有一个User实体,该实体和权限相关,该实体具有诸如用户名等属性,同时还具有用于告诉应用程序组件如何展示数据的个性化设置属性。通常的情况是,有些页面会有几个地方需要加载当前用户对象——控制器(controller)要检查用户的权限,标题栏要显示用户名称,某个数据组件需要知道用户喜欢怎样展示数据。所以性能问题出现了:如果实体对象能缓存在内存中并被复用的话,那要比重复查询数据库快得多。
虽然像MVC之类的优秀框架在一些场景中有助于缓和上述多处加载同一对象的问题,但更通用的方案是采用一级缓存技术。LightSpeed始终围绕着工作单元模式,它的UnitOfWork类型提供了一级缓存。遵守“毎请求一工作单元”模式的应用程序具有一次页面请求范围的一级缓存。具体点说就是,在页面请求期间,如果你用ID作为条件查询数据(包括访问延迟加载的关联对象所需的查询),而工作单元已经包含对应于该ID的实体对象,则LightSpeed会绕开数据库查询操作,直接返回已经存在的实体对象。没有比这种方式更快的了!
大多数大型ORM框架包含了类似特性——比如NHibernate的session对象就具有一级缓存功能。然而很多Micro ORM(轻量级ORM框架)并不提供一级缓存,它们仅关注实体对象的加载效率。大型ORM框架不仅试图在查询时尽量高效,也首先试图能不查询数据库就不查询数据库。
一级缓存是由ORM自动控制的。我们的User实体对象会自动在工作单元期间(一次页面请求)被复用,不需要我们写代码干涉。
二级缓存
假设我们的订单管理系统能够处理多种货币类型——支持以美元、欧元或日元下订单。为了以恰当方式展示货币数据,我们需要用一些字节来处理货币信息——比如货币名称(US dollar),编码(USD)以及符号($)。接着,我们定义货币实体类型Currency就可以开始探讨二级缓存了。
依靠预先加载和一级缓存,就可以拥有很高的性能了,但还有方法能使性能更上一层楼。因为一级缓存的作用范围是其所对应的工作单元,而工作单元的生命周期在一次页面请求之内,这导致应用程序每次处理需要访问货币信息的页面都会查询一次货币数据库表。而货币信息是基准数据——它们几乎是永远不变的,我们并不真的必须在每次处理页面请求时查询数据库才能获取最新数据。更高效的做法是只查询数据库一次并缓存基准数据,然后在每次页面请求时使用缓存的数据。
上述设想可以使用二级缓存实现。LightSpeed二级缓存的生命周期比单个UnitOfWork要长,你可以决定二级缓存实体对象能够存在多久(可以通过设置expiry来决定缓存多长时间)。LightSpeed包含两种二级缓存实现方式,一种是使用ASP.NET的缓存机制,另一种是使用能横跨几台服务器的强大开源程序库memcached。其他的一些ORM框架也提供二级缓存功能,但大多数ORM不提供。
通过让LightSpeed缓存Currency实体对象到二级缓存中,我们就可以复用Currency实体数据,避开多次数据库查询的开销。要缓存Currency实体到二级缓存中,您需先在配置中指定一种缓存实现机制,接着只要选择Currency实体并将它的Cached选项设置为True就可以了。
(点击图片可查看大图)
编译好的查询
上面我们通过多个示例讲解了提高ORM性能的技术,但有一个地方我们没有考虑,就是C# LINQ表达式到最终查询数据库的SQL语句之间的转换是要花费额外开销的。这种开销在每条LINQ查询上都会出现,不过通常来说,它和数据库查询的开销比起来是微不足道的。如果你真想在你的服务器上挖出最后一点性能空间的话,那就是通过减少上述转换开销了。也许你会想通过直接写原生SQL代码,而在最新的ORM(包括LightSpeed)中,你在享受LINQ的便利时也仍然有方法减少这种转换开销。
LightSpeed消除掉转换开销的方法是使用编译好的查询。编译好的查询由通常的LINQ查询语句构建而来:LightSpeed将LINQ查询语句转换成可立即执行的格式,并将这种格式保存起来,这样每次执行LINQ查询时都可以用这种转换好的格式,而不用每次查询时都转换。这样你就不必自己编写和维护原生的SQL语句来提升性能。
事实上,和直觉相反,编译好的查询比手写的SQL代码在性能上要高些。这是因为当LightSpeed执行手写的SQL代码时,它丝毫不能推断结果集会是怎么样的。相反,当LightSpeed执行编译好的查询时,它能够推断出结果集的形式(因为SQL是由它构建的),它能在查询出数据填充实体对象时做些优化。
编译LINQ查询可能要比我们之前讨论的技术恼人些(有的ORM产商正在研究如何使这个过程更方便些)。原因是你必须将编译好的查询存储起来复用,其必须是参数化的,需要在编译和执行时利用相关API指定动态参数。
让我们来看看获取客户订单的查询:
int id = /* get the customer ID from somewhere */; var customerOrders = unitOfWork.Orders.Where(o => o.CustomerId == id);
如果这条LINQ查询会被多次执行,而我们想获得尽可能高的性能,我们可以用Compile()扩展方法来编译它。除此之外,我们还需要将上面语句的局部变量id替换为执行编译好的查询时能动态指定值的参数形式。下面是编译LINQ查询的代码:
var customerOrdersQuery = unitOfWork.Orders.Where(o => o.CustomerId == CompiledQuery.Parameter("id")).Compile();
你可以看到我们将局部变量id替换为了CompiledQuery.Parameter(“id”)扩展,之后再调用Compile()扩展方法。结果得到一个CompiledQuery对象,通常我们会将其存储为长久对象或静态类的成员。现在我们可以执行CompiledQuery查询如下:
int id = /* get customer ID from somewhere */ var results = customerOrdersQuery.Execute(unitOfWork, CompiledQuery.Parameters(new { id }));
(如果你真下定决心了,你可以通过调优参数值解析过程使查询性能达到极限,请参考这篇文章。)
结论
许多开发者认为对象关系映射技术是在以性能为代价换得编程上的便捷。然而,现代ORM框架封装了预先加载和批量更新等技术,这些特性若通过手写数据访问层实现的话是相当复杂的。这种性能技术表明ORM代码的性能可以和手写的数据访问层代码一样高效,而您再也不必费劲管理和维护手写的复杂SQL代码和映射代码。使用ORM框架,只要改变一比特的值或修改一下映射文件就可以解决N+1性能问题,这比修改SQL代码为嵌套形式的以及修改映射代码以处理多种结果集容易多了。
并非每种ORM框架都提供了所有本文讨论的特性,但大多数现代ORM框架都或多或少支持其中一些特性。找出应用程序中哪些地方有和数据库相关的性能瓶颈,然后使用支持本文所讨论特性的ORM框架,你就能解决大多数访问数据库的性能瓶颈,你就能以微小的付出换得应用程序性能的大幅提高。