在Ruby on Rails中优化ActiveRecord的方法

 Ruby on Rails 编程常常会将您宠坏。这一不断发展的框架会让您从其他框架的沉闷乏味中解脱出来。您可以用习以为常的几行代码片断表达自己的意图。而且还可以使用 ActiveRecord。

对于我这样的一个老 Java? 程序员而言,ActiveRecord 多少有点生疏。通过 Java 框架,我通常都会在独立的模型和模式之间构建一种映射。像这样的框架就是映射框架。通过 ActiveRecord,我只定义数据库模式:或者用 SQL 或者用称为迁移(migration)的 Ruby 类。将对象模型设计建立于数据库结构之上的那些框架称为包装框架。与大多数包装框架不同,Rails 能通过查询数据库表发现对象模型的特征。与构建复杂查询不同,我使用模型在 Ruby(而非 SQL)中遍历关系。这样一来,我既获得了包装框架的简单性,又具备了映射框架的大部分功能。ActiveRecord 易于使用和扩展。有时,甚至有些过于简单。

与任何数据库框架一样,ActiveRecord 让我极易做出很多惹麻烦的事。我所能获取的列太多,又很容易遗漏重要的结构化数据库特性,比如索引或空约束。我并不是说 ActiveRecord 是个不好的框架。只不过若是需要扩展,您需要知道如何坚固自己的应用程序。在本篇文章中,我将带您亲历在使用 Rails 这一独树一帜的持久性框架时可能需要的一些重要优化。
基础管理

生成受模式支持的模型异常容易,只需很少的代码,即 script/generate model model_name。正如您所知,该命令可生成模型、迁移、单元测试甚至一个默认的 fixture。在该迁移中填上一些数据列,并输入一些测试数据、编写几个测试、添加几个验证就算大功告成,这样做真是很有诱惑力。但请您三思而行。您应该考虑总体的数据库设计,要特别注意以下这些事情:

  •     Rails 不会让您摆脱基本的数据库性能问题。数据库需要信息,这些信息经常以索引的格式才能有不错的性能。
  •     Rails 不会让您摆脱数据完整性问题。虽然大多数 Rails 开发人员都不喜欢在数据库中保留限制,但您应该考虑像空列这样的事情。
  •     Rails 为很多元素提供了方便的默认属性。有时,像文本字段的长度这样的默认属性对于大多数实用的应用程序而言都会过大。
  •     Rails 不会强制您创建有效的数据库设计。

在您继续跋涉,深入学习 ActiveRecord 之前,应该首先确保您已经打好了足够的基础。请确保索引结构可以为您所用。如果给定的表很大,如果将在列上而不是 id 上搜索,如果索引能对您有所帮助(更多细节,请参见数据库管理器文档 —— 不同的数据库以不同方式使用索引),那么就需要创建索引。无需采用 SQL 创建索引 —— 可以简单地使用迁移创建。可以轻松地使用 create_table 迁移创建索引,也可以创建一个额外的迁移来创建索引。以下是一个迁移示例,可用来为 ChangingThePresent.org (请参见 参考资料)创建索引:
清单 1. 在迁移中创建索引

class AddIndexesToUsers < ActiveRecord::Migration
 def self.up
  add_index :members, :login
  add_index :members, :email
  add_index :members, :first_name
  add_index :members, :last_name
 end

 def self.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.find 1。可以看到追加到日志的如下内容,如清单 3 所示:
清单 3. 从 Member.find(1) 登录

^[[4;35;1mMember Columns (0.006198)^[[0m  ^[[0mSHOW FIELDS FROM members^[[0m
^[[4;36;1mMember Load (0.002835)^[[0m  ^[[0;1mSELECT * FROM members WHERE
 (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;1mAddress Load (0.252084)^[[0m  ^[[0mSELECT * FROM addresses WHERE
 (addresses.addressable_id = 1 AND addresses.addressable_type = 'Member') LIMIT 1^[[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| puts member.address.city}

由于您应该看到针对每个地址的查询,所以就性能而言,结果并不尽如人意。清单 6 给出了问题的全部:
清单 6. N+1 问题的查询

^[[4;36;1mMember Load (0.004063)^[[0m  ^[[0;1mSELECT * FROM members WHERE
 (members.`id` IN (1,2,3)) ^[[0m
 ^[[36;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in `find'^[[0m
^[[4;35;1mAddress Load (0.000989)^[[0m  ^[[0mSELECT * FROM addresses WHERE
 (addresses.addressable_id = 1 AND addresses.addressable_type = 'Member') LIMIT 1^[[0m
 ^[[35;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in `find'^[[0m
^[[4;36;1mAddress Columns (0.073840)^[[0m  ^[[0;1mSHOW FIELDS FROM addresses^[[0m
^[[4;35;1mAddress Load (0.002012)^[[0m  ^[[0mSELECT * FROM addresses WHERE
 (addresses.addressable_id = 2 AND addresses.addressable_type = 'Member') LIMIT 1^[[0m
 ^[[35;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in `find'^[[0m
^[[4;36;1mAddress Load (0.000792)^[[0m  ^[[0;1mSELECT * FROM addresses WHERE
 (addresses.addressable_id = 3 AND addresses.addressable_type = 'Member') LIMIT 1^[[0m
 ^[[36;2m./vendor/plugins/paginating_find/lib/paginating_find.rb:98:in `find'^[[0m

结果正如我所预见的那样糟糕。所有成员共用一个查询,而每个地址各用一个查询。我们检索了三个成员,所以一共用了四个查询。如果是 N 个成员,就会有 N+1 个查询。这就是可怕的 N+1 问题。大多数持久性框架都采用热关联(eager association)来解决该问题。Rails 也不例外。如果需要访问关系,就可以选择将其包括到初始查询中。ActiveRecord 使用 :include 选项来实现此目的。如果将查询更改为 Member.find([1,2,3], :include => :address).each {|member| puts member.address.city},结果就会稍好一些:
清单 7. 解决 N+1 问题

^[[4;35;1mMember Load Including Associations (0.004458)^[[0m  ^[
  [0mSELECT members.`id` AS t0_r0, members.`type` AS t0_r1,
  members.`about_me` AS t0_r2, members.`about_philanthropy`

  ...

  addresses.`id` AS t1_r0, addresses.`address1` AS t1_r1,
  addresses.`address2` AS t1_r2, addresses.`city` AS t1_r3,

  ...

  addresses.`addressable_id` AS t1_r8 FROM members
  LEFT OUTER JOIN addresses ON addresses.addressable_id
  = members.id AND addresses.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| puts contact.address.city}

该代码应该能够工作,但必须要针对此成员、每个联系人以及每个联系人的地址进行查询。通过用 :include => :contacts 包括 :contacts,可以稍许提高性能。也可以通过将二者都包括进来进一步地改进,如清单 9 所示:
清单 9: 为某个成员的联系人获取城市

member = Member.find(1)
member.contacts.each {|contact| puts contact.address.city}

通过使用嵌套包含选项还能获得更好的改进:

member = Member.find(1, :include => {:contacts => :address})
member.contacts.each {|contact| puts contact.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

class Person < ActiveRecord::Base
end

class Customer < 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. 站点内容关系的两个方面

class Cause < ActiveRecord::Base
 has_one :content_base, :as => :displayable, :dependent => :destroy
 ...
end

class Nonprofit < ActiveRecord::Base
 has_one :content_base, :as => :displayable, :dependent => :destroy
 ...
end


class ContentBase < ActiveRecord::Base
 belongs_to :displayable, :polymorphic => true
end

通常,belongs_to 关系只有一个类,但 ContentBase 中的关系却是多态的。外键不仅具有标识记录的标识符,而且还具有标识表的一个类型。使用这种技术,我获得了继承的诸多益处。常见的功能在单一类中就都包括了。但这也带来了几个副作用。我无需将 Cause 和 Nonprofit 中的所有列都放在单一表中。

一些数据库管理员不太看好多态关联,原因是他们不怎么使用真正意义上的外键,但对于 ChangingThePresent,我们*地使用了多态关联。实际上,数据模型并不像理论上那样美好。不能使用诸如引用完整性这样的数据库特性,也不能依赖于工具来基于列的名称发现这些关系。简洁的对象模型的好处对我们来说要比此方式所存在的问题更为重要。

create_table "content_bases", :force => true do |t|
 t.column "short_description",     :string

 ...

 t.column "displayable_type", :string
 t.column "displayable_id",  :integer
end

结束语

ActiveRecord 是一种功能完善的持久性框架。用它可以构建可伸缩的可靠系统,但与其他数据库框架一样,您必须要格外注意框架所生成的 SQL。当偶尔遇到问题时,您必须调整自己的方式和策略。保留索引、借助 include 使用热加载和在某些地方使用多态关联代替继承是三种可用来改进代码库的方法。在下月,我将带您亲历另一个示例去领略如何编写真实世界中的 Rails。