在Ruby on Rails中优化ActiveRecord的方法
RubyonRails编程常常会将您宠坏。这一不断发展的框架会让您从其他框架的沉闷乏味中解脱出来。您可以用习以为常的几行代码片断表达自己的意图。而且还可以使用ActiveRecord。
对于我这样的一个老Java?程序员而言,ActiveRecord多少有点生疏。通过Java框架,我通常都会在独立的模型和模式之间构建一种映射。像这样的框架就是映射框架。通过ActiveRecord,我只定义数据库模式:或者用SQL或者用称为迁移(migration)的Ruby类。将对象模型设计建立于数据库结构之上的那些框架称为包装框架。与大多数包装框架不同,Rails能通过查询数据库表发现对象模型的特征。与构建复杂查询不同,我使用模型在Ruby(而非SQL)中遍历关系。这样一来,我既获得了包装框架的简单性,又具备了映射框架的大部分功能。ActiveRecord易于使用和扩展。有时,甚至有些过于简单。
与任何数据库框架一样,ActiveRecord让我极易做出很多惹麻烦的事。我所能获取的列太多,又很容易遗漏重要的结构化数据库特性,比如索引或空约束。我并不是说ActiveRecord是个不好的框架。只不过若是需要扩展,您需要知道如何坚固自己的应用程序。在本篇文章中,我将带您亲历在使用Rails这一独树一帜的持久性框架时可能需要的一些重要优化。
基础管理
生成受模式支持的模型异常容易,只需很少的代码,即script/generatemodelmodel_name。正如您所知,该命令可生成模型、迁移、单元测试甚至一个默认的fixture。在该迁移中填上一些数据列,并输入一些测试数据、编写几个测试、添加几个验证就算大功告成,这样做真是很有诱惑力。但请您三思而行。您应该考虑总体的数据库设计,要特别注意以下这些事情:
- Rails不会让您摆脱基本的数据库性能问题。数据库需要信息,这些信息经常以索引的格式才能有不错的性能。
- Rails不会让您摆脱数据完整性问题。虽然大多数Rails开发人员都不喜欢在数据库中保留限制,但您应该考虑像空列这样的事情。
- Rails为很多元素提供了方便的默认属性。有时,像文本字段的长度这样的默认属性对于大多数实用的应用程序而言都会过大。
- Rails不会强制您创建有效的数据库设计。
在您继续跋涉,深入学习ActiveRecord之前,应该首先确保您已经打好了足够的基础。请确保索引结构可以为您所用。如果给定的表很大,如果将在列上而不是id上搜索,如果索引能对您有所帮助(更多细节,请参见数据库管理器文档——不同的数据库以不同方式使用索引),那么就需要创建索引。无需采用SQL创建索引——可以简单地使用迁移创建。可以轻松地使用create_table迁移创建索引,也可以创建一个额外的迁移来创建索引。以下是一个迁移示例,可用来为ChangingThePresent.org(请参见参考资料)创建索引:
清单1.在迁移中创建索引
classAddIndexesToUsers<ActiveRecord::Migration defself.up add_index:members,:login add_index:members,:email add_index:members,:first_name add_index:members,:last_name end defself.down remove_index:members,:login remove_index:members,:email remove_index:members,:first_name remove_index:members,:last_name end end
ActiveRecord会负责id上的索引,我显式地添加了可在各种搜索中使用的索引,原因是此表很大、不经常更新却经常被搜索。通常,我们会等到对给定的查询中的问题有一定的把握后才会采取相应动作。这种策略可以让我们不必二次猜测数据库引擎。但从用户这方面来看,我们知道该表将会很快具有数百万的用户,如果在经常搜索的列上没有索引,该表的效率会很低。
另外两个常见问题也与迁移有关。如果字符串和列都不应该为空,那么就请确保正确编写了迁移。大多数DBA(数据库管理员)都会认为Rails为空列提供了错误的默认属性:默认情况下列可以为空。如果希望创建一个不能为空的列,您必须显式地添加参数:null=>false。如果具有字符串列,请务必确保编写应用程序的限值。默认地,Rails迁移会将string列按varchar(255)编码。通常,这个值过于庞大。应该尽量保持能如实反应应用程序的数据库结构。与提供无任何限制的login相反,如果应用程序限制login只能为10个字符,那么就应该相应地编写数据库,如清单2所示:
清单2.用限值和非空列编写迁移
t.column:login,:string,:limit=>10,:null=>false
此外,还应该考虑默认值以及其他任何能安全提供的信息。通过一点预备工作,就可以节省日后跟踪数据完整性问题的大量时间。在考虑数据库基础的同时,还应该注意哪些页是静态且容易缓存的。在优化查询和缓存页面这两个选项当中,如果您能“消受”复杂性,缓存页面将会带来更大的回报。有时,页面或片段都是纯静态的,比如一列状态或一组经常问到的问题。在这种情况下,缓存更胜一筹。而在其他的一些时候,您可能会决定牺牲数据库性能,以减少复杂性。对于ChangingThePresent,根据问题和环境的具体情况,我们二者都尝试了。如果您也决定要牺牲查询性能,就请继续阅读吧。
N+1问题
默认情况下,ActiveRecord关系十分懒散。这意味着框架会一直等待访问关系直到您实际访问了该关系。比方说,每个成员都会有一个地址。可以打开一个控制台并输入如下命令:member=Member.find1。可以看到追加到日志的如下内容,如清单3所示:
清单3.从Member.find(1)登录
^[[4;35;1mMemberColumns(0.006198)^[[0m^[[0mSHOWFIELDSFROMmembers^[[0m ^[[4;36;1mMemberLoad(0.002835)^[[0m^[[0;1mSELECT*FROMmembersWHERE (members.`id`=1)^[[0m
Member具有到此地址的关系,并由宏has_one:address,:as=>:addressable,:dependent=>:destroy定义。注意当ActiveRecord加载了Member时,您并不会看到地址字段。但如果在控制台中键入member.address,就可以在development.log中看到清单4中的内容:
清单4.访问关系会强制数据库访问
^[[36;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in`find'^[[0m ^[[4;35;1mAddressLoad(0.252084)^[[0m^[[0mSELECT*FROMaddressesWHERE (addresses.addressable_id=1ANDaddresses.addressable_type='Member')LIMIT1^[[0m ^[[35;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in`find'^[[0m
所以ActiveRecord并不会为地址关系执行查询,直到您实际访问member.address。通常,这种懒散设计会工作得很好,因为持久性框架无需移动如此多的数据来加载成员。但如果您想要访问很多成员以及所有成员的地址,如清单5所示:
清单5.用地址检索多个成员
Member.find([1,2,3]).each{|member|putsmember.address.city}
由于您应该看到针对每个地址的查询,所以就性能而言,结果并不尽如人意。清单6给出了问题的全部:
清单6.N+1问题的查询
^[[4;36;1mMemberLoad(0.004063)^[[0m^[[0;1mSELECT*FROMmembersWHERE (members.`id`IN(1,2,3))^[[0m ^[[36;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in`find'^[[0m ^[[4;35;1mAddressLoad(0.000989)^[[0m^[[0mSELECT*FROMaddressesWHERE (addresses.addressable_id=1ANDaddresses.addressable_type='Member')LIMIT1^[[0m ^[[35;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in`find'^[[0m ^[[4;36;1mAddressColumns(0.073840)^[[0m^[[0;1mSHOWFIELDSFROMaddresses^[[0m ^[[4;35;1mAddressLoad(0.002012)^[[0m^[[0mSELECT*FROMaddressesWHERE (addresses.addressable_id=2ANDaddresses.addressable_type='Member')LIMIT1^[[0m ^[[35;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in`find'^[[0m ^[[4;36;1mAddressLoad(0.000792)^[[0m^[[0;1mSELECT*FROMaddressesWHERE (addresses.addressable_id=3ANDaddresses.addressable_type='Member')LIMIT1^[[0m ^[[36;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in`find'^[[0m
结果正如我所预见的那样糟糕。所有成员共用一个查询,而每个地址各用一个查询。我们检索了三个成员,所以一共用了四个查询。如果是N个成员,就会有N+1个查询。这就是可怕的N+1问题。大多数持久性框架都采用热关联(eagerassociation)来解决该问题。Rails也不例外。如果需要访问关系,就可以选择将其包括到初始查询中。ActiveRecord使用:include选项来实现此目的。如果将查询更改为Member.find([1,2,3],:include=>:address).each{|member|putsmember.address.city},结果就会稍好一些:
清单7.解决N+1问题
^[[4;35;1mMemberLoadIncludingAssociations(0.004458)^[[0m^[ [0mSELECTmembers.`id`ASt0_r0,members.`type`ASt0_r1, members.`about_me`ASt0_r2,members.`about_philanthropy` ... addresses.`id`ASt1_r0,addresses.`address1`ASt1_r1, addresses.`address2`ASt1_r2,addresses.`city`ASt1_r3, ... addresses.`addressable_id`ASt1_r8FROMmembers LEFTOUTERJOINaddressesONaddresses.addressable_id =members.idANDaddresses.addressable_type= 'Member'WHERE(members.`id`IN(1,2,3))^[ [0m ^[[35;2m./vendor/plugins/paginating_find/lib/paginating_find.rb: 98:in`find'^[[0m
该查询的速度也会更快。一个查询会检索所有成员和地址。这就是热关联的工作原理。
通过ActiveRecord,还可以嵌套:include选项,但嵌套深度只有一级。例如,有多个contacts的Member以及有一个address的Contact就属于这种情况。如果想要为某个成员的联系人显示所有城市,就可以使用清单8中所示的代码:
清单8:为某个成员的联系人获取城市
member=Member.find(1) member.contacts.each{|contact|putscontact.address.city}
该代码应该能够工作,但必须要针对此成员、每个联系人以及每个联系人的地址进行查询。通过用:include=>:contacts包括:contacts,可以稍许提高性能。也可以通过将二者都包括进来进一步地改进,如清单9所示:
清单9:为某个成员的联系人获取城市
member=Member.find(1) member.contacts.each{|contact|putscontact.address.city}
通过使用嵌套包含选项还能获得更好的改进:
member=Member.find(1,:include=>{:contacts=>:address}) member.contacts.each{|contact|putscontact.address.city}
该嵌套包含可让Rails热包含contacts和address关系。一旦要在给定的查询中使用关系,就可以采用热加载技术。此技术是我们在ChangingThePresent.org中使用得最为频繁的一种性能优化技术,但它还是有一些限制的。当必须要连接两个以上的表时,最好还是采用SQL。如果需要进行报告,最好是简单地采取数据库连接,跨过ActiveRecord以及ActiveRecord::Base.execute("SELECT*FROM...")。通常来讲,热关联足够解决问题。现在,我将转变话题,探讨Rails开发人员所关心的另一个麻烦问题:继承。
继承和Rails
当大多数Rails开发人员第一次接触到Rails时,他们就会立刻被迷住。它太简单了。您只需在数据库表上创建一个type类,然后再从父类中继承子类即可。Rails会负责其余的事情。比如,有一个名为Customer表,它可以从名为Person类继承。一个客户可以有Person的所有列,外加信誉度和订购历史。清单10显示了该种解决方案的简洁之美。主表具有父类和子类的所有列。
清单10.实现继承
create_table"people"do|t| t.column"type",:string t.column"first_name",:string t.column"last_name",:string t.column"loyalty_number",:string end classPerson<ActiveRecord::Base end classCustomer<Person has_many:orders end
在很多方面,这种解决方案都可以很好地工作。代码简单且无重复性。这些查询简单且性能很好,因为您无需进行任何连接来访问多个子类,ActiveRecord可以使用type列决定哪个记录能够返回。
在某些方面,ActiveRecord继承十分有限。如果已有的继承等级非常宽,继承就会失效。例如,在ChangingThePresent,内容有很多类型,每种类型都有自己的名称、或短或长的描述、某些常见的表示属性以及几个定制属性。我们很希望cause、nonprofit、gift、member、drive、registry以及其他一些类型的对象都能够从通用的基类中继承,以便我们能以同样的方式处理所有类型的内容。但我们却不能如此,因为Rails模型将会在单一表中拥有我们所有对象模型的实质内容,这不是一个可行的解决方案。
探索其他可选方案
我们针对此问题试验了三种解决方案。第一,我们在类自身的表中放置每个类,使用视图为内容构建通用表。我们很快抛弃了此种解决方案,因为Rails不能很好地处理数据库视图。
我们的第二个解决方案是使用简单的多态。通过这种策略,每个子类都会拥有其自身的表。我们将通用列推入每个表。例如,比方说我需要一个名为Content的子类,它只包含name属性,以及Gift、Cause和Nonprofit子类。Gift、Nonprofit和Cause都可有name属性。由于Ruby是动态类型的,所以这些子类无需从通用基类中继承。它们只需对相同的一组方法进行响应。ChangingThePresent在几个地方使用了多态以提供通用的行为,尤其是在处理图像的时候。
第三种方法是提供一种通用的功能,但采用的是关联而非继承。ActiveRecord具有一种称为多态关联的特性,非常适合将通用行为附加给类,完全无需继承。在之前的Address,您已经看到了多态关联的示例。我可以使用相同的技术(而非继承)附加通用属性用于内容管理。考虑名为ContentBase的类。通常,为了将该类关联到另一个类,可以使用has_one关系和一个简单的外键。但您可能更想让ContentBase能与多个类共同工作。这时,您需要一个外键,还需要一个能定义目标类的类型的列。而这恰好是ActiveRecord多态关联所擅长的方面。请参看清单11。
清单11.站点内容关系的两个方面
classCause<ActiveRecord::Base has_one:content_base,:as=>:displayable,:dependent=>:destroy ... end classNonprofit<ActiveRecord::Base has_one:content_base,:as=>:displayable,:dependent=>:destroy ... end classContentBase<ActiveRecord::Base belongs_to:displayable,:polymorphic=>true end
通常,belongs_to关系只有一个类,但ContentBase中的关系却是多态的。外键不仅具有标识记录的标识符,而且还具有标识表的一个类型。使用这种技术,我获得了继承的诸多益处。常见的功能在单一类中就都包括了。但这也带来了几个副作用。我无需将Cause和Nonprofit中的所有列都放在单一表中。
一些数据库管理员不太看好多态关联,原因是他们不怎么使用真正意义上的外键,但对于ChangingThePresent,我们自由地使用了多态关联。实际上,数据模型并不像理论上那样美好。不能使用诸如引用完整性这样的数据库特性,也不能依赖于工具来基于列的名称发现这些关系。简洁的对象模型的好处对我们来说要比此方式所存在的问题更为重要。
create_table"content_bases",:force=>truedo|t| t.column"short_description",:string ... t.column"displayable_type",:string t.column"displayable_id",:integer end
结束语
ActiveRecord是一种功能完善的持久性框架。用它可以构建可伸缩的可靠系统,但与其他数据库框架一样,您必须要格外注意框架所生成的SQL。当偶尔遇到问题时,您必须调整自己的方式和策略。保留索引、借助include使用热加载和在某些地方使用多态关联代替继承是三种可用来改进代码库的方法。在下月,我将带您亲历另一个示例去领略如何编写真实世界中的Rails。