解決常見的性能問題
簡介: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。