有些开发者因为曾有性能上的不快经验而拒绝采用对象关系映射(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<int>("id")).Compile();</int>
你可以看到我们将局部变量 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 框架,你就能解决大多数访问数据库的性能瓶颈,你就能以微小的付出换得应用程序性能的大幅提高。
关于作者
John-Daniel Trask 是 Mindscape 的联合创始人之一。Mindscape 一个由开发者领头的组件产商,在全球拥有数以千计的客户。John-Daniel 同时还是微软 ASP.NET 技术最有价值专家(MVP),有近 20 年的软件开发经验。
查看英文原文: Optimizing ORM Performance
感谢侯伯薇对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ )或者腾讯微博( @InfoQ )关注我们,并与我们的编辑和其他读者朋友交流。
评论