美妙的開端
includes
等方法在ActiveRecord
中有廣泛的使用论笔,是解決N+1
問題的神器饰序,使用非常方便:
@users = User.where(id: [1,2,3,4,5,6]).includes(:area)
這樣可以僅通過兩條SQL谤民,將用戶及其所在地區(qū)的數(shù)據(jù)提取出來,避免了通過User逐條查詢對應(yīng)的地區(qū)數(shù)據(jù),減少了與數(shù)據(jù)庫的交互次數(shù)畏妖。在IO密集型的Web領(lǐng)域,這是最基本的性能優(yōu)化點之一。
同時夫啊,includes
可以適應(yīng)多數(shù)量、復(fù)雜的關(guān)聯(lián)關(guān)系辆憔,通過關(guān)聯(lián)關(guān)系可以非常方便地拿到對應(yīng)的數(shù)據(jù)撇眯,不用一個個去查詢數(shù)據(jù)庫。
@users = User.where(id: [1,2,3,4,5,6]).includes(:area, wife: [:father, :mother])
@users.first.wife.father.name # 小馬哥虱咧。(小馬哥最近在朋友圈憤怒辟謠)
但這個看似美妙的東西也帶來了不少困擾熊榛。
中途的困境
看看這樣的一個場景:
有一個頁面,這個頁面中的數(shù)據(jù)以
table
的形式呈現(xiàn)腕巡,table
中需要顯示的行與列的數(shù)據(jù)(也就是字段)玄坦,通過實時動態(tài)的配置來決定。這些字段數(shù)據(jù)分散在不同的model
中。每次頁面請求煎楣,需要先解析一下配置中要顯示的字段都屬于哪些model
, 然后用includes
將這些model
加載進來豺总,進而進行table
渲染。
includes
在這個場景下有這個很大的用處择懂,便捷地打包了數(shù)據(jù)喻喳,同時避免了N+1
。
includes
加載對應(yīng)數(shù)據(jù)時困曙,會默認select *
表伦,加載全部字段。當(dāng)數(shù)據(jù)量比較小時慷丽,一切都不是事蹦哼。
但是,當(dāng)可能有很多個model
需要關(guān)聯(lián)要糊,而每個model
中可能只有少部分字段值被正真需要時(偏偏有些表字段還挺多)纲熏,這是不是一種巨大的浪費?
加載沒有意義的字段杨耙,會在序列化時浪費寶貴的CPU以及內(nèi)存赤套,ActiveRecord本身對內(nèi)存就揮霍無度。當(dāng)并發(fā)數(shù)量稍大時珊膜,在Ruby的GC特性下容握,這些無意義的數(shù)據(jù)被反復(fù)加載(單次加載過多數(shù)據(jù)),可能會使得Ruby進程的內(nèi)存消耗變得無比龐大车柠。
網(wǎng)絡(luò)剔氏、GC、CPU 竹祷、內(nèi)存等等的開銷谈跛,使得這個點有著不小的優(yōu)化空間,可以預(yù)見塑陵,如果每次只select
需要的字段感憾,積少成多,性能肯定會有明顯的提升令花, 特別是面對當(dāng)前龐大的數(shù)據(jù)量阻桅。
為了實現(xiàn)這個目標,我們得帶上下面這兩個問題兼都,一起去看看源碼嫂沉。
- 為什么是
select *
? - 如何在
加載關(guān)聯(lián)數(shù)據(jù)
時只查詢需要的字段扮碧?
實現(xiàn)原理
基于4.2.10趟章,相較于最新的實現(xiàn)杏糙,在實現(xiàn)細節(jié)上有所差異,但可忽略蚓土。
#https://github.com/rails/rails/blob/v4.2.10/activerecord/lib/active_record/relation/query_methods.rb#L144
def includes(*args)
check_if_method_has_arguments!(:includes, args)
spawn.includes!(*ar gs)
end
def includes!(*args) # :nodoc:
args.reject!(&:blank?)
args.flatten!
self.includes_values |= args
self
end
relation
對象執(zhí)行includes方法的時候宏侍,只是簡單地將要加載的關(guān)聯(lián)對象追加到了數(shù)組中。實際的查詢是由lazy query
機制實現(xiàn)的蜀漆,通過to_a
方法负芋,在數(shù)據(jù)被真正使用的時候才觸發(fā)數(shù)據(jù)庫查詢。
#https://github.com/rails/rails/blob/v4.2.10/activerecord/lib/active_record/relation.rb#L194
def to_a
load
@records
end
def load(&block)
exec_queries(&block) unless loaded?
self
end
def exec_queries
@records = eager_loading? ? find_with_associations : @klass.find_by_sql(arel, arel.bind_values + bind_values)
preload = preload_values
preload += includes_values unless eager_loading?
preloader = build_preloader
# 拿到所有需要加載的關(guān)聯(lián)關(guān)系嗜愈,依次加載
preload.each do |associations|
preloader.preload @records, associations # 沒有傳遞第三個參數(shù)
end
@records.each { |record| record.readonly! } if readonly_value
@loaded = true
@records
end
通過ActiveRecord::Associations::Preloader.new
的preload
方法實現(xiàn)數(shù)據(jù)的加載:
#https://github.com/rails/rails/blob/v4.2.10/activerecord/lib/active_record/associations/preloader.rb#L92
NULL_RELATION = Struct.new(:values, :bind_values).new({}, [])
def preload(records, associations, preload_scope = nil)
records = Array.wrap(records).compact.uniq
associations = Array.wrap(associations)
# 這個參數(shù)默認為一個空的結(jié)構(gòu)體,后面會用到這個數(shù)據(jù)
preload_scope = preload_scope || NULL_RELATION
if records.empty?
[]
else
associations.flat_map { |association|
preloaders_on association, records, preload_scope
}
end
end
順著方法的調(diào)用邏輯莽龟,最后會發(fā)現(xiàn)這幾個方法:
def scope
@scope ||= build_scope
end
def records_for(ids)
query_scope(ids)
end
# 通過where將數(shù)據(jù)查詢出來蠕嫁, where("id in (***)")
def query_scope(ids)
scope.where(association_key.in(ids))
end
# 有刪減
def build_scope
scope = klass.unscoped
# 用belongs_to/has_many 等定義關(guān)系時的數(shù)據(jù)
values = reflection_scope.values
reflection_binds = reflection_scope.bind_values
# preload_scope 就是上面那個默認的結(jié)構(gòu)體
preload_values = preload_scope.values
preload_binds = preload_scope.bind_values
scope.where_values = Array(values[:where]) + Array(preload_values[:where])
scope.references_values = Array(values[:references]) + Array(preload_values[:references])
scope.bind_values = (reflection_binds + preload_binds)
# 先讀結(jié)構(gòu)體中的值,再讀關(guān)系定義時的數(shù)據(jù)毯盈,不然就是 Arel.star 就是 select *
scope._select! preload_values[:select] || values[:select] || table[Arel.star]
scope.includes! preload_values[:includes] || values[:includes]
scope.joins! preload_values[:joins] || values[:joins]
scope.order! preload_values[:order] || values[:order]
scope.unscope_values = Array(values[:unscope]) + Array(preload_values[:unscope])
klass.default_scoped.merge(scope)
end
關(guān)聯(lián)數(shù)據(jù)查詢出來后剃毒,通過遍歷這些數(shù)據(jù),修改關(guān)聯(lián)關(guān)系的target搂赋,將對應(yīng)的數(shù)據(jù)關(guān)聯(lián)起來赘阀, 這樣便實現(xiàn)了includes背后的功能。
從上面可以看出在includes查詢中脑奠,select *
的原因了基公。includes方法執(zhí)行時,無法傳遞對應(yīng)的select
參數(shù)進去宋欺,默認就是提取全部字段轰豆。另一種常規(guī)的做法是在關(guān)聯(lián)關(guān)系定義時,在第二個
參數(shù)中將要select的列寫進去:
class User < ActiveRecord::Base
belongs_to :area, ->{select(:name, :id)}
end
但這樣寫是死的齿诞,即使給proc加上參數(shù)酸休,在執(zhí)行includes的時候也傳遞不進去,這在個點上的優(yōu)化意義不大祷杈。
解決方案
由于在includes執(zhí)行時斑司,難以將需要select的列數(shù)據(jù)傳遞進去,導(dǎo)致一般情況先都是select *
, 要解決這個問題但汞,有兩種方法:
- 改寫includes及懶查詢的實現(xiàn)方式宿刮,實現(xiàn)能動態(tài)定義需要select的字段
- 放棄includes方法,直接使用 ActiveRecord::Associations::Preloader 實現(xiàn)預(yù)加載
第一種方式特占,從目前看在社區(qū)優(yōu)雅
的政治正確背景下糙置,可能需要一段時日,自己實現(xiàn)的話成本不小是目。第二種方式相對簡單谤饭,在執(zhí)行preload方法時,傳遞相應(yīng)的preload_scope參數(shù)。
#https://github.com/rails/rails/blob/v4.2.10/activerecord/lib/active_record/associations/preloader.rb#L92
NULL_RELATION = Struct.new(:values, :bind_values).new({}, [])
def preload(records, associations, preload_scope = nil)
records = Array.wrap(records).compact
if records.empty?
[]
else
records.uniq!
Array.wrap(associations).flat_map { |association|
preloaders_on association, records, preload_scope
}
end
end
# 構(gòu)造 preload_scope 參數(shù)
columns = need_columns
# Rails4.2 中需要傳遞一個結(jié)構(gòu)體
preload_scope = Struct.new(:values, :bind_values).new({select: columns }, [])
# 最新的Rails中揉抵,只需要傳遞一個哈希就行
preload_scope = {select: columns}
preloader = ActiveRecord::Associations::Preloader.new
records = User.where(id: [1,2,3])
preloader.preload(records, [:area], preload_scope)
上面的方法可以簡單地實現(xiàn)動態(tài)地加載字段亡容,不過有幾個缺點:
- 每次只能對單個關(guān)聯(lián)關(guān)系進行操作,不能用于hash表達的復(fù)雜關(guān)系
- 如果動態(tài)傳入的字段參數(shù)不夠完整冤今,執(zhí)行會報錯闺兢,也容易留下bug
- 不能繼續(xù)懶加載了
為了實現(xiàn)類似 User.where(id:1).includes(:area, wife: [:father, :mother])
的批量效果,還是得自己動手做些修改戏罢,有兩個途徑:
- patch 一下
ActiveRecord::Associations::Preloader
屋谭, 將preload_scope變成Hash,用多個key -> value
的映射來表達數(shù)據(jù)龟糕。增加能適應(yīng)這個Hash參數(shù)的方法 - 解析復(fù)雜的
(:area, wife: [:father, :mother])
,將復(fù)雜關(guān)系拆解成單個桐磁,依次調(diào)用原有的preload
方法
這兩種途徑在本質(zhì)上是一樣的, 這里有一個我實現(xiàn)的簡單樣例讲岁。 到此我擂,看起來像一件不錯的事情,不過我似乎已經(jīng)看到缓艳,在這套機制背后校摩,有一大波bugs正蠢蠢欲動。
不知是劫是緣阶淘?