includes的实现原理与困境

美妙的开端

includes等方法在ActiveRecord中有广泛的使用,是解决N+1问题的神器,使用非常方便:

@users = User.where(id: [1,2,3,4,5,6]).includes(:area)

这样可以仅通过两条SQL,将用户及其所在地区的数据提取出来,避免了通过User逐条查询对应的地区数据,减少了与数据库的交互次数。在IO密集型的Web领域,这是最基本的性能优化点之一。

同时,includes可以适应多数量、复杂的关联关系,通过关联关系可以非常方便地拿到对应的数据,不用一个个去查询数据库。

@users = User.where(id: [1,2,3,4,5,6]).includes(:area, wife: [:father, :mother])
@users.first.wife.father.name #  小马哥。(小马哥最近在朋友圈愤怒辟谣)

但这个看似美妙的东西也带来了不少困扰。

中途的困境

看看这样的一个场景:

有一个页面,这个页面中的数据以table的形式呈现,table中需要显示的行与列的数据(也就是字段),通过实时动态的配置来决定。这些字段数据分散在不同的model中。每次页面请求,需要先解析一下配置中要显示的字段都属于哪些model, 然后用includes将这些model加载进来,进而进行table渲染。

includes在这个场景下有这个很大的用处,便捷地打包了数据,同时避免了N+1

includes加载对应数据时,会默认select *,加载全部字段。当数据量比较小时,一切都不是事。

但是,当可能有很多个model需要关联,而每个model中可能只有少部分字段值被正真需要时(偏偏有些表字段还挺多),这是不是一种巨大的浪费?

加载没有意义的字段,会在序列化时浪费宝贵的CPU以及内存,ActiveRecord本身对内存就挥霍无度。当并发数量稍大时,在Ruby的GC特性下,这些无意义的数据被反复加载(单次加载过多数据),可能会使得Ruby进程的内存消耗变得无比庞大。

网络、GC、CPU 、内存等等的开销,使得这个点有着不小的优化空间,可以预见,如果每次只select需要的字段,积少成多,性能肯定会有明显的提升, 特别是面对当前庞大的数据量。

为了实现这个目标,我们得带上下面这两个问题,一起去看看源码。

  • 为什么是select *
  • 如何在加载关联数据时只查询需要的字段?

实现原理

基于4.2.10,相较于最新的实现,在实现细节上有所差异,但可忽略。

 #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对象执行includes方法的时候,只是简单地将要加载的关联对象追加到了数组中。实际的查询是由lazy query机制实现的,通过to_a方法,在数据被真正使用的时候才触发数据库查询。

 #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
     # 拿到所有需要加载的关联关系,依次加载
      preload.each do |associations|
        preloader.preload @records, associations  # 没有传递第三个参数
      end

      @records.each { |record| record.readonly! } if readonly_value

      @loaded = true
      @records
    end

通过ActiveRecord::Associations::Preloader.newpreload方法实现数据的加载:

#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)
       # 这个参数默认为一个空的结构体,后面会用到这个数据
        preload_scope = preload_scope || NULL_RELATION
        if records.empty?
          []
        else
          associations.flat_map { |association|
            preloaders_on association, records, preload_scope
          }
        end
      end

顺着方法的调用逻辑,最后会发现这几个方法

        def scope
          @scope ||= build_scope
        end

        def records_for(ids)
          query_scope(ids)
        end
       # 通过where将数据查询出来, where("id in (***)")
        def query_scope(ids)
          scope.where(association_key.in(ids))
        end
       # 有删减
        def build_scope
          scope = klass.unscoped
          # 用belongs_to/has_many 等定义关系时的数据
          values         = reflection_scope.values
          reflection_binds = reflection_scope.bind_values
          # preload_scope 就是上面那个默认的结构体
          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)
         # 先读结构体中的值,再读关系定义时的数据,不然就是 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

关联数据查询出来后,通过遍历这些数据,修改关联关系的target,将对应的数据关联起来, 这样便实现了includes背后的功能。

从上面可以看出在includes查询中,select * 的原因了。includes方法执行时,无法传递对应的select参数进去,默认就是提取全部字段。另一种常规的做法是在关联关系定义时,在第二个参数中将要select的列写进去:

class User < ActiveRecord::Base
  belongs_to :area, ->{select(:name, :id)}
end

但这样写是死的,即使给proc加上参数,在执行includes的时候也传递不进去,这在个点上的优化意义不大。

解决方案

由于在includes执行时,难以将需要select的列数据传递进去,导致一般情况先都是select *, 要解决这个问题,有两种方法:

  • 改写includes及懒查询的实现方式,实现能动态定义需要select的字段
  • 放弃includes方法,直接使用 ActiveRecord::Associations::Preloader 实现预加载

第一种方式,从目前看在社区优雅政治正确背景下,可能需要一段时日,自己实现的话成本不小。第二种方式相对简单,在执行preload方法时,传递相应的preload_scope参数。

#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
# 构造 preload_scope 参数
columns = need_columns
# Rails4.2 中需要传递一个结构体
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)

上面的方法可以简单地实现动态地加载字段,不过有几个缺点:

  • 每次只能对单个关联关系进行操作,不能用于hash表达的复杂关系
  • 如果动态传入的字段参数不够完整,执行会报错,也容易留下bug
  • 不能继续懒加载了

为了实现类似 User.where(id:1).includes(:area, wife: [:father, :mother])的批量效果,还是得自己动手做些修改,有两个途径:

  • patch 一下 ActiveRecord::Associations::Preloader, 将preload_scope变成Hash,用多个key -> value的映射来表达数据。增加能适应这个Hash参数的方法
  • 解析复杂的 (:area, wife: [:father, :mother]),将复杂关系拆解成单个,依次调用原有的preload方法

这两种途径在本质上是一样的, 这里有一个我实现的简单样例。 到此,看起来像一件不错的事情,不过我似乎已经看到,在这套机制背后,有一大波bugs正蠢蠢欲动。

不知是劫是缘?

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,980评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,178评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,868评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,498评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,492评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,521评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,910评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,569评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,793评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,559评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,639评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,342评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,931评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,904评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,144评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,833评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,350评论 2 342

推荐阅读更多精彩内容

  • 关于Mongodb的全面总结 MongoDB的内部构造《MongoDB The Definitive Guide》...
    中v中阅读 31,893评论 2 89
  • 在条件合理的情况下,想做的事情就立马去做,想见的人就立马去见! 也许今天的故事在明天便会失掉意义… 走在武汉的街头...
    杂货集阅读 114评论 0 0
  • 有些东西需要打破 做你认为正确和想做的事 机遇妙不可言 另外,村上是个很有趣的人 下一篇要记得看《挪威的森林》啊!
    阿月海阅读 217评论 0 0
  • 近期更新
    叶景轩阅读 131评论 0 0