includes的實現(xiàn)原理與困境

美妙的開端

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.newpreload方法實現(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正蠢蠢欲動。

不知是劫是緣阶淘?

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末衙吩,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子溪窒,更是在濱河造成了極大的恐慌分井,老刑警劉巖,帶你破解...
    沈念sama閱讀 221,635評論 6 515
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件霉猛,死亡現(xiàn)場離奇詭異尺锚,居然都是意外死亡,警方通過查閱死者的電腦和手機惜浅,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 94,543評論 3 399
  • 文/潘曉璐 我一進店門瘫辩,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人坛悉,你說我怎么就攤上這事伐厌。” “怎么了裸影?”我有些...
    開封第一講書人閱讀 168,083評論 0 360
  • 文/不壞的土叔 我叫張陵挣轨,是天一觀的道長。 經(jīng)常有香客問我轩猩,道長卷扮,這世上最難降的妖魔是什么荡澎? 我笑而不...
    開封第一講書人閱讀 59,640評論 1 296
  • 正文 為了忘掉前任,我火速辦了婚禮晤锹,結(jié)果婚禮上摩幔,老公的妹妹穿的比我還像新娘。我一直安慰自己鞭铆,他們只是感情好或衡,可當(dāng)我...
    茶點故事閱讀 68,640評論 6 397
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著车遂,像睡著了一般封断。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上舶担,一...
    開封第一講書人閱讀 52,262評論 1 308
  • 那天澄港,我揣著相機與錄音,去河邊找鬼柄沮。 笑死,一個胖子當(dāng)著我的面吹牛废岂,可吹牛的內(nèi)容都是我干的祖搓。 我是一名探鬼主播,決...
    沈念sama閱讀 40,833評論 3 421
  • 文/蒼蘭香墨 我猛地睜開眼湖苞,長吁一口氣:“原來是場噩夢啊……” “哼拯欧!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起财骨,我...
    開封第一講書人閱讀 39,736評論 0 276
  • 序言:老撾萬榮一對情侶失蹤镐作,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后隆箩,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體该贾,經(jīng)...
    沈念sama閱讀 46,280評論 1 319
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 38,369評論 3 340
  • 正文 我和宋清朗相戀三年捌臊,在試婚紗的時候發(fā)現(xiàn)自己被綠了杨蛋。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 40,503評論 1 352
  • 序言:一個原本活蹦亂跳的男人離奇死亡理澎,死狀恐怖逞力,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情糠爬,我是刑警寧澤寇荧,帶...
    沈念sama閱讀 36,185評論 5 350
  • 正文 年R本政府宣布,位于F島的核電站执隧,受9級特大地震影響揩抡,放射性物質(zhì)發(fā)生泄漏户侥。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 41,870評論 3 333
  • 文/蒙蒙 一捅膘、第九天 我趴在偏房一處隱蔽的房頂上張望添祸。 院中可真熱鬧,春花似錦寻仗、人聲如沸刃泌。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,340評論 0 24
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽耙替。三九已至,卻和暖如春曹体,著一層夾襖步出監(jiān)牢的瞬間俗扇,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 33,460評論 1 272
  • 我被黑心中介騙來泰國打工箕别, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留铜幽,地道東北人。 一個月前我還...
    沈念sama閱讀 48,909評論 3 376
  • 正文 我出身青樓串稀,卻偏偏與公主長得像除抛,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子母截,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 45,512評論 2 359

推薦閱讀更多精彩內(nèi)容

  • 關(guān)于Mongodb的全面總結(jié) MongoDB的內(nèi)部構(gòu)造《MongoDB The Definitive Guide》...
    中v中閱讀 31,947評論 2 89
  • 在條件合理的情況下到忽,想做的事情就立馬去做,想見的人就立馬去見清寇! 也許今天的故事在明天便會失掉意義… 走在武漢的街頭...
    雜貨集閱讀 124評論 0 0
  • 有些東西需要打破 做你認為正確和想做的事 機遇妙不可言 另外喘漏,村上是個很有趣的人 下一篇要記得看《挪威的森林》啊华烟!
    阿月海閱讀 221評論 0 0
  • 近期更新
    葉景軒閱讀 133評論 0 0