美妙的开端
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.new
的preload
方法实现数据的加载:
#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正蠢蠢欲动。
不知是劫是缘?